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}