Skip to main content

reddb_server/auth/
cert.rs

1//! Client-certificate authentication (Phase 3.4 PG parity).
2//!
3//! Validates mTLS client certificates against a trust store and extracts
4//! the user identity + role from the cert's subject / extensions. Used
5//! by TLS-terminated listeners (wire, gRPC, PG wire) to authenticate
6//! callers without sending passwords.
7//!
8//! # Identity mapping
9//!
10//! Two mapping modes, configured per deployment:
11//!
12//! * **`CommonName`** — take the subject CN ("CN=alice") as the RedDB
13//!   username. Matches PG's `cert` auth default. Simplest option but
14//!   conflates identity with naming.
15//! * **`SanRfc822Name`** — take the first rfc822Name (email) SAN entry
16//!   as the username. Works well with corporate PKIs that encode email
17//!   in the cert subject alternative name.
18//!
19//! Additional extension-based mapping (custom OIDs for role tags) lives
20//! behind `CertAuthConfig::role_oid` — when set, the validator extracts
21//! the role string from that OID; otherwise the role defaults to
22//! `CertAuthConfig::default_role`.
23//!
24//! # Trust store
25//!
26//! For Phase 3.4 the trust store is a file path holding one or more
27//! PEM-encoded CA certificates. Any leaf cert signed by any of those
28//! CAs validates. Chain verification delegates to the underlying TLS
29//! stack (`rustls`) — we only consume the already-validated
30//! certificate at the handler layer.
31
32use std::path::PathBuf;
33
34use super::{Role, User};
35
36/// Per-deployment cert-auth policy. Enabled on a per-listener basis
37/// (the TLS listeners inject this into their accept loop).
38#[derive(Debug, Clone)]
39pub struct CertAuthConfig {
40    /// Whether cert auth is active for this listener. When false the
41    /// validator is skipped entirely.
42    pub enabled: bool,
43    /// Path to a PEM file containing trusted CA certificates. Client
44    /// certs must chain to one of these.
45    pub trust_bundle: PathBuf,
46    /// Identity extraction mode.
47    pub identity_mode: CertIdentityMode,
48    /// Optional X.509 extension OID (dotted notation) that carries the
49    /// role string. When unset, `default_role` is used.
50    pub role_oid: Option<String>,
51    /// Role assigned when the cert does not carry an explicit role.
52    pub default_role: Role,
53    /// When `true`, a cert whose CN / email matches an existing RedDB
54    /// user maps to that user (and inherits the user's stored role).
55    /// When `false`, the cert-derived role is always authoritative.
56    pub map_to_existing_users: bool,
57}
58
59impl Default for CertAuthConfig {
60    fn default() -> Self {
61        Self {
62            enabled: false,
63            trust_bundle: PathBuf::from("./certs/client-ca.pem"),
64            identity_mode: CertIdentityMode::CommonName,
65            role_oid: None,
66            default_role: Role::Read,
67            map_to_existing_users: true,
68        }
69    }
70}
71
72/// How to derive the RedDB username from a client certificate.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum CertIdentityMode {
75    /// Subject CN field ("CN=alice").
76    CommonName,
77    /// First rfc822Name (email) Subject Alternative Name.
78    SanRfc822Name,
79}
80
81/// Parsed identity extracted from a validated client certificate.
82///
83/// The auth store consumes this to either look up a matching persisted
84/// user or treat it as an ephemeral identity (no entry in the user
85/// table, role is cert-derived).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct CertIdentity {
88    pub username: String,
89    pub role: Role,
90    /// Subject DN — preserved for audit logging even when we don't map
91    /// it back to a persisted user.
92    pub subject_dn: String,
93    /// Certificate serial number in uppercase hex — audit identifier.
94    pub serial_hex: String,
95    /// Unix-seconds expiry of the certificate. Auth middleware rejects
96    /// requests once this passes even if the cert is still in cache.
97    pub not_after_unix_secs: i64,
98}
99
100/// Errors raised while validating a client certificate.
101#[derive(Debug, Clone)]
102pub enum CertAuthError {
103    /// TLS layer validated the chain but the cert does not carry the
104    /// identity field required by `identity_mode`.
105    MissingIdentity(CertIdentityMode),
106    /// `role_oid` was configured but the cert does not carry that
107    /// extension.
108    MissingRoleExtension(String),
109    /// `map_to_existing_users` is on but no stored user matches.
110    UnknownUser(String),
111    /// Cert expired (wall-clock beyond `not_after`).
112    Expired { not_after_unix_secs: i64 },
113    /// Trust-bundle configuration failure (file missing / malformed).
114    TrustBundle(String),
115    /// Arbitrary parse failure in the cert surface bytes.
116    Parse(String),
117}
118
119impl std::fmt::Display for CertAuthError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            CertAuthError::MissingIdentity(mode) => {
123                write!(f, "client cert missing {:?} identity field", mode)
124            }
125            CertAuthError::MissingRoleExtension(oid) => {
126                write!(f, "client cert missing role extension {oid}")
127            }
128            CertAuthError::UnknownUser(u) => write!(f, "cert user '{u}' not in auth store"),
129            CertAuthError::Expired {
130                not_after_unix_secs,
131            } => write!(f, "client cert expired at unix {not_after_unix_secs}"),
132            CertAuthError::TrustBundle(m) => write!(f, "trust bundle error: {m}"),
133            CertAuthError::Parse(m) => write!(f, "cert parse error: {m}"),
134        }
135    }
136}
137
138impl std::error::Error for CertAuthError {}
139
140/// Subset of the cert surface the validator consumes. TLS listeners
141/// construct this from their `rustls::Certificate` payload via an
142/// ASN.1 parser (`x509-parser` or similar); we model the fields we
143/// actually look at so tests don't need a real PEM.
144#[derive(Debug, Clone)]
145pub struct ParsedClientCert {
146    pub subject_dn: String,
147    pub common_name: Option<String>,
148    pub san_rfc822: Vec<String>,
149    pub serial_hex: String,
150    pub not_after_unix_secs: i64,
151    /// Map of X.509 extension OID → raw bytes. Populated for any
152    /// extension the parser saw; the validator only looks at
153    /// `role_oid` when configured.
154    pub extensions: std::collections::HashMap<String, Vec<u8>>,
155}
156
157/// Stateless validator. Holds the config + lookup closure; TLS
158/// listeners wrap it in an Arc and call `validate` on every accepted
159/// connection.
160pub struct CertAuthenticator {
161    config: CertAuthConfig,
162}
163
164impl CertAuthenticator {
165    pub fn new(config: CertAuthConfig) -> Self {
166        Self { config }
167    }
168
169    /// Validate a parsed client cert and extract the RedDB identity.
170    ///
171    /// `lookup_user` is invoked when `map_to_existing_users=true` so the
172    /// caller can consult the auth store (any closure returning
173    /// `Option<User>` works — tests inject a fake).
174    pub fn validate<F>(
175        &self,
176        cert: &ParsedClientCert,
177        now_unix_secs: i64,
178        lookup_user: F,
179    ) -> Result<CertIdentity, CertAuthError>
180    where
181        F: Fn(&str) -> Option<User>,
182    {
183        if !self.config.enabled {
184            return Err(CertAuthError::Parse(
185                "cert auth disabled on this listener".into(),
186            ));
187        }
188
189        if cert.not_after_unix_secs < now_unix_secs {
190            return Err(CertAuthError::Expired {
191                not_after_unix_secs: cert.not_after_unix_secs,
192            });
193        }
194
195        let username = match self.config.identity_mode {
196            CertIdentityMode::CommonName => cert
197                .common_name
198                .clone()
199                .ok_or(CertAuthError::MissingIdentity(CertIdentityMode::CommonName))?,
200            CertIdentityMode::SanRfc822Name => {
201                cert.san_rfc822
202                    .first()
203                    .cloned()
204                    .ok_or(CertAuthError::MissingIdentity(
205                        CertIdentityMode::SanRfc822Name,
206                    ))?
207            }
208        };
209
210        // Prefer persisted user role; fall back to cert-derived role.
211        let role = if self.config.map_to_existing_users {
212            match lookup_user(&username) {
213                Some(user) => user.role,
214                None => self.derive_role_from_cert(cert)?,
215            }
216        } else {
217            self.derive_role_from_cert(cert)?
218        };
219
220        Ok(CertIdentity {
221            username,
222            role,
223            subject_dn: cert.subject_dn.clone(),
224            serial_hex: cert.serial_hex.clone(),
225            not_after_unix_secs: cert.not_after_unix_secs,
226        })
227    }
228
229    fn derive_role_from_cert(&self, cert: &ParsedClientCert) -> Result<Role, CertAuthError> {
230        let Some(oid) = &self.config.role_oid else {
231            return Ok(self.config.default_role);
232        };
233        let bytes = cert
234            .extensions
235            .get(oid)
236            .ok_or_else(|| CertAuthError::MissingRoleExtension(oid.clone()))?;
237        // Extension payload is expected to be a DER-encoded UTF-8 string.
238        // For Phase 3.4 we accept raw bytes interpreted as UTF-8 to keep
239        // the validator independent of an ASN.1 dependency.
240        let name = std::str::from_utf8(bytes)
241            .map_err(|e| CertAuthError::Parse(format!("role extension not valid UTF-8: {e}")))?;
242        Role::from_str(name.trim())
243            .ok_or_else(|| CertAuthError::Parse(format!("unknown role '{name}'")))
244    }
245
246    pub fn config(&self) -> &CertAuthConfig {
247        &self.config
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use std::collections::HashMap;
255
256    fn base_cert() -> ParsedClientCert {
257        ParsedClientCert {
258            subject_dn: "CN=alice,O=reddb,C=BR".to_string(),
259            common_name: Some("alice".to_string()),
260            san_rfc822: vec!["alice@example.com".to_string()],
261            serial_hex: "ABCDEF".to_string(),
262            not_after_unix_secs: 2_000_000_000,
263            extensions: HashMap::new(),
264        }
265    }
266
267    fn cfg(mode: CertIdentityMode) -> CertAuthConfig {
268        CertAuthConfig {
269            enabled: true,
270            identity_mode: mode,
271            ..CertAuthConfig::default()
272        }
273    }
274
275    #[test]
276    fn common_name_maps_to_username() {
277        let auth = CertAuthenticator::new(cfg(CertIdentityMode::CommonName));
278        let id = auth
279            .validate(&base_cert(), 1_000_000_000, |_| None)
280            .unwrap();
281        assert_eq!(id.username, "alice");
282        assert_eq!(id.role, Role::Read);
283    }
284
285    #[test]
286    fn san_rfc822_maps_to_email() {
287        let auth = CertAuthenticator::new(cfg(CertIdentityMode::SanRfc822Name));
288        let id = auth
289            .validate(&base_cert(), 1_000_000_000, |_| None)
290            .unwrap();
291        assert_eq!(id.username, "alice@example.com");
292    }
293
294    #[test]
295    fn missing_cn_field_rejected() {
296        let mut cert = base_cert();
297        cert.common_name = None;
298        let auth = CertAuthenticator::new(cfg(CertIdentityMode::CommonName));
299        let err = auth.validate(&cert, 1_000_000_000, |_| None).unwrap_err();
300        assert!(matches!(err, CertAuthError::MissingIdentity(_)));
301    }
302
303    #[test]
304    fn expired_cert_rejected() {
305        let mut cert = base_cert();
306        cert.not_after_unix_secs = 500;
307        let auth = CertAuthenticator::new(cfg(CertIdentityMode::CommonName));
308        let err = auth.validate(&cert, 1_000, |_| None).unwrap_err();
309        assert!(matches!(err, CertAuthError::Expired { .. }));
310    }
311
312    #[test]
313    fn role_extension_overrides_default_role() {
314        let mut cert = base_cert();
315        cert.extensions
316            .insert("1.3.6.1.4.1.99999.1".to_string(), b"admin".to_vec());
317        let mut config = cfg(CertIdentityMode::CommonName);
318        config.role_oid = Some("1.3.6.1.4.1.99999.1".to_string());
319        config.map_to_existing_users = false;
320        let auth = CertAuthenticator::new(config);
321        let id = auth.validate(&cert, 1_000_000_000, |_| None).unwrap();
322        assert_eq!(id.role, Role::Admin);
323    }
324
325    #[test]
326    fn missing_role_extension_errors_when_configured() {
327        let mut config = cfg(CertIdentityMode::CommonName);
328        config.role_oid = Some("1.2.3".to_string());
329        config.map_to_existing_users = false;
330        let auth = CertAuthenticator::new(config);
331        let err = auth
332            .validate(&base_cert(), 1_000_000_000, |_| None)
333            .unwrap_err();
334        assert!(matches!(err, CertAuthError::MissingRoleExtension(_)));
335    }
336}