Skip to main content

atd_runtime/
capability.rs

1//! Connection-scoped capability allow-list.
2//!
3//! SP-12 demonstrates the §VI least-privilege shape without committing to a
4//! cryptographic token format. Capabilities are trusted free-form strings the
5//! server operator declares at start time via `--grant-capability`; the
6//! connection's `Hello` request asks for a subset, and the server replies with
7//! what it granted. A future SP can swap this allow-list for UCAN verification
8//! without changing `CapabilitySet`'s public surface.
9
10use std::collections::BTreeSet;
11
12use crate::audit::CapProvenance;
13
14/// Set of capabilities currently granted to a connection. Deterministically
15/// ordered (BTreeSet) so `granted()` returns a stable sequence — important for
16/// wire-level reproducibility and test assertions.
17///
18/// SP-observability-completeness-v1 Axis C: carries optional per-capability
19/// provenance alongside the granted set. The granted set is the *result*;
20/// provenance records the *source* of each grant (operator allow-list vs a
21/// UCAN chain link). Provenance is best-effort metadata for the audit sink —
22/// it never affects gating (which reads `contains` / `granted` only).
23#[derive(Debug, Clone, Default)]
24pub struct CapabilitySet {
25    granted: BTreeSet<String>,
26    provenance: Vec<CapProvenance>,
27}
28
29impl CapabilitySet {
30    pub fn empty() -> Self {
31        Self::default()
32    }
33
34    /// Construct a set from a granted iterable plus its provenance records.
35    /// Used at `Hello` time so the audit sink can attribute each capability.
36    pub fn with_provenance<I>(granted: I, provenance: Vec<CapProvenance>) -> Self
37    where
38        I: IntoIterator<Item = String>,
39    {
40        Self {
41            granted: granted.into_iter().collect(),
42            provenance,
43        }
44    }
45
46    pub fn contains(&self, cap: &str) -> bool {
47        self.granted.contains(cap)
48    }
49
50    /// Sorted, deterministic view of the granted set.
51    pub fn granted(&self) -> Vec<String> {
52        self.granted.iter().cloned().collect()
53    }
54
55    /// Per-capability source attribution (SP-observability-completeness-v1
56    /// Axis C). Empty when provenance wasn't recorded. A capability granted
57    /// by both the string allow-list and a UCAN chain appears twice (once
58    /// per source) — that's the honest record.
59    pub fn provenance(&self) -> &[CapProvenance] {
60        &self.provenance
61    }
62
63    /// Intersect `requested` with the server's allow-list. Returns
64    /// `(granted_subset, denied_subset)`, each deterministically ordered.
65    /// Used during the `Hello` handshake to decide what to echo back.
66    pub fn intersect(&self, requested: &[String]) -> (Vec<String>, Vec<String>) {
67        let mut granted: Vec<String> = Vec::new();
68        let mut denied: Vec<String> = Vec::new();
69        let req_sorted: BTreeSet<&String> = requested.iter().collect();
70        for r in req_sorted {
71            if self.granted.contains(r) {
72                granted.push(r.clone());
73            } else {
74                denied.push(r.clone());
75            }
76        }
77        (granted, denied)
78    }
79
80    /// Union two capability sets. Used by SP-capability-v2 dispatch to
81    /// combine SP-12 string-allow-list results with UCAN-derived caps
82    /// (spec §4.2: `granted = granted_strings ∪ granted_ucan`). Returns
83    /// a new set; neither input is mutated.
84    pub fn union(&self, other: &Self) -> Self {
85        // Dedup exact (cap, source) duplicates — e.g. two UCAN chains both
86        // granting the same cap from the same issuer+depth — so provenance
87        // doesn't accumulate identical rows. Cross-source entries (the same
88        // cap via both the string allow-list AND a UCAN chain) are KEPT: that
89        // is the honest record, and the reason provenance is NOT guaranteed
90        // 1:1 with the deduped `granted` set (see `provenance()`).
91        let mut provenance = self.provenance.clone();
92        for p in &other.provenance {
93            if !provenance.contains(p) {
94                provenance.push(p.clone());
95            }
96        }
97        Self {
98            granted: self.granted.union(&other.granted).cloned().collect(),
99            provenance,
100        }
101    }
102}
103
104impl FromIterator<String> for CapabilitySet {
105    fn from_iter<I: IntoIterator<Item = String>>(iter: I) -> Self {
106        Self {
107            granted: iter.into_iter().collect(),
108            provenance: Vec::new(),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn empty_contains_nothing() {
119        let s = CapabilitySet::empty();
120        assert!(!s.contains("anything"));
121        assert!(s.granted().is_empty());
122    }
123
124    #[test]
125    fn from_iter_builds_set() {
126        let s = CapabilitySet::from_iter(["read".to_string(), "exec".to_string()]);
127        assert!(s.contains("read"));
128        assert!(s.contains("exec"));
129        assert!(!s.contains("admin"));
130    }
131
132    #[test]
133    fn granted_returns_sorted_deterministic_order() {
134        // Insert in reverse; output must be sorted.
135        let s =
136            CapabilitySet::from_iter(["zeta".to_string(), "alpha".to_string(), "mu".to_string()]);
137        assert_eq!(s.granted(), vec!["alpha", "mu", "zeta"]);
138    }
139
140    #[test]
141    fn intersect_with_empty_granted_denies_all() {
142        let s = CapabilitySet::empty();
143        let (granted, denied) = s.intersect(&["read".into(), "exec".into()]);
144        assert!(granted.is_empty());
145        assert_eq!(denied, vec!["exec", "read"]);
146    }
147
148    #[test]
149    fn intersect_partial() {
150        let s = CapabilitySet::from_iter(["read".to_string(), "exec".to_string()]);
151        let (granted, denied) = s.intersect(&["read".into(), "admin".into(), "exec".into()]);
152        assert_eq!(granted, vec!["exec", "read"]);
153        assert_eq!(denied, vec!["admin"]);
154    }
155
156    #[test]
157    fn intersect_full_grants_all_requested() {
158        let s = CapabilitySet::from_iter(["a".to_string(), "b".to_string(), "c".to_string()]);
159        let (granted, denied) = s.intersect(&["a".into(), "b".into()]);
160        assert_eq!(granted, vec!["a", "b"]);
161        assert!(denied.is_empty());
162    }
163
164    #[test]
165    fn intersect_dedupes_via_sorted_input() {
166        // BTreeSet in `intersect` deduplicates repeated requests.
167        let s = CapabilitySet::from_iter(["read".to_string()]);
168        let (granted, denied) = s.intersect(&["read".into(), "read".into(), "admin".into()]);
169        assert_eq!(granted, vec!["read"]);
170        assert_eq!(denied, vec!["admin"]);
171    }
172}