Skip to main content

didbot_scope/
include.rs

1//! What an `include:` scope names, and what the permission set it names
2//! grants.
3//!
4//! A permission set is a lexicon: a `permission-set` definition published
5//! under the set's own NSID, whose `permissions` are `repo` and `rpc`
6//! permissions in their JSON form. [`Include::grants`] reads that list the
7//! way the permission spec's "Permission Sets" section says an
8//! authorization server must:
9//!
10//! - It ignores a permission for any other resource, and a permission with a
11//!   field or a value it cannot read. It never grants a permission in part.
12//! - Every collection and method must sit under the set's own NSID group.
13//!   `app.example.authFull` may grant `app.example.post` and
14//!   `app.example.feed.like`, and nothing under `app.other`. A wildcard is
15//!   never one of them.
16//! - An `rpc` permission names `*` as its audience, or sets `inheritAud` and
17//!   takes the audience the `include:` scope names. With `inheritAud` and no
18//!   audience to take, it grants nothing.
19//!
20//! Finding the set is the caller's job: the DNS record, the authority's
21//! repository, and the proof the record comes with.
22
23use std::collections::BTreeSet;
24
25use didbot_lexicon::nsid_syntax;
26use serde_json::{Map, Value};
27
28use crate::scope::ANY_AUD;
29use crate::{Action, ActionSet, NsidPattern, Scope};
30
31/// An `include:` scope, read: the permission set it names, and the audience
32/// that set's `rpc` permissions may inherit.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Include {
35    nsid: String,
36    aud: Option<String>,
37}
38
39/// Why an `include:` scope names no permission set.
40#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum IncludeError {
42    /// The set's name is not an NSID.
43    #[error("`{0}` is not an NSID")]
44    NotAnNsid(String),
45    /// `aud` is not a `did:plc` or a `did:web` with a service fragment.
46    #[error("`{0}` is not a DID with a service fragment")]
47    NotAServiceReference(String),
48}
49
50impl Include {
51    /// The set an `include:` scope names, and the audience it passes on,
52    /// both percent-decoded as [`Scope::Include`] holds them.
53    ///
54    /// `nsid` must be an NSID, and `aud` must name one service:
55    /// `did:web:api.example.com#svc_appview`. `*` is refused.
56    pub fn new(nsid: &str, aud: Option<&str>) -> Result<Self, IncludeError> {
57        if nsid_syntax::validate(nsid).is_err() {
58            return Err(IncludeError::NotAnNsid(nsid.to_owned()));
59        }
60        if let Some(aud) = aud.filter(|aud| !is_service_reference(aud)) {
61            return Err(IncludeError::NotAServiceReference(aud.to_owned()));
62        }
63        Ok(Self {
64            nsid: nsid.to_owned(),
65            aud: aud.map(str::to_owned),
66        })
67    }
68
69    /// The permission set's NSID.
70    pub fn nsid(&self) -> &str {
71        &self.nsid
72    }
73
74    /// The audience the set's `inheritAud` permissions take, percent-decoded.
75    pub fn aud(&self) -> Option<&str> {
76        self.aud.as_deref()
77    }
78
79    /// The atoms a set's published `permissions` grant through this
80    /// `include:`: one per collection and one per method.
81    pub fn grants(&self, permissions: &[Value]) -> Vec<Scope> {
82        permissions
83            .iter()
84            .filter_map(|permission| self.grant(permission.as_object()?))
85            .flatten()
86            .collect()
87    }
88
89    /// One permission's atoms, or `None` for a permission this ignores.
90    fn grant(&self, permission: &Map<String, Value>) -> Option<Vec<Scope>> {
91        if permission.get("type")?.as_str()? != "permission" {
92            return None;
93        }
94        if permission
95            .get("description")
96            .is_some_and(|description| !description.is_string())
97        {
98            return None;
99        }
100        match permission.get("resource")?.as_str()? {
101            "repo" => self.repo(permission),
102            "rpc" => self.rpc(permission),
103            _ => None,
104        }
105    }
106
107    fn repo(&self, permission: &Map<String, Value>) -> Option<Vec<Scope>> {
108        has_only(permission, &["collection", "action"])?;
109        let collections = self.nsids(permission.get("collection")?, true)?;
110        let actions = match permission.get("action") {
111            None => ActionSet::All,
112            Some(actions) => ActionSet::Only(repo_actions(actions)?),
113        };
114        Some(
115            collections
116                .into_iter()
117                .map(|collection| Scope::Repo {
118                    collection: NsidPattern::Exact(collection),
119                    actions: actions.clone(),
120                })
121                .collect(),
122        )
123    }
124
125    fn rpc(&self, permission: &Map<String, Value>) -> Option<Vec<Scope>> {
126        has_only(permission, &["lxm", "aud", "inheritAud"])?;
127        let methods = self.nsids(permission.get("lxm")?, false)?;
128        let inherits = match permission.get("inheritAud") {
129            None => false,
130            Some(inherits) => inherits.as_bool()?,
131        };
132        let aud = match (inherits, permission.get("aud")) {
133            (true, None) => self.aud.clone()?,
134            (false, Some(aud)) if aud.as_str() == Some(ANY_AUD) => ANY_AUD.to_owned(),
135            _ => return None,
136        };
137        Some(
138            methods
139                .into_iter()
140                .map(|method| Scope::Rpc {
141                    method: NsidPattern::Exact(method),
142                    aud: Some(aud.clone()),
143                })
144                .collect(),
145        )
146    }
147
148    /// A non-empty list of NSIDs under this set's group, or `None` if any
149    /// entry is not one. `unique` refuses a list that names one twice.
150    fn nsids(&self, list: &Value, unique: bool) -> Option<BTreeSet<String>> {
151        let list = list.as_array().filter(|list| !list.is_empty())?;
152        let mut nsids = BTreeSet::new();
153        for nsid in list {
154            let nsid = nsid.as_str()?;
155            if nsid_syntax::validate(nsid).is_err() || !self.authorizes(nsid) {
156                return None;
157            }
158            if !nsids.insert(nsid.to_owned()) && unique {
159                return None;
160            }
161        }
162        Some(nsids)
163    }
164
165    /// Whether `nsid` sits under this set's group: the set's own NSID less
166    /// its name. The group's parent and its siblings are outside it.
167    fn authorizes(&self, nsid: &str) -> bool {
168        let Some((group, _)) = self.nsid.rsplit_once('.') else {
169            return false;
170        };
171        nsid.strip_prefix(group)
172            .and_then(|rest| rest.strip_prefix('.'))
173            .is_some_and(|rest| !rest.is_empty())
174    }
175}
176
177/// `Some` when every field of `permission` is `type`, `resource`,
178/// `description`, or one of `fields`.
179fn has_only(permission: &Map<String, Value>, fields: &[&str]) -> Option<()> {
180    permission
181        .keys()
182        .all(|key| {
183            matches!(key.as_str(), "type" | "resource" | "description")
184                || fields.contains(&key.as_str())
185        })
186        .then_some(())
187}
188
189/// A non-empty list of distinct `repo` actions, or `None`.
190fn repo_actions(list: &Value) -> Option<BTreeSet<Action>> {
191    let list = list.as_array().filter(|list| !list.is_empty())?;
192    let mut actions = BTreeSet::new();
193    for action in list {
194        let action = match action.as_str()? {
195            "create" => Action::Create,
196            "update" => Action::Update,
197            "delete" => Action::Delete,
198            _ => return None,
199        };
200        if !actions.insert(action) {
201            return None;
202        }
203    }
204    Some(actions)
205}
206
207/// Whether `aud` names one service: a `did:plc` or a `did:web`, then `#`
208/// and a fragment.
209pub(crate) fn is_service_reference(aud: &str) -> bool {
210    let Some((did, fragment)) = aud.split_once('#') else {
211        return false;
212    };
213    (did.starts_with("did:plc:") || did.starts_with("did:web:"))
214        && didbot_identity::validate_did(did).is_ok()
215        && is_fragment(fragment)
216}
217
218/// Whether `fragment` is a non-empty RFC 3986 fragment: `pchar`, `/` and
219/// `?`, with `%` only as an escape.
220fn is_fragment(fragment: &str) -> bool {
221    let bytes = fragment.as_bytes();
222    let mut at = 0;
223    while at < bytes.len() {
224        match bytes[at] {
225            b'%' if bytes
226                .get(at + 1..at + 3)
227                .is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit)) =>
228            {
229                at += 3;
230            }
231            byte if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=:@/?".contains(&byte) => {
232                at += 1;
233            }
234            _ => return false,
235        }
236    }
237    !bytes.is_empty()
238}
239
240#[cfg(test)]
241mod tests {
242    use serde_json::json;
243
244    use super::*;
245    use crate::ScopeSet;
246
247    const APPVIEW: &str = "did:web:api.example.com#svc_appview";
248
249    /// What `include:<name>` reads as, or why it names no set.
250    fn read(name: &str) -> Result<Include, IncludeError> {
251        let Scope::Include { nsid, aud } = Scope::parse(&format!("include:{name}")).unwrap() else {
252            panic!("`include:{name}` is not an include");
253        };
254        Include::new(&nsid, aud.as_deref())
255    }
256
257    fn include(name: &str) -> Include {
258        read(name).unwrap_or_else(|error| panic!("`{name}`: {error}"))
259    }
260
261    /// What `permissions` grants through `include:<name>`, printed.
262    fn granted(name: &str, permissions: Value) -> String {
263        let permissions = permissions.as_array().expect("a list").clone();
264        ScopeSet::new(include(name).grants(&permissions)).to_string()
265    }
266
267    #[test]
268    fn a_set_grants_each_collection_and_method_it_names() {
269        let permissions = json!([
270            {
271                "type": "permission",
272                "resource": "repo",
273                "collection": ["com.example.post", "com.example.feed.like"],
274                "action": ["create", "delete"],
275            },
276            {
277                "type": "permission",
278                "resource": "repo",
279                "collection": ["com.example.profile"],
280            },
281            {
282                "type": "permission",
283                "resource": "rpc",
284                "lxm": ["com.example.getFeedSkeleton"],
285                "aud": "*",
286            },
287        ]);
288        assert_eq!(
289            granted("com.example.authFull", permissions),
290            "repo:com.example.feed.like?action=create&action=delete \
291             repo:com.example.post?action=create&action=delete \
292             repo:com.example.profile \
293             rpc:com.example.getFeedSkeleton?aud=*"
294        );
295    }
296
297    /// The spec's own example: `inheritAud` takes the audience the
298    /// `include:` names, percent-decoded, and it prints the way a client
299    /// would have written it.
300    #[test]
301    fn the_include_aud_reaches_the_rpc_permissions_that_inherit_it() {
302        let permissions = json!([{
303            "type": "permission",
304            "resource": "rpc",
305            "inheritAud": true,
306            "lxm": ["com.example.getFeed", "com.example.getProfile"],
307        }]);
308        let name = "com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview";
309        assert_eq!(include(name).aud(), Some(APPVIEW));
310        let grant = granted(name, permissions);
311        assert_eq!(
312            grant,
313            "rpc:com.example.getFeed?aud=did:web:api.example.com%23svc_appview \
314             rpc:com.example.getProfile?aud=did:web:api.example.com%23svc_appview"
315        );
316        assert!(ScopeSet::parse(&grant)
317            .expect("a grant parses")
318            .permits_rpc(Some("com.example.getFeed"), APPVIEW));
319    }
320
321    /// `inheritAud` with nothing to inherit, or beside an `aud` of its own,
322    /// is invalid: the permission grants nothing, and never an `rpc:` scope
323    /// with no audience, which would reach every service.
324    #[test]
325    fn inherit_aud_grants_nothing_without_one_audience_to_take() {
326        let inherits = json!([{
327            "type": "permission",
328            "resource": "rpc",
329            "inheritAud": true,
330            "lxm": ["com.example.getFeed"],
331        }]);
332        assert_eq!(granted("com.example.authFull", inherits), "");
333
334        let both = json!([{
335            "type": "permission",
336            "resource": "rpc",
337            "inheritAud": true,
338            "aud": "*",
339            "lxm": ["com.example.getFeed"],
340        }]);
341        assert_eq!(
342            granted(
343                "com.example.authFull?aud=did:web:api.example.com%23svc_appview",
344                both
345            ),
346            ""
347        );
348    }
349
350    /// A set grants lexicons under its own group and the groups below it,
351    /// never its group's parent, a sibling, or a name that only shares the
352    /// group's text.
353    #[test]
354    fn a_set_grants_only_under_its_own_group() {
355        let repo = |collection: &str| {
356            json!([{
357                "type": "permission",
358                "resource": "repo",
359                "collection": [collection],
360            }])
361        };
362        for (collection, granted_it) in [
363            ("app.example.feed.post", true),
364            ("app.example.feed.thread.gate", true),
365            ("app.example.feed", false),
366            ("app.example.actor.profile", false),
367            ("app.example.feedx.post", false),
368            ("app.other.feed.post", false),
369            ("*", false),
370            ("app.example.feed.*", false),
371        ] {
372            let grant = granted("app.example.feed.authOnlyPost", repo(collection));
373            assert_eq!(!grant.is_empty(), granted_it, "`{collection}`: `{grant}`");
374        }
375    }
376
377    /// The spec: "ignore any individual permission declarations within a
378    /// permission set that describe an unknown resource, or include
379    /// unexpected parameter names or values". Each of these sits beside one
380    /// permission that is fine, which still grants.
381    #[test]
382    fn a_permission_this_cannot_read_is_ignored_whole() {
383        let fine = json!({
384            "type": "permission",
385            "resource": "repo",
386            "collection": ["com.example.post"],
387        });
388        for ignored in [
389            json!({"type": "permission", "resource": "blob", "accept": ["image/*"]}),
390            json!({"type": "permission", "resource": "account", "attr": "email"}),
391            json!({"type": "permission", "resource": "identity", "attr": "handle"}),
392            json!({"type": "permission", "resource": "scry", "collection": ["com.example.x"]}),
393            json!({"type": "token", "resource": "repo", "collection": ["com.example.x"]}),
394            json!({"resource": "repo", "collection": ["com.example.x"]}),
395            json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
396                   "maxRecords": 1}),
397            json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
398                   "action": ["manage"]}),
399            json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
400                   "action": ["create", "create"]}),
401            json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
402                   "action": []}),
403            json!({"type": "permission", "resource": "repo",
404                   "collection": ["com.example.x", "com.example.x"]}),
405            json!({"type": "permission", "resource": "repo", "collection": []}),
406            json!({"type": "permission", "resource": "repo", "collection": "com.example.x"}),
407            json!({"type": "permission", "resource": "repo",
408                   "collection": ["com.example.x", "com.other.x"]}),
409            json!({"type": "permission", "resource": "repo", "collection": ["com.example.x"],
410                   "description": 7}),
411            json!({"type": "permission", "resource": "rpc", "lxm": ["com.example.get"],
412                   "aud": APPVIEW}),
413            json!({"type": "permission", "resource": "rpc", "lxm": ["com.example.get"]}),
414            json!({"type": "permission", "resource": "rpc", "lxm": ["com.example.get"],
415                   "inheritAud": "yes"}),
416            json!({"type": "permission", "resource": "rpc", "lxm": ["*"], "aud": "*"}),
417            json!("repo:com.example.x"),
418        ] {
419            assert_eq!(
420                granted(
421                    "com.example.authFull",
422                    json!([ignored.clone(), fine.clone()])
423                ),
424                "repo:com.example.post",
425                "{ignored}"
426            );
427        }
428    }
429
430    /// Fields the lexicon spec defines: `description` on every schema
431    /// object, and `inheritAud` as a boolean that may be `false`.
432    #[test]
433    fn a_description_and_a_false_inherit_aud_are_read() {
434        let permissions = json!([
435            {
436                "type": "permission",
437                "resource": "repo",
438                "collection": ["com.example.post"],
439                "description": "Posts.",
440            },
441            {
442                "type": "permission",
443                "resource": "rpc",
444                "lxm": ["com.example.getFeed"],
445                "aud": "*",
446                "inheritAud": false,
447            },
448        ]);
449        assert_eq!(
450            granted("com.example.authFull", permissions),
451            "repo:com.example.post rpc:com.example.getFeed?aud=*"
452        );
453    }
454
455    #[test]
456    fn an_include_names_a_set_by_its_nsid_and_one_service() {
457        assert_eq!(
458            include("com.example.authFull").nsid(),
459            "com.example.authFull"
460        );
461        assert_eq!(include("com.example.authFull").aud(), None);
462        assert_eq!(
463            include("com.example.authFull?aud=did:web:api.example.com#svc_appview").aud(),
464            Some(APPVIEW)
465        );
466        assert_eq!(
467            include("com.example.authFull?aud=did:plc:ewvi7nxzyoun6zhxrhs64oiz%23atproto_labeler")
468                .aud(),
469            Some("did:plc:ewvi7nxzyoun6zhxrhs64oiz#atproto_labeler")
470        );
471        for refused in [
472            "authFull",
473            "com.example",
474            "com.example.*",
475            "com.example.authFull?aud=*",
476            "com.example.authFull?aud=did:web:api.example.com",
477            "com.example.authFull?aud=did:web:api.example.com%23",
478            "com.example.authFull?aud=did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme%23x",
479            "com.example.authFull?aud=did:web:a.example%23x%23y",
480        ] {
481            assert!(read(refused).is_err(), "`include:{refused}` names a set");
482        }
483        // Forms the spec does not define, read as well as they can be.
484        for (spelled, aud) in [
485            (
486                "com.example.authFull?aud=did:web:a.example%23x&aud=did:web:b.example%23x",
487                Some("did:web:a.example#x"),
488            ),
489            ("com.example.authFull?nsid=com.example.other", None),
490            ("com.example.authFull?lang=en", None),
491            ("?nsid=com.example.authFull", None),
492        ] {
493            let read = include(spelled);
494            assert_eq!(read.nsid(), "com.example.authFull", "`include:{spelled}`");
495            assert_eq!(read.aud(), aud, "`include:{spelled}`");
496        }
497        for unread in [
498            "include:?aud=did:web:api.example.com%23svc",
499            "include:com.example.authFull?aud",
500        ] {
501            assert!(
502                Scope::parse(unread).is_err(),
503                "`{unread}` read as an include"
504            );
505        }
506    }
507}