didbot_scope/lib.rs
1//! The granular atproto OAuth scope grammar, parsed into a typed
2//! representation with intersection and containment operations.
3//!
4//! `plan/oauth.md` calls this "the single most important thing" this epic
5//! produces, because `plan/scope-policy.md` has to intersect a request
6//! against a per-agent ceiling and report exactly which scope was refused —
7//! impossible to do correctly over bare strings, where `repo:app.bsky.feed.*`
8//! and `repo:app.bsky.feed.post` are unrelated text but one contains the
9//! other. Everything here is inert with respect to policy: this module
10//! parses the wire grammar, tests whether one [`Scope`] contains another, and
11//! intersects a requested [`ScopeSet`] against a ceiling `ScopeSet`. It does
12//! not decide what a ceiling *is* — that is `plan/scope-policy.md`, blocked
13//! on this and on `policy-store`, and it is deliberately not built here.
14//!
15//! # The grammar
16//!
17//! Space-separated scope strings, one atom each:
18//!
19//! - `atproto` — the base scope every atproto OAuth grant carries.
20//! - `transition:generic`, `transition:chat.bsky`, `transition:email` — the
21//! legacy, ungranular scopes the spec keeps for compatibility. Each is
22//! modelled as its own variant rather than desugared into the granular
23//! scopes it stands in for, because `plan/scope-policy.md` is explicit that
24//! `transition:*` is the one exception every granular rule has to reckon
25//! with by name, not the one this module should quietly launder away.
26//! - `repo:<collection>[?action=create&action=update…]` — write access to
27//! one collection. Several actions repeat the parameter, as the permission
28//! spec writes every list parameter; no `action` query means all three.
29//! - `rpc:<lxm>?aud=<aud>` — permission to call one XRPC method at another
30//! service: the audience is a DID and a service fragment, with its `#`
31//! written `%23`, or `*` for every audience.
32//! - `blob:<mime>`, or `blob?accept=<mime>[&accept=<mime>…]` — permission to
33//! upload a blob of one MIME type or type prefix. The two spellings are
34//! the same scope: the positional one names a single type, the `accept`
35//! one names as many as it repeats. How large a blob may be is not in this
36//! grammar; it is the operator's, in policy and in the server's blob
37//! limit.
38//! - `identity:<attr>` — control of the account's identity:
39//! [`IdentityAttr`] names the handle, or `*` for the whole DID document
40//! and handle. `plan/scope-policy.md` never grants one.
41//! - `account:<attr>[?action=<action>]` — the account's hosting:
42//! [`AccountAttr`] names what, and [`AccountAction`] how much, `read` when
43//! the atom names none. Never granted either.
44//! - `include:<nsid>[?aud=<did>%23<service>]` — a permission set, published
45//! as a lexicon. As an atom it admits only itself. [`Include`] reads it,
46//! and [`Include::grants`] turns the set's published permissions into the
47//! `repo:` and `rpc:` atoms it grants.
48//!
49//! Every value may be percent-encoded, and every positional value may be
50//! written as a parameter instead: `repo?collection=a&collection=b`,
51//! `rpc?lxm=*&aud=…`, `identity?attr=handle`, `include?nsid=…`. A list
52//! parameter names one scope per value, so `repo?collection=a&collection=b`
53//! reads as `repo:a repo:b`, and that is how it prints.
54//!
55//! `mime` patterns may wildcard either half of `type/subtype` (`image/*`
56//! contains `image/png`).
57//!
58//! # Forms the spec does not define
59//!
60//! [`ScopeSet::read`] reads an atom written in a form the permission spec
61//! does not define as well as it can, and says how in a [`ScopeWarning`].
62//! One such form is a partial wildcard, `some.prefix.*`, which this grammar
63//! reads as the prefix and every NSID beneath it at any depth: an operator's
64//! ceiling uses it without a warning.
65//!
66//! An atom this grammar cannot read at all — an unknown kind, or a value its
67//! kind does not define — is kept as [`Scope::Unknown`], which admits only
68//! itself, rather than failing the whole string. A request carrying one can
69//! then be judged on the rest. `didbot-serve` never grants one.
70
71#![forbid(unsafe_code)]
72
73mod account;
74mod action;
75mod error;
76mod identity;
77mod include;
78mod parse;
79mod pattern;
80mod read;
81mod scope;
82mod set;
83mod transition;
84mod warning;
85
86#[cfg(test)]
87mod tests;
88
89pub use account::{AccountAction, AccountAttr};
90pub use action::{Action, ActionSet};
91pub use error::{
92 ScopeParseError, MAX_ATOM_BYTES, MAX_GRANT_ATOMS, MAX_GRANT_BYTES, MAX_SCOPE_ATOMS,
93 MAX_SCOPE_BYTES,
94};
95pub use identity::IdentityAttr;
96pub use include::{Include, IncludeError};
97pub use pattern::{MimePattern, NsidPattern};
98pub use scope::Scope;
99pub use set::{ScopeRefused, ScopeSet};
100pub use transition::Transition;
101pub use warning::ScopeWarning;