Skip to main content
This page traces one connection from the client through Envoy, through the relay, to the upstream and back. Read it to find out where a policy denial happens, why Postgres masking takes a different path from HTTP masking, and which knob in config.yaml controls which behavior.
For the commands, start at Get Started. For every config field, see the Config File reference.

The two tiers

Envoy owns TLS and the network path. OPA answers reachability. hoop-inspect reads the payload. Envoy sees less on each lane going down the table. On HTTP, ext_authz handles request-side authorization well. The gap sits on the response: ext_authz decides before Envoy calls the upstream, so no configuration of it reaches a response status or a response body. On Postgres the gap is wider. Envoy forwards bytes it cannot parse, so OPA never receives a statement to judge.

Where Envoy ends and the relay begins

Envoy is not blind here, and an argument that says otherwise loses a technical review. The relay covers what remains.

One process, one lane per Envoy cluster

hoop-inspect serves one listener per Envoy cluster. Each listener resolves its own rules, its own masking and its own OPA endpoint.
Each lane binds a unix socket or a TCP port, set per listener. Nothing above the transport changes, because the gate reads a net.Conn and never asks what kind it is. Most sidecars run one lane. A per-user pod fronting both a database and an API runs two in one process instead of two containers. The relay resolves the merge between top-level defaults and a lane’s overrides once at startup, and reports every broken lane in one run.

Inside one connection

The relay accepts a socket, builds per-connection state, and starts two goroutines that pump in opposite directions through one Gate.
Two Inspectors, because a codec reassembles messages across reads. Feeding both halves of a duplex stream into one reassembly buffer corrupts both. pump reads 32 KiB at a time and hands every chunk to the Gate before anything reaches the far side:
A re-framing codec holds rows back until their result set ends, so pump defers a flush on the server direction. Skipping that flush drops the tail of a client’s output, and the user reads it as a truncated result rather than as a masking bug. Source: hoopinspect/proxy/proxy.go

Inside the Gate

Four steps run per chunk, and the order carries weight. Auditing before the forward costs a write on the hot path. It buys you the property that a crash between the two cannot lose the record of the statement that caused it. Reverse the order and you lose the one row an incident review needs most. A clean payload aliases the input rather than copying it, so a statement nothing touched allocates nothing. Source: hoopinspect/gate/gate.go

Inside the Inspector

A codec registers a factory, not an instance. Two connections sharing one stateful codec would corrupt each other’s reassembly buffer, and one tenant’s SQL would surface in another tenant’s audit trail.
Two details in that path decide real verdicts. splitSimpleQuery splits on semicolons, because SELECT 1; DROP TABLE users arrives as one Q message. A decoder that classified by leading verb would report a harmless select and wave the DROP through. ClassifySQL strips comments and string literals before it looks for a verb, so SELECT 'DROP TABLE customers' classifies as select. That is why you prefer type: operation over deny_words_list: the word list denies the harmless one. Source: hoopinspect/inspect.go, hoopinspect/codec/postgres/

Protocols

The Postgres codec is stateful because one RowDescription describes every DataRow after it, and those land in different TCP reads.

Policy evaluation

Two evaluators compose, local rules first, so a statement the local set already forbids never costs a network round trip.
Both evaluators fail closed. An unreachable OPA, a 500, or an undefined decision denies. The pii rule type dispatches at the rule-set level rather than per rule, because the detector belongs to the rule set and not to any single rule. Source: hoopinspect/policy/

Masking, and why Postgres needed a second mechanism

Masking runs on responses only. The gate picks a mechanism per protocol by asking the codec, not by consulting a list of protocol names:
HTTP declares its body length in a header, so the gate substitutes bytes and retags Content-Length. Leave that header stale and the client reads the old count, stops mid-document, and you get a bug report about a corrupt upstream. Postgres length-prefixes every row and every column. Substituting ada@example.com (15 bytes) with [REDACTED:EMAIL_ADDRESS] (24 bytes) desynchronizes psql on the next message, and the user sees “lost synchronization with server”. The Postgres codec rebuilds the DataRow frames around the masked values instead. A codec offering neither mechanism gets its mask section refused at startup. Accepting a mask config that can never fire is the failure that ends with an unmasked SSN in a screenshot. Source: hoopinspect/codec/postgres/rewrite.go, hoopinspect/gate/contentlength.go

Where PII detection plugs in

The core library ships zero dependencies. A detection engine worth having carries recognizers for dozens of national identifier formats, so it lives behind two interfaces the core already declares.
One detector drives both paths: masking rewrites what comes back, and the pii policy rule denies what goes in. alcatraz supplies 45 entity types across 12 countries, 25 of them checksum-verified. It also carries three secret recognizers, which you name in config like any other entity: AWS_ACCESS_KEY, JWT (decodes the header rather than matching its shape) and PRIVATE_KEY. There are no build tags. The config file decides every capability, so an operator turning on PII detection does not also have to swap the binary. Source: hoopinspect/pii/alcatraz/

Audit: six kinds, one write path, three sinks

session_start fires even for a connection that issues nothing, so an abandoned session leaves a trace. Without it, a client that connects and disappears is invisible.

Where the events go

The JSONL sink goes first, and the multi-sink attempts every sink regardless of what an earlier one returned, so the durable record survives a failure in the in-memory ring or the query store. Both in-memory sinks drop their oldest entry when full and report the drop count, so a reader can tell a partial window from a complete one. The JSONL stream stays the record of truth. Source: hoopinspect/audit/, hoopinspect/store/

Reading it back

