Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ you came to do.

## What this project is

`request-validator` is a generic Envoy / Istio **ext-authz HTTP** service.
Envoy forwards an incoming request to us, we say `allow` or `deny`, and
Envoy enforces. The decision is driven by a **CEL-based** YAML policy.
`request-validator` is a generic Envoy / Istio policy service with two
engines driven by one **CEL-based** YAML policy:

- **extAuthz** (HTTP ext-authz): Envoy forwards a request, we say `allow`
or `deny`, Envoy enforces.
- **extProc** (gRPC ext_proc): we inspect and mutate live traffic
(request or response headers and body), or short-circuit and serve our
own response.

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

## When you arrive, read this first

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

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

## Common tasks → where to start

| You want to... | Read | Touch |
| ------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------- |
| Add a new CEL function (e.g. `b64Url`) | `ARCHITECTURE.md` → "CEL environment", `CODE_CONVENTIONS.md` | `internal/celenv/<family>.go` |
| Add a new source method for `facts:` | `ARCHITECTURE.md` → "Facts lifecycle" | `internal/facts/facts.go` |
| Tweak the access log shape or redaction rules | `POLICY_DSL.md` → "logging", `ARCHITECTURE.md` → "Logging" | `internal/httpserver/access.go` + `policy/` |
| Add a new top-level YAML section | `POLICY_DSL.md`, `ARCHITECTURE.md` → "Config types" | `internal/policy/policy.go` |
| Speed up the request hot path | `ARCHITECTURE.md` → "Request lifecycle" | `internal/httpserver/server.go` |
| Change deploy/observability behaviour | `OPERATIONS.md` | Dockerfile, `.github/workflows/`, README |
| Understand a past decision (e.g. "why no gRPC?") | `DECISIONS.md` | - |
## Common tasks: where to start

| You want to... | Read | Touch |
| ------------------------------------------------ | ------------------------------------------------------------- | ------------------------------------------- |
| Add a new CEL function (e.g. `b64Url`) | `ARCHITECTURE.md` ("CEL environment"), `CODE_CONVENTIONS.md` | `internal/celenv/<family>.go` |
| Add a new source method for `facts:` | `ARCHITECTURE.md` ("Facts lifecycle") | `internal/facts/facts.go` |
| Tweak the access log shape or redaction rules | `POLICY_DSL.md` ("logging"), `ARCHITECTURE.md` ("Logging") | `internal/httpserver/access.go` + `policy/` |
| Add a new top-level YAML section | `POLICY_DSL.md`, `ARCHITECTURE.md` ("Config types") | `internal/policy/policy.go` |
| Add or change an extProc mutation op | `POLICY_DSL.md` ("Mutation ops"), `DECISIONS.md` (D-019/D-023) | `internal/policy/` + `internal/grpcserver/` |
| Speed up the request hot path | `ARCHITECTURE.md` ("Request lifecycle") | `internal/httpserver/server.go` |
| Change deploy/observability behaviour | `OPERATIONS.md` | Dockerfile, `.github/workflows/`, README |
| Understand a past decision | `DECISIONS.md` | - |

## Hard rules - do not break

