didbot_scope/set.rs
1//! A canonical set of atoms, and the intersection a ceiling is applied with.
2
3use std::fmt;
4
5use crate::read::read;
6use crate::{
7 NsidPattern, Scope, ScopeParseError, ScopeWarning, Transition, MAX_GRANT_ATOMS,
8 MAX_GRANT_BYTES, MAX_SCOPE_ATOMS, MAX_SCOPE_BYTES,
9};
10
11/// A space-separated set of [`Scope`]s, as carried on a `scope` parameter or
12/// a token response.
13///
14/// Canonical by construction: [`ScopeSet::new`] sorts, dedupes, and drops
15/// any atom already admitted by another atom of its kind, so two sets
16/// built from the same members in different orders — or with redundant
17/// members — compare equal. Policy is a set of records in a repository,
18/// which has no inherent order, so a ceiling has to be a function of the
19/// rule set alone; a `ScopeSet` that could represent the same ceiling two
20/// different ways would let vector order silently change what a policy
21/// means, which is exactly the failure this type exists to rule out.
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
23pub struct ScopeSet(pub Vec<Scope>);
24
25impl ScopeSet {
26 /// Builds a canonical [`ScopeSet`] from a bag of atoms: sorted, deduped,
27 /// and with every atom already admitted by another atom of its kind
28 /// dropped. Containment among distinct atoms is a strict partial order —
29 /// two distinct atoms can never admit each other — so which atom
30 /// survives never depends on the input order.
31 ///
32 /// Only an atom of the same kind stands in for another. A ceiling judges
33 /// each kind on its own ([`ScopeSet::narrow`]), and this server never
34 /// grants a `transition:` scope, so `transition:generic` does not absorb
35 /// the `repo:` scopes asked for beside it.
36 pub fn new(atoms: Vec<Scope>) -> Self {
37 let mut atoms = atoms;
38 atoms.sort();
39 atoms.dedup();
40 let kept = atoms
41 .iter()
42 .enumerate()
43 .filter(|(i, atom)| {
44 !atoms.iter().enumerate().any(|(j, other)| {
45 j != *i && other.kind() == atom.kind() && other.contains(atom)
46 })
47 })
48 .map(|(_, atom)| atom.clone())
49 .collect();
50 ScopeSet(kept)
51 }
52
53 /// Whether some atom of this set admits `wanted`, which is what a token
54 /// carrying this set may do.
55 pub fn covers(&self, wanted: &Scope) -> bool {
56 self.0.iter().any(|atom| atom.contains(wanted))
57 }
58
59 /// Whether a token carrying this set may act at another service: call
60 /// `lxm` there through `atproto-proxy`, or take a token for it from
61 /// `com.atproto.server.getServiceAuth`. `aud` names the service, as a
62 /// DID or a DID and a service fragment.
63 ///
64 /// `None` asks for a token bound to no method. An `rpc:*` scope for
65 /// `aud` admits one, and so does `transition:generic`, as the reference
66 /// PDS reads it.
67 pub fn permits_rpc(&self, lxm: Option<&str>, aud: &str) -> bool {
68 if lxm.is_none() && self.0.contains(&Scope::Transition(Transition::Generic)) {
69 return true;
70 }
71 self.covers(&Scope::Rpc {
72 method: lxm.map_or(NsidPattern::Any, |lxm| NsidPattern::Exact(lxm.to_owned())),
73 aud: Some(aud.to_owned()),
74 })
75 }
76
77 /// Parses a whole `scope` string: OAuth2's space-separated list of atoms.
78 ///
79 /// Checks [`MAX_SCOPE_BYTES`] before reading any atom, and
80 /// [`MAX_SCOPE_ATOMS`] against the scopes they name. An atom this grammar
81 /// cannot read is kept as [`Scope::Unknown`]; see [`ScopeSet::read`].
82 pub fn parse(s: &str) -> Result<Self, ScopeParseError> {
83 Self::read(s).map(|(set, _)| set)
84 }
85
86 /// Reads a scope an app asked for: the set, and each warning its atoms'
87 /// forms carry, beside the atom as the app wrote it.
88 ///
89 /// Every form the permission spec defines reads without a warning. A form
90 /// it does not define is read as well as it can be, with a warning
91 /// saying how. An atom that names no scope this grammar reads is kept as
92 /// [`Scope::Unknown`], with a warning, rather than failing the string: a
93 /// server can drop it and grant the rest. Only the bounds and a character
94 /// no scope may hold fail the whole string.
95 pub fn read(s: &str) -> Result<(Self, Vec<(String, ScopeWarning)>), ScopeParseError> {
96 Self::read_within(s, MAX_SCOPE_BYTES, MAX_SCOPE_ATOMS)
97 }
98
99 /// Parses a grant this server stored, which may be larger than any
100 /// request: each `include:` in the request became the atoms its
101 /// permission set grants.
102 ///
103 /// Checks [`MAX_GRANT_BYTES`] and [`MAX_GRANT_ATOMS`] before parsing any
104 /// atom.
105 pub fn parse_grant(s: &str) -> Result<Self, ScopeParseError> {
106 Self::read_within(s, MAX_GRANT_BYTES, MAX_GRANT_ATOMS).map(|(set, _)| set)
107 }
108
109 fn read_within(
110 s: &str,
111 max_bytes: usize,
112 max_scopes: usize,
113 ) -> Result<(Self, Vec<(String, ScopeWarning)>), ScopeParseError> {
114 if s.len() > max_bytes {
115 return Err(ScopeParseError::TooLong {
116 len: s.len(),
117 max: max_bytes,
118 });
119 }
120 // Every atom names at least one scope, so a string with too many atoms
121 // is refused before any is read.
122 let atoms: Vec<&str> = s.split_whitespace().collect();
123 if atoms.len() > max_scopes {
124 return Err(ScopeParseError::TooManyAtoms {
125 count: atoms.len(),
126 max: max_scopes,
127 });
128 }
129 let mut scopes = Vec::new();
130 let mut warnings = Vec::new();
131 for atom in atoms {
132 let read = match read(atom) {
133 Ok(read) => read,
134 Err(
135 error @ (ScopeParseError::AtomTooLong(_)
136 | ScopeParseError::ForbiddenCharacter(_)),
137 ) => return Err(error),
138 Err(error) => crate::read::Read {
139 scopes: vec![Scope::Unknown(atom.to_owned())],
140 warnings: vec![ScopeWarning::Unreadable(error.to_string())],
141 },
142 };
143 scopes.extend(read.scopes);
144 warnings.extend(
145 read.warnings
146 .into_iter()
147 .map(|warning| (atom.to_owned(), warning)),
148 );
149 }
150 if scopes.len() > max_scopes {
151 return Err(ScopeParseError::TooManyAtoms {
152 count: scopes.len(),
153 max: max_scopes,
154 });
155 }
156 Ok((ScopeSet::new(scopes), warnings))
157 }
158
159 /// Is every requested scope admitted by some scope in `ceiling`?
160 ///
161 /// Returns the whole intersected grant on success. On refusal, returns
162 /// the first requested scope that no ceiling scope admits at all —
163 /// `plan/scope-policy.md`'s "refuse rather than silently narrow, and say
164 /// which scope", which needs this exact atom to build the
165 /// `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`
166 /// header.
167 ///
168 /// A requested atom's grant is the *union* of its overlap with every
169 /// ceiling atom, not the single largest overlap: `ceiling` is a set with
170 /// no inherent order, so a ceiling atom that grants `create` and one
171 /// that grants `delete` must both survive regardless of which one this
172 /// loop visits first. [`ScopeSet::new`] then collapses any overlap that
173 /// one of its siblings already subsumes.
174 ///
175 /// An overlap counts only in the requested atom's own kind, as in
176 /// [`ScopeSet::narrow`]: `transition:generic` under a `repo:*` ceiling is
177 /// refused, not paid out as `repo:*`.
178 pub fn intersect(&self, ceiling: &ScopeSet) -> Result<ScopeSet, ScopeRefused> {
179 let mut granted = Vec::with_capacity(self.0.len());
180 for requested in &self.0 {
181 let mut admitted = false;
182 for ceiling_scope in &ceiling.0 {
183 let Some(overlap) = ceiling_scope.intersect(requested) else {
184 continue;
185 };
186 if overlap.kind() == requested.kind() {
187 granted.push(overlap);
188 admitted = true;
189 }
190 }
191 if !admitted {
192 return Err(ScopeRefused(requested.clone()));
193 }
194 }
195 Ok(ScopeSet::new(granted))
196 }
197
198 /// The same overlap, split into what the ceiling granted and what it
199 /// did not.
200 ///
201 /// [`ScopeSet::intersect`] refuses the whole request the moment one
202 /// atom falls outside the ceiling, which is what `token` and every
203 /// other caller checking a grant against a ceiling wants. A consent
204 /// decision wants the other answer: the grant that *is* available, plus
205 /// the part of the request it does not cover, so the record an agent
206 /// approves can name both.
207 ///
208 /// Projection runs from the requested atom onto the ceiling and never
209 /// the other way, and only ever within one kind: a granted atom is
210 /// always a narrowing of something the request named, of that atom's own
211 /// kind. So a wide atom is never paid out in capabilities of a kind
212 /// nobody asked for — `transition:generic` overlaps `repo:*` and
213 /// `rpc:*`, and a ceiling holding those grants it nothing.
214 ///
215 /// `cut` is every requested atom that no granted atom contains — the
216 /// atoms the ceiling dropped outright and the atoms it shrank alike.
217 /// The grammar has no difference operation, so an atom the ceiling
218 /// narrowed (`repo:*?action=create&action=delete` against a ceiling holding
219 /// `repo:*?action=create`) is named whole rather than as the sliver
220 /// removed from it. An empty `cut` means the ceiling granted the
221 /// request exactly as asked, which is the only case
222 /// [`ScopeSet::intersect`] and this agree on completely.
223 pub fn narrow(&self, ceiling: &ScopeSet) -> (ScopeSet, ScopeSet) {
224 let mut overlaps = Vec::with_capacity(self.0.len());
225 for requested in &self.0 {
226 for ceiling_scope in &ceiling.0 {
227 let Some(overlap) = requested.intersect(ceiling_scope) else {
228 continue;
229 };
230 // Same kind, or nothing. An overlap of a different variant
231 // than the atom that was asked for is not a narrowing of
232 // that atom, it is a *re-expression* of it as capabilities
233 // the request never named: `transition:generic` overlaps
234 // `repo:*` and `rpc:*`, and taking those would hand back a
235 // grant nobody asked for in that form — a wide atom paid out
236 // as its pieces. An atom whose every overlap is refused this
237 // way is granted nothing and lands in `cut`.
238 if overlap.kind() == requested.kind() {
239 overlaps.push(overlap);
240 }
241 }
242 }
243 let granted = ScopeSet::new(overlaps);
244 let cut = ScopeSet::new(
245 self.0
246 .iter()
247 .filter(|requested| !granted.0.iter().any(|atom| atom.contains(requested)))
248 .cloned()
249 .collect(),
250 );
251 (granted, cut)
252 }
253}
254
255/// The exact scope a ceiling refused, for `insufficient_scope`.
256#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
257#[error("scope `{0}` is outside this agent's ceiling")]
258pub struct ScopeRefused(pub Scope);
259
260impl fmt::Display for ScopeSet {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 let joined = self
263 .0
264 .iter()
265 .map(ToString::to_string)
266 .collect::<Vec<_>>()
267 .join(" ");
268 write!(f, "{joined}")
269 }
270}