The query endpoints accept filters: principal, connection, protocol, since, until, denied, open, q for substring search, plus limit and cursor for paging. /api/events also takes session_id and a repeatable kind.

Deploying it

Envoy over a unix socket

A TCP listener on 15432 is reachable by anything that can route to the pod. A NetworkPolicy narrows that; it does not remove it. With a socket there is no port, so reachability stops being a network question and becomes a filesystem one, which is the argument for a sidecar sharing a namespace with one workload. The cost is the setup below. Where it does not fit, a TCP port carries the same traffic. Three pieces have to agree: the lane, the Envoy cluster, and the directory both mount.
config.yaml
The matching cluster uses pipe: and must be STATIC, since a filesystem path resolves to nothing:
envoy.yaml
The directory needs an owner both sides can work with. In compose that is a one-shot init container:
docker-compose.yml
The setgid bit on the directory and umask 0002 on the relay are what make the sockets group-writable, which is the permission Envoy needs to connect.
Two permission traps cost real time to diagnose.Creating the socket. A shared volume arrives root-owned, and a relay running as a non-root uid cannot bind: listen unix /run/hoop-inspect/pg.sock: bind: permission denied. Chown the directory before either container starts.Connecting to the socket. connect() on a unix socket needs write permission, not read. Envoy runs as uid 101, and docker exec hands you a root shell that hides this. A socket left at the default 0755 is unreachable, and the only symptom is a 503 with flags=UF and upstream_cx_connect_fail while the cluster still reports healthy, because the endpoint resolved.
Keep the admin listener on TCP. It serves /healthz to a healthcheck and /stats to a scraper, and moving it to a socket means exec-ing into the container to read either.

Proving the port is gone

Ask the relay’s own namespace what it bound:
That is the whole list. On a TCP deployment the same command also shows :::15432 and :::18080. From a peer, nc -z -w2 hoop-inspect 15432 reports closed.

Restarting after an unclean exit

Go unlinks the socket when the listener closes, so SIGTERM leaves nothing behind. A SIGKILL, an OOM kill or docker kill skips that and the file outlives the process. The relay reclaims it at startup by dialing the path: a socket nothing answers on gets unlinked with a warning, and one that answers is left alone while the bind fails, naming the conflict. Two relays sharing a socket would split a client’s connections between them at random.

Envoy over a TCP port

Sockets need both processes to mount one directory and agree on uids. That is cheap in a pod spec and awkward where the relay and its peer sit on different hosts. Drop network from the lane, give listen a host:port, and point a STRICT_DNS cluster at it:
config.yaml
envoy.yaml
Bind loopback where the two share a network namespace. A 0.0.0.0 bind is reachable by anything that can route to the host, and the relay authenticates nobody: it assumes whatever reaches it already passed identity.

As a sidecar container

The relay ships as a small static binary that reads one file. A Dockerfile is in the repository; the process needs no privileges, so run it as a non-root user.
docker-compose.yml
Expose the admin port to your scraper, never the data lanes.

On Kubernetes

The same shape, with an emptyDir in place of the named volume:
fsGroup on the pod securityContext replaces the chown step: the kubelet applies it to the emptyDir before any container starts. Set it to Envoy’s gid and both sides can use the directory. Mount the config as a ConfigMap and set HOOP_INSPECT_CONFIG instead of passing a flag.

Identity

Every session records principal: anonymous unless a deployment fills it. The plumbing runs end to end: the session carries a subject, and the proxy exposes a seam a deployment fills from a verified JWT, an mTLS peer cert or a credential token. A listener names its identity_header, and the current implementation contributes only the peer address. Until that function is written, a Rego policy keyed on input.context.user reads anonymous from every lane.

Known limits

Read these before writing a policy against the relay.
  • Two codecs ship: postgres and http. Adding one means a new codec/<name> package and nothing else.
  • Tables is best effort. A lexer, not a SQL grammar, which is the same ceiling Envoy’s own docs acknowledge for postgres_proxy. Empty means “could not determine” and never “touches nothing”. Set require_table_match: true on rules protecting something critical and accept the false positives.
  • A response batch can be truncated. The Postgres codec stops decoding columns past 1000 rows in one result set, to keep the relay’s memory out of a query’s hands. It keeps counting and marks the batch truncated. A policy must read that as inconclusive, never as proof a value is absent.
  • A response statement carries no verb. For database codecs a server-direction statement reports unknown, because the operation belongs to the request the audit trail already recorded. Key a response-side SQL rule on the result, not on the operation.
  • PII detection is neither sound nor complete. A checksum-verified identifier holds up. Everything else is a pattern. Detecting a name column needs NER, which this does not wire, and a caller can split a value across two responses. Masking raises the cost of accidental exposure without replacing “do not grant access to that table”.
  • HTTP/1.x only for stream decoding. HTTP/2 and HTTP/3 framing belongs to whatever terminated the connection.
  • Plaintext at the relay. A client negotiating TLS end to end past the relay leaves nothing to parse. Something in front terminates it: an HTTPS listener covers HTTP, and postgres_proxy with a starttls socket covers Postgres. The upstream leg may be TLS, which the relay originates itself.
  • Statements are not transactions. Each one gets its own verdict, and no cross-statement session state exists.
  • No SSH lane. No SSH codec ships. Envoy ships no SSH filter at any fidelity either, so every service reached over SSH sits unpoliced by the Envoy and OPA layer.

Reference stack

The repository ships a compose stack that runs every component on this page end to end.

Next

Get Started

Configure Envoy, run the relay, and watch a denial land in psql.

Config File

Every section, every rule type, and what startup refuses.