Skip to main content
An 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: Bearer token with a POST to https://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.
  • http lanes only, and exclusive with identity_header: startup refuses a lane that sets both.
The result names who holds the token, not what it may do. The upstream still checks the same token against its own IAM and RBAC. The tokeninfo call trusts the 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 appends Via: <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 returns 403 Forbidden with the rule’s message and Connection: close:
A request refused for its identity or as a request loop is also answered with a 403. On an h2 connection the 403 ends that stream only.

The Envoy lane

Keep your existing ext_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
Envoy already terminated the client’s TLS on the HTTPS listener, so this lane carries plaintext HTTP/1.1 or h2c to the Sidecar and there is nothing extra to configure. The compose stack’s httpbin lane in deploy/docker-compose/envoy-stack/ runs exactly this shape:
To keep h2 on the hop to the lane, give Envoy two clusters to the same lane port: one with 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.