Skip to main content

protocol_droid/domain/registry/
auth.rs

1//! `AuthContext` — the identity a visibility rule is evaluated against.
2//!
3//! # Why this exists at all
4//!
5//! A registry that lists the same tools to every session is wrong the moment
6//! two sessions belong to different principals, and it is wrong *silently*:
7//! nothing fails, the tool is simply visible to someone who should not see it.
8//! The fix is to make the caller state who is asking, so that "should this
9//! session see this primitive" is a question with an answer rather than an
10//! assumption.
11//!
12//! # Why this is a value object and not a mechanism
13//!
14//! This type is a rule **input**. It carries a principal's identity and the
15//! set of scopes they hold, and it knows how to answer "do you hold this set".
16//! It does not parse a bearer token, verify a signature, consult an
17//! introspection endpoint, or decide *why* a principal holds a scope — every one
18//! of those is T16's OAuth work, and every one of them belongs in an adapter
19//! that produces this value rather than here. The split is the same one the
20//! project already draws for [`Clock`](crate::application::session::clock::Clock): the
21//! decision consumes the reading, it never takes it.
22//!
23//! # Default deny
24//!
25//! [`ScopeSet::holds`] is a subset test, so a principal holding nothing sees
26//! nothing that requires anything. A primitive with no required scopes is
27//! visible to every authenticated principal, which is the one case where "no
28//! rule" means "allow" — and it is expressed as an empty subset rather than as
29//! a special case, so there is no second path through the predicate.
30
31use core::fmt;
32use std::sync::Arc;
33
34/// A permission a principal may hold.
35///
36/// Deliberately not a hierarchy. `Read` does not imply `Write` and `Write` does
37/// not imply `Admin`: a primitive states the exact set it needs, and the
38/// principal states the exact set it holds. A hierarchy would make the visible
39/// set depend on an ordering the specification does not define, and would make
40/// a missing rule resolve by accident of declaration order rather than by
41/// intent.
42///
43/// T16 replaces this with whatever the peer's token actually carries. The
44/// visibility rule in [`super::visibility`] is written against the subset test
45/// and will not need to change when it does.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum Scope {
48    /// Reading a primitive: listing it, and calling one that changes nothing.
49    Read,
50    /// Mutating through a primitive: calling a tool that writes.
51    Write,
52    /// Administration: primitives that alter the server's own configuration.
53    Admin,
54}
55
56impl Scope {
57    /// Every scope, in declaration order.
58    pub const ALL: [Self; 3] = [Self::Read, Self::Write, Self::Admin];
59
60    /// The name used in a deny message and in tracing.
61    #[must_use]
62    pub const fn as_str(self) -> &'static str {
63        match self {
64            Self::Read => "read",
65            Self::Write => "write",
66            Self::Admin => "admin",
67        }
68    }
69
70    const fn bit(self) -> u128 {
71        match self {
72            Self::Read => 1,
73            Self::Write => 2,
74            Self::Admin => 4,
75        }
76    }
77}
78
79impl fmt::Display for Scope {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(self.as_str())
82    }
83}
84
85/// The scopes a principal holds.
86///
87/// A `u128` bitset for the same reason [`FeatureProfile`](crate::domain::FeatureProfile)
88/// is one: the set is copied into every visibility decision, so it has to be
89/// `Copy` and eight bytes of comparison beats a `HashSet` on a path that runs
90/// once per primitive per list.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
92pub struct ScopeSet {
93    bits: u128,
94}
95
96impl ScopeSet {
97    /// No scopes. The default, so a context built without a set denies
98    /// everything that requires one.
99    #[must_use]
100    pub const fn empty() -> Self {
101        Self { bits: 0 }
102    }
103
104    /// Every scope.
105    #[must_use]
106    pub const fn all() -> Self {
107        Self { bits: 0b111 }
108    }
109
110    /// Adds a scope.
111    #[must_use]
112    pub const fn with(self, scope: Scope) -> Self {
113        Self {
114            bits: self.bits | scope.bit(),
115        }
116    }
117
118    /// Whether the scope is held.
119    #[must_use]
120    pub const fn contains(self, scope: Scope) -> bool {
121        self.bits & scope.bit() != 0
122    }
123
124    /// Whether no scope is held.
125    #[must_use]
126    pub const fn is_empty(self) -> bool {
127        self.bits == 0
128    }
129
130    /// Whether every scope in `required` is held.
131    ///
132    /// The direction matters: this is "does the principal have everything the
133    /// primitive needs", and the subset relation is what makes an empty
134    /// requirement visible to everyone while a single missing scope hides the
135    /// primitive from everyone.
136    #[must_use]
137    pub const fn holds(self, required: Self) -> bool {
138        self.bits & required.bits == required.bits
139    }
140
141    /// The scopes held but not required, and the reverse — for a message that
142    /// says which side was short.
143    #[must_use]
144    pub const fn difference(self, other: Self) -> Self {
145        Self {
146            bits: self.bits & !other.bits,
147        }
148    }
149}
150
151/// Who is asking, and what they hold.
152///
153/// Built once per request at the transport boundary from whatever the peer
154/// proved, and then read — never mutated — by the visibility predicate.
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156pub struct AuthContext {
157    principal: Arc<str>,
158    scopes: ScopeSet,
159}
160
161impl AuthContext {
162    /// An unauthenticated principal holding nothing.
163    ///
164    /// Distinct from [`AuthContext::new`] with a name and no scopes in exactly
165    /// one way, and it is worth being explicit about the difference: an
166    /// anonymous caller is a peer that has not presented credentials, while a
167    /// named principal holding nothing is a peer that presented credentials
168    /// which granted nothing. Both see the same primitives. The name differs so
169    /// a trace can say which happened.
170    #[must_use]
171    pub fn anonymous() -> Self {
172        Self {
173            principal: Arc::from("anonymous"),
174            scopes: ScopeSet::empty(),
175        }
176    }
177
178    /// A named principal holding `scopes`.
179    #[must_use]
180    pub fn new(principal: impl Into<Arc<str>>, scopes: ScopeSet) -> Self {
181        Self {
182            principal: principal.into(),
183            scopes,
184        }
185    }
186
187    /// A principal holding every scope. For a server with no authorization of
188    /// its own, which is the common case before T16.
189    #[must_use]
190    pub fn unrestricted(principal: impl Into<Arc<str>>) -> Self {
191        Self::new(principal, ScopeSet::all())
192    }
193
194    /// Who is asking.
195    #[must_use]
196    pub fn principal(&self) -> &str {
197        &self.principal
198    }
199
200    /// What they hold.
201    #[must_use]
202    pub const fn scopes(&self) -> ScopeSet {
203        self.scopes
204    }
205
206    /// Whether the principal holds every scope in `required`.
207    #[must_use]
208    pub const fn holds(&self, required: ScopeSet) -> bool {
209        self.scopes.holds(required)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::{AuthContext, Scope, ScopeSet};
216
217    /// The default must deny. A context that accidentally defaulted to
218    /// permissive would make every visibility rule a no-op, and nothing else
219    /// in the tree would catch it.
220    #[test]
221    fn the_default_holds_nothing() {
222        let context = AuthContext::default();
223        assert!(!context.holds(ScopeSet::empty().with(Scope::Read)));
224        assert!(context.holds(ScopeSet::empty()));
225    }
226
227    /// A subset test, not a hierarchy: `Write` does not imply `Admin`, because
228    /// a primitive that needs `Admin` was written to be reachable by nobody but
229    /// an administrator and a hierarchy would quietly widen that.
230    #[test]
231    fn scopes_do_not_imply_one_another() {
232        let write = ScopeSet::empty().with(Scope::Write);
233        assert!(!write.contains(Scope::Admin));
234        assert!(!write.contains(Scope::Read));
235        assert!(!write.holds(ScopeSet::all()));
236    }
237
238    #[test]
239    fn holds_is_the_subset_relation() {
240        let admin = ScopeSet::empty().with(Scope::Admin);
241        let read_write_admin = ScopeSet::empty()
242            .with(Scope::Read)
243            .with(Scope::Write)
244            .with(Scope::Admin);
245        assert!(read_write_admin.holds(admin));
246        assert!(admin.holds(admin));
247        assert!(!admin.holds(read_write_admin));
248        // An empty requirement is held by everyone, including nobody.
249        assert!(admin.holds(ScopeSet::empty()));
250    }
251
252    /// `difference` is used to say which side was short. Getting the direction
253    /// wrong would print the held scopes and hide the missing ones, which is
254    /// the one fact the message exists to convey.
255    #[test]
256    fn difference_reports_what_is_missing() {
257        let missing = ScopeSet::empty()
258            .with(Scope::Read)
259            .with(Scope::Write)
260            .difference(ScopeSet::empty().with(Scope::Read));
261        assert!(missing.contains(Scope::Write));
262        assert!(!missing.contains(Scope::Read));
263    }
264
265    #[test]
266    fn anonymous_and_named_are_both_denial_by_default() {
267        let anonymous = AuthContext::anonymous();
268        assert_eq!(anonymous.principal(), "anonymous");
269        assert!(!anonymous.holds(ScopeSet::empty().with(Scope::Read)));
270
271        let named = AuthContext::new("alice", ScopeSet::empty());
272        assert_eq!(named.principal(), "alice");
273        assert!(!named.holds(ScopeSet::empty().with(Scope::Read)));
274    }
275
276    #[test]
277    fn unrestricted_holds_everything() {
278        let context = AuthContext::unrestricted("bob");
279        assert!(context.holds(ScopeSet::all()));
280    }
281
282    /// Every scope needs a distinct bit, or two scopes would share one and
283    /// holding one would silently grant the other.
284    #[test]
285    fn every_scope_has_its_own_bit() {
286        for scope in Scope::ALL {
287            let set = ScopeSet::empty().with(scope);
288            assert!(set.contains(scope), "{scope} missing from its own set");
289            for other in Scope::ALL {
290                if other != scope {
291                    assert!(!set.contains(other), "bit for {scope} also grants {other}");
292                }
293            }
294        }
295    }
296}