Skip to content

Commit fbb4389

Browse files
authored
Merge pull request #3 from achetronic/feat/extproc-engine
feat: add extProc engine for traffic mutation
2 parents 1109da8 + 54b31aa commit fbb4389

30 files changed

Lines changed: 6175 additions & 1030 deletions

.agents/AGENTS.md

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ you came to do.
66

77
## What this project is
88

9-
`request-validator` is a generic Envoy / Istio **ext-authz HTTP** service.
10-
Envoy forwards an incoming request to us, we say `allow` or `deny`, and
11-
Envoy enforces. The decision is driven by a **CEL-based** YAML policy.
9+
`request-validator` is a generic Envoy / Istio policy service with two
10+
engines driven by one **CEL-based** YAML policy:
11+
12+
- **extAuthz** (HTTP ext-authz): Envoy forwards a request, we say `allow`
13+
or `deny`, Envoy enforces.
14+
- **extProc** (gRPC ext_proc): we inspect and mutate live traffic
15+
(request or response headers and body), or short-circuit and serve our
16+
own response.
1217

1318
It was built to cover the cases plain Istio `AuthorizationPolicy` cannot
1419
(inspecting request bodies, combining CIDRs with JSON contents, validating
15-
OAuth `redirect_uris`, etc.).
20+
OAuth `redirect_uris`, rewriting an untrusted DCR redirect into a warning
21+
page, etc.).
1622

1723
## When you arrive, read this first
1824

@@ -29,7 +35,7 @@ Only after that, drill into:
2935

3036
- **`.agents/DECISIONS.md`** - _why_ the project looks the way it does
3137
(CEL over a custom DSL, fail-closed semantics, in-process feed fetching,
32-
HTTP ext-authz over gRPC for now, etc.).
38+
the two-engine split, the mutation and directResponse model, etc.).
3339
- **`.agents/CODE_CONVENTIONS.md`** - house style for Go in this repo.
3440
- **`.agents/TESTING.md`** - how to run the test suite and the E2E recipe.
3541
- **`.agents/OPERATIONS.md`** - deploy notes, observability, troubleshooting.
@@ -44,7 +50,8 @@ Only after that, drill into:
4450
│ ├── celenv/ CEL environment + custom functions
4551
│ ├── configwatch/ fsnotify wrapper for policy hot-reload
4652
│ ├── facts/ facts registry (inline/file/url sources)
47-
│ ├── httpserver/ ext-authz HTTP endpoint + metrics
53+
│ ├── httpserver/ extAuthz HTTP endpoint + metrics
54+
│ ├── grpcserver/ extProc gRPC endpoint (Envoy ext_proc)
4855
│ ├── jsonpath/ tiny JSONPath subset used by the engine
4956
│ ├── log/ slog wrapper (json | console handlers)
5057
│ └── policy/ policy types, parser, evaluator
@@ -56,17 +63,18 @@ Only after that, drill into:
5663
└── .agents/ this directory
5764
```
5865

59-
## Common tasks → where to start
60-
61-
| You want to... | Read | Touch |
62-
| ------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------- |
63-
| Add a new CEL function (e.g. `b64Url`) | `ARCHITECTURE.md` → "CEL environment", `CODE_CONVENTIONS.md` | `internal/celenv/<family>.go` |
64-
| Add a new source method for `facts:` | `ARCHITECTURE.md` → "Facts lifecycle" | `internal/facts/facts.go` |
65-
| Tweak the access log shape or redaction rules | `POLICY_DSL.md` → "logging", `ARCHITECTURE.md` → "Logging" | `internal/httpserver/access.go` + `policy/` |
66-
| Add a new top-level YAML section | `POLICY_DSL.md`, `ARCHITECTURE.md` → "Config types" | `internal/policy/policy.go` |
67-
| Speed up the request hot path | `ARCHITECTURE.md` → "Request lifecycle" | `internal/httpserver/server.go` |
68-
| Change deploy/observability behaviour | `OPERATIONS.md` | Dockerfile, `.github/workflows/`, README |
69-
| Understand a past decision (e.g. "why no gRPC?") | `DECISIONS.md` | - |
66+
## Common tasks: where to start
67+
68+
| You want to... | Read | Touch |
69+
| ------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------- |
70+
| Add a new CEL function (e.g. `b64Url`) | `ARCHITECTURE.md` ("CEL environment"), `CODE_CONVENTIONS.md` | `internal/celenv/<family>.go` |
71+
| Add a new source method for `facts:` | `ARCHITECTURE.md` ("Facts lifecycle") | `internal/facts/facts.go` |
72+
| Tweak the access log shape or redaction rules | `POLICY_DSL.md` ("logging"), `ARCHITECTURE.md` ("Logging") | `internal/httpserver/access.go` + `policy/` |
73+
| Add a new top-level YAML section | `POLICY_DSL.md`, `ARCHITECTURE.md` ("Config types") | `internal/policy/policy.go` |
74+
| Add or change an extProc mutation op | `POLICY_DSL.md` ("Mutation ops"), `DECISIONS.md` (D-019/D-023) | `internal/policy/` + `internal/grpcserver/` |
75+
| Speed up the request hot path | `ARCHITECTURE.md` ("Request lifecycle") | `internal/httpserver/server.go` |
76+
| Change deploy/observability behaviour | `OPERATIONS.md` | Dockerfile, `.github/workflows/`, README |
77+
| Understand a past decision | `DECISIONS.md` | - |
7078

7179
## Hard rules - do not break
7280

.agents/ARCHITECTURE.md

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,23 @@ internal/
1212
celenv/ CEL environment, custom functions, program cache
1313
jsonpath/ tiny JSONPath subset (used by `jsonPath` CEL fn)
1414
facts/ facts registry: inline values, file reads, URL fetchers
15-
policy/ config types, YAML parser, evaluator
15+
policy/ config types, YAML parser, evaluators (extAuthz + extProc)
1616
configwatch/ fsnotify wrapper, debounce, k8s ConfigMap-aware
17-
httpserver/ /healthz, /readyz, /metrics, ext-authz endpoint
17+
httpserver/ /healthz, /readyz, /metrics, extAuthz endpoint
18+
grpcserver/ extProc endpoint (Envoy ext_proc gRPC stream)
1819
```
1920

