1use 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#[derive(Debug)]
23pub struct LiveSecurity(std::sync::RwLock<Arc<SecurityCtx>>);
24
25impl LiveSecurity {
26 #[must_use]
28 pub fn new(ctx: SecurityCtx) -> Self {
29 Self(std::sync::RwLock::new(Arc::new(ctx)))
30 }
31
32 #[must_use]
34 pub fn current(&self) -> Arc<SecurityCtx> {
35 match self.0.read() {
36 Ok(g) => Arc::clone(&g),
37 Err(p) => Arc::clone(&p.into_inner()),
38 }
39 }
40
41 pub fn replace(&self, ctx: SecurityCtx) {
43 match self.0.write() {
44 Ok(mut g) => *g = Arc::new(ctx),
45 Err(p) => *p.into_inner() = Arc::new(ctx),
46 }
47 }
48}
49
50#[derive(Clone)]
52pub struct SecurityCtx {
53 pub tls: Option<Arc<rustls::ServerConfig>>,
55 pub auth: Arc<AuthMode>,
57 pub acl: Arc<Acl>,
59}
60
61impl core::fmt::Debug for SecurityCtx {
62 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63 f.debug_struct("SecurityCtx")
64 .field("tls_enabled", &self.tls.is_some())
65 .field("auth", &self.auth)
66 .finish_non_exhaustive()
67 }
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct SecurityConfig {
73 pub tls_cert: Option<PathBuf>,
75 pub tls_key: Option<PathBuf>,
77 pub client_ca: Option<PathBuf>,
79 pub auth_mode: String,
81 pub bearer_tokens: HashMap<String, String>,
83 pub jwt_pubkey_der: Option<Vec<u8>>,
85 pub jwt_expected_iss: Option<String>,
87 pub jwt_expected_aud: Option<String>,
89 pub jwt_required_claim: Option<(String, String)>,
91 pub sasl_users: HashMap<String, String>,
93 pub topic_acl: HashMap<String, AclEntry>,
95 pub topic_acl_default: Option<AclEntry>,
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct JwtSettings {
103 pub public_key: String,
105 pub issuer: Option<String>,
107 pub audience: Option<String>,
109 pub required_claim: Option<String>,
111}
112
113impl SecurityConfig {
114 pub fn apply_jwt(&mut self, jwt: &JwtSettings) -> Result<(), SecurityError> {
119 if jwt.public_key.is_empty() {
120 return Err(SecurityError::Config(
121 "auth.mode: jwt needs auth.jwt.public_key".into(),
122 ));
123 }
124 let pem = std::fs::read_to_string(&jwt.public_key).map_err(|e| {
125 SecurityError::Config(format!("auth.jwt.public_key {}: {e}", jwt.public_key))
126 })?;
127 self.jwt_pubkey_der = Some(
128 crate::auth::jwt_public_key_from_pem(&pem)
129 .map_err(|e| SecurityError::Config(format!("auth.jwt.public_key: {e}")))?,
130 );
131 self.jwt_expected_iss = jwt.issuer.clone();
132 self.jwt_expected_aud = jwt.audience.clone();
133 self.jwt_required_claim = match &jwt.required_claim {
134 None => None,
135 Some(rc) => {
136 let (k, v) = rc.split_once('=').ok_or_else(|| {
137 SecurityError::Config(format!(
138 "auth.jwt.required_claim {rc}: expected claim=value"
139 ))
140 })?;
141 Some((k.trim().to_string(), v.trim().to_string()))
142 }
143 };
144 Ok(())
145 }
146}
147
148#[derive(Debug)]
150pub enum SecurityError {
151 Tls(TlsConfigError),
153 UnknownAuthMode(String),
155 MissingAuthInput(&'static str),
157 Config(String),
160}
161
162impl core::fmt::Display for SecurityError {
163 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
164 match self {
165 Self::Tls(e) => write!(f, "tls: {e}"),
166 Self::UnknownAuthMode(m) => write!(f, "unknown auth-mode: {m}"),
167 Self::MissingAuthInput(s) => write!(f, "missing auth input: {s}"),
168 Self::Config(m) => f.write_str(m),
169 }
170 }
171}
172
173impl std::error::Error for SecurityError {}
174
175impl From<TlsConfigError> for SecurityError {
176 fn from(e: TlsConfigError) -> Self {
177 Self::Tls(e)
178 }
179}
180
181pub fn build_ctx(cfg: &SecurityConfig) -> Result<SecurityCtx, SecurityError> {
187 let tls = match (&cfg.tls_cert, &cfg.tls_key) {
188 (Some(c), Some(k)) => match &cfg.client_ca {
189 Some(ca) => Some(load_server_config_with_client_auth(c, k, ca)?),
190 None => Some(load_server_config(c, k)?),
191 },
192 (None, None) => None,
193 _ => {
194 return Err(SecurityError::Tls(TlsConfigError::Rustls(
195 "tls_cert and tls_key must be set together".to_string(),
196 )));
197 }
198 };
199
200 let auth = match cfg.auth_mode.as_str() {
201 "" | "none" => AuthMode::None,
202 "bearer" => {
203 let mut tokens: HashMap<String, AuthSubject> = HashMap::new();
204 for (tok, name) in &cfg.bearer_tokens {
205 tokens.insert(tok.clone(), AuthSubject::new(name.clone()));
206 }
207 AuthMode::Bearer { tokens }
208 }
209 "jwt" => {
210 let der = cfg
211 .jwt_pubkey_der
212 .clone()
213 .ok_or(SecurityError::MissingAuthInput("jwt_pubkey_der"))?;
214 AuthMode::Jwt {
215 pkcs1_pubkey_der: der,
216 expected_issuer: cfg.jwt_expected_iss.clone(),
217 expected_audience: cfg.jwt_expected_aud.clone(),
218 required_claim: cfg.jwt_required_claim.clone(),
219 }
220 }
221 "mtls" => AuthMode::Mtls,
222 "sasl" => {
223 if cfg.sasl_users.is_empty() {
224 return Err(SecurityError::MissingAuthInput("sasl_users"));
225 }
226 AuthMode::SaslPlain {
227 users: cfg.sasl_users.clone(),
228 }
229 }
230 other => return Err(SecurityError::UnknownAuthMode(other.to_string())),
231 };
232
233 let mut acl = if cfg.topic_acl.is_empty() && cfg.topic_acl_default.is_none() {
234 if matches!(auth, AuthMode::None) {
235 Acl::allow_all()
236 } else {
237 Acl::deny_all()
238 }
239 } else {
240 Acl::deny_all()
241 };
242 for (topic, entry) in &cfg.topic_acl {
243 acl.set(topic.clone(), entry.clone());
244 }
245 if let Some(d) = &cfg.topic_acl_default {
246 acl.set_default(d.clone());
247 }
248
249 Ok(SecurityCtx {
250 tls,
251 auth: Arc::new(auth),
252 acl: Arc::new(acl),
253 })
254}
255
256pub fn authenticate(
264 auth: &AuthMode,
265 authorization_header: Option<&str>,
266 sasl_plain_blob: Option<&[u8]>,
267 mtls_subject: Option<AuthSubject>,
268) -> Result<AuthSubject, AuthError> {
269 let input = AuthInput {
270 authorization_header,
271 sasl_plain_blob,
272 mtls_subject,
273 };
274 auth.validate(&input)
275}
276
277#[must_use]
279pub fn authorize(acl: &Acl, subject: &AuthSubject, op: AclOp, topic: &str) -> bool {
280 acl.check(subject, op, topic)
281}
282
283#[must_use]
289pub fn extract_mtls_subject(conn: &rustls::ServerConnection) -> Option<AuthSubject> {
290 let certs = conn.peer_certificates()?;
291 Some(mtls_subject_from_der(certs.first()?.as_ref()))
292}
293
294#[must_use]
302pub fn mtls_subject_from_der(leaf_der: &[u8]) -> AuthSubject {
303 let fingerprint = format!("mtls:{}", sha256_hex(leaf_der));
304 let mut subject = AuthSubject::new(subject_dn(leaf_der).unwrap_or_else(|| fingerprint.clone()));
305 subject.fingerprint = Some(fingerprint);
306 subject
307}
308
309fn subject_dn(der: &[u8]) -> Option<String> {
313 use x509_parser::prelude::{FromDer, X509Certificate};
314 let (_, cert) = X509Certificate::from_der(der).ok()?;
315 let registry = x509_parser::objects::oid_registry();
316 let mut rdns: Vec<String> = Vec::new();
317 for rdn in cert.subject().iter() {
318 let mut parts: Vec<String> = Vec::new();
319 for attr in rdn.iter() {
320 let key = x509_parser::objects::oid2abbrev(attr.attr_type(), registry)
321 .map_or_else(|_| attr.attr_type().to_id_string(), ToString::to_string);
322 let value = attr.as_str().ok()?;
323 parts.push(format!("{key}={}", escape_dn_value(value)));
324 }
325 rdns.push(parts.join("+"));
326 }
327 rdns.reverse();
328 let dn = rdns.join(",");
329 (!dn.is_empty()).then_some(dn)
330}
331
332fn escape_dn_value(v: &str) -> String {
334 let mut out = String::with_capacity(v.len());
335 let last = v.chars().count().saturating_sub(1);
336 for (i, c) in v.chars().enumerate() {
337 let special = matches!(c, '"' | '+' | ',' | ';' | '<' | '>' | '\\')
338 || (i == 0 && (c == ' ' || c == '#'))
339 || (i == last && c == ' ');
340 if c == '\0' {
341 out.push_str("\\00");
342 continue;
343 }
344 if special {
345 out.push('\\');
346 }
347 out.push(c);
348 }
349 out
350}
351
352fn sha256_hex(data: &[u8]) -> String {
353 use crate::backend::digest::{Context, SHA256};
354 let mut ctx = Context::new(&SHA256);
355 ctx.update(data);
356 let d = ctx.finish();
357 let mut s = String::with_capacity(64);
358 for b in d.as_ref() {
359 s.push_str(&format!("{b:02x}"));
360 }
361 s
362}
363
364#[cfg(test)]
365#[allow(clippy::expect_used, clippy::unwrap_used)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn build_default_yields_none_auth_allow_all_acl() {
371 let cfg = SecurityConfig::default();
372 let ctx = build_ctx(&cfg).unwrap();
373 assert!(ctx.tls.is_none());
374 assert!(matches!(*ctx.auth, AuthMode::None));
375 assert!(ctx.acl.check(&AuthSubject::anonymous(), AclOp::Read, "X"));
376 }
377
378 #[test]
379 fn build_bearer_with_tokens() {
380 let mut cfg = SecurityConfig {
381 auth_mode: "bearer".into(),
382 ..Default::default()
383 };
384 cfg.bearer_tokens.insert("t".into(), "alice".into());
385 let ctx = build_ctx(&cfg).unwrap();
386 let s = authenticate(&ctx.auth, Some("Bearer t"), None, None).unwrap();
387 assert_eq!(s.name, "alice");
388 }
389
390 #[test]
391 fn build_sasl_with_users() {
392 let mut cfg = SecurityConfig {
393 auth_mode: "sasl".into(),
394 ..Default::default()
395 };
396 cfg.sasl_users.insert("u".into(), "p".into());
397 let ctx = build_ctx(&cfg).unwrap();
398 let s = authenticate(&ctx.auth, None, Some(b"\0u\0p"), None).unwrap();
399 assert_eq!(s.name, "u");
400 }
401
402 #[test]
403 fn build_sasl_without_users_rejected() {
404 let cfg = SecurityConfig {
405 auth_mode: "sasl".into(),
406 ..Default::default()
407 };
408 let err = build_ctx(&cfg).unwrap_err();
409 assert!(matches!(err, SecurityError::MissingAuthInput(_)));
410 }
411
412 #[test]
413 fn unknown_auth_mode_rejected() {
414 let cfg = SecurityConfig {
415 auth_mode: "weird".into(),
416 ..Default::default()
417 };
418 let err = build_ctx(&cfg).unwrap_err();
419 assert!(matches!(err, SecurityError::UnknownAuthMode(_)));
420 }
421
422 #[test]
423 fn jwt_without_key_rejected() {
424 let cfg = SecurityConfig {
425 auth_mode: "jwt".into(),
426 ..Default::default()
427 };
428 let err = build_ctx(&cfg).unwrap_err();
429 assert!(matches!(err, SecurityError::MissingAuthInput(_)));
430 }
431
432 #[test]
433 fn explicit_acl_overrides_open_default() {
434 let mut cfg = SecurityConfig::default();
435 cfg.topic_acl.insert(
436 "T".into(),
437 AclEntry {
438 read: vec!["alice".into()],
439 write: vec!["alice".into()],
440 },
441 );
442 let ctx = build_ctx(&cfg).unwrap();
443 let alice = AuthSubject::new("alice");
444 let bob = AuthSubject::new("bob");
445 assert!(authorize(&ctx.acl, &alice, AclOp::Read, "T"));
446 assert!(!authorize(&ctx.acl, &bob, AclOp::Read, "T"));
447 assert!(!authorize(&ctx.acl, &alice, AclOp::Read, "Other"));
448 }
449
450 #[test]
451 fn explicit_acl_default_used_for_unknown() {
452 let cfg = SecurityConfig {
453 topic_acl_default: Some(AclEntry {
454 read: vec!["*".into()],
455 write: vec![],
456 }),
457 ..SecurityConfig::default()
458 };
459 let ctx = build_ctx(&cfg).unwrap();
460 let bob = AuthSubject::new("bob");
461 assert!(authorize(&ctx.acl, &bob, AclOp::Read, "Anything"));
462 assert!(!authorize(&ctx.acl, &bob, AclOp::Write, "Anything"));
463 }
464
465 #[test]
466 fn partial_tls_paths_rejected() {
467 let cfg = SecurityConfig {
468 tls_cert: Some("/x".into()),
469 tls_key: None,
470 ..Default::default()
471 };
472 let err = build_ctx(&cfg).unwrap_err();
473 assert!(matches!(err, SecurityError::Tls(_)));
474 }
475
476 #[test]
480 fn the_mtls_subject_is_the_dn_and_the_acl_also_takes_the_fingerprint() {
481 let mut p = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
482 p.distinguished_name
483 .push(rcgen::DnType::OrganizationName, "Example, Inc");
484 p.distinguished_name
485 .push(rcgen::DnType::CommonName, "alice");
486 let key = rcgen::KeyPair::generate().unwrap();
487 let cert = p.self_signed(&key).unwrap();
488 let s = mtls_subject_from_der(cert.der());
489 let pem = std::env::temp_dir().join(format!("zd-dn-{}.pem", std::process::id()));
492 std::fs::write(&pem, cert.pem()).unwrap();
493 let out = std::process::Command::new("openssl")
494 .args(["x509", "-noout", "-subject", "-nameopt", "RFC2253", "-in"])
495 .arg(&pem)
496 .output()
497 .expect("openssl on PATH: the reference for the DN string");
498 let _ = std::fs::remove_file(&pem);
499 let reference = String::from_utf8(out.stdout).unwrap();
500 let reference = reference.trim().trim_start_matches("subject=");
501 assert_eq!(s.name, reference);
502 assert!(s.name.contains("Example\\, Inc"), "{}", s.name);
503 let fp = s.fingerprint.clone().unwrap();
504 assert!(fp.starts_with("mtls:") && fp.len() == 5 + 64, "{fp}");
505 for pat in [s.name.clone(), fp] {
506 let mut cfg = SecurityConfig::default();
507 cfg.topic_acl.insert(
508 "T".into(),
509 AclEntry {
510 read: vec![pat.clone()],
511 write: Vec::new(),
512 },
513 );
514 let ctx = build_ctx(&cfg).unwrap();
515 assert!(authorize(&ctx.acl, &s, AclOp::Read, "T"), "{pat}");
516 assert!(!authorize(
517 &ctx.acl,
518 &AuthSubject::new("bob"),
519 AclOp::Read,
520 "T"
521 ));
522 }
523 }
524}