Skip to main content

zerodds_web/
access_control.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! DDS-WEB Access Control Decision Engine — Spec §7.3.
5//!
6//! Implementiert §7.3 von partial auf
7//! done.
8//!
9//! Spec-Quelle: OMG DDS-WEB 1.0 §7.3 (S. 11-13) — `AccessController`
10//! mit Permission-Rules-Engine, die fuer jede REST-Resource-Operation
11//! eine `Permit` / `Deny`-Entscheidung trifft.
12//!
13//! Das Datenmodell folgt DDS-Security 1.2 §9.4.1.2 Permissions
14//! Document (subject_name + grants[] + rules[allow|deny] mit
15//! topic-Filter + Validity-Window).
16
17use alloc::string::{String, ToString};
18use alloc::vec::Vec;
19
20/// Rules-Engine-Entscheidung.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum Decision {
23    /// Anfrage erlaubt.
24    Permit,
25    /// Anfrage abgelehnt — der Caller erhaelt HTTP 403.
26    #[default]
27    Deny,
28}
29
30/// Operations-Klasse (publish/subscribe/admin) auf einem Topic /
31/// einer Resource.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Operation {
34    /// `POST /domain_participant/.../publishers` etc.
35    Publish,
36    /// `POST /domain_participant/.../subscribers` etc.
37    Subscribe,
38    /// `POST/PUT/DELETE` auf `qos_profile`/`type`/`application`.
39    Admin,
40    /// `GET` (read-only).
41    Read,
42}
43
44/// Eine einzelne Allow-/Deny-Rule (Spec §7.3 + DDS-Security §9.4.1.2.2).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Rule {
47    /// Allow vs Deny.
48    pub effect: Decision,
49    /// Topic-Glob (`*` = alle, sonst exact-match oder
50    /// `prefix*`/`*suffix`).
51    pub topic_glob: String,
52    /// Erlaubte Operationen.
53    pub operations: Vec<Operation>,
54}
55
56/// Subject-spezifischer Permissions-Block (Spec §7.3).
57#[derive(Debug, Clone, PartialEq, Eq, Default)]
58pub struct Permissions {
59    /// Subject-Name (entspricht DDS-Security Identity-Certificate-DN
60    /// oder REST-API-Key-Owner).
61    pub subject_name: String,
62    /// Default-Decision wenn keine Rule matched.
63    pub default: Decision,
64    /// Liste der Rules in Reihenfolge.
65    pub rules: Vec<Rule>,
66}
67
68impl Permissions {
69    /// Liefert die Decision fuer eine konkrete Operation auf einem
70    /// Topic. Spec §7.3 Decision-Tree:
71    ///
72    /// 1. Erste matchende Rule mit `effect = Deny` -> `Deny`.
73    /// 2. Erste matchende Rule mit `effect = Permit` -> `Permit`.
74    /// 3. Sonst `default`.
75    #[must_use]
76    pub fn evaluate(&self, op: Operation, topic: &str) -> Decision {
77        let mut found_permit = false;
78        for r in &self.rules {
79            if !r.operations.contains(&op) {
80                continue;
81            }
82            if !match_glob(&r.topic_glob, topic) {
83                continue;
84            }
85            match r.effect {
86                Decision::Deny => return Decision::Deny,
87                Decision::Permit => found_permit = true,
88            }
89        }
90        if found_permit {
91            Decision::Permit
92        } else {
93            self.default
94        }
95    }
96}
97
98/// Glob-Matcher fuer Topic-Patterns analog zu `fnmatch_simple` in
99/// `model.rs`.
100fn match_glob(pattern: &str, topic: &str) -> bool {
101    if pattern == "*" {
102        return true;
103    }
104    if let Some(rest) = pattern.strip_prefix('*') {
105        return topic.ends_with(rest);
106    }
107    if let Some(rest) = pattern.strip_suffix('*') {
108        return topic.starts_with(rest);
109    }
110    pattern == topic
111}
112
113impl Rule {
114    /// Convenience-Konstruktor.
115    #[must_use]
116    pub fn allow(topic_glob: &str, operations: Vec<Operation>) -> Self {
117        Self {
118            effect: Decision::Permit,
119            topic_glob: topic_glob.to_string(),
120            operations,
121        }
122    }
123
124    /// Convenience-Konstruktor.
125    #[must_use]
126    pub fn deny(topic_glob: &str, operations: Vec<Operation>) -> Self {
127        Self {
128            effect: Decision::Deny,
129            topic_glob: topic_glob.to_string(),
130            operations,
131        }
132    }
133}
134
135#[cfg(test)]
136#[allow(clippy::expect_used)]
137mod tests {
138    use super::*;
139
140    fn ops_all() -> Vec<Operation> {
141        alloc::vec![
142            Operation::Publish,
143            Operation::Subscribe,
144            Operation::Admin,
145            Operation::Read,
146        ]
147    }
148
149    #[test]
150    fn empty_permissions_returns_default_deny() {
151        let p = Permissions {
152            subject_name: "Alice".to_string(),
153            default: Decision::Deny,
154            rules: Vec::new(),
155        };
156        assert_eq!(p.evaluate(Operation::Publish, "Sensor"), Decision::Deny);
157    }
158
159    #[test]
160    fn allow_rule_grants_permit() {
161        let p = Permissions {
162            subject_name: "Alice".to_string(),
163            default: Decision::Deny,
164            rules: alloc::vec![Rule::allow("Sensor", alloc::vec![Operation::Publish])],
165        };
166        assert_eq!(p.evaluate(Operation::Publish, "Sensor"), Decision::Permit);
167    }
168
169    #[test]
170    fn deny_rule_overrides_allow_when_listed_first() {
171        let p = Permissions {
172            subject_name: "Alice".to_string(),
173            default: Decision::Permit,
174            rules: alloc::vec![
175                Rule::deny("SecretTopic", alloc::vec![Operation::Subscribe]),
176                Rule::allow("*", ops_all()),
177            ],
178        };
179        assert_eq!(
180            p.evaluate(Operation::Subscribe, "SecretTopic"),
181            Decision::Deny
182        );
183        assert_eq!(
184            p.evaluate(Operation::Subscribe, "PublicTopic"),
185            Decision::Permit
186        );
187    }
188
189    #[test]
190    fn glob_prefix_pattern_matches() {
191        let p = Permissions {
192            subject_name: "Alice".to_string(),
193            default: Decision::Deny,
194            rules: alloc::vec![Rule::allow("Sensor*", alloc::vec![Operation::Subscribe])],
195        };
196        assert_eq!(
197            p.evaluate(Operation::Subscribe, "SensorTemperature"),
198            Decision::Permit
199        );
200        assert_eq!(p.evaluate(Operation::Subscribe, "Other"), Decision::Deny);
201    }
202
203    #[test]
204    fn glob_suffix_pattern_matches() {
205        let p = Permissions {
206            subject_name: "Alice".to_string(),
207            default: Decision::Deny,
208            rules: alloc::vec![Rule::allow("*Result", alloc::vec![Operation::Read])],
209        };
210        assert_eq!(
211            p.evaluate(Operation::Read, "TrackingResult"),
212            Decision::Permit
213        );
214        assert_eq!(p.evaluate(Operation::Read, "Other"), Decision::Deny);
215    }
216
217    #[test]
218    fn operation_mismatch_falls_through_to_default() {
219        let p = Permissions {
220            subject_name: "Alice".to_string(),
221            default: Decision::Deny,
222            rules: alloc::vec![Rule::allow("*", alloc::vec![Operation::Publish])],
223        };
224        assert_eq!(p.evaluate(Operation::Subscribe, "Anything"), Decision::Deny);
225    }
226
227    #[test]
228    fn first_matching_deny_wins_over_later_allow() {
229        let p = Permissions {
230            subject_name: "Alice".to_string(),
231            default: Decision::Permit,
232            rules: alloc::vec![
233                Rule::deny("Restricted", alloc::vec![Operation::Admin]),
234                Rule::allow("*", alloc::vec![Operation::Admin]),
235            ],
236        };
237        assert_eq!(p.evaluate(Operation::Admin, "Restricted"), Decision::Deny);
238        assert_eq!(p.evaluate(Operation::Admin, "Other"), Decision::Permit);
239    }
240}