2021
The dependency graph is acyclic and one-directional:
2122

2223
```
23-
cmd ──► policy ──► facts
24-
│ │
25-
├──► celenv ──► jsonpath
26-
27-
cmd ──► httpserver ──► policy
28-
cmd ──► configwatch
29-
cmd ──► log (everyone else also imports log)
24+
cmd --> policy --> facts
25+
| |
26+
+--> celenv --> jsonpath
27+
|
28+
cmd --> httpserver --> policy
29+
cmd --> grpcserver --> policy
30+
cmd --> configwatch
31+
cmd --> log (everyone else also imports log)
3032
```
3133

3234
`log` is the only package every other one depends on. It must stay
@@ -38,20 +40,23 @@ dependency-free of the rest.
3840

3941
```
4042
Config{
41-
Defaults Defaults // action, denyStatus, denyBody, maxBodyBytes, allowOnError
43+
Defaults Defaults // per-engine: Defaults.ExtAuthz + Defaults.ExtProc + global DryRun
4244
Logging Logging // level, format, exclude/redact headers, etc.
4345
Facts []facts.Spec // declared facts
44-
Groups []Group // ordered list of rule buckets
46+
Groups []Group // ordered list of rule buckets, each bound to one engine
4547
4648
// not in YAML, set during LoadBytes:
4749
env *celenv.Env // shared CEL env + program cache
4850
registry *facts.Registry // facts runtime (with URL fetchers etc.)
4951
}
5052
```
5153

52-
A `Group` carries its compiled `matchProg cel.Program`, and each `Rule`
53-
carries its own. Compilation happens once in `LoadBytes()`; the request
54-
path only executes already-compiled programs.
54+
A `Group` carries `parameters` (engine, mode, phase) and its compiled
55+
`matchProg cel.Program`. Each `Rule` carries its own `matchProg`, plus
56+
either a `Validation` (extAuthz) or a list of `Mutation` whose CEL
57+
expressions are also compiled (value/code/headers/body). Compilation
58+
happens once in `LoadBytes()`; the request path only executes
59+
already-compiled programs.
5560

5661
## Request lifecycle
5762

