Skip to main content

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}