Skip to main content
A postgres lane decodes the PostgreSQL v3 frontend/backend protocol: every statement a client sends, and every row the server returns. It is the lane the compose stack demonstrates and the one Running the Sidecar builds step by step.
config.yaml

What the codec reads

Two message types carry SQL, and the codec reads both: The codec skips everything else by length, so a bulk COPY stream costs no memory. It reassembles a statement split across TCP segments before classifying it. The codec splits a multi-statement Q message with PostgreSQL’s own lexical rules (dollar quoting, standard-conforming strings, nested comments) and evaluates each statement on its own. Operation is the statement’s most consequential effect, so a DELETE hidden inside a CTE classifies as delete; see the worked example.
CALL and EXECUTE report unknown, because their bodies live in the catalog and no parser can say what they touch. To catch them, write operations: [call, unknown].

TLS on each leg

pgwire negotiates TLS in-band: the client sends an 8-byte SSLRequest and waits for a one-byte reply. That shapes both ends of the lane. A postgres lane can terminate the client’s TLS itself because a generic TLS proxy cannot speak pgwire’s in-band exchange:
config.yaml
Clients then connect with PGSSLMODE=require and the gate still reads plaintext, because the lane decrypts what it terminates. Two upstream details:
  • A refusal fails the connection. If the server answers N to the SSLRequest, the lane errors out instead of downgrading. You asked for an encrypted hop, and a downgrade would send credentials in the clear without telling you.
  • The Sidecar strips channel binding. It removes SCRAM-SHA-256-PLUS from the server’s SASL offer, because channel binding ties SCRAM to a single TLS session and a terminating relay has two. Plain SCRAM-SHA-256 remains and authenticates the same password against the same verifier, so you change no credential and no server setting.

Trace ids from the startup packet

The lane reads the client’s StartupMessage before any statement. It records user as the principal, in the audit trail and in OPA’s input.context.principal. By default it also records every setting the client sends in options, so a caller can stamp a trace id on a connection with nothing but libpq and no Sidecar config:
Every statement record and the session_end record carry the value in metadata, and OPA reads it as input.context["postgres.option.claude.session.id"]:
PostgreSQL accepts a dotted name as a custom setting, so the database runs with the same value and current_setting('claude.session.id', true) returns it.
To record only one or two values, or none, add a postgres block:
config.yaml
startup_metadata: written with no value is refused at startup, because it reads as absent: the opposite of what it looks like. A source the client did not send records no key. A value longer than 256 bytes is cut at a character boundary.
These values are the client’s claim. PostgreSQL authenticates the user, not the options beside it, so use a recorded value to trace a session, never to decide who it is. For that reason startup refuses an as that names a key the Sidecar writes into input.context itself (principal, subject, session_id and the rest), and the default keeps every client-chosen name under postgres.option..
The default records whatever the client puts in options, search_path and application settings included, in the audit trail, in what OPA receives and in the Sidecar’s process log, which your log pipeline ships under its own retention and access rules. audit.redact_statements covers statement text, not metadata. If your clients may carry a secret in options, name the settings you want, or write [].
One limit: PgBouncer in front of the database refuses an unknown options parameter unless ignore_startup_parameters lists it, and listing it drops the value before the database sees it. The Sidecar still records it.

Masking

Every row and column in a pgwire DataRow is length-prefixed, so the codec re-frames: it rebuilds each message around the rewritten values, and a mask that grows or shrinks a value cannot desynchronize the client. Both rule shapes work:
Column rules match the names in the result set’s RowDescription, and wherever the protocol names its values they beat detection.

Denials

A denied statement returns a real pgwire ErrorResponse carrying the rule’s message, so the developer reads it in psql instead of watching the socket drop:
Severity is FATAL rather than ERROR because the connection closes with the denial; ERROR would leave psql waiting for a ReadyForQuery that never arrives.

The Envoy lane

Envoy has no pgwire parser, so the lane is plain tcp_proxy and the Sidecar sees every byte Envoy could not examine:
envoy.yaml
To terminate the client’s TLS in Envoy instead of at the lane, use the contrib postgres_proxy filter with a starttls transport socket. Terminating client TLS has the full shape and its caveats. Try it end to end with the compose stack in deploy/docker-compose/envoy-stack/: its appdb lane runs this protocol with upstream_tls on and masking live.

Next

Running the Sidecar

Builds a Postgres lane from zero, behind Envoy, with the compose stack to prove it.

Guardrail Rules

Every rule type this lane evaluates, including deferring a match to Rego.