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