Skip to main content

zerodds_bridge_security/
acl.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! §7.3 Topic-ACL — Read/Write-Permissions pro Topic mit
5//! Wildcard- und Group-Match.
6//!
7//! Match-Rules:
8//!
9//! * `*` — alle Subjects (Wildcard).
10//! * `<name>` — exakter Match auf [`AuthSubject::name`].
11//! * `*group:<g>*` — `subject.groups.contains(<g>)`.
12//!
13//! Bei Failure: 403 (HTTP-Bridges) oder Subscribe-Reject (TCP).
14
15use std::collections::HashMap;
16
17use crate::auth::AuthSubject;
18
19/// Welche Operation der Caller ausführen will.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum AclOp {
22    /// Lese-Permission (Subscribe / Read-Sample).
23    Read,
24    /// Schreib-Permission (Publish / Write-Sample).
25    Write,
26}
27
28/// ACL-Entry für ein Topic.
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct AclEntry {
31    /// Subjects mit Read-Permission.
32    pub read: Vec<String>,
33    /// Subjects mit Write-Permission.
34    pub write: Vec<String>,
35}
36
37impl AclEntry {
38    /// Convenience: alle dürfen lesen+schreiben.
39    #[must_use]
40    pub fn allow_all() -> Self {
41        Self {
42            read: vec!["*".to_string()],
43            write: vec!["*".to_string()],
44        }
45    }
46}
47
48/// Komplette ACL über alle Topics.
49#[derive(Debug, Clone, Default)]
50pub struct Acl {
51    entries: HashMap<String, AclEntry>,
52    /// Default für Topics, die nicht in der Map sind.
53    /// `None` = deny-by-default (Spec §7.3 Default).
54    default: Option<AclEntry>,
55}
56
57impl Acl {
58    /// Leere ACL — alles deny.
59    #[must_use]
60    pub fn deny_all() -> Self {
61        Self {
62            entries: HashMap::new(),
63            default: None,
64        }
65    }
66
67    /// Open ACL — alles allow (für `--auth-mode none` ohne Topic-Limit).
68    #[must_use]
69    pub fn allow_all() -> Self {
70        Self {
71            entries: HashMap::new(),
72            default: Some(AclEntry::allow_all()),
73        }
74    }
75
76    /// Setze einen Topic-Entry.
77    pub fn set(&mut self, topic: impl Into<String>, entry: AclEntry) {
78        self.entries.insert(topic.into(), entry);
79    }
80
81    /// Setze den Default-Entry für unbekannte Topics.
82    pub fn set_default(&mut self, entry: AclEntry) {
83        self.default = Some(entry);
84    }
85
86    /// Prüfe Permission. Liefert `true` = allow, `false` = deny.
87    #[must_use]
88    pub fn check(&self, subject: &AuthSubject, op: AclOp, topic: &str) -> bool {
89        let entry = self.entries.get(topic).or(self.default.as_ref());
90        let Some(entry) = entry else {
91            return false;
92        };
93        let list = match op {
94            AclOp::Read => &entry.read,
95            AclOp::Write => &entry.write,
96        };
97        list.iter().any(|pat| match_subject(pat, subject))
98    }
99}
100
101fn match_subject(pat: &str, subject: &AuthSubject) -> bool {
102    if pat == "*" {
103        return true;
104    }
105    if let Some(group) = pat
106        .strip_prefix("*group:")
107        .and_then(|s| s.strip_suffix('*'))
108    {
109        return subject.groups.iter().any(|g| g == group);
110    }
111    pat == subject.name
112}
113
114#[cfg(test)]
115#[allow(clippy::expect_used, clippy::unwrap_used)]
116mod tests {
117    use super::*;
118
119    fn alice() -> AuthSubject {
120        AuthSubject::new("alice").with_group("engineers")
121    }
122    fn bob() -> AuthSubject {
123        AuthSubject::new("bob")
124    }
125
126    #[test]
127    fn deny_by_default() {
128        let acl = Acl::deny_all();
129        assert!(!acl.check(&alice(), AclOp::Read, "Trade"));
130    }
131
132    #[test]
133    fn explicit_allow_for_user() {
134        let mut acl = Acl::deny_all();
135        acl.set(
136            "Trade",
137            AclEntry {
138                read: vec!["alice".into()],
139                write: vec!["alice".into()],
140            },
141        );
142        assert!(acl.check(&alice(), AclOp::Read, "Trade"));
143        assert!(acl.check(&alice(), AclOp::Write, "Trade"));
144        assert!(!acl.check(&bob(), AclOp::Read, "Trade"));
145    }
146
147    #[test]
148    fn star_wildcard_allows_anyone() {
149        let mut acl = Acl::deny_all();
150        acl.set(
151            "Public",
152            AclEntry {
153                read: vec!["*".into()],
154                write: vec!["alice".into()],
155            },
156        );
157        assert!(acl.check(&bob(), AclOp::Read, "Public"));
158        assert!(!acl.check(&bob(), AclOp::Write, "Public"));
159    }
160
161    #[test]
162    fn group_wildcard_allows_members() {
163        let mut acl = Acl::deny_all();
164        acl.set(
165            "EngOnly",
166            AclEntry {
167                read: vec!["*group:engineers*".into()],
168                write: vec!["*group:engineers*".into()],
169            },
170        );
171        assert!(acl.check(&alice(), AclOp::Read, "EngOnly"));
172        assert!(!acl.check(&bob(), AclOp::Read, "EngOnly"));
173    }
174
175    #[test]
176    fn default_entry_used_for_unknown_topic() {
177        let mut acl = Acl::deny_all();
178        acl.set_default(AclEntry {
179            read: vec!["*".into()],
180            write: vec![],
181        });
182        assert!(acl.check(&bob(), AclOp::Read, "AnythingNew"));
183        assert!(!acl.check(&bob(), AclOp::Write, "AnythingNew"));
184    }
185
186    #[test]
187    fn write_uses_write_list_only() {
188        let mut acl = Acl::deny_all();
189        acl.set(
190            "T",
191            AclEntry {
192                read: vec!["*".into()],
193                write: vec!["alice".into()],
194            },
195        );
196        assert!(acl.check(&alice(), AclOp::Write, "T"));
197        assert!(!acl.check(&bob(), AclOp::Write, "T"));
198    }
199
200    #[test]
201    fn allow_all_constructor_lets_everyone_through() {
202        let acl = Acl::allow_all();
203        assert!(acl.check(&bob(), AclOp::Read, "X"));
204        assert!(acl.check(&bob(), AclOp::Write, "Y"));
205    }
206}