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/// See [`preset`] for why the presets below draw their boundary at `exec`
84/// rather than at `fs.*`.
85///
86/// The practical consequence — and what `src/fs/root.rs` points here for: a
87/// `--fs-root` jail confines something only for a token holding `fs.*`
88/// **without** `exec`, which is exactly what `file-read` and `file-write`
89/// grant. Against `operator` or `full-control` it is a convenience boundary —
90/// chunked, resumable, checksummed transfer instead of piping bytes through a
91/// command — and not a containment one, because `exec` already reaches every
92/// file this process can. Both halves matter: the second is why the file API
93/// needs no flag to exist, the first is why `--fs-root` still has a job.
94pub const KNOWN_CAPABILITIES: &[&str] = &[
95 "exec",
96 "session.read",
97 "session.manage",
98 "fs.read",
99 "fs.write",
100];
101
102/// Resolve a role **preset** name to its capability set (spec §6).
103///
104/// Presets are a **non-contract** convenience mapping — they may change freely
105/// and are not part of the frozen wire contract. Returns `None` for an unknown
106/// name so the caller can surface a clear error.
107///
108/// **The gradient's cut line is `exec`.** A token holding `exec` reaches every
109/// file this process can, so withholding the file API from it confines nothing
110/// and only forces callers onto the slow path. The presets below therefore
111/// split into "carries exec, and so carries everything" and "carries no exec,
112/// and so the file capabilities are a real boundary".
113pub fn preset(name: &str) -> Option<CapabilitySet> {
114 match name {
115 // `fs.read`/`fs.write` sit alongside `exec` here rather than being
116 // withheld from it: this preset already grants command execution, which
117 // reaches every file this process can. See `KNOWN_CAPABILITIES` above.
118 "operator" => Some(
119 [
120 "exec",
121 "session.read",
122 "session.manage",
123 "fs.read",
124 "fs.write",
125 ]
126 .into_iter()
127 .collect(),
128 ),
129 // No `exec`, so the file capabilities are the whole grant and a
130 // `--fs-root` jail actually confines something. `session.*` is
131 // deliberately absent: without `exec` there is no session to read.
132 "file-write" => Some(["fs.read", "fs.write"].into_iter().collect()),
133 "file-read" => Some(["fs.read"].into_iter().collect()),
134 "full-control" => Some(CapabilitySet::wildcard()),
135 _ => None,
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn test_empty_set_satisfies_nothing() {
145 let set = CapabilitySet::new();
146 assert!(set.is_empty());
147 assert!(!set.satisfies("exec"));
148 assert!(!set.is_wildcard());
149 }
150
151 #[test]
152 fn test_wildcard_satisfies_everything() {
153 let set = CapabilitySet::wildcard();
154 assert!(set.is_wildcard());
155 assert!(set.satisfies("exec"));
156 assert!(set.satisfies("session.read"));
157 assert!(set.satisfies("anything.at.all"));
158 }
159
160 #[test]
161 fn test_membership_is_exact() {
162 let set: CapabilitySet = ["session.read"].into_iter().collect();
163 assert!(set.satisfies("session.read"));
164 // Not a prefix/hierarchy match — membership is exact.
165 assert!(!set.satisfies("session.manage"));
166 assert!(!set.satisfies("session"));
167 assert!(!set.is_wildcard());
168 }
169
170 #[test]
171 fn test_multiple_capabilities() {
172 let set: CapabilitySet = ["exec", "session.read", "session.manage"]
173 .into_iter()
174 .collect();
175 assert_eq!(set.len(), 3);
176 assert!(set.satisfies("exec"));
177 assert!(set.satisfies("session.read"));
178 assert!(set.satisfies("session.manage"));
179 assert!(!set.satisfies("fs.read"));
180 }
181
182 #[test]
183 fn test_insert() {
184 let mut set = CapabilitySet::new();
185 set.insert("exec");
186 assert!(set.satisfies("exec"));
187 assert_eq!(set.len(), 1);
188 }
189
190 #[test]
191 fn file_presets_carry_no_exec() {
192 let read = preset("file-read").expect("file-read must exist");
193 assert!(read.satisfies("fs.read"));
194 assert!(!read.satisfies("fs.write"));
195 assert!(!read.satisfies("exec"));
196 // Not slipping in anything the name doesn't promise: without `exec`
197 // there is no session to create, so session lookup would be useless.
198 assert!(!read.satisfies("session.read"));
199 assert_eq!(read.len(), 1);
200
201 let write = preset("file-write").expect("file-write must exist");
202 assert!(write.satisfies("fs.read"));
203 assert!(write.satisfies("fs.write"));
204 assert!(!write.satisfies("exec"));
205 assert!(!write.satisfies("session.read"));
206 assert_eq!(write.len(), 2);
207 }
208
209 #[test]
210 fn read_only_is_gone_rather_than_silently_redefined() {
211 // This preset's name and behaviour used to disagree ("read-only" that
212 // couldn't read a file). Keeping the name as an alias while changing
213 // its meaning would be a silent capability escalation for existing
214 // tokens picking up `fs.read` — removal is the honest direction.
215 assert!(preset("read-only").is_none());
216 }
217
218 #[test]
219 fn test_presets() {
220 let operator = preset("operator").unwrap();
221 assert!(operator.satisfies("exec"));
222 assert!(operator.satisfies("session.read"));
223 assert!(operator.satisfies("session.manage"));
224 // Carried because `exec` above already reaches every file this process
225 // can: withholding them confined nothing. Asserted rather than left
226 // implicit so removing them again has to be a deliberate act.
227 assert!(operator.satisfies("fs.read"));
228 assert!(operator.satisfies("fs.write"));
229 assert!(!operator.is_wildcard());
230
231 let full = preset("full-control").unwrap();
232 assert!(full.is_wildcard());
233 assert!(full.satisfies("anything"));
234
235 assert!(preset("nonexistent").is_none());
236 }
237}