Skip to main content

zerodds_bridge_security/
ctx.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Daemon-facing convenience: `SecurityConfig` (CLI/YAML surface) →
5//! [`SecurityCtx`] (resolved). Used identically by all six bridge
6//! daemons — the only difference is the connection path into which the
7//! ctx is hooked.
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use crate::acl::{Acl, AclEntry, AclOp};
14use crate::auth::{AuthError, AuthInput, AuthMode, AuthSubject};
15use crate::tls::{TlsConfigError, load_server_config, load_server_config_with_client_auth};
16
17/// Resolved security config — the output of this layer.
18#[derive(Clone)]
19pub struct SecurityCtx {
20    /// `Some(...)` ⇒ TLS active; rustls ServerConfig (with or without mTLS).
21    pub tls: Option<Arc<rustls::ServerConfig>>,
22    /// Auth mode (none/bearer/jwt/mtls/sasl).
23    pub auth: Arc<AuthMode>,
24    /// Topic ACL.
25    pub acl: Arc<Acl>,
26}
27
28impl core::fmt::Debug for SecurityCtx {
29    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
30        f.debug_struct("SecurityCtx")
31            .field("tls_enabled", &self.tls.is_some())
32            .field("auth", &self.auth)
33            .finish_non_exhaustive()
34    }
35}
36
37/// Raw config from YAML/CLI.
38#[derive(Debug, Clone, Default)]
39pub struct SecurityConfig {
40    /// PEM cert path (`--tls-cert`).
41    pub tls_cert: Option<PathBuf>,
42    /// PEM key path (`--tls-key`).
43    pub tls_key: Option<PathBuf>,
44    /// PEM CA bundle for mTLS client cert validation (`--client-ca`).
45    pub client_ca: Option<PathBuf>,
46    /// Auth mode string (`none|bearer|jwt|mtls|sasl`).
47    pub auth_mode: String,
48    /// Bearer tokens as a map `token → subject name`.
49    pub bearer_tokens: HashMap<String, String>,
50    /// JWT RSA public key (PKCS#1 DER).
51    pub jwt_pubkey_der: Option<Vec<u8>>,
52    /// JWT expected `iss` claim.
53    pub jwt_expected_iss: Option<String>,
54    /// SASL-PLAIN: `user → password` map (for AMQP/MQTT).
55    pub sasl_users: HashMap<String, String>,
56    /// ACL per topic name.
57    pub topic_acl: HashMap<String, AclEntry>,
58    /// ACL default entry for unknown topics. `None` = deny-by-default.
59    pub topic_acl_default: Option<AclEntry>,
60}
61
62/// Setup error.
63#[derive(Debug)]
64pub enum SecurityError {
65    /// Cert/key loader reports an error.
66    Tls(TlsConfigError),
67    /// Auth mode string unknown.
68    UnknownAuthMode(String),
69    /// Auth mode requires input that is missing.
70    MissingAuthInput(&'static str),
71}
72
73impl core::fmt::Display for SecurityError {
74    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75        match self {
76            Self::Tls(e) => write!(f, "tls: {e}"),
77            Self::UnknownAuthMode(m) => write!(f, "unknown auth-mode: {m}"),
78            Self::MissingAuthInput(s) => write!(f, "missing auth input: {s}"),
79        }
80    }
81}
82
83impl std::error::Error for SecurityError {}
84
85impl From<TlsConfigError> for SecurityError {
86    fn from(e: TlsConfigError) -> Self {
87        Self::Tls(e)
88    }
89}
90
91/// Builds [`SecurityCtx`] from [`SecurityConfig`]. Called at daemon
92/// start and on SIGHUP reload.
93///
94/// # Errors
95/// [`SecurityError`] on TLS config error or unknown auth mode.
96pub fn build_ctx(cfg: &SecurityConfig) -> Result<SecurityCtx, SecurityError> {
97    let tls = match (&cfg.tls_cert, &cfg.tls_key) {
98        (Some(c), Some(k)) => match &cfg.client_ca {
99            Some(ca) => Some(load_server_config_with_client_auth(c, k, ca)?),
100            None => Some(load_server_config(c, k)?),
101        },
102        (None, None) => None,
103        _ => {
104            return Err(SecurityError::Tls(TlsConfigError::Rustls(
105                "tls_cert and tls_key must be set together".to_string(),
106            )));
107        }
108    };
109
110    let auth = match cfg.auth_mode.as_str() {
111        "" | "none" => AuthMode::None,
112        "bearer" => {
113            let mut tokens: HashMap<String, AuthSubject> = HashMap::new();
114            for (tok, name) in &cfg.bearer_tokens {
115                tokens.insert(tok.clone(), AuthSubject::new(name.clone()));
116            }
117            AuthMode::Bearer { tokens }
118        }
119        "jwt" => {
120            let der = cfg
121                .jwt_pubkey_der
122                .clone()
123                .ok_or(SecurityError::MissingAuthInput("jwt_pubkey_der"))?;
124            AuthMode::Jwt {
125                pkcs1_pubkey_der: der,
126                expected_issuer: cfg.jwt_expected_iss.clone(),
127            }
128        }
129        "mtls" => AuthMode::Mtls,
130        "sasl" => {
131            if cfg.sasl_users.is_empty() {
132                return Err(SecurityError::MissingAuthInput("sasl_users"));
133            }
134            AuthMode::SaslPlain {
135                users: cfg.sasl_users.clone(),
136            }
137        }
138        other => return Err(SecurityError::UnknownAuthMode(other.to_string())),
139    };
140
141    let mut acl = if cfg.topic_acl.is_empty() && cfg.topic_acl_default.is_none() {
142        if matches!(auth, AuthMode::None) {
143            Acl::allow_all()
144        } else {
145            Acl::deny_all()
146        }
147    } else {
148        Acl::deny_all()
149    };
150    for (topic, entry) in &cfg.topic_acl {
151        acl.set(topic.clone(), entry.clone());
152    }
153    if let Some(d) = &cfg.topic_acl_default {
154        acl.set_default(d.clone());
155    }
156
157    Ok(SecurityCtx {
158        tls,
159        auth: Arc::new(auth),
160        acl: Arc::new(acl),
161    })
162}
163
164/// Authentication wrapper. Each daemon fills the inputs relevant to it:
165/// * HTTP/WS/gRPC: `authorization_header`
166/// * mTLS (all TCP bridges): `mtls_subject` extracted from `rustls::ServerConnection::peer_certificates()`
167/// * MQTT/AMQP SASL-PLAIN: `sasl_plain_blob`
168///
169/// # Errors
170/// [`AuthError`] on any form of reject/missing/malformed.
171pub fn authenticate(
172    auth: &AuthMode,
173    authorization_header: Option<&str>,
174    sasl_plain_blob: Option<&[u8]>,
175    mtls_subject: Option<AuthSubject>,
176) -> Result<AuthSubject, AuthError> {
177    let input = AuthInput {
178        authorization_header,
179        sasl_plain_blob,
180        mtls_subject,
181    };
182    auth.validate(&input)
183}
184
185/// Convenience: topic ACL check.
186#[must_use]
187pub fn authorize(acl: &Acl, subject: &AuthSubject, op: AclOp, topic: &str) -> bool {
188    acl.check(subject, op, topic)
189}
190
191/// Extracts an `AuthSubject` from a `rustls::ServerConnection` peer cert
192/// (for `AuthMode::Mtls`). Returns `None` if no cert was presented.
193///
194/// Subject name = X.509 subject DN as a string (DER bytes hex-encoded
195/// as a fallback if X.500 does not parse).
196#[must_use]
197pub fn extract_mtls_subject(conn: &rustls::ServerConnection) -> Option<AuthSubject> {
198    let certs = conn.peer_certificates()?;
199    let leaf = certs.first()?;
200    // We use the cert DER SHA256 fingerprint as a stable identity.
201    // Spec §7.2: mTLS subject = SubjectDN OR hash. We choose hash for
202    // determinism without an X.500 DN parser dep.
203    let hash = sha256_hex(leaf.as_ref());
204    Some(AuthSubject::new(format!("mtls:{hash}")))
205}
206
207fn sha256_hex(data: &[u8]) -> String {
208    use crate::backend::digest::{Context, SHA256};
209    let mut ctx = Context::new(&SHA256);
210    ctx.update(data);
211    let d = ctx.finish();
212    let mut s = String::with_capacity(64);
213    for b in d.as_ref() {
214        s.push_str(&format!("{b:02x}"));
215    }
216    s
217}
218
219#[cfg(test)]
220#[allow(clippy::expect_used, clippy::unwrap_used)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn build_default_yields_none_auth_allow_all_acl() {
226        let cfg = SecurityConfig::default();
227        let ctx = build_ctx(&cfg).unwrap();
228        assert!(ctx.tls.is_none());
229        assert!(matches!(*ctx.auth, AuthMode::None));
230        assert!(ctx.acl.check(&AuthSubject::anonymous(), AclOp::Read, "X"));
231    }
232
233    #[test]
234    fn build_bearer_with_tokens() {
235        let mut cfg = SecurityConfig {
236            auth_mode: "bearer".into(),
237            ..Default::default()
238        };
239        cfg.bearer_tokens.insert("t".into(), "alice".into());
240        let ctx = build_ctx(&cfg).unwrap();
241        let s = authenticate(&ctx.auth, Some("Bearer t"), None, None).unwrap();
242        assert_eq!(s.name, "alice");
243    }
244
245    #[test]
246    fn build_sasl_with_users() {
247        let mut cfg = SecurityConfig {
248            auth_mode: "sasl".into(),
249            ..Default::default()
250        };
251        cfg.sasl_users.insert("u".into(), "p".into());
252        let ctx = build_ctx(&cfg).unwrap();
253        let s = authenticate(&ctx.auth, None, Some(b"\0u\0p"), None).unwrap();
254        assert_eq!(s.name, "u");
255    }
256
257    #[test]
258    fn build_sasl_without_users_rejected() {
259        let cfg = SecurityConfig {
260            auth_mode: "sasl".into(),
261            ..Default::default()
262        };
263        let err = build_ctx(&cfg).unwrap_err();
264        assert!(matches!(err, SecurityError::MissingAuthInput(_)));
265    }
266
267    #[test]
268    fn unknown_auth_mode_rejected() {
269        let cfg = SecurityConfig {
270            auth_mode: "weird".into(),
271            ..Default::default()
272        };
273        let err = build_ctx(&cfg).unwrap_err();
274        assert!(matches!(err, SecurityError::UnknownAuthMode(_)));
275    }
276
277    #[test]
278    fn jwt_without_key_rejected() {
279        let cfg = SecurityConfig {
280            auth_mode: "jwt".into(),
281            ..Default::default()
282        };
283        let err = build_ctx(&cfg).unwrap_err();
284        assert!(matches!(err, SecurityError::MissingAuthInput(_)));
285    }
286
287    #[test]
288    fn explicit_acl_overrides_open_default() {
289        let mut cfg = SecurityConfig::default();
290        cfg.topic_acl.insert(
291            "T".into(),
292            AclEntry {
293                read: vec!["alice".into()],
294                write: vec!["alice".into()],
295            },
296        );
297        let ctx = build_ctx(&cfg).unwrap();
298        let alice = AuthSubject::new("alice");
299        let bob = AuthSubject::new("bob");
300        assert!(authorize(&ctx.acl, &alice, AclOp::Read, "T"));
301        assert!(!authorize(&ctx.acl, &bob, AclOp::Read, "T"));
302        assert!(!authorize(&ctx.acl, &alice, AclOp::Read, "Other"));
303    }
304
305    #[test]
306    fn explicit_acl_default_used_for_unknown() {
307        let cfg = SecurityConfig {
308            topic_acl_default: Some(AclEntry {
309                read: vec!["*".into()],
310                write: vec![],
311            }),
312            ..SecurityConfig::default()
313        };
314        let ctx = build_ctx(&cfg).unwrap();
315        let bob = AuthSubject::new("bob");
316        assert!(authorize(&ctx.acl, &bob, AclOp::Read, "Anything"));
317        assert!(!authorize(&ctx.acl, &bob, AclOp::Write, "Anything"));
318    }
319
320    #[test]
321    fn partial_tls_paths_rejected() {
322        let cfg = SecurityConfig {
323            tls_cert: Some("/x".into()),
324            tls_key: None,
325            ..Default::default()
326        };
327        let err = build_ctx(&cfg).unwrap_err();
328        assert!(matches!(err, SecurityError::Tls(_)));
329    }
330}