http lane inspects requests and responses. Envoy’s ext_authz already hands OPA the method, path and headers of a request, and the lane keeps that arrangement. It adds two things ext_authz cannot do by construction: it reads the response, which is where data leaves the building, and it keys policy on a stable resource identity instead of raw paths.
config.yaml
The normalized resource
Policy keyed on raw paths needs a regex per endpoint. The codec collapses dynamic segments to*, so /users/12345/orders/98765 becomes /users/*/orders/* and one http_resource rule covers the endpoint:
The slug survives on purpose: nothing distinguishes
/users/alice from /users/settings, and collapsing it would widen every rule written against either without warning. In doubt the codec keeps the segment, so a policy can end up too narrow but never too broad. File extensions survive too (/reports/12345.pdf → /reports/*.pdf), because a policy may allow *.csv and deny *.sql.
The http block
The defaults expose nothing: no bodies, no headers. Everything you capture reaches the policy engine, the audit trail and, where an analyzer is configured, a third party, so capture is opt-in per field.
Identity
identity_header names the header your authenticating proxy sets. The lane reads it on every request, because Envoy pools upstream connections and sends requests from different callers down one keep-alive connection. The first request names the first session before it opens, so the session_start audit row already carries the subject. A later request that names a different principal ends that session (session_end) and opens a new one on the same connection (session_start), so each request’s statement row and input.context.principal for OPA carry its own caller. A caller change while an earlier response on the connection is still outstanding is refused with a 403 under rule identity: that response belongs to the session that sent its request. A request without the header runs as anonymous and is forwarded, so a policy that requires a caller must deny input.context.principal == "anonymous" itself. The header value reaches policy input and the audit trail only when http.headers names it. A client that connects and sends no request within ten seconds is closed with no session and no session_start, the way a pgwire client that abandons its handshake is; HTTP has no server greeting, so waiting on the client’s first bytes is the protocol’s own ordering.
Trusting a header is safe only when nothing but that proxy can reach the listener, so bind loopback or a unix socket. On a listener reachable from anywhere else, a caller can assert any identity.
Google identity
google_identity names the caller from Google’s own bearer, for a lane with no authenticating proxy in front. kubectl through GKE Connect Gateway sends a Google OAuth2 access token on every request; without this block, Envoy would need ext_authz only to learn the name the token already holds.
- The lane verifies the request’s
Authorization: Bearertoken with a POST tohttps://oauth2.googleapis.com/tokeninfo. It never sends the token in a query string, because proxies log URLs. - The principal is the verified email, else the account’s Google
sub. - A verified answer is cached under the token’s SHA-256 for the token’s lifetime, at most five minutes. A rejected token is cached for 30 seconds. An unreachable Google is never cached.
- Any failure (no bearer, a token Google rejects, Google unreachable) refuses that request with a 403. A caller that cannot be verified is never served under the identity the connection had before.
- The token reaches no audit row, no policy input and no analyzer prompt.
httplanes only, and exclusive withidentity_header: startup refuses a lane that sets both.
trust.ca_file bundle, so it works through a proxy that re-signs egress TLS.
h2 and h2c
An http lane accepts HTTP/2 beside HTTP/1.1:
Each h2 stream is bridged into the unchanged HTTP/1.1 relay as one request, so policy, the per-stream 403, masking, audit, per-request identity and
Via apply to it exactly as to an HTTP/1.1 client. The upstream hop stays HTTP/1.1. Streams do not count toward max_conns: the client connection was admitted once, and HTTP/2’s concurrent-stream limit bounds its streams.
An h2 CONNECT, extended CONNECT included, gets 501. A request with an Upgrade header (kubectl exec, attach, port-forward) must reach the lane as HTTP/1.1. kubectl already does that when it talks to the lane directly; an Envoy with an h2 cluster to the lane needs a route that sends Upgrade requests to a second, HTTP/1.1 cluster. See The Envoy lane.
Via and loop detection
Every http lane appendsVia: <request version> hoop-<16 hex> as the last request header, so the upstream sees one extra header. The pseudonym is random per process. A request that already carries this process’s pseudonym is refused with a 403 whose message starts request loop:. That catches a transparent MITM proxy that routes the Sidecar’s own upstream traffic back into its listener, where each lap would otherwise look like a fresh client. After a request with Upgrade, or a CONNECT, the rest of that connection is another protocol and is no longer marked.
Masking
The codec rebuilds the response around the masked values. A JSON body is walked value by value, and each string or number reaches the masker under its dotted key path (data.password, items.metadata.name), so a columns rule names a JSON key the way it names a result-set column and masks it whatever it holds. A text body (text/*, XML, YAML, form data, or no Content-Type) is masked as text. Binary types pass through untouched.
Content-Length is corrected. A chunked body is re-chunked and streams: each complete top-level JSON value, or each line of text, goes out as soon as it is whole, so a watch or a log follow is not held to its end and a value split across two chunks is still masked whole. A gzip or deflate body is undone around the masker and compressed again. A text or JSON body under an encoding the codec cannot undo (zstd, br, lz4) is refused with a 403 and an error audit row rather than forwarded unmasked; a binary body under such an encoding passes. A body the codec is holding that grows past 16 MiB goes out unmasked, and the next response is masked again.
See Kubernetes API for columns: [data] against kube-apiserver’s chunked responses, and the ClickHouse HTTP interface for TSV, JSONEachRow and FORMAT JSON results.
Denials
A denied request returns403 Forbidden with the rule’s message and Connection: close:
The Envoy lane
Keep your existingext_authz filter, and OPA still answers reachability first. The one change is the route’s cluster, which points at the Sidecar instead of at the service:
envoy.yaml
httpbin lane in deploy/docker-compose/envoy-stack/ runs exactly this shape:
http2_protocol_options for ordinary requests, one plain HTTP/1.1 for upgrades. Match Upgrade first, because Envoy turns an upgrade on an h2 cluster into an extended CONNECT, which the lane answers with 501:
envoy.yaml
Next
Kubernetes API
kube-apiserver behind an http lane: header guardrails, masking by JSON key, kubectl exec.
Guardrail Rules
http_resource globs, http_status ranges, http_header and headers_not patterns, and deferring a match to Rego.Risk Analysis
What the lane’s analyzer sees with and without
capture_body, and what it costs.