Skip to main content

didbot_scope/
transition.rs

1//! The three legacy `transition:*` scopes and what each subsumes.
2
3use std::fmt;
4
5use crate::pattern::under_prefix;
6use crate::{AccountAction, AccountAttr, NsidPattern, Scope, ScopeParseError};
7
8/// The three legacy `transition:*` scopes.
9///
10/// Each is modelled as opaquely subsuming the granular scopes it stands in
11/// for — `plan/scope-policy.md` names `transition:generic` as the scope that
12/// "makes every granular rule beside it decorative", and that is a fact
13/// about containment this type has to get right rather than paper over.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum Transition {
16    /// Full read/write over every collection, every method outside
17    /// `chat.bsky.*`, and blob uploads — the scope most existing atproto
18    /// clients still request.
19    Generic,
20    /// The same, narrowed to the `chat.bsky.*` namespace.
21    ChatBsky,
22    /// Reading the account's email address, as `account:email` does.
23    Email,
24}
25
26impl Transition {
27    pub(crate) fn as_str(self) -> &'static str {
28        match self {
29            Transition::Generic => "generic",
30            Transition::ChatBsky => "chat.bsky",
31            Transition::Email => "email",
32        }
33    }
34
35    pub(crate) fn parse(s: &str) -> Result<Self, ScopeParseError> {
36        match s {
37            "generic" => Ok(Transition::Generic),
38            "chat.bsky" => Ok(Transition::ChatBsky),
39            "email" => Ok(Transition::Email),
40            other => Err(ScopeParseError::UnknownTransition(other.to_owned())),
41        }
42    }
43
44    /// Does this legacy scope's real-world grant cover `scope`?
45    ///
46    /// This is the one containment rule in this module that is not derived
47    /// structurally from the two operands — it is a fact this grammar has to
48    /// assert about what the legacy scope means, taken from the atproto OAuth
49    /// spec's own description of `transition:*`'s compatibility grant.
50    pub(crate) fn covers(self, scope: &Scope) -> bool {
51        match (self, scope) {
52            (Transition::Generic, Scope::Repo { .. } | Scope::Blob { .. }) => true,
53            (Transition::Generic, Scope::Rpc { method, .. }) => !reaches_chat_bsky(method),
54            (
55                Transition::ChatBsky,
56                Scope::Repo {
57                    collection: NsidPattern::Exact(nsid) | NsidPattern::Prefix(nsid),
58                    ..
59                },
60            )
61            | (
62                Transition::ChatBsky,
63                Scope::Rpc {
64                    method: NsidPattern::Exact(nsid) | NsidPattern::Prefix(nsid),
65                    ..
66                },
67            ) => under_prefix("chat.bsky", nsid) || nsid == "chat.bsky",
68            (
69                Transition::Email,
70                Scope::Account {
71                    attr: AccountAttr::Email,
72                    action: AccountAction::Read,
73                },
74            ) => true,
75            _ => false,
76        }
77    }
78}
79
80/// Whether `method` admits any `chat.bsky.*` method.
81///
82/// The OAuth spec keeps direct messages out of `transition:generic`: an app
83/// reaches `chat.bsky.*` through `transition:chat.bsky` or a granular scope.
84fn reaches_chat_bsky(method: &NsidPattern) -> bool {
85    match method {
86        NsidPattern::Any => true,
87        NsidPattern::Exact(nsid) => under_prefix("chat.bsky", nsid),
88        NsidPattern::Prefix(prefix) => {
89            under_prefix("chat.bsky", prefix) || under_prefix(prefix, "chat.bsky")
90        }
91    }
92}
93
94impl fmt::Display for Transition {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "transition:{}", self.as_str())
97    }
98}