diff --git a/doc/developer/design/20260821_mutual_tls_client_authentication.md b/doc/developer/design/20260821_mutual_tls_client_authentication.md new file mode 100644 index 0000000000000..dd5b454fcbdf7 --- /dev/null +++ b/doc/developer/design/20260821_mutual_tls_client_authentication.md @@ -0,0 +1,1018 @@ +# Mutual TLS client authentication + +- Associated: MaterializeInc/materialize#38419 (this design), + MaterializeInc/materialize#38420 (prototype) + +## The Problem + +Today the only mechanisms to restrict user access are user authentication, and +network policies. The latter uses IP allow lists to provide a deeper level +of access restrictions that actually prevents auth challenges to materialize +wholesale putting a harder boundary on access. IP allowlists are not the best +fit for all customers: + +* Egress IPs are not stable. Customers behind NAT gateways, corporate VPNs, + cloud NAT pools, or serverless platforms cannot enumerate their source + addresses, so they either give up on network policies or widen them until + they mean nothing. +* An IP is not an identity. Anyone sharing the allowlisted egress path (a + compromised pod in the same VPC, another tenant of the same NAT gateway) + inherits the admission decision. +* IP squatting presents itself as a new concern, particularly for those using + cloud services. +* Egress IPs are not always subject to fine grain control such as when a shared + corporate network is used. +* Maintaining the allowlist is a human process with no expiry. There is no + rotation, no revocation, and no cryptographic proof behind an entry. + +Customers with a strict posture would be better served by requiring possession +of a private key whose certificate was issued by an authority the customer +controls, via mTLS. + +The obstacle specific to Materialize is `balancerd`. Every connection to a +cloud environment lands on `balancerd`, which terminates TLS and opens a +*separate* pgwire connection to `environmentd`. The client certificate is +consumed by a process that has no catalog, no tenant configuration, and is +deliberately stateless and shared across all tenants. `environmentd`, which +does have the tenant's configuration, never sees the certificate at all. + +## Success Criteria + +1. An operator can require that every external SQL connection present an X.509 + certificate that chains to an authority the tenant configured, and can do so + without an `environmentd` restart. +2. The requirement holds for connections that arrive through `balancerd`. +3. The trust anchors are tenant configuration, expressed in SQL, durable in the + catalog, and changeable at runtime. +4. A connection presenting no certificate, an expired certificate, or a + certificate from an untrusted issuer is rejected before any password, + token, or SASL exchange occurs. +5. Failing closed is the default: a misconfiguration denies connections rather + than admitting them. In particular, `environmentd` must not accept a + forwarded identity from an unauthenticated peer. +6. No behaviour change, and no measurable connection-setup cost, for + deployments that have not opted in. + +## Out of Scope + +* Replacing network policies. mTLS is an additional, independent admission + gate. An operator who wants mTLS to be the only gate sets their network + policy to allow everything. Fusing the two (a network policy rule keyed on + certificate issuer rather than CIDR) is a plausible follow-up and is + discussed under Alternatives. +* Using the certificate as the *authenticator*, i.e. logging in with a + certificate and no password. This design treats the certificate as an + admission gate and, optionally, as an assertion that must agree with the + username. Certificate-only login is a natural extension once the identity + plumbing exists. +* Certificate revocation lists and OCSP. Discussed under Open questions. +* Choosing mTLS *per surface*. An authority applies to the whole environment, + so requiring mTLS requires it of pgwire, the SQL HTTP API, and webhook + senders alike. Since third-party webhook senders cannot present a client + certificate, `require` costs an operator their webhook sources until the + surfaces can be separated by port; see "One policy for every surface, and + what that costs". +* mTLS for internal listeners, cluster-to-cluster traffic, or `clusterd`. +* Verification at `balancerd` (phase 2), separating the surfaces (phase 3), and + `CREATE CERTIFICATE AUTHORITY` (phase 4); see Roadmap. `environmentd` is the + sole enforcement point in phase 1, so untrusted connections do reach the + dataplane before being rejected there. + +## Five Core Decisions + +1. **Where is the request terminated?** `balancerd`, `environmentd`, or both. +2. **Which identities are impacted?** Everyone, or particular roles. +3. **Which endpoints are impacted?** Every external surface, or particular ones. +4. **How does an operator define it?** System parameters, a catalog object, or + something outside SQL. +5. **Is the certificate enforcing or identifying?** A door key that admits + anyone holding it, or an assertion about who the holder is. + +**Moving question 1 toward the edge constrains questions 2 and 5.** A filter in +front of the dataplane may only reject on what it can determine with certainty, +or it breaks clients the authoritative check would have admitted. A proxy can +evaluate a certificate chain against a set of anchors, and can tell surfaces +apart if they are separated by port. It has no role graph, and in most resolver +modes it does not learn the username the adapter will settle on. So per-identity +scope and identity binding stay authoritative-side however far the filter moves. + +## Solution Proposal + +The design rests on one observation: **proof of possession and trust +evaluation are separable.** + +When a client completes a TLS handshake that includes a client certificate, the +`CertificateVerify` message proves the client holds the corresponding private +key. That proof is inherently local to whoever terminated the handshake, and +`balancerd` is the only process positioned to obtain it. But deciding whether +the certificate's *issuer* is acceptable is a pure function of the certificate +chain and a set of trust anchors. It needs no live handshake, and it can happen +anywhere the chain and the anchors are both available. + +So: `balancerd` obtains the proof and forwards the chain. `environmentd`, which +holds the tenant's trust anchors, evaluates it. `balancerd` never needs to know +any tenant's certificate authority, and stays stateless. + +```mermaid +flowchart LR + client["client
holds leaf + private key"] + balancer["balancerd
stateless, multi-tenant"] + envd["environmentd
holds tenant catalog"] + anchors[("mtls_client_ca
trust anchors")] + proof["proves key possession
no anchors to judge with"] + judge["evaluates issuer
against trust anchors"] + + client -- "TLS handshake,
presents chain" --> balancer + balancer -- "TLS + chain in
mz_client_cert" --> envd + anchors -. "ALTER SYSTEM SET" .-> envd + balancer -.- proof + envd -.- judge + + classDef role fill:#fff,stroke:#999,stroke-dasharray:3 3,color:#555 + class proof,judge role +``` + +The split is the whole design. Possession can only be proven where TLS +terminates, and trust can only be evaluated where the tenant's configuration +lives. Those are two different processes, so the chain travels between them. + +A connection through the balancer therefore involves two independent trust +decisions over two different handshakes: + +```mermaid +sequenceDiagram + autonumber + participant C as client + participant B as balancerd + participant E as environmentd + + C->>B: SSLRequest + B-->>C: accept + C->>B: TLS handshake + client certificate + Note over B: possession proven.
verify callback accepts every
chain: no anchors here to judge with + C->>B: Startup { user, ... } + Note over B: reject if the client supplied
mz_client_cert itself + B->>B: read peer chain, encode leaf-first PEM as base64 + B->>E: TLS handshake, presenting proxy identity + B->>E: Startup { user, mz_client_cert, ... } + Note over E: decision 1: does the peer chain
to --tls-proxy-ca? if not, the
forwarded chain is ignored + Note over E: decision 2: does the client's chain
reach an mtls_client_ca anchor? + alt trusted (and identity binding satisfied) + E->>E: proceed to authenticator + E-->>C: AuthenticationOk + else absent / untrusted / identity mismatch + E-->>C: FATAL 28000, before any credential exchange + end +``` + +Connecting straight to `environmentd` collapses this: the peer certificate *is* +the client certificate, decision 1 finds no proxy, and decision 2 runs on the +handshake chain. Self-managed deployments get mTLS with no proxy involved. + +### 1. `balancerd` requests and forwards, but does not judge + +`balancerd`'s pgwire acceptor is configured to request a client certificate +(`SSL_VERIFY_PEER` without `FAIL_IF_NO_PEER_CERT`) with a verification callback +that accepts every chain. OpenSSL still validates the `CertificateVerify` +signature, so possession is proven. Only the *trust* decision is suppressed, +because `balancerd` has no basis on which to make it. + +Suppressing the trust decision is what makes this work. A `balancerd` that +validated chains would need every tenant's trust anchors pushed to it by the +control plane, turning a stateless shared proxy into a stateful one with a +fan-out cache invalidation problem, and coupling tenant-visible SQL (`ALTER +SYSTEM SET ...`) to a control-plane propagation delay. + +After the handshake, `balancerd` serialises the leaf certificate and any +intermediates the client sent into the `mz_client_cert` startup parameter, +alongside the `mz_forwarded_for` and `mz_connection_uuid` parameters it already +injects. As with those, a client that supplies `mz_client_cert` itself is +rejected outright. + +Encoding: PEM blocks, leaf first, concatenated, then base64 (standard alphabet, +no line breaks) so the value survives a protocol that carries NUL-terminated +strings. A 2048-bit RSA leaf plus one intermediate is roughly 2.5 KiB encoded, +which is immaterial next to the startup message's `i32` length field. A cap +(64 KiB) is enforced on both ends so a hostile client cannot make `balancerd` +buffer an unbounded chain. + +### 2. `environmentd` authenticates `balancerd` before believing it + +A forwarded identity is only as trustworthy as the peer asserting it. Today +`environmentd`'s external pgwire port already accepts `mz_forwarded_for` from +anyone who can reach it. That is tolerable for a hint used in logging and +network-policy evaluation, but not for the mechanism we are asking customers to +use *instead of* network policies. An attacker who can +reach `environmentd` directly would otherwise forge `mz_client_cert` and walk +straight through the new gate. + +So the `balancerd` → `environmentd` leg becomes mutually authenticated: + +* `balancerd` gains `--internal-tls-cert` / `--internal-tls-key` and presents + that identity when `--internal-tls` is set. (Today this connector runs with + `SslVerifyMode::NONE` and no client certificate.) +* `environmentd` gains `--tls-proxy-ca=`: the authority that issues proxy + identities. It is a file, not SQL, because it is infrastructure identity that + must be trustworthy before any SQL is evaluated, and because it is the + deployer's concern rather than the tenant's. +* `environmentd` honours `mz_client_cert` only from a peer whose own + certificate chains to `--tls-proxy-ca`. If no proxy CA is configured, the + parameter is ignored entirely and a connection that depended on it is + rejected. Fail closed. + +`environmentd` therefore ends up with two independent trust decisions over the +same handshake, which is why it also requests client certificates with a +permissive callback and defers both: + +| peer certificate chains to | meaning | +| --- | --- | +| `--tls-proxy-ca` (file) | a trusted proxy, so it may assert `mz_client_cert` | +| a SQL-configured trust anchor | an end client connecting directly | +| neither | no certificate identity | + +### 3. Trust anchors and policy come from SQL + +Three system parameters, following the pattern established by the OIDC +authentication work (`oidc_issuer`, `oidc_audience`, `oidc_authentication_claim`): + +```sql +ALTER SYSTEM SET mtls_client_ca = '-----BEGIN CERTIFICATE-----...'; +ALTER SYSTEM SET mtls_mode = 'require'; +ALTER SYSTEM SET mtls_identity_binding = 'common-name'; +``` + +* **`mtls_client_ca`**. A PEM bundle of trust anchors. A bundle rather than a + single certificate, so an operator can stage a CA rotation by trusting the + old and new authorities simultaneously, and can trust several authorities at + once. This is the same shape as every `--client-ca-file` in the ecosystem. + Every certificate in the bundle is a valid chain terminus, so pinning an + intermediate works (see the prototype findings below). +* **`mtls_mode`**. + * `disable` (default): certificates are ignored. + * `allow`: a certificate that chains to an anchor is recorded, and a + connection without one is still admitted. This is the rollout mode. It lets an + operator watch the metric climb and confirm every client is issuing + certificates before flipping to `require`. + * `require`: external logins must present a certificate that chains to an + anchor. Internal users (`mz_system`, `mz_support`) are exempt, matching the + existing network-policy carve-out. Internal listeners are secured by the + deployment, not by tenant SQL. +* **`mtls_identity_binding`**. + * `none` (default): any trusted certificate admits any username. The + certificate is a gate, orthogonal to authentication. + * `common-name`: the leaf's Subject Common Name must equal the connecting + username. This is PostgreSQL's `clientcert=verify-full`, and it turns the + certificate into a second factor bound to the identity rather than a shared + door key. + +Enforcement sits in `mz_pgwire::protocol::run`, immediately after the +`allowed_roles` check and *before* the authenticator is dispatched. An +unauthorised client is turned away without being offered a password prompt, a +SASL challenge, or a token exchange, which is the property that makes this a +real replacement for a network-level gate. Rejections use +`SqlState::INVALID_AUTHORIZATION_SPECIFICATION`. + +The trust store is parsed from the PEM bundle and cached, keyed on the bundle +contents, so the OpenSSL parse happens once per configuration change rather +than once per connection. + +### 4. Why system parameters in phase 1 + +**Decision: system parameters for phase 1.** A `CREATE CERTIFICATE AUTHORITY` +catalog object is the better end state and is specified in full under +Alternatives, but it belongs to phase 4 (see Roadmap), not to the first +iteration. + +Three reasons. First, it is the pattern the most recent authentication feature +in the codebase already uses. OIDC is configured entirely through dyncfgs and +nobody has found that limiting. Second, a PEM bundle in one parameter is not a +capability reduction for the *trust* decision itself, which is a set membership +test over anchors. What the object model adds is naming and per-anchor policy. +Third, and most importantly, the interesting risk in this project is the +`balancerd` path, not the storage of a PEM blob. Spending the first iteration +on catalog boilerplate would validate nothing. + +The migration is mechanical and forward-compatible. Enforcement takes a trust +store and a policy, so `CREATE CERTIFICATE AUTHORITY` rows would build the same +`MtlsPolicy` the bundle builds today and `MtlsPolicy::check` would not change. +Nothing built here becomes throwaway. + +The triggers for moving to phase 4 are specific. First, **a customer who needs one +authority scoped to a subset of roles**: that is the one capability a PEM bundle +cannot express, since every anchor in a bundle is equally powerful. +Per-authority identity binding is the second. Edge filtering is *not* a +trigger: phase 2 moves filtering to `balancerd` while system parameters are +still the configuration surface (see Roadmap). + +### Carrying the chain over HTTP + +`balancerd`'s HTTPS listener splices raw TCP after terminating TLS and never +parses HTTP, so it cannot inject a header the way Envoy injects +`x-forwarded-client-cert`. Teaching it to parse HTTP for this would be a +significant regression in what that path is. + +The mechanism that fits is already half-built: `balancerd` can prepend a PROXY +protocol v2 header to the upstream connection (`INJECT_PROXY_PROTOCOL_HEADER_HTTP`), +and `environmentd`'s HTTP server already parses one +(`take_proxy_header_address`). PROXY v2 carries typed TLVs, including a +registered `PP2_TYPE_SSL` block, and a TLV's `u16` length comfortably holds a +certificate chain. Carrying the chain as a TLV also unifies the two paths: the +same header could replace the pgwire startup parameter, at the cost of a +`balancerd` change on a path that currently needs none. + +So the transport is solvable, and HTTP is in scope. The prototype implements +pgwire first because that is where the customer requirement lives; wiring the +HTTP path is remaining implementation, not an unanswered design question. + +#### One policy for every surface, and what that costs + +An authority applies to every external path: pgwire, the SQL HTTP API, +WebSocket, and webhook sources alike. Internal listeners are excluded, as they +are from network policies, because they are secured by the deployment rather +than by tenant configuration. Choosing *per surface* is the non-goal; covering +the external surfaces is the intent. + +That is the simplifying choice, and it is worth being explicit about what it +buys and what it costs. + +What it buys is that no component has to tell the surfaces apart. In particular +the objection that would otherwise block edge filtering on HTTPS disappears: +`balancerd` splices raw TCP and cannot distinguish `/api/webhook/...` from +`/api/sql` without parsing HTTP, but under a single policy it does not need to. +Filtering is all-or-nothing per connection, which is exactly the desired +semantics rather than a limitation to work around. + +What it costs is that **`mtls_mode = 'require'` breaks webhook sources.** +`/api/webhook/**` is posted to by third parties, and a payment processor or a +git host offers nowhere to configure a client certificate. An operator who +requires mTLS is choosing to give up webhook ingestion until the surfaces can be +separated. The same applies to the console, where browsers can present client +certificates but the prompt is poor UX and enterprise policy often blocks it. +This is a real footgun and the `require` documentation has to say so plainly. + +There is an implementation consequence worth recording, because the obvious +placement is wrong under this model. `auth_middleware` is attached to +`base_router`, and the webhook router is built standalone and merged into the +outer router, so it never passes through authentication; that is why +`authenticator_kind: "Frontegg"` does not break webhooks today. Putting the mTLS +check there would therefore exempt webhooks by construction, which is precisely +what we do *not* want. It belongs on the merged router, or better, evaluated +once per connection when the TLS stream is accepted, since the certificate is +fixed for the life of the connection while keepalive and HTTP/2 multiplexing put +many requests on it. + +The future direction is phase 3 under Roadmap: give webhooks their own +`environmentd` port so they are a distinct surface rather than a path prefix, and let an operator choose independently whether pgwire, the SQL +HTTP API, and webhooks each require a certificate. Separating by port means the +edge learns the surface from the listener a connection arrived on, with neither +an HTTP parser nor SNI. + +Separately, and independently of mTLS: network policies do not cover webhook +requests today. The webhook path never creates a session, so +`handle_startup_inner` never runs and `client_ip` is not plumbed in. That is a +coverage gap rather than an impossibility, since vendors commonly publish the +CIDR ranges their webhooks originate from and an operator could allow them. It +is worth confirming whether the current behaviour was chosen or merely happened. + +### Observability + +* `balancerd`: `mz_balancer_client_certs_forwarded_total{source, presented}`, + counting how many connections presented a certificate. This is what an + operator watches during a rollout in `allow` mode. +* `environmentd`: `mz_pgwire_client_cert_validations_total{source, result}` + with `result` in `trusted`, `untrusted_issuer`, `absent`, + `identity_mismatch`, `no_trust_anchors`, `forwarded_by_untrusted_peer`. + An expired certificate counts as `untrusted_issuer`, because OpenSSL reports + expiry as a chain-validation failure and the two are not worth separating for + an operator. `forwarded_by_untrusted_peer` is a security signal rather than an + error: someone is talking to `environmentd` directly and claiming to be a + proxy. + +Rejections log at `warn!` with the leaf's subject and issuer, never the +certificate itself. + +## Roadmap + +Four phases. Each is independently shippable and each delivers something the +previous one cannot, so none of them is a prerequisite kept around for its own +sake. In terms of the five questions: phase 1 answers all five, and each later +phase moves one, taking question 4 along when the new scope has nowhere else to +live. + +| | 1. terminated at | 2. identities | 3. endpoints | 4. defined by | 5. certificate is | +| --- | --- | --- | --- | --- | --- | +| **Phase 1** | `environmentd` | all | all external | system parameters | gate, optionally a CN assertion | +| **Phase 2** | `balancerd` filters, `environmentd` decides | all | all external | system parameters | unchanged | +| **Phase 3** | unchanged | all | per surface | per-surface parameters | unchanged | +| **Phase 4** | unchanged | per authority | per surface | catalog object | gate, assertion, and authorization scope | + +**Each phase moves one question, or two that are genuinely coupled.** Phase 1 to +phase 2 changes only where termination happens. Phase 3 changes which endpoints +are covered, and with it how policy is spelled, because "require on pgwire, not +on webhooks" has nowhere to live in a single global mode. Phase 4 changes which +identities are covered, and with it how policy is spelled again, because a PEM +bundle has nowhere to put a role restriction. No phase moves a column that a +later phase has to move back. + +**Endpoints before identities.** Question 3 is answered first because it removes +a blocker rather than adding expressiveness: until the surfaces are separable, +turning mTLS on costs an operator their webhook sources, since third-party +senders cannot present a certificate. Question 2 is a refinement a customer can +live without. + +**Phase 1, specified above. System parameters, `environmentd` validates.** +`ALTER SYSTEM SET mtls_client_ca`, and `environmentd` is the sole enforcement +point. Untrusted connections do reach the dataplane, and are rejected there +before any credential exchange. `balancerd` captures and forwards the chain but +does not judge it. This closes the customer requirement. + +**Phase 2. System parameters, `balancerd` filters.** *Moves question 1 only.* +`balancerd` fetches the tenant's mTLS configuration from `environmentd` over a +cacheable API, verifies locally, and refuses to open the upstream connection +when the chain is untrusted. `environmentd` keeps checking and stays authoritative, so the edge is +a filter and a stale or unavailable cache degrades filtering rather than +correctness. This delivers the security property: unauthorized traffic stops +before the dataplane. + +**Phase 3. Separate the surfaces.** *Moves question 3, and question 4 with it.* +Give webhooks their own `environmentd` port so they are a distinct surface +rather than a path prefix, and let an operator choose independently whether +pgwire, the SQL HTTP API, and webhooks each require a certificate. This unblocks +adoption for anyone using webhook sources, who until now has had to choose +between mTLS and webhook ingestion. + +A port is enough, and a separate hostname is not required. The two facts the +edge needs are independent and come from different places: the *surface* comes +from the listener a connection arrived on, and the *tenant* comes from the SNI +servername. A webhook listener on its own port serving the same hostname gives +both, with no HTTP parsing, no new DNS, and no certificate changes. The client +URL simply gains a port. + +Whether to add a hostname anyway is a reachability question rather than a +routing one. Webhook senders are third parties, and some will not post to a +non-standard port, whether because their configuration rejects it or because +their egress policy allows only 443. A `webhooks.` hostname on 443 avoids +that at the cost of DNS, a certificate SAN, and SNI-pattern routing in +`balancerd`. Port first, hostname if the senders demand it. + +#### A note on SNI + +Separating webhooks by hostname relies on SNI, which is a non-issue for the +surface that needs it. `HttpsBalancer` already routes purely on the SNI +servername, its no-SNI path is commented as "not expected for HTTPS in +practice", and HTTPS clients send SNI as a matter of course. So a +webhook-specific hostname resolves the same way every other HTTPS hostname +already does. + +SNI is only optional on pgwire, and pgwire does not need it here: it is a single +surface, so there is nothing to discriminate. Using SNI to learn the *tenant* +early, which would let phase 2's filter reject before the Frontegg credential +exchange, is a separate and optional matter, discussed under "The question phase +2 has to answer". It is an optimization rather than a requirement, because +`environmentd` is authoritative either way and only the earliness of the +rejection differs. + +**Phase 4. `CREATE CERTIFICATE AUTHORITY`.** *Moves question 2, and question 4 +with it: a PEM bundle has nowhere to put a role restriction.* The +catalog object replaces the PEM bundle, buying `FOR ROLES` scoping, +per-authority identity binding, named rotation, and audit. This delivers +expressiveness, and it is the last of the four because a customer can run +without it. The new expressiveness is enforced at `environmentd`, not at the +edge, for the reason below. + +The order is blockers before refinements. Phase 2 keeps unauthenticated traffic +off the dataplane, which is the security property customers are asking for and +much the cheapest build. Phase 3 removes the reason an operator with webhook +sources cannot turn mTLS on at all. Phase 4 is the only one a customer can +reasonably run without, so it goes last. + +### Why phase 2 does not wait for the catalog object + +An earlier draft of this document argued that the catalog object and edge +filtering had to ship together, on the grounds that a set of named objects is a +better thing to distribute than the current value of a string. That is true +about the *payload* and wrong about the *sequencing*. The reusable asset in +phase 2 is the transport: the endpoint, ETag revalidation, the cache, the +fail-open behaviour, and `balancerd`'s verify path. None of that cares whether +the payload describes one anonymous bundle or twelve named authorities. + +To keep phase 4 from disturbing the wire at all, **give the phase 2 payload its +phase 4 shape from the start**: a list of authorities with a generation, where +phase 2 projects the PEM bundle into entries with no name and no role scope. + +```json +{ + "generation": 41, + "mode": "require", + "identity_binding": "none", + "authorities": [ + { "name": null, "for_roles": null, "certificate": "-----BEGIN..." } + ] +} +``` + +Phase 4 then starts populating `name` and `for_roles` and changes nothing about +how the data moves or is cached. + +### What each phase preserves + +* The wire format and the proxy-authentication model never change. + `environmentd` still needs the chain to make the authoritative decision, so + `mz_client_cert` and `--tls-proxy-ca` stay exactly as built in phase 1. +* `MtlsPolicy::check` is the same function throughout. It takes a chain, a trust + store, and a username, and has no catalog or adapter dependency, so phase 2 + links it into `balancerd` unchanged. +* Trust anchors from `CREATE CERTIFICATE AUTHORITY` build the same in-memory + trust store the PEM bundle builds today, so phase 4 does not touch enforcement. + +### What the edge can soundly enforce + +`balancerd` knows nothing about roles, and it should not learn. Role membership +lives in the catalog, and a proxy that had to resolve it would need the role +graph, its inheritance, and its invalidation. So the *membership* reading of +`FOR ROLES`, "the connecting user is a member of one of these roles", cannot be +enforced at the edge. + +That is correct layering rather than a shortfall. The edge filter answers "is +this connection from an authority we recognise", which is admission. `FOR ROLES` +answers "may this identity log in as this role", which is authorization, and +authorization has always belonged to `environmentd` because RBAC lives in the +catalog. + +There is a structural reason too, independent of where the role graph lives. +Materialize does not separate read from write at the network layer: there is one +pgwire endpoint and one SQL HTTP endpoint, and every role reaches the same one. +So knowing the role earlier would not let the edge route, rate-limit, or reject +differently in any way that maps onto blast radius. Role judgement at the edge +would buy nothing structural even if the data were free, which is a better +reason not to do it than the cost of getting the data there. + +A certificate from a trusted but role-scoped authority *is* admissible. It is +simply not authorized to be `admin`, and `environmentd` is the thing that knows +that. The edge filters the unknown, `environmentd` filters the +known-but-unauthorized, and the residual exposure is a connection slot consumed +by a client holding a genuinely trusted certificate, which is a much smaller +surface than one holding a certificate from an authority nobody configured. + +The general rule this implies is worth stating, because it constrains every +future addition to the edge filter: + +> A filter that can produce false rejections is worse than no filter, because it +> breaks clients `environmentd` would have admitted. The edge may only reject on +> conditions it can evaluate with certainty from data it holds. + +Applied to the policy: + +| check | edge | `environmentd` | +| --- | --- | --- | +| chain reaches a trusted anchor | yes | yes | +| `mtls_mode` | yes | yes | +| authority is enabled | yes, by omitting disabled ones from the payload | yes | +| CN equals the login user | sound only where `balancerd` authenticates; see below | yes | +| `FOR ROLES` as a set of login names | same condition as above | yes | +| `FOR ROLES` as role membership | no | yes | + +### Identity checks at the edge: available in one mode, valuable in the other + +A check on the *login name* is within reach in a way role membership is not, but +it is only sound when `balancerd` knows the username `environmentd` will settle +on, and that depends on the resolver: + +* **Frontegg resolution.** `balancerd` performs the same Frontegg + authentication `environmentd` will, and already holds the resulting + `auth_session`; it just reads `tenant_id()` and discards the rest. Taking + `user()` from it gives exactly the canonical username `environmentd` uses, so + the comparison is sound. +* **SNI routing, and every pass-through mode.** `balancerd` does not + authenticate, so it has only the username the client claimed in its startup + message. `environmentd` may settle on something else. Frontegg canonicalises + casing, and OIDC is sharper still: `environmentd` takes the session username + from the JWT's authentication claim, so the startup parameter may be unrelated + rather than merely cased differently. + +These line up awkwardly. The mode where the check is *sound* is Frontegg +resolution, which is also the mode where the credential has already been +collected before the check can run. The mode where an early rejection is most +*valuable* is SNI routing, where `balancerd` can refuse before any credential is +exchanged, and that is precisely where it does not know the username. + +Awkward is not worthless, though. Rejecting at the edge in Frontegg mode still +keeps the connection off `environmentd` entirely, and that is the point of the +filter rather than a consolation: a client holding a **valid password but a +certificate issued to someone else** never consumes a connection slot, a TLS +handshake, or a session on the dataplane. Someone who has obtained a working +credential is exactly the adversary the certificate is meant to stop, and +denying them a cheap way to generate load on a tenant's single `environmentd` is +worth something on its own. + +The marginal value is narrower than it first appears, because the anchor check +already covers most of that scenario. A valid password with no certificate, or +with one from an untrusted authority, is rejected at the edge by the anchor +check alone, with no notion of identity. What a CN comparison adds is only the +case of a valid password plus a *trusted* certificate issued to a different +user, which is lateral movement rather than an outsider. + +So: worth doing eventually in Frontegg mode, not worth blocking phase 2 on, and +never sound in SNI mode. `environmentd` remains authoritative for it regardless. + +### The question phase 2 has to answer + +Tenant resolution, not the certificate, determines how early `balancerd` can +reject, and the two resolver modes differ: + +| resolver | tenant known at | edge filter can reject before | +| --- | --- | --- | +| SNI routing | `ClientHello` | any credential, even before `Startup` | +| Frontegg | after the password is authenticated | contacting `environmentd` | + +In Frontegg mode `balancerd` cannot know which anchors apply until it has +collected the password and authenticated it, so the "reject before any +credential exchange" property that phase 1 gives at `environmentd` does not hold +at the edge filter. The credential goes to the identity provider rather than to +Materialize, and the dataplane is still untouched, so this is probably +acceptable. But it is a real asymmetry, and phase 2 has to decide whether edge +filtering is SNI-mode only, where it is strongest, or whether post-Frontegg +filtering is good enough. + +Phase 3 answers this by picking the first option, though only as a side effect: +requiring SNI so the edge can tell surfaces apart also means the Frontegg branch +is never taken, so the asymmetry disappears for environments that have reached +phase 3. It does not disappear for phase 2 on its own, which is why phase 2 has +to answer the question rather than wait. + +Distribution mechanisms are compared under Alternatives. The cacheable +`environmentd` API is the assumed choice for phase 2; a Kubernetes +`ClientAuthority` CRD watch is the alternative, better on freshness and worse on +coupling. + +## Minimal Viable Prototype + +Working end to end, both directly against `environmentd` and through +`balancerd`, in MaterializeInc/materialize#38420. Roughly 950 lines across the +crates below. + +It covers pgwire. The HTTP, WebSocket, and webhook paths are in scope for the +feature and are not yet wired; see "Carrying the chain over HTTP". + +| Where | What | +| --- | --- | +| `mz-pgwire-common::client_cert` | The `mz_client_cert` wire format: chain to base64 PEM and back, with a size cap | +| `mz-pgwire-common::Conn::peer_cert_chain` | Pulls the chain out of a finished handshake (the leaf sits outside the peer chain on the server side, so it is prepended) | +| `mz-server-core::ClientCertMode` | `--tls-request-client-certs`: requests certificates with a permissive verify callback | +| `mz-authenticator::client_cert` | The trust decision: `MtlsPolicy`, `ProxyCa`, `TrustStoreCache` | +| `mz-pgwire::protocol` | The enforcement point, before the authenticator dispatches | +| `mz-pgwire::server` | Resolves which certificate speaks for the client, direct or forwarded | +| `mz-balancerd` | Captures and forwards the chain, and presents its own identity upstream | + +### Try it + +```console +# One CA for clients, one for the proxy identity, one for serving certs. +$ environmentd --tls-mode=require --tls-cert=server.crt --tls-key=server.key \ + --tls-request-client-certs --tls-proxy-ca=proxy-ca.crt +$ psql -h localhost -p 6877 -U mz_system -c \ + "ALTER SYSTEM SET mtls_client_ca = '$(cat client-ca.crt)'; \ + ALTER SYSTEM SET mtls_mode = 'require'" + +$ psql "sslmode=require host=localhost user=materialize" +FATAL: a client certificate is required +$ psql "sslmode=require host=localhost user=materialize \ + sslcert=client.crt sslkey=client.key" +materialize=> +``` + +### Tests + +`cargo test -p mz-authenticator --lib client_cert` runs 14 tests over the policy +itself: trusted and untrusted issuers, expired leaves, chains through an +intermediate, a pinned intermediate as the sole anchor, multi-anchor bundles, +malformed anchors, each mode, the CN binding, trust-store cache reuse and +invalidation, and proxy-authority scoping. + +`bin/cargo-test -p mz-environmentd --test mtls` runs 8 tests over the direct +path: each mode's behaviour, runtime mode and anchor changes taking effect +without a restart, the CN binding, intermediate chains, and the internal-user +exemption. + +`bin/cargo-test -p mz-balancerd --test server` runs 3 tests over the forwarded +path: mTLS end to end through the balancer including anchor rotation, a +balancer with no proxy identity having its assertion ignored, and a client that +supplies `mz_client_cert` itself being rejected. + +### Two things the prototype changed about the design + +**Trust anchors need `X509_V_FLAG_PARTIAL_CHAIN`.** Without it, OpenSSL insists +on building a chain to a self-signed root, so an operator who pins an +intermediate (a sub-CA scoped to one team, say) gets "unable to get issuer +certificate" for the exact authority they named. The flag makes every +certificate in the bundle a valid terminus, which is what "these are my trust +anchors" should mean. Found by a test that assumed the obvious behaviour. + +**A missing Common Name must not compare equal to an empty username.** The +first cut treated an absent CN as the empty string. pgwire defaults an absent +`user` parameter to the empty string too, so a certificate from a SPIFFE-shaped +PKI (identity in a URI SAN, CN empty) would have satisfied a `common-name` +binding against an empty username. An absent CN now fails the binding +outright. + +## Alternatives + +Each of these answers the five questions differently from the chosen design, and +in most cases differs on exactly one. That is what distinguishes them from the +phases: a phase is a move this design intends to make, an alternative is a move +it declines to make, or defers. + +| | 1. terminated at | 2. identities | 3. endpoints | 4. defined by | 5. certificate is | +| --- | --- | --- | --- | --- | --- | +| *This design, phase 1* | `environmentd` | all | all external | system parameters | gate, optionally a CN assertion | +| `balancerd` decides | `balancerd` alone | all | all external | pushed to the proxy | unchanged | +| TLS passthrough | `environmentd`, real handshake | all | all external | system parameters | unchanged | +| Network policy rule | `environmentd`, after authentication | per policy | all external | existing object | gate | +| Certificate as authenticator | `environmentd` | all | all external | system parameters | the sole credential | + +The network policy row is the instructive one. It differs on question 1 in a way +that looks minor and is not: a network policy is evaluated in the coordinator +during `handle_startup`, which happens *after* authentication, so it cannot +deliver the property this design exists to provide. + + +**`CREATE CERTIFICATE AUTHORITY`, a first-class catalog object (phase 4).** The +better end state, deferred rather than rejected. Independent of the edge +filtering entry below; see Roadmap. +Modelled on `CREATE NETWORK POLICY`, which is the established shape in this +codebase for a named, RBAC'd, non-schema object with structured options. + +```sql +CREATE CERTIFICATE AUTHORITY corp_root ( + CERTIFICATE = '-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----' +); + +-- The capability a PEM bundle cannot express: an authority scoped to a +-- subset of roles, with its own identity binding. +CREATE CERTIFICATE AUTHORITY partner_ca ( + CERTIFICATE = '...', + FOR ROLES = (analytics_ro, reporting), + IDENTITY BINDING = 'common-name', + ENABLED = true +); + +ALTER CERTIFICATE AUTHORITY corp_root SET (CERTIFICATE = ''); +ALTER CERTIFICATE AUTHORITY partner_ca SET (ENABLED = false); +ALTER CERTIFICATE AUTHORITY corp_root RENAME TO corp_root_2027; +ALTER CERTIFICATE AUTHORITY corp_root OWNER TO security_team; + +DROP CERTIFICATE AUTHORITY IF EXISTS partner_ca; + +GRANT USAGE ON CERTIFICATE AUTHORITY corp_root TO security_team; +ALTER DEFAULT PRIVILEGES FOR ALL ROLES + GRANT USAGE ON CERTIFICATE AUTHORITIES TO auditors; + +SHOW CERTIFICATE AUTHORITIES; +SHOW CREATE CERTIFICATE AUTHORITY corp_root; +SELECT id, name, subject, issuer, not_after, enabled, fingerprint +FROM mz_internal.mz_certificate_authorities; +``` + +`mtls_mode` stays a system parameter either way, since it is global rather than +per-authority. + +What this buys over the bundle: + +* **`FOR ROLES`.** "This partner's CA may only admit `analytics_ro`." Not + expressible with anchors in one string, where every anchor is equally + powerful. This is the deciding capability. Enforced at `environmentd` only: + `balancerd` has no role graph, and role scoping is authorization rather than + admission. A literal login-name reading is within reach at the edge but not + worth it; see "What the edge can soundly enforce" under Roadmap. +* **Per-authority `IDENTITY BINDING`.** A CN-shaped corporate CA and a + SPIFFE-shaped workload CA can coexist; a single parameter forces one global + choice. +* **Rotation and audit as operations rather than string surgery.** Add the new + authority, watch the metric drain, `DROP` the old one by name. Two operators + rotating concurrently do not clobber each other, and subject/expiry/ + fingerprint are queryable instead of buried in a blob. + +What it costs: new keywords (`CERTIFICATE`, `AUTHORITY`, `AUTHORITIES`, +`BINDING`), `Statement::{Create,Alter,Drop}CertificateAuthority`, an +`ObjectType::CertificateAuthority` threaded through drop/rename/owner/grant, a +`CertificateAuthorityId` in `mz_repr`, a durable protobuf object and catalog +version bump, a `CatalogCertificateAuthority` trait, planner and sequencer +support, two builtin tables, audit-log variants, `mz-deploy` support, and +`SHOW CREATE` round-trip tests. That is the `NetworkPolicyId`-shaped footprint. + +One sub-question if this is built: inline `CERTIFICATE = '...'` or a `SECRET` +reference? A CA certificate is public by definition, so a secret is +semantically wrong, but it would supply blob storage and rotation machinery for +free. The prototype uses an inline literal. + +**`balancerd` verifies too, as a filter in front of the dataplane (phase 2).** +Deferred, and worth building. Does not depend on the catalog object above; see +Roadmap. This is the strongest security posture available: an +unauthorized client never reaches `environmentd` at all. + +The version of this idea that fails is "move the trust decision to +`balancerd`". That makes a stateless multi-tenant proxy the authority on tenant +access control, so a stale or missing anchor cache becomes an access-control +error, and `ALTER SYSTEM SET mtls_client_ca` only takes effect after an +unbounded propagation delay. + +The version that works keeps `environmentd` authoritative and adds `balancerd` +as a *filter*. Both check; only one decides. Staleness in the proxy's copy then +degrades filtering, never correctness. A missing or expired cache entry means +`balancerd` forwards and lets `environmentd` reject, which is exactly today's +behaviour. That single property is what makes the whole thing tractable, and it +is why this is additive: **nothing in the design above changes.** The wire +format, the proxy authentication, and the `environmentd` enforcement all stay +as they are. + +```mermaid +flowchart LR + c["client"] --> b["balancerd
filter: anchors + mode"] + b -- "trusted, or
anchors unknown" --> e["environmentd
authority: anchors, mode,
identity binding
"] + b -. "untrusted:
rejected here" .-> x(["dataplane never sees it"]) + + classDef drop fill:#fff,stroke:#999,stroke-dasharray:3 3,color:#555 + class x drop +``` + +What it buys, beyond what `environmentd`-side enforcement already gives: + +* Unauthenticated traffic never reaches the dataplane, so a bug anywhere in + `environmentd`'s pgwire, TLS, or startup-parameter handling stops being + reachable by clients that cannot present a trusted certificate. +* Connection floods are absorbed at a horizontally scalable stateless tier + rather than at the single `environmentd` per tenant, where connection slots + and memory are the scarce resource. +* It generalises. The same distribution mechanism could carry network policy + CIDRs, letting `balancerd` drop disallowed addresses before the dataplane. + Network policies are evaluated in the coordinator today, well after the + connection has been accepted and authenticated, so they have the same + shape of gap. + +Two places the check can sit, with different reach: + +1. **During the handshake**, rejecting before the TLS session completes. The + `ClientHello` carries SNI, and OpenSSL's servername callback runs before the + client's certificate is verified, so the connection's verify store can be + selected per tenant there (swapping the `SslContext`, as a + multi-certificate server does, or setting a per-connection verify store). + Strongest, but only available in SNI-routing mode: Frontegg resolution needs + the password, which is inside TLS. +2. **After the handshake, before dialing upstream.** `balancerd` already has + the chain at this point, because it forwards it. It resolves the tenant, in + either mode, evaluates, and refuses to open the upstream connection. The + dataplane is still never touched. This works everywhere and is much the + simpler build. + +Shape 2 is the place to start, and it is close to free: the policy code in +`mz_authenticator::client_cert` deliberately has no catalog or adapter +dependency (it needs only `mz-pgwire-common`, `mz-dyncfg`, and the config +constants, two of which `balancerd` already links), so moving it to a leaf +crate and calling `MtlsPolicy::check` from the proxy is mechanical. Identity +binding stays at `environmentd` in both shapes, since the proxy has no reason +to be authoritative about usernames. + +The remaining work is distribution. Pushing every tenant's authorities to every +replica eagerly does not scale, since a balancer serves every tenant in its +region, but only tenants that enable mTLS have any authorities at all, so +whatever is distributed is sparse. Two mechanisms fit, and the catalog stays the +source of truth in both. + +**A `ClientAuthority` CRD that `balancerd` watches.** `environmentd` reflects +the catalog into a per-tenant CRD, and `balancerd` keeps an +`ArcSwap>` updated from a watch, holding +already-parsed `X509Store`s so the connection path is a map lookup and one +signature verification. Convergence is push-driven and takes milliseconds, with +no TTL, no stampede on rotation, and no cold-cache miss after the initial list. +`environmentd` already links `mz-cloud-resources` and builds a +`KubernetesOrchestrator`, so it has the CRD types and a kube client already; the +reflection is a write on catalog change plus a periodic reconcile. + +The cost is that `balancerd` gains Kubernetes API access, which it has no +dependency on today. Read-only, namespace-scoped, single-resource RBAC contains +it, but giving the internet-facing proxy cluster credentials is a real change to +its threat profile and the mirror image of the tenant-PEM concern below. It also +does not work for deployments without the operator. + +**An authoritative, cacheable API on `environmentd`.** `balancerd` fetches a +tenant's authorities from that tenant's `environmentd` over the internal HTTP +port, caches them in an LRU, and revalidates with an ETag so unchanged +configuration costs a `304`. No new service discovery is needed, because +`balancerd` already resolves tenant to `environmentd` address in order to proxy +at all. A long-poll variant (`?wait=`) recovers most of the watch's +push latency without a watch. + +This keeps `balancerd` free of Kubernetes, works in self-managed and +non-Kubernetes deployments, and has one fewer consistency hop, since +`balancerd` reads from the authority itself rather than from a reflection of it. +The costs are a cold-start fetch per tenant after a restart and a dependency on +`environmentd` being reachable, which it must be anyway for the connection to +be proxied. + +| | CRD watch | cacheable `environmentd` API | +| --- | --- | --- | +| `balancerd` needs k8s credentials | yes | no | +| Works without the operator | no | yes | +| Hops from source of truth | two | one | +| Freshness | push, milliseconds | revalidation, or long-poll | +| Service discovery | k8s watch | reuses tenant resolution | +| Cold start after restart | list once | lazy, per tenant | + +The API is the better default for those reasons, with the CRD watch worth adding +for cloud if push latency turns out to matter. Either way a dropped watch or a +failed fetch is safe: `balancerd` forwards and `environmentd` decides, which is +phase 1's behaviour. + +One residual concern from the rejected version survives: this puts a +tenant-controlled PEM blob into a shared multi-tenant process's parsing path. +It is the same OpenSSL X509 parsing already exposed to client certificates on +that port, and a size bound plus per-tenant cache limits contain it, but it is +worth naming. + +**TLS passthrough: `balancerd` routes on SNI without terminating.** The +strongest version of the feature, because the certificate reaches +`environmentd`'s own handshake and nothing is delegated. `balancerd` would +answer the pgwire `SSLRequest`, peek the `ClientHello` for SNI without +completing a handshake, replay the `SSLRequest` exchange upstream, and splice. +Rejected for now because it is only available in SNI-routing mode (Frontegg +resolution needs to read the password, which is inside TLS), and because +everything `balancerd` currently injects into the startup packet +(`mz_connection_uuid`, `mz_forwarded_for`) becomes unreachable, taking +per-tenant byte metrics and connection correlation with it. Worth revisiting as +a mode rather than a replacement: a tenant on SNI routing who wants end-to-end +mTLS could opt into a passthrough listener. + +**Fold certificates into network policies**, e.g. a rule that matches on +issuer DN instead of a CIDR. Attractive because it puts one admission concept +in one place and reuses the existing object, RBAC, and `mz-deploy` support. +Rejected as the starting point because a network policy rule is evaluated in +the coordinator during `handle_startup`, which happens *after* authentication. +An admission gate that runs after the password exchange does not deliver the +property customers are asking for. Unifying the two once the certificate is +available earlier in the connection is a reasonable follow-up. + +**A shared secret or HMAC binding the forwarded parameter** instead of mutual +TLS on the internal leg. Cheaper to deploy, no proxy CA to manage. Rejected +because it introduces a new secret with no rotation story into the connection +path, when the internal leg is already TLS and adding a client certificate to +it is a strictly smaller change with a standard rotation story. + +**Certificate as authenticator** (`AuthenticatorKind::ClientCertificate`), +replacing the password exchange entirely. This is where the feature probably +wants to end up, and the plumbing here is a prerequisite. Out of scope now +because it changes what a Materialize login *is* and interacts with role +auto-provisioning, whereas the admission gate composes with every existing +authenticator untouched. + +## Open questions + +* **Revocation.** Neither CRLs nor OCSP are handled. Short-lived certificates + (the SPIFFE/service-mesh model) sidestep the problem, and rotating + `mtls_client_ca` revokes an entire authority. But a customer who wants to + revoke one leaf has no mechanism. Is a deny-list of serial numbers or + fingerprints enough for phase 1? +* **Which identity field binds to the username?** `common-name` is what + PostgreSQL does and what a CN-shaped corporate PKI produces, but a + SPIFFE-shaped PKI puts the identity in a URI SAN (`spiffe://.../workload`) + and leaves the CN empty. `mtls_identity_binding` should probably grow + `san-dns`, `san-uri`, and a regex-extract form. Which do customers actually + have? +* **Should the verified principal be visible in SQL?** A `mz_client_cert_subject` + read-only session variable, and a column in `mz_sessions` and the statement + log, would let operators audit which certificate opened which session. Cheap + to add. Is it wanted? +* **Certificate requesting is a global `environmentd` setting in the POC** + (`--tls-request-client-certs`), because `environmentd` builds one + `SslContext` shared by every listener. Should it move into the per-listener + config (`SqlListenerConfig`), which would mean a per-listener `SslContext`? + Requesting a certificate is harmless on a listener that ignores it, so this + is a tidiness question, not a correctness one. +* **Should CI default `mtls_mode` to `allow`?** Project convention is that a new + flag defaults off in production but on in the test configuration, so the new + path gets exercised before it earns trust. That does not translate cleanly + here: `require` in CI would break every test, since no test client presents a + certificate. `allow` with no anchors configured is a no-op for admission but + would run the policy read and mode parse on every connection in every + mzcompose test, which is cheap coverage for a regression in that path. The + prototype leaves the default at `disable` and registers all three parameters + as uninteresting (matching how the OIDC parameters are registered), because + turning this on fleet-wide in CI has a broad blast radius for modest gain. + Worth a second opinion. +* **`mtls_mode = 'require'` with an empty `mtls_client_ca`** locks everyone out + by design. The network-policy code logs loudly in the analogous situation + (default policy missing). Should `ALTER SYSTEM SET mtls_mode = 'require'` + instead refuse when no anchors are configured, the way `network_policy` + validates that the named policy exists? +* **Were webhooks meant to be outside network policy?** They are today, and for + a structural reason rather than an explicit carve-out: the webhook path never + creates a session, so the policy check never runs. Unlike mTLS this is not + forced, since vendors publish their webhook CIDRs. Someone should confirm + whether that is the intended behaviour, independently of this design. +* **Interaction with Frontegg's own TLS requirements** in cloud: `balancerd` + already requires TLS when Frontegg resolution is configured, so requesting a + certificate is free there. But cloud's `balancerd` fleet would begin sending + `CertificateRequest` to every client of every tenant, including tenants with + mTLS disabled. Clients are supposed to ignore a `CertificateRequest` they + cannot satisfy, but we should confirm that against the driver matrix before + enabling it fleet-wide, and gate it on a dyncfg so it can be rolled out and + rolled back.