Skip to main content

zerodds_security_permissions/
plugin.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `AccessControlPlugin`-Impl auf Basis der parsten Permissions-XML.
5
6use alloc::collections::BTreeMap;
7use alloc::string::String;
8use core::sync::atomic::{AtomicU64, Ordering};
9
10use zerodds_security::access_control::{AccessControlPlugin, AccessDecision, PermissionsHandle};
11use zerodds_security::authentication::IdentityHandle;
12use zerodds_security::error::{SecurityError, SecurityErrorKind, SecurityResult};
13use zerodds_security::properties::PropertyList;
14
15use crate::topic_match::topic_match;
16use crate::xml::{Grant, Permissions, parse_permissions_xml};
17
18/// Property-Key fuer das Permissions-XML (inline als String).
19pub const PROP_PERMISSIONS_XML: &str = "dds.sec.access.permissions";
20/// Property-Key fuer das Subject-Name (CN aus dem X.509). Bis future-major
21/// wird das explizit vom Caller gesetzt — spaeter direkt aus dem
22/// `IdentityHandle` abgeleitet.
23pub const PROP_SUBJECT_NAME: &str = "dds.sec.access.subject_name";
24
25/// Access-Control-Plugin: erlaubt Topics nur, wenn sie im Permissions-
26/// XML fuer den Subject-Name matchen.
27pub struct PermissionsAccessControl {
28    next_handle: AtomicU64,
29    slots: BTreeMap<PermissionsHandle, Slot>,
30}
31
32struct Slot {
33    subject_name: String,
34    permissions: Permissions,
35}
36
37impl Default for PermissionsAccessControl {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl PermissionsAccessControl {
44    /// Konstruktor.
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            next_handle: AtomicU64::new(0),
49            slots: BTreeMap::new(),
50        }
51    }
52
53    fn next_id(&self) -> u64 {
54        self.next_handle.fetch_add(1, Ordering::Relaxed) + 1
55    }
56
57    /// Programmatischer Constructor fuer Slot — nuetzlich fuer Tests
58    /// ohne PropertyList-Weg.
59    pub fn register(
60        &mut self,
61        subject_name: String,
62        permissions: Permissions,
63    ) -> PermissionsHandle {
64        let handle = PermissionsHandle(self.next_id());
65        self.slots.insert(
66            handle,
67            Slot {
68                subject_name,
69                permissions,
70            },
71        );
72        handle
73    }
74
75    fn grant(&self, handle: PermissionsHandle) -> Option<(&str, Option<&Grant>)> {
76        let slot = self.slots.get(&handle)?;
77        let g = slot.permissions.find_grant(&slot.subject_name);
78        Some((slot.subject_name.as_str(), g))
79    }
80}
81
82fn topics_allow(patterns: &[String], topic: &str) -> bool {
83    patterns.iter().any(|p| topic_match(p, topic))
84}
85
86fn decide(grant: Option<&Grant>, topic: &str, is_publish: bool) -> AccessDecision {
87    match grant {
88        None => AccessDecision::Deny,
89        Some(g) => {
90            let hit = if is_publish {
91                topics_allow(&g.allow_publish_topics, topic)
92            } else {
93                topics_allow(&g.allow_subscribe_topics, topic)
94            };
95            if hit {
96                AccessDecision::Permit
97            } else if g.default_deny {
98                AccessDecision::Deny
99            } else {
100                // Default-Allow (selten) — trotzdem respektieren.
101                AccessDecision::Permit
102            }
103        }
104    }
105}
106
107impl AccessControlPlugin for PermissionsAccessControl {
108    fn validate_local_permissions(
109        &mut self,
110        _local: IdentityHandle,
111        _participant_guid: [u8; 16],
112        props: &PropertyList,
113    ) -> SecurityResult<PermissionsHandle> {
114        let xml = props.get(PROP_PERMISSIONS_XML).ok_or_else(|| {
115            SecurityError::new(
116                SecurityErrorKind::InvalidConfiguration,
117                "permissions: fehlt dds.sec.access.permissions",
118            )
119        })?;
120        let subject = props.get(PROP_SUBJECT_NAME).ok_or_else(|| {
121            SecurityError::new(
122                SecurityErrorKind::InvalidConfiguration,
123                "permissions: fehlt dds.sec.access.subject_name",
124            )
125        })?;
126        let perms = parse_permissions_xml(xml).map_err(|e| {
127            SecurityError::new(
128                SecurityErrorKind::InvalidConfiguration,
129                alloc::format!("permissions: {e}"),
130            )
131        })?;
132        Ok(self.register(subject.to_string(), perms))
133    }
134
135    fn validate_remote_permissions(
136        &mut self,
137        _local: IdentityHandle,
138        _remote: IdentityHandle,
139        remote_permissions_token: &[u8],
140        _remote_credential: &[u8],
141    ) -> SecurityResult<PermissionsHandle> {
142        // Remote-Permissions-Token = das Permissions-XML als UTF-8.
143        // Subject-Name aus dem Credential extrahieren ist future-major —
144        // hier nutzen wir den Token selbst als Subject-Quelle.
145        let xml = core::str::from_utf8(remote_permissions_token).map_err(|_| {
146            SecurityError::new(
147                SecurityErrorKind::BadArgument,
148                "permissions: remote_permissions_token ist kein UTF-8",
149            )
150        })?;
151        let perms = parse_permissions_xml(xml).map_err(|e| {
152            SecurityError::new(
153                SecurityErrorKind::BadArgument,
154                alloc::format!("permissions: {e}"),
155            )
156        })?;
157        // Wir speichern den ersten Subject-Namen als den des Remote.
158        let subject = perms
159            .grants
160            .first()
161            .map(|g| g.subject_name.clone())
162            .unwrap_or_default();
163        Ok(self.register(subject, perms))
164    }
165
166    fn check_create_datawriter(
167        &self,
168        perms: PermissionsHandle,
169        topic_name: &str,
170    ) -> SecurityResult<AccessDecision> {
171        let (_, g) = self.grant(perms).ok_or_else(|| {
172            SecurityError::new(
173                SecurityErrorKind::BadArgument,
174                "permissions: unbekannter PermissionsHandle",
175            )
176        })?;
177        Ok(decide(g, topic_name, true))
178    }
179
180    fn check_create_datareader(
181        &self,
182        perms: PermissionsHandle,
183        topic_name: &str,
184    ) -> SecurityResult<AccessDecision> {
185        let (_, g) = self.grant(perms).ok_or_else(|| {
186            SecurityError::new(
187                SecurityErrorKind::BadArgument,
188                "permissions: unbekannter PermissionsHandle",
189            )
190        })?;
191        Ok(decide(g, topic_name, false))
192    }
193
194    fn check_remote_datawriter_match(
195        &self,
196        _local: PermissionsHandle,
197        remote: PermissionsHandle,
198        topic_name: &str,
199    ) -> SecurityResult<AccessDecision> {
200        // Match nur wenn der Remote-Writer auch wirklich publish-Recht
201        // auf dem Topic hat.
202        self.check_create_datawriter(remote, topic_name)
203    }
204
205    fn check_remote_datareader_match(
206        &self,
207        _local: PermissionsHandle,
208        remote: PermissionsHandle,
209        topic_name: &str,
210    ) -> SecurityResult<AccessDecision> {
211        self.check_create_datareader(remote, topic_name)
212    }
213
214    fn plugin_class_id(&self) -> &str {
215        "DDS:Access:Permissions:1.2"
216    }
217}
218
219#[cfg(test)]
220#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
221mod tests {
222    use super::*;
223    use zerodds_security::properties::Property;
224
225    const ALICE_XML: &str = r#"
226<permissions>
227  <grant><subject_name>CN=alice</subject_name>
228    <allow_rule>
229      <publish><topic>Chatter</topic><topic>sensor_*</topic></publish>
230      <subscribe><topic>Echo</topic></subscribe>
231    </allow_rule>
232    <default>DENY</default>
233  </grant>
234</permissions>
235"#;
236
237    fn build_alice() -> (PermissionsAccessControl, PermissionsHandle) {
238        let mut ac = PermissionsAccessControl::new();
239        let perms = parse_permissions_xml(ALICE_XML).unwrap();
240        let h = ac.register("CN=alice".into(), perms);
241        (ac, h)
242    }
243
244    #[test]
245    fn permit_on_exact_match() {
246        let (ac, h) = build_alice();
247        let d = ac.check_create_datawriter(h, "Chatter").unwrap();
248        assert_eq!(d, AccessDecision::Permit);
249    }
250
251    #[test]
252    fn permit_on_wildcard_match() {
253        let (ac, h) = build_alice();
254        let d = ac.check_create_datawriter(h, "sensor_temp").unwrap();
255        assert_eq!(d, AccessDecision::Permit);
256    }
257
258    #[test]
259    fn deny_on_non_matching_topic() {
260        let (ac, h) = build_alice();
261        let d = ac.check_create_datawriter(h, "actuator_x").unwrap();
262        assert_eq!(d, AccessDecision::Deny);
263    }
264
265    #[test]
266    fn deny_writer_when_only_subscribe_granted() {
267        let (ac, h) = build_alice();
268        let d = ac.check_create_datawriter(h, "Echo").unwrap();
269        assert_eq!(d, AccessDecision::Deny);
270    }
271
272    #[test]
273    fn permit_reader_on_subscribe_allowed_topic() {
274        let (ac, h) = build_alice();
275        let d = ac.check_create_datareader(h, "Echo").unwrap();
276        assert_eq!(d, AccessDecision::Permit);
277    }
278
279    #[test]
280    fn plugin_class_id_matches_spec() {
281        let ac = PermissionsAccessControl::new();
282        assert_eq!(ac.plugin_class_id(), "DDS:Access:Permissions:1.2");
283    }
284
285    #[test]
286    fn property_list_driver_loads_permissions() {
287        let mut ac = PermissionsAccessControl::new();
288        let props = PropertyList::new()
289            .with(Property::local(PROP_PERMISSIONS_XML, ALICE_XML.to_string()))
290            .with(Property::local(PROP_SUBJECT_NAME, "CN=alice"));
291        let h = ac
292            .validate_local_permissions(IdentityHandle(1), [0xAA; 16], &props)
293            .expect("validate via props");
294        assert_eq!(
295            ac.check_create_datawriter(h, "Chatter").unwrap(),
296            AccessDecision::Permit,
297        );
298    }
299
300    #[test]
301    fn missing_permissions_property_is_invalid_configuration() {
302        let mut ac = PermissionsAccessControl::new();
303        let props = PropertyList::new();
304        let err = ac
305            .validate_local_permissions(IdentityHandle(1), [0xAA; 16], &props)
306            .unwrap_err();
307        assert_eq!(err.kind, SecurityErrorKind::InvalidConfiguration);
308    }
309}