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, but what that buys differs sharply between the two,
85/// and it is worth being exact rather than claiming a boundary twice:
86///
87/// - `read-only` holds `session.read` and **no `exec`**, so withholding
88/// `fs.read` is a real containment boundary: such a token genuinely cannot
89/// read a file on this machine, and adding `fs.read` to the preset would
90/// have granted an access it did not have.
91/// - `operator` holds `exec`. A token that can run commands can already read
92/// and write anything the process can reach — `Get-Content`, `cp`, a
93/// redirect. Withholding `fs.*` there contains **nothing**; it keeps an
94/// issued token's capability surface from changing under it, which is a
95/// least-surprise property, not a security one. Do not describe it as
96/// confinement.
97///
98/// `full-control`'s [`CapabilitySet::wildcard`] covers both, as it does every
99/// capability — there is no way to keep such a token from the file API once
100/// `--fs-root` is set, and no reason to try, since `exec` already dominates it.
101///
102/// The practical consequence: `--fs-root` is a meaningful jail only for a
103/// token that has `fs.*` **without** `exec` (`--capabilities fs.write` for a
104/// deploy push, say). Against `operator` or `full-control` it is a convenience
105/// boundary — chunked, resumable, checksummed transfer instead of piping bytes
106/// through a command — not a containment one.
107pub const KNOWN_CAPABILITIES: &[&str] = &[
108 "exec",
109 "session.read",
110 "session.manage",
111 "fs.read",
112 "fs.write",
113];
114
115/// Resolve a role **preset** name to its capability set (spec §6).
116///
117/// Presets are a **non-contract** convenience mapping — they may change freely
118/// and are not part of the frozen wire contract. Returns `None` for an unknown
119/// name so the caller can surface a clear error.
120pub fn preset(name: &str) -> Option<CapabilitySet> {
121 match name {
122 // `fs.read`/`fs.write` sit alongside `exec` here rather than being
123 // withheld from it: this preset already grants command execution, which
124 // reaches every file this process can. Withholding the file API from it
125 // confined nothing and only pushed callers onto the slow path — see
126 // `KNOWN_CAPABILITIES` above.
127 "operator" => Some(
128 [
129 "exec",
130 "session.read",
131 "session.manage",
132 "fs.read",
133 "fs.write",
134 ]
135 .into_iter()
136 .collect(),
137 ),
138 // Not given `fs.read`, and this one is a real boundary: `read-only`
139 // has no `exec`, so a token holding it genuinely cannot read a file on
140 // this machine. Adding it here would be a grant, not a convenience.
141 "read-only" => Some(["session.read"].into_iter().collect()),
142 "full-control" => Some(CapabilitySet::wildcard()),
143 _ => None,
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn test_empty_set_satisfies_nothing() {
153 let set = CapabilitySet::new();
154 assert!(set.is_empty());
155 assert!(!set.satisfies("exec"));
156 assert!(!set.is_wildcard());
157 }
158
159 #[test]
160 fn test_wildcard_satisfies_everything() {
161 let set = CapabilitySet::wildcard();
162 assert!(set.is_wildcard());
163 assert!(set.satisfies("exec"));
164 assert!(set.satisfies("session.read"));
165 assert!(set.satisfies("anything.at.all"));
166 }
167
168 #[test]
169 fn test_membership_is_exact() {
170 let set: CapabilitySet = ["session.read"].into_iter().collect();
171 assert!(set.satisfies("session.read"));
172 // Not a prefix/hierarchy match — membership is exact.
173 assert!(!set.satisfies("session.manage"));
174 assert!(!set.satisfies("session"));
175 assert!(!set.is_wildcard());
176 }
177
178 #[test]
179 fn test_multiple_capabilities() {
180 let set: CapabilitySet = ["exec", "session.read", "session.manage"]
181 .into_iter()
182 .collect();
183 assert_eq!(set.len(), 3);
184 assert!(set.satisfies("exec"));
185 assert!(set.satisfies("session.read"));
186 assert!(set.satisfies("session.manage"));
187 assert!(!set.satisfies("fs.read"));
188 }
189
190 #[test]
191 fn test_insert() {
192 let mut set = CapabilitySet::new();
193 set.insert("exec");
194 assert!(set.satisfies("exec"));
195 assert_eq!(set.len(), 1);
196 }
197
198 #[test]
199 fn test_presets() {
200 let operator = preset("operator").unwrap();
201 assert!(operator.satisfies("exec"));
202 assert!(operator.satisfies("session.read"));
203 assert!(operator.satisfies("session.manage"));
204 // Carried because `exec` above already reaches every file this process
205 // can: withholding them confined nothing. Asserted rather than left
206 // implicit so removing them again has to be a deliberate act.
207 assert!(operator.satisfies("fs.read"));
208 assert!(operator.satisfies("fs.write"));
209 assert!(!operator.is_wildcard());
210
211 let read_only = preset("read-only").unwrap();
212 assert!(read_only.satisfies("session.read"));
213 assert!(!read_only.satisfies("session.manage"));
214 assert!(!read_only.satisfies("exec"));
215 // The one preset where withholding the file API is a real boundary:
216 // with no `exec`, this token has no other route to a file's contents.
217 assert!(!read_only.satisfies("fs.read"));
218 assert!(!read_only.satisfies("fs.write"));
219
220 let full = preset("full-control").unwrap();
221 assert!(full.is_wildcard());
222 assert!(full.satisfies("anything"));
223
224 assert!(preset("nonexistent").is_none());
225 }
226}