Skip to main content

shell_tunnel/security/
capability.rs

1//! Capability set — the frozen access-control *mechanism* (Phase A wire contract v1).
2//!
3//! A token carries a **set** of capability strings. A route declares a
4//! **required-capability**; an access decision is a **set-membership** check.
5//! The literal `"*"` is a **wildcard** that satisfies every capability check.
6//!
7//! Only this *mechanism* is contractual (frozen). The capability *vocabulary*
8//! (which strings exist, e.g. `exec`, `session.read`) and role *presets* are
9//! **non-contract** and grow additively — new strings may be added, but renaming,
10//! removing, or tightening an existing string is breaking. See the Phase A spec.
11
12use std::collections::HashSet;
13
14/// The wildcard capability: a token holding it passes every capability check.
15pub const WILDCARD: &str = "*";
16
17/// An unordered set of capability strings held by a token.
18///
19/// Access control is pure set membership with a single special case: the
20/// [`WILDCARD`] string satisfies any required capability.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct CapabilitySet(HashSet<String>);
23
24impl CapabilitySet {
25    /// Create an empty capability set (holds no capabilities).
26    pub fn new() -> Self {
27        Self(HashSet::new())
28    }
29
30    /// Create a wildcard set — satisfies **every** capability check.
31    ///
32    /// This is the set held by the `full-control` preset and the legacy-key
33    /// mapping target (spec §4).
34    pub fn wildcard() -> Self {
35        let mut set = HashSet::new();
36        set.insert(WILDCARD.to_string());
37        Self(set)
38    }
39
40    /// Whether this set **satisfies** `required` — either it holds the wildcard,
41    /// or it directly contains the required capability string.
42    ///
43    /// This is the frozen access-decision primitive (spec §2.1).
44    pub fn satisfies(&self, required: &str) -> bool {
45        self.0.contains(WILDCARD) || self.0.contains(required)
46    }
47
48    /// Whether this set holds the wildcard capability.
49    pub fn is_wildcard(&self) -> bool {
50        self.0.contains(WILDCARD)
51    }
52
53    /// Insert a capability string into the set.
54    pub fn insert(&mut self, capability: impl Into<String>) {
55        self.0.insert(capability.into());
56    }
57
58    /// Number of capability strings in the set.
59    pub fn len(&self) -> usize {
60        self.0.len()
61    }
62
63    /// Whether the set holds no capabilities.
64    pub fn is_empty(&self) -> bool {
65        self.0.is_empty()
66    }
67
68    /// Iterate over the capability strings.
69    pub fn iter(&self) -> impl Iterator<Item = &String> {
70        self.0.iter()
71    }
72}
73
74impl<S: Into<String>> FromIterator<S> for CapabilitySet {
75    fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
76        Self(iter.into_iter().map(Into::into).collect())
77    }
78}
79
80/// Capability strings the router currently maps routes onto.
81///
82/// Vocabulary, not mechanism — additive by design (see the module header).
83/// `fs.read` and `fs.write` are deliberately absent from the `operator` and
84/// `read-only` presets: adding them there would hand file access to tokens
85/// already issued, which is a privilege change nobody asked for. An existing
86/// `operator` or `read-only` token must name them explicitly, e.g.
87/// `--capabilities fs.read,fs.write`. `full-control`'s [`CapabilitySet::wildcard`]
88/// already covers both, as it does every capability — there is no way to keep
89/// a `full-control` token from gaining file access once `--fs-root` is set.
90pub const KNOWN_CAPABILITIES: &[&str] = &[
91    "exec",
92    "session.read",
93    "session.manage",
94    "fs.read",
95    "fs.write",
96];
97
98/// Resolve a role **preset** name to its capability set (spec §6).
99///
100/// Presets are a **non-contract** convenience mapping — they may change freely
101/// and are not part of the frozen wire contract. Returns `None` for an unknown
102/// name so the caller can surface a clear error.
103pub fn preset(name: &str) -> Option<CapabilitySet> {
104    match name {
105        "operator" => Some(
106            ["exec", "session.read", "session.manage"]
107                .into_iter()
108                .collect(),
109        ),
110        "read-only" => Some(["session.read"].into_iter().collect()),
111        "full-control" => Some(CapabilitySet::wildcard()),
112        _ => None,
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_empty_set_satisfies_nothing() {
122        let set = CapabilitySet::new();
123        assert!(set.is_empty());
124        assert!(!set.satisfies("exec"));
125        assert!(!set.is_wildcard());
126    }
127
128    #[test]
129    fn test_wildcard_satisfies_everything() {
130        let set = CapabilitySet::wildcard();
131        assert!(set.is_wildcard());
132        assert!(set.satisfies("exec"));
133        assert!(set.satisfies("session.read"));
134        assert!(set.satisfies("anything.at.all"));
135    }
136
137    #[test]
138    fn test_membership_is_exact() {
139        let set: CapabilitySet = ["session.read"].into_iter().collect();
140        assert!(set.satisfies("session.read"));
141        // Not a prefix/hierarchy match — membership is exact.
142        assert!(!set.satisfies("session.manage"));
143        assert!(!set.satisfies("session"));
144        assert!(!set.is_wildcard());
145    }
146
147    #[test]
148    fn test_multiple_capabilities() {
149        let set: CapabilitySet = ["exec", "session.read", "session.manage"]
150            .into_iter()
151            .collect();
152        assert_eq!(set.len(), 3);
153        assert!(set.satisfies("exec"));
154        assert!(set.satisfies("session.read"));
155        assert!(set.satisfies("session.manage"));
156        assert!(!set.satisfies("fs.read"));
157    }
158
159    #[test]
160    fn test_insert() {
161        let mut set = CapabilitySet::new();
162        set.insert("exec");
163        assert!(set.satisfies("exec"));
164        assert_eq!(set.len(), 1);
165    }
166
167    #[test]
168    fn test_presets() {
169        let operator = preset("operator").unwrap();
170        assert!(operator.satisfies("exec"));
171        assert!(operator.satisfies("session.read"));
172        assert!(operator.satisfies("session.manage"));
173        assert!(!operator.is_wildcard());
174
175        let read_only = preset("read-only").unwrap();
176        assert!(read_only.satisfies("session.read"));
177        assert!(!read_only.satisfies("session.manage"));
178        assert!(!read_only.satisfies("exec"));
179
180        let full = preset("full-control").unwrap();
181        assert!(full.is_wildcard());
182        assert!(full.satisfies("anything"));
183
184        assert!(preset("nonexistent").is_none());
185    }
186}