Skip to content

Commit 9108432

Browse files
jubradclaude
andcommitted
auth: prototype mutual TLS client authentication
Adds mutual TLS as an admission gate for external pgwire connections, enforced before any credential exchange. Off by default; no behaviour change for deployments that do not opt in. Proof of possession and trust evaluation are separated, which is what makes this work through balancerd. balancerd requests client certificates with a permissive verify callback, so OpenSSL still validates CertificateVerify (possession is proven) while the trust decision is deferred to the party that holds the tenant's anchors. The chain is forwarded to environmentd in a new mz_client_cert startup parameter as base64 concatenated PEM, leaf first. balancerd never learns any tenant's certificate authority and stays stateless. environmentd honours a forwarded chain only from a peer whose own certificate chains to --tls-proxy-ca, so the balancerd to environmentd leg gains a client identity (--internal-tls-cert/--internal-tls-key). Without a proxy authority configured, forwarded certificates are ignored entirely. A client that supplies mz_client_cert itself is rejected, as with mz_forwarded_for. Configuration is three system parameters, following the OIDC precedent: mtls_client_ca (PEM bundle of trust anchors), mtls_mode (disable, allow, require), and mtls_identity_binding (none, common-name). Anchors and mode are read from the live ConfigSet rather than via get_system_vars, so the check costs no coordinator round trip on the connection path. Two details worth noting. Trust anchors are built with X509_V_FLAG_PARTIAL_CHAIN, so pinning an intermediate as the sole anchor works rather than failing with "unable to get local issuer certificate". A leaf with no Common Name fails a common-name binding outright, because pgwire defaults an absent user parameter to the empty string and a SPIFFE-shaped certificate would otherwise satisfy the binding. Also fixes a pre-existing balancerd bug this work surfaced: the startup parameter rejection paths called FramedConn::send without flushing, so a client rejected for supplying mz_connection_uuid or mz_forwarded_for saw a bare connection close instead of the error. Adds unit tests for the policy in mz-authenticator, integration tests for the direct path in src/environmentd/tests/mtls.rs, and tests for the forwarded path in src/balancerd/tests/server.rs covering anchor rotation, an unauthenticated proxy being disbelieved, and a forged mz_client_cert being refused. The HTTP, WebSocket, and webhook paths are in scope for the feature but not yet wired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e12bb7f commit 9108432

24 files changed

Lines changed: 2434 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

misc/python/materialize/mzcompose/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,9 @@ def get_default_system_parameters(
768768
"oidc_group_role_sync_enabled",
769769
"oidc_group_claim",
770770
"oidc_group_role_sync_strict",
771+
"mtls_client_ca",
772+
"mtls_mode",
773+
"mtls_identity_binding",
771774
"console_oidc_client_id",
772775
"console_oidc_scopes",
773776
"enable_public_metrics_endpoint",

misc/python/materialize/parallel_workload/action.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3326,6 +3326,11 @@ def __init__(
33263326
"oidc_group_role_sync_enabled",
33273327
"oidc_group_claim",
33283328
"oidc_group_role_sync_strict",
3329+
# Flipping an admission policy would sever parallel-workload's own
3330+
# connections, which present no client certificate.
3331+
"mtls_client_ca",
3332+
"mtls_mode",
3333+
"mtls_identity_binding",
33293334
"console_oidc_client_id",
33303335
"console_oidc_scopes",
33313336
"cluster_controller_tick_interval",

src/adapter-types/src/dyncfgs.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,38 @@ pub const OIDC_GROUP_ROLE_SYNC_STRICT: Config<bool> = Config::new(
187187
"When true, reject login if OIDC group-to-role sync fails (fail-closed).",
188188
);
189189

190+
/// Trust anchors for mutual TLS client authentication: a PEM bundle of
191+
/// certificate authorities whose leaves are accepted.
192+
///
193+
/// A bundle rather than a single certificate so that an operator can trust
194+
/// several authorities at once, and can stage a CA rotation by trusting the old
195+
/// and the new authority simultaneously.
196+
pub const MTLS_CLIENT_CA: Config<Option<&'static str>> = Config::new(
197+
"mtls_client_ca",
198+
None,
199+
"PEM bundle of certificate authorities trusted to issue client certificates for mutual TLS.",
200+
);
201+
202+
/// How strictly to enforce mutual TLS on external logins: `disable`, `allow`,
203+
/// or `require`. See `mz_authenticator::client_cert::MtlsMode`.
204+
pub const MTLS_MODE: Config<&'static str> = Config::new(
205+
"mtls_mode",
206+
"disable",
207+
"How strictly to enforce mutual TLS client authentication for external logins: \
208+
'disable' ignores client certificates, 'allow' accepts a trusted certificate but does \
209+
not require one, 'require' rejects logins without one.",
210+
);
211+
212+
/// Which certificate field, if any, must agree with the connecting username.
213+
/// See `mz_authenticator::client_cert::IdentityBinding`.
214+
pub const MTLS_IDENTITY_BINDING: Config<&'static str> = Config::new(
215+
"mtls_identity_binding",
216+
"none",
217+
"Which client certificate field must match the connecting username: 'none' to treat the \
218+
certificate purely as an admission gate, or 'common-name' to require the leaf's Subject \
219+
Common Name to equal the username.",
220+
);
221+
190222
pub const PERSIST_FAST_PATH_ORDER: Config<bool> = Config::new(
191223
"persist_fast_path_order",
192224
false,
@@ -452,6 +484,9 @@ pub fn all_dyncfgs(configs: ConfigSet) -> ConfigSet {
452484
.add(&OIDC_GROUP_ROLE_SYNC_ENABLED)
453485
.add(&OIDC_GROUP_CLAIM)
454486
.add(&OIDC_GROUP_ROLE_SYNC_STRICT)
487+
.add(&MTLS_CLIENT_CA)
488+
.add(&MTLS_MODE)
489+
.add(&MTLS_IDENTITY_BINDING)
455490
.add(&PERSIST_FAST_PATH_ORDER)
456491
.add(&ENABLE_S3_TABLES_REGION_CHECK)
457492
.add(&ENABLE_MCP_AGENT)

src/authenticator/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ publish = false
99

1010
[dependencies]
1111
jsonwebtoken.workspace = true
12+
anyhow.workspace = true
1213
mz-adapter = { path = "../adapter", default-features = false }
1314
mz-adapter-types = { path = "../adapter-types", default-features = false }
1415
mz-auth = { path = "../auth", default-features = false }
16+
mz-dyncfg = { path = "../dyncfg" }
1517
mz-frontegg-auth = { path = "../frontegg-auth", default-features = false }
1618
mz-ore = { path = "../ore", features = ["assert"] }
1719
mz-pgwire-common = { path = "../pgwire-common", default-features = false }
20+
openssl.workspace = true
1821
reqwest.workspace = true
1922
tokio-postgres.workspace = true
2023
serde.workspace = true

0 commit comments

Comments
 (0)