Expand Down
100 changes: 72 additions & 28 deletions .agents/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ internal/
celenv/ CEL environment, custom functions, program cache
jsonpath/ tiny JSONPath subset (used by `jsonPath` CEL fn)
facts/ facts registry: inline values, file reads, URL fetchers
policy/ config types, YAML parser, evaluator
policy/ config types, YAML parser, evaluators (extAuthz + extProc)
configwatch/ fsnotify wrapper, debounce, k8s ConfigMap-aware
httpserver/ /healthz, /readyz, /metrics, ext-authz endpoint
httpserver/ /healthz, /readyz, /metrics, extAuthz endpoint
grpcserver/ extProc endpoint (Envoy ext_proc gRPC stream)
```

The dependency graph is acyclic and one-directional:

```
cmd ──► policy ──► facts
│ │
├──► celenv ──► jsonpath
cmd ──► httpserver ──► policy
cmd ──► configwatch
cmd ──► log (everyone else also imports log)
cmd --> policy --> facts
| |
+--> celenv --> jsonpath
|
cmd --> httpserver --> policy
cmd --> grpcserver --> policy
cmd --> configwatch
cmd --> log (everyone else also imports log)
```

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

```
Config{
Defaults Defaults // action, denyStatus, denyBody, maxBodyBytes, allowOnError
Defaults Defaults // per-engine: Defaults.ExtAuthz + Defaults.ExtProc + global DryRun
Logging Logging // level, format, exclude/redact headers, etc.
Facts []facts.Spec // declared facts
Groups []Group // ordered list of rule buckets
Groups []Group // ordered list of rule buckets, each bound to one engine

// not in YAML, set during LoadBytes:
env *celenv.Env // shared CEL env + program cache
registry *facts.Registry // facts runtime (with URL fetchers etc.)
}
```

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

## Request lifecycle

Expand All @@ -72,14 +77,14 @@ path only executes already-compiled programs.
┌────────────┴────────────┐
│ for each Group, in order│
└────────────┬────────────┘
│ 6. group.matchProg bool
│ 6. group.matchProg to bool
│ (skip silently if false)
┌──────────────────────────┐
│ Group.Mode == firstMatch │ every rule:
│ or == all │ rule.matchProg bool
└──────────────┬───────────┘ + action inheritance
▼ + dryRun + fallthrough
│ or == all │ rule.matchProg to bool
└──────────────┬───────────┘ + validation.action
▼ + dryRun (no fallthrough)
┌──────────┐
│ Decision │
│ {Allowed,│
Expand All @@ -96,12 +101,44 @@ path only executes already-compiled programs.
There is exactly **one** access-log record per request, level `INFO` for
allow / `WARN` for deny. The CEL programs were compiled at policy load,
so the only per-request cost is body read + map build + a few CEL calls.
Only groups whose `parameters.engine` is `extAuthz` are evaluated here;
`firstMatch` lets the first matching rule decide, `matchAll` requires
every rule to match or denies. There is no action inheritance and no
fallthrough.

## Response lifecycle (extProc, gRPC)

`grpcserver` implements Envoy's `ext_proc` bidirectional stream. Each
stream message maps to a phase (`requestHeaders`, `requestBody`,
`responseHeaders`, `responseBody`); the server keeps per-stream state
(the request, then the response) and calls `policy.EvaluateProc(phase,
req, resp)`, which walks the extProc groups bound to that phase
(`firstMatch` or `applyAll`) and returns the resolved mutations (CEL
values already evaluated). The server then:

- if a `directResponse` is applicable, emits an Envoy `ImmediateResponse`
(status + headers + body) and ignores the rest (short-circuit);
- otherwise builds a `CommonResponse` with the header/body mutations;
- under dry-run (global or per-rule), responds CONTINUE while logging what
it would have done;
- on a body phase, enforces `extProc.maxBodyBytes` with
`onBodyOverflow: skip | fail`.

Live CEL variables follow the phase: `request`/`facts` in request phases,
plus `response` in response phases.

## CEL environment (`internal/celenv`)

Built once per policy load, in `celenv.New()`:
Built once per policy load, in `celenv.New()`, as two scoped environments:

- Variables declared: `request` (dyn) and `facts` (dyn).
- `ScopeRequest` declares `request` (dyn) and `facts` (dyn).
- `ScopeResponse` declares `request`, `response` (dyn) and `facts`.
An expression that references `response` in a request scope fails to
compile, which is how the per-phase variable contract is enforced.
- Compilation is typed: `Compile` (bool, for `match`), `CompileString`
(header/body values), `CompileInt` (`setStatus` code), `CompileStringMap`
(`directResponse.headers`, `map<string,string>`). Output type is checked
at load. Their `Eval*` counterparts re-check the type at runtime.
- Standard library + these extensions enabled:
`ext.Strings()`, `ext.Encoders()`, `ext.Lists()`, `ext.Sets()`,
`ext.Math()`, `ext.Bindings()`.
Expand Down Expand Up @@ -136,17 +173,17 @@ policy.LoadFile
policy.LoadBytes
├─ yaml.Unmarshal Config{Defaults, Logging, Facts, Groups}
├─ yaml.Unmarshal to Config{Defaults, Logging, Facts, Groups}
├─ applyDefaults
├─ validate
├─ celenv.New every Compile() is cached
├─ facts.New(Facts) builds Registry, value entries already populated
└─ compile turn match strings into cel.Program
├─ celenv.New (every Compile() is cached)
├─ facts.New(Facts) (builds Registry, value entries already populated)
└─ compile (turn match/mutation strings into cel.Program)

cfg.Start(ctx)
└─ for each fact:
file os.ReadFile store as string
url http GET store as string + spawn goroutine
file os.ReadFile store as string
url http GET store as string + spawn goroutine
with time.Ticker(interval)
```

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

## HTTP server endpoints
## Servers and endpoints

The HTTP server (`httpserver`, default `:8080`) serves extAuthz plus
operational endpoints:

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

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

The gRPC server (`grpcserver`, default `:9090`) serves the Envoy ext_proc
`ExternalProcessor` service for the extProc engine. Both servers share the
same `*policy.Config` pointer and the same hot-reload path in `cmd`.

## Build & ship

- `Dockerfile` produces a `gcr.io/distroless/static:nonroot` image with
Expand Down
Loading
Loading