Skip to main content

zerodds_webdds_bridge/daemon/
security.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Security wireup: TLS acceptor, auth validation, per-Application ACL
5//! (see `daemon::config`'s module doc for why Application, not topic).
6//! The actual logic comes from [`zerodds_bridge_security`] — this
7//! module is only the HTTP-specific hook (the `Authorization` header)
8//! and the config -> `SecurityCtx` translation, the same shape every
9//! other `zerodds-*-bridged` daemon uses.
10
11pub use zerodds_bridge_security::{
12    Acl, AclEntry, AclOp, AuthError, AuthMode, AuthSubject, RotatingTlsConfig, SecurityConfig,
13    SecurityCtx, SecurityError, authenticate as bs_authenticate, authorize, build_ctx,
14    extract_mtls_subject, serve_tls_handshake,
15};
16
17/// Translates the daemon CLI/YAML `DaemonConfig` into a
18/// [`SecurityCtx`] + optional [`RotatingTlsConfig`] for hot reload.
19///
20/// # Errors
21/// [`SecurityError`] on a TLS-load or auth-mode config error.
22pub fn ctx_from_daemon_config(
23    cfg: &super::config::DaemonConfig,
24) -> Result<(SecurityCtx, Option<RotatingTlsConfig>), SecurityError> {
25    let mut sc = SecurityConfig::default();
26    if cfg.tls_enabled {
27        if cfg.tls_cert_file.is_empty() || cfg.tls_key_file.is_empty() {
28            return Err(SecurityError::Tls(
29                zerodds_bridge_security::TlsConfigError::Rustls(
30                    "tls.enabled=true requires cert_file + key_file".into(),
31                ),
32            ));
33        }
34        sc.tls_cert = Some(cfg.tls_cert_file.clone().into());
35        sc.tls_key = Some(cfg.tls_key_file.clone().into());
36        if !cfg.tls_client_ca_file.is_empty() {
37            sc.client_ca = Some(cfg.tls_client_ca_file.clone().into());
38        }
39    }
40    sc.auth_mode = cfg.auth_mode.clone();
41    match cfg.auth_mode.as_str() {
42        "jwt" => sc.apply_jwt(&cfg.auth_jwt)?,
43        "mtls" if !cfg.tls_enabled || cfg.tls_client_ca_file.is_empty() => {
44            return Err(SecurityError::Config(
45                "auth.mode: mtls needs tls.enabled and tls.client_ca_file".into(),
46            ));
47        }
48        _ => {}
49    }
50    if let (Some(tok), Some(subj)) = (
51        cfg.auth_bearer_token.as_ref(),
52        cfg.auth_bearer_subject.as_ref(),
53    ) {
54        sc.bearer_tokens.insert(tok.clone(), subj.clone());
55    } else if let Some(tok) = cfg.auth_bearer_token.as_ref() {
56        sc.bearer_tokens.insert(tok.clone(), "anonymous".into());
57    }
58    for (app, (read, write)) in &cfg.app_acl {
59        sc.topic_acl.insert(
60            app.clone(),
61            AclEntry {
62                read: read.clone(),
63                write: write.clone(),
64            },
65        );
66    }
67    let ctx = build_ctx(&sc)?;
68    let rotating = match (cfg.tls_enabled, sc.tls_cert.clone(), sc.tls_key.clone()) {
69        (true, Some(c), Some(k)) => {
70            Some(RotatingTlsConfig::load(c, k, sc.client_ca.clone()).map_err(SecurityError::Tls)?)
71        }
72        _ => None,
73    };
74    Ok((ctx, rotating))
75}
76
77/// HTTP-specific auth hook: extracts the bearer/JWT token from the
78/// request's `Authorization` header and checks it against [`AuthMode`].
79///
80/// # Errors
81/// [`AuthError`] on missing/malformed/rejected.
82pub fn authenticate_http(
83    auth: &AuthMode,
84    authorization_header: Option<&str>,
85    mtls_subject: Option<AuthSubject>,
86) -> Result<AuthSubject, AuthError> {
87    bs_authenticate(auth, authorization_header, None, mtls_subject)
88}
89
90/// The Application name a request's path names — the ACL's resource
91/// unit (see the module doc). `None` for a request path that names no
92/// application (`/dds/rest1/applications` itself, `/wsdl/*`, an admin
93/// endpoint) — those are `Read`-checked against `*`.
94#[must_use]
95pub fn application_of_path(path: &str) -> Option<String> {
96    let marker = "/applications/";
97    let start = path.find(marker)? + marker.len();
98    let rest = &path[start..];
99    let end = rest.find('/').unwrap_or(rest.len());
100    let name = &rest[..end];
101    (!name.is_empty()).then(|| name.to_string())
102}
103
104/// The [`AclOp`] a request implies: a `GET`/`HEAD` reads, everything
105/// else (`POST`/`PUT`/`DELETE`) writes.
106#[must_use]
107pub fn op_of_method(method: &str) -> AclOp {
108    if method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD") {
109        AclOp::Read
110    } else {
111        AclOp::Write
112    }
113}
114
115#[cfg(test)]
116#[allow(clippy::unwrap_used, clippy::expect_used)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn application_of_path_extracts_the_name() {
122        assert_eq!(
123            application_of_path("/dds/rest1/applications/app1/domain_participants"),
124            Some("app1".to_string())
125        );
126    }
127
128    #[test]
129    fn application_of_path_none_for_the_collection_itself() {
130        assert_eq!(application_of_path("/dds/rest1/applications"), None);
131    }
132
133    #[test]
134    fn op_of_method_maps_get_to_read() {
135        assert_eq!(op_of_method("GET"), AclOp::Read);
136        assert_eq!(op_of_method("HEAD"), AclOp::Read);
137        assert_eq!(op_of_method("POST"), AclOp::Write);
138        assert_eq!(op_of_method("DELETE"), AclOp::Write);
139    }
140
141    #[test]
142    fn ctx_from_config_with_bearer_tokens() {
143        let mut cfg = super::super::config::DaemonConfig::default_for_dev();
144        cfg.auth_mode = "bearer".into();
145        cfg.auth_bearer_token = Some("tok123".into());
146        cfg.auth_bearer_subject = Some("alice".into());
147        cfg.app_acl
148            .insert("app1".into(), (vec!["alice".into()], vec!["alice".into()]));
149        let (ctx, rot) = ctx_from_daemon_config(&cfg).expect("build");
150        assert!(rot.is_none());
151        assert!(matches!(*ctx.auth, AuthMode::Bearer { .. }));
152        let alice = AuthSubject::new("alice");
153        assert!(authorize(&ctx.acl, &alice, AclOp::Read, "app1"));
154    }
155
156    #[test]
157    fn ctx_from_config_tls_enabled_without_paths_rejected() {
158        let mut cfg = super::super::config::DaemonConfig::default_for_dev();
159        cfg.tls_enabled = true;
160        assert!(matches!(
161            ctx_from_daemon_config(&cfg).unwrap_err(),
162            SecurityError::Tls(_)
163        ));
164    }
165}