Skip to main content

matter_controller/
acl.rs

1//! `AccessControl` (0x001F) controller support: ACL entry types, the pure Value
2//! encode/parse, and the lockout guard. Decoder-agnostic (hand-built Value);
3//! the generated matter-clusters decoder is the read byte-parity oracle. M9-D3.
4
5use matter_codec::{Tag, Value};
6use matter_interaction::AttributePath;
7
8/// Cluster ID for `AccessControl` (Matter spec §9.10).
9pub(crate) const ACCESS_CONTROL_CLUSTER: u32 = 0x001F;
10/// Attribute id for the `ACL` list attribute (§9.10.4.1).
11pub(crate) const ATTR_ACL: u32 = 0x0000;
12
13// AccessControlEntryStruct context tags (Matter spec §9.10.5.2).
14const TAG_PRIVILEGE: u8 = 1;
15const TAG_AUTH_MODE: u8 = 2;
16const TAG_SUBJECTS: u8 = 3;
17const TAG_TARGETS: u8 = 4;
18const TAG_FABRIC_INDEX: u8 = 254;
19
20// AccessControlTargetStruct context tags (Matter spec §9.10.5.4).
21const TAG_TARGET_CLUSTER: u8 = 0;
22const TAG_TARGET_ENDPOINT: u8 = 1;
23const TAG_TARGET_DEVICE_TYPE: u8 = 2;
24
25/// ACL privilege level (`AccessControlEntryPrivilegeEnum`, Matter spec §9.10.5.3).
26///
27/// `#[non_exhaustive]` so future spec revisions can add privilege levels without
28/// a breaking change.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum AclPrivilege {
32    /// Can read non-sensitive attributes and invoke non-privileged commands.
33    View,
34    /// Can act as a proxy for a View-level principal.
35    ProxyView,
36    /// Can perform data model operations including writes and commands.
37    Operate,
38    /// Can manage fabric-scoped data.
39    Manage,
40    /// Full administrative control including fabric membership.
41    Administer,
42    /// A privilege value not recognised by this version of the library.
43    Unknown(u8),
44}
45
46impl AclPrivilege {
47    #[allow(clippy::cast_possible_truncation)]
48    // Privilege enum values are spec-typed as uint8 in range 1–5; to_raw is
49    // always within u8 range.
50    fn to_raw(self) -> u8 {
51        match self {
52            Self::View => 1,
53            Self::ProxyView => 2,
54            Self::Operate => 3,
55            Self::Manage => 4,
56            Self::Administer => 5,
57            Self::Unknown(v) => v,
58        }
59    }
60
61    fn from_raw(v: u8) -> Self {
62        match v {
63            1 => Self::View,
64            2 => Self::ProxyView,
65            3 => Self::Operate,
66            4 => Self::Manage,
67            5 => Self::Administer,
68            o => Self::Unknown(o),
69        }
70    }
71}
72
73/// ACL authentication mode (`AccessControlEntryAuthModeEnum`, Matter spec §9.10.5.3).
74///
75/// `#[non_exhaustive]` so future spec revisions can add modes without a breaking change.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum AclAuthMode {
79    /// Password Authenticated Session Establishment.
80    Pase,
81    /// Certificate Authenticated Session Establishment (operational sessions).
82    Case,
83    /// Group messaging.
84    Group,
85    /// An auth-mode value not recognised by this version of the library.
86    Unknown(u8),
87}
88
89impl AclAuthMode {
90    #[allow(clippy::cast_possible_truncation)]
91    // AuthMode enum values are spec-typed as uint8 in range 1–3; to_raw is
92    // always within u8 range.
93    fn to_raw(self) -> u8 {
94        match self {
95            Self::Pase => 1,
96            Self::Case => 2,
97            Self::Group => 3,
98            Self::Unknown(v) => v,
99        }
100    }
101
102    fn from_raw(v: u8) -> Self {
103        match v {
104            1 => Self::Pase,
105            2 => Self::Case,
106            3 => Self::Group,
107            o => Self::Unknown(o),
108        }
109    }
110}
111
112/// One ACL target (`AccessControlTargetStruct`, Matter spec §9.10.5.4).
113///
114/// Any field set to `None` is a wildcard: it matches any cluster, endpoint, or
115/// device-type respectively.
116///
117/// `#[non_exhaustive]` so additional target fields from future spec revisions
118/// can be added without a breaking change.
119#[derive(Clone, Debug, PartialEq, Eq, Default)]
120#[non_exhaustive]
121pub struct AclTarget {
122    /// Target cluster id (`None` ⇒ all clusters).
123    pub cluster: Option<u32>,
124    /// Target endpoint id (`None` ⇒ all endpoints).
125    pub endpoint: Option<u16>,
126    /// Target device-type id (`None` ⇒ all device types).
127    pub device_type: Option<u32>,
128}
129
130/// One ACL entry (`AccessControlEntryStruct`, Matter spec §9.10.5.2).
131///
132/// `#[non_exhaustive]` so additional entry fields from future spec revisions
133/// can be added without a breaking change.
134#[derive(Clone, Debug, PartialEq, Eq)]
135#[non_exhaustive]
136pub struct AclEntry {
137    /// Privilege granted by this entry.
138    pub privilege: AclPrivilege,
139    /// Authentication mode required to use this entry.
140    pub auth_mode: AclAuthMode,
141    /// Subject list: `None` ⇒ wildcard (applies to all subjects). `Some(v)` ⇒
142    /// specific node IDs, CAT IDs, or group IDs.
143    pub subjects: Option<Vec<u64>>,
144    /// Target list: `None` ⇒ wildcard (all targets). `Some(v)` ⇒ specific targets.
145    pub targets: Option<Vec<AclTarget>>,
146    /// Fabric index assigned by the device. `None` on write (the device fills
147    /// this in for the accessing fabric); always `Some` on read.
148    pub fabric_index: Option<u8>,
149}
150
151impl AclTarget {
152    /// Construct a target restricting to the given cluster / endpoint /
153    /// device-type. Any `None` is a wildcard for that dimension.
154    ///
155    /// Provided because the struct is `#[non_exhaustive]` and so cannot be
156    /// built with a struct literal outside this crate.
157    #[must_use]
158    pub fn new(cluster: Option<u32>, endpoint: Option<u16>, device_type: Option<u32>) -> Self {
159        Self {
160            cluster,
161            endpoint,
162            device_type,
163        }
164    }
165}
166
167impl AclEntry {
168    /// Construct an ACL entry for a write. `subjects`/`targets` `None` ⇒
169    /// wildcard. `fabric_index` is left `None` — the device fills it in for the
170    /// accessing fabric.
171    ///
172    /// Provided because the struct is `#[non_exhaustive]` and so cannot be
173    /// built with a struct literal outside this crate (e.g. when assembling an
174    /// ACL to pass to [`crate::Node::write_acl`]).
175    #[must_use]
176    pub fn new(
177        privilege: AclPrivilege,
178        auth_mode: AclAuthMode,
179        subjects: Option<Vec<u64>>,
180        targets: Option<Vec<AclTarget>>,
181    ) -> Self {
182        Self {
183            privilege,
184            auth_mode,
185            subjects,
186            targets,
187            fabric_index: None,
188        }
189    }
190}
191
192// ── helpers ──────────────────────────────────────────────────────────────────
193
194fn struct_members(v: &Value) -> Option<&[(Tag, Value)]> {
195    match v {
196        Value::Structure(m) | Value::List(m) => Some(m),
197        _ => None,
198    }
199}
200
201fn ctx(members: &[(Tag, Value)], tag: u8) -> Option<&Value> {
202    members
203        .iter()
204        .find(|(t, _)| *t == Tag::Context(tag))
205        .map(|(_, v)| v)
206}
207
208fn opt_u64_list(v: Option<&Vec<u64>>) -> Value {
209    match v {
210        None => Value::Null,
211        Some(xs) => Value::Array(xs.iter().map(|x| Value::Uint(*x)).collect()),
212    }
213}
214
215fn target_value(t: &AclTarget) -> Value {
216    Value::Structure(vec![
217        (
218            Tag::Context(TAG_TARGET_CLUSTER),
219            t.cluster.map_or(Value::Null, |c| Value::Uint(u64::from(c))),
220        ),
221        (
222            Tag::Context(TAG_TARGET_ENDPOINT),
223            t.endpoint
224                .map_or(Value::Null, |e| Value::Uint(u64::from(e))),
225        ),
226        (
227            Tag::Context(TAG_TARGET_DEVICE_TYPE),
228            t.device_type
229                .map_or(Value::Null, |d| Value::Uint(u64::from(d))),
230        ),
231    ])
232}
233
234// ── encode ───────────────────────────────────────────────────────────────────
235
236/// Encode one ACL entry as an anonymous-tagged `Value::Structure` using the
237/// spec context tags (privilege=1, auth-mode=2, subjects=3, targets=4,
238/// fabric-index=254). The `fabric_index` field is omitted when `None` (write
239/// path: the device fills it in for the accessing fabric).
240pub(crate) fn acl_entry_value(e: &AclEntry) -> Value {
241    let mut m = vec![
242        (
243            Tag::Context(TAG_PRIVILEGE),
244            Value::Uint(u64::from(e.privilege.to_raw())),
245        ),
246        (
247            Tag::Context(TAG_AUTH_MODE),
248            Value::Uint(u64::from(e.auth_mode.to_raw())),
249        ),
250        (
251            Tag::Context(TAG_SUBJECTS),
252            opt_u64_list(e.subjects.as_ref()),
253        ),
254        (
255            Tag::Context(TAG_TARGETS),
256            match &e.targets {
257                None => Value::Null,
258                Some(ts) => Value::Array(ts.iter().map(target_value).collect()),
259            },
260        ),
261    ];
262    if let Some(fi) = e.fabric_index {
263        m.push((Tag::Context(TAG_FABRIC_INDEX), Value::Uint(u64::from(fi))));
264    }
265    Value::Structure(m)
266}
267
268// ── parse ────────────────────────────────────────────────────────────────────
269
270fn parse_target(v: &Value) -> Option<AclTarget> {
271    let m = struct_members(v)?;
272    #[allow(clippy::cast_possible_truncation)]
273    // Target fields are spec-typed: ClusterId=uint32, EndpointNo=uint16,
274    // DeviceTypeId=uint32. Truncation from u64 to the spec width is correct for
275    // all valid wire values.
276    Some(AclTarget {
277        cluster: match ctx(m, TAG_TARGET_CLUSTER) {
278            Some(Value::Uint(u)) => Some(*u as u32),
279            _ => None,
280        },
281        endpoint: match ctx(m, TAG_TARGET_ENDPOINT) {
282            Some(Value::Uint(u)) => Some(*u as u16),
283            _ => None,
284        },
285        device_type: match ctx(m, TAG_TARGET_DEVICE_TYPE) {
286            Some(Value::Uint(u)) => Some(*u as u32),
287            _ => None,
288        },
289    })
290}
291
292fn parse_entry(v: &Value) -> Option<AclEntry> {
293    let m = struct_members(v)?;
294    #[allow(clippy::cast_possible_truncation)]
295    // ACL entry fields are spec-typed: Privilege/AuthMode = enum8 (uint8);
296    // FabricIndex = uint8. Truncation from u64 to u8 is correct for all
297    // valid wire values.
298    Some(AclEntry {
299        privilege: AclPrivilege::from_raw(match ctx(m, TAG_PRIVILEGE)? {
300            Value::Uint(u) => *u as u8,
301            _ => return None,
302        }),
303        auth_mode: AclAuthMode::from_raw(match ctx(m, TAG_AUTH_MODE)? {
304            Value::Uint(u) => *u as u8,
305            _ => return None,
306        }),
307        subjects: match ctx(m, TAG_SUBJECTS) {
308            Some(Value::Array(a)) => Some(
309                a.iter()
310                    .filter_map(|x| {
311                        if let Value::Uint(u) = x {
312                            Some(*u)
313                        } else {
314                            None
315                        }
316                    })
317                    .collect(),
318            ),
319            _ => None,
320        },
321        targets: match ctx(m, TAG_TARGETS) {
322            Some(Value::Array(a)) => Some(a.iter().filter_map(parse_target).collect()),
323            _ => None,
324        },
325        fabric_index: match ctx(m, TAG_FABRIC_INDEX) {
326            Some(Value::Uint(u)) => Some(*u as u8),
327            _ => None,
328        },
329    })
330}
331
332/// Parse the `ACL` list attribute (0x0000) from a read result.
333///
334/// Searches `reports` for the attribute path whose `cluster` field equals
335/// [`ACCESS_CONTROL_CLUSTER`] and whose `attribute` field equals [`ATTR_ACL`],
336/// then decodes each `AccessControlEntryStruct` inside it.
337/// Malformed entries are silently skipped. Returns an empty `Vec` when the
338/// attribute is absent or contains no decodable entries (infallible).
339pub(crate) fn parse_acl(reports: &[(AttributePath, Value)]) -> Vec<AclEntry> {
340    for (path, value) in reports {
341        if path.cluster == ACCESS_CONTROL_CLUSTER && path.attribute == ATTR_ACL {
342            if let Value::Array(items) = value {
343                return items.iter().filter_map(parse_entry).collect();
344            }
345        }
346    }
347    Vec::new()
348}
349
350// ── lockout guard ─────────────────────────────────────────────────────────────
351
352/// Returns `true` iff `entries` retains administrative access for `our_node_id`.
353///
354/// An entry "retains admin" when:
355/// - `privilege == Administer`
356/// - `auth_mode == Case`
357/// - `subjects` is `None` (wildcard — covers all CASE principals) **or**
358///   `subjects` contains `our_node_id`
359///
360/// An empty slice returns `false`.
361pub(crate) fn acl_retains_admin(entries: &[AclEntry], our_node_id: u64) -> bool {
362    entries.iter().any(|e| {
363        e.privilege == AclPrivilege::Administer
364            && e.auth_mode == AclAuthMode::Case
365            && match &e.subjects {
366                None => true,
367                Some(s) => s.contains(&our_node_id),
368            }
369    })
370}
371
372// ── tests ────────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)] // Test code: CLAUDE.md test-code carve-out.
376mod tests {
377    use super::*;
378    use matter_codec::{TlvReader, TlvWriter};
379
380    fn admin(node: u64) -> AclEntry {
381        AclEntry {
382            privilege: AclPrivilege::Administer,
383            auth_mode: AclAuthMode::Case,
384            subjects: Some(vec![node]),
385            targets: None,
386            fabric_index: None,
387        }
388    }
389
390    #[test]
391    fn entry_value_uses_spec_tags() {
392        let v = acl_entry_value(&admin(0x1234));
393        let Value::Structure(m) = v else {
394            panic!("expected Structure")
395        };
396        // Tag::Context(1) = privilege, Administer = 5
397        assert_eq!(m[0], (Tag::Context(1), Value::Uint(5)));
398        // Tag::Context(2) = auth_mode, CASE = 2
399        assert_eq!(m[1], (Tag::Context(2), Value::Uint(2)));
400        // Tag::Context(3) = subjects list
401        assert_eq!(
402            m[2],
403            (Tag::Context(3), Value::Array(vec![Value::Uint(0x1234)]))
404        );
405        // Tag::Context(4) = targets, None → Null
406        assert_eq!(m[3], (Tag::Context(4), Value::Null));
407        // fabric_index None ⇒ tag 254 omitted
408        assert!(m.iter().all(|(t, _)| *t != Tag::Context(254)));
409    }
410
411    #[test]
412    fn lockout_guard_truth_table() {
413        // Our node id is in the subject list ⇒ retained.
414        assert!(acl_retains_admin(&[admin(7)], 7));
415
416        // Wildcard subjects (None) ⇒ covers us regardless of node id.
417        let wild = AclEntry {
418            subjects: None,
419            ..admin(0)
420        };
421        assert!(acl_retains_admin(&[wild], 7));
422
423        // Different node id ⇒ not retained.
424        assert!(!acl_retains_admin(&[admin(9)], 7));
425
426        // Empty entry list ⇒ not retained.
427        assert!(!acl_retains_admin(&[], 7));
428
429        // Operate privilege (not Administer) ⇒ not retained.
430        let op = AclEntry {
431            privilege: AclPrivilege::Operate,
432            ..admin(7)
433        };
434        assert!(!acl_retains_admin(&[op], 7));
435
436        // PASE auth mode (not CASE) ⇒ not retained.
437        let pase = AclEntry {
438            auth_mode: AclAuthMode::Pase,
439            ..admin(7)
440        };
441        assert!(!acl_retains_admin(&[pase], 7));
442    }
443
444    #[test]
445    fn parse_acl_roundtrips_through_codec() {
446        // Build two entries, encode to TLV, decode back to Value, then parse.
447        let entries = [
448            admin(7),
449            AclEntry {
450                privilege: AclPrivilege::Operate,
451                auth_mode: AclAuthMode::Case,
452                subjects: Some(vec![1, 2]),
453                targets: Some(vec![AclTarget {
454                    cluster: Some(6),
455                    endpoint: Some(1),
456                    device_type: None,
457                }]),
458                fabric_index: Some(1),
459            },
460        ];
461
462        let arr = Value::Array(entries.iter().map(acl_entry_value).collect());
463
464        let mut buf = Vec::new();
465        TlvWriter::new(&mut buf)
466            .write_value(Tag::Anonymous, &arr)
467            .unwrap();
468
469        // read_value() returns Result<(Tag, Value)>; we want only the Value.
470        let (_, decoded) = TlvReader::new(&buf).read_value().unwrap();
471
472        let path = AttributePath {
473            endpoint: 0,
474            cluster: ACCESS_CONTROL_CLUSTER,
475            attribute: ATTR_ACL,
476        };
477        let parsed = parse_acl(&[(path, decoded)]);
478
479        assert_eq!(parsed.len(), 2);
480        assert_eq!(parsed[0].privilege, AclPrivilege::Administer);
481        assert_eq!(parsed[0].auth_mode, AclAuthMode::Case);
482        assert_eq!(parsed[0].subjects, Some(vec![7]));
483        assert_eq!(parsed[0].targets, None);
484
485        assert_eq!(parsed[1].privilege, AclPrivilege::Operate);
486        let targets = parsed[1].targets.as_ref().unwrap();
487        assert_eq!(targets.len(), 1);
488        assert_eq!(targets[0].cluster, Some(6));
489        assert_eq!(targets[0].endpoint, Some(1));
490        assert_eq!(targets[0].device_type, None);
491        assert_eq!(parsed[1].fabric_index, Some(1));
492    }
493
494    #[test]
495    fn constructors_build_writable_entries() {
496        let t = AclTarget::new(Some(6), Some(1), None);
497        assert_eq!(t.cluster, Some(6));
498        let e = AclEntry::new(
499            AclPrivilege::Administer,
500            AclAuthMode::Case,
501            Some(vec![7]),
502            Some(vec![t]),
503        );
504        assert_eq!(e.privilege, AclPrivilege::Administer);
505        assert_eq!(e.subjects, Some(vec![7]));
506        // fabric_index defaults to None (device assigns it on write).
507        assert_eq!(e.fabric_index, None);
508        // The constructed entry round-trips through the encoder.
509        assert!(matches!(acl_entry_value(&e), Value::Structure(_)));
510    }
511}