@@ -72,14 +77,14 @@ path only executes already-compiled programs.
7277
┌────────────┴────────────┐
7378
│ for each Group, in order│
7479
└────────────┬────────────┘
75-
│ 6. group.matchProg bool
80+
│ 6. group.matchProg to bool
7681
│ (skip silently if false)
7782
7883
┌──────────────────────────┐
7984
│ Group.Mode == firstMatch │ every rule:
80-
│ or == all │ rule.matchProg bool
81-
└──────────────┬───────────┘ + action inheritance
82-
▼ + dryRun + fallthrough
85+
│ or == all │ rule.matchProg to bool
86+
└──────────────┬───────────┘ + validation.action
87+
▼ + dryRun (no fallthrough)
8388
┌──────────┐
8489
│ Decision │
8590
│ {Allowed,│
@@ -96,12 +101,44 @@ path only executes already-compiled programs.
96101
There is exactly **one** access-log record per request, level `INFO` for
97102
allow / `WARN` for deny. The CEL programs were compiled at policy load,
98103
so the only per-request cost is body read + map build + a few CEL calls.
104+
Only groups whose `parameters.engine` is `extAuthz` are evaluated here;
105+
`firstMatch` lets the first matching rule decide, `matchAll` requires
106+
every rule to match or denies. There is no action inheritance and no
107+
fallthrough.
108+
109+
## Response lifecycle (extProc, gRPC)
110+
111+
`grpcserver` implements Envoy's `ext_proc` bidirectional stream. Each
112+
stream message maps to a phase (`requestHeaders`, `requestBody`,
113+
`responseHeaders`, `responseBody`); the server keeps per-stream state
114+
(the request, then the response) and calls `policy.EvaluateProc(phase,
115+
req, resp)`, which walks the extProc groups bound to that phase
116+
(`firstMatch` or `applyAll`) and returns the resolved mutations (CEL
117+
values already evaluated). The server then:
118+
119+
- if a `directResponse` is applicable, emits an Envoy `ImmediateResponse`
120+
(status + headers + body) and ignores the rest (short-circuit);
121+
- otherwise builds a `CommonResponse` with the header/body mutations;
122+
- under dry-run (global or per-rule), responds CONTINUE while logging what
123+
it would have done;
124+
- on a body phase, enforces `extProc.maxBodyBytes` with
125+
`onBodyOverflow: skip | fail`.
126+
127+
Live CEL variables follow the phase: `request`/`facts` in request phases,
128+
plus `response` in response phases.
99129

100130
## CEL environment (`internal/celenv`)
101131

102-
Built once per policy load, in `celenv.New()`:
132+
Built once per policy load, in `celenv.New()`, as two scoped environments:
103133

104-
- Variables declared: `request` (dyn) and `facts` (dyn).
134+
- `ScopeRequest` declares `request` (dyn) and `facts` (dyn).
135+
- `ScopeResponse` declares `request`, `response` (dyn) and `facts`.
136+
An expression that references `response` in a request scope fails to
137+
compile, which is how the per-phase variable contract is enforced.
138+
- Compilation is typed: `Compile` (bool, for `match`), `CompileString`
139+
(header/body values), `CompileInt` (`setStatus` code), `CompileStringMap`
140+
(`directResponse.headers`, `map<string,string>`). Output type is checked
141+
at load. Their `Eval*` counterparts re-check the type at runtime.
105142
- Standard library + these extensions enabled:
106143
`ext.Strings()`, `ext.Encoders()`, `ext.Lists()`, `ext.Sets()`,
107144
`ext.Math()`, `ext.Bindings()`.
@@ -136,17 +173,17 @@ policy.LoadFile
136173
137174
138175
policy.LoadBytes
139-
├─ yaml.Unmarshal Config{Defaults, Logging, Facts, Groups}
176+
├─ yaml.Unmarshal to Config{Defaults, Logging, Facts, Groups}
140177
├─ applyDefaults
141178
├─ validate
142-
├─ celenv.New every Compile() is cached
143-
├─ facts.New(Facts) builds Registry, value entries already populated
144-
└─ compile turn match strings into cel.Program
179+
├─ celenv.New (every Compile() is cached)
180+
├─ facts.New(Facts) (builds Registry, value entries already populated)
181+
└─ compile (turn match/mutation strings into cel.Program)
145182
146183
cfg.Start(ctx)
147184
└─ for each fact:
148-
file os.ReadFile store as string
149-
url http GET store as string + spawn goroutine
185+
file os.ReadFile store as string
186+
url http GET store as string + spawn goroutine
150187
with time.Ticker(interval)
151188
```
152189

@@ -205,17 +242,24 @@ masked. The redaction policy: a value of length `< 2 * redactReveal` is
205242
fully masked; otherwise the first `redactReveal` characters are shown
206243
and the rest replaced with `*`.
207244

208-
## HTTP server endpoints
245+
## Servers and endpoints
246+
247+
The HTTP server (`httpserver`, default `:8080`) serves extAuthz plus
248+
operational endpoints:
209249

210250
| Path | Purpose |
211251
| ---------- | ----------------------------------------------------------------------- |
212-
| `/` | ext-authz check. Envoy POSTs the original request here. |
252+
| `/` | extAuthz check. Envoy POSTs the original request here. |
213253
| `/healthz` | always 200 once the process is up. |
214254
| `/readyz` | 200 only after the first policy is installed (used as readiness probe). |
215255
| `/metrics` | Prometheus text format. Counters per (rule, outcome, dry_run). |
216256

217257
`/` accepts any method and path; it inspects whatever Envoy forwarded.
218258

259+
The gRPC server (`grpcserver`, default `:9090`) serves the Envoy ext_proc
260+
`ExternalProcessor` service for the extProc engine. Both servers share the
261+
same `*policy.Config` pointer and the same hot-reload path in `cmd`.
262+
219263
## Build & ship
220264

221265
- `Dockerfile` produces a `gcr.io/distroless/static:nonroot` image with

0 commit comments

Comments
 (0)