didbot_policy/subject.rs
1//! What a policy is asked about.
2//!
3//! Per `plan/policy.md`'s "Two trees, one set of mechanics": the same
4//! indexing, evaluators and outcomes serve every subject shape.
5
6use time::OffsetDateTime;
7
8use crate::client_id::ClientId;
9
10/// What kind of write this is, carried alongside the diff rather than
11/// inferred from it.
12///
13/// `plan/policy.md` deliberately leaves "what a delete means" unsettled: a
14/// diff that happens to remove every path is not the same as an explicit
15/// signal that this is a deletion, and different evaluators may want
16/// different logic for one. This crate takes no position on that meaning —
17/// it only makes the action routable, so a policy can declare that it
18/// applies to deletes, or does not, and an evaluator can see which case it
19/// is looking at.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum WriteAction {
22 /// The record did not exist before this write.
23 Create,
24 /// The record existed and this write changes it.
25 Update,
26 /// This write removes the record.
27 Delete,
28}
29
30impl WriteAction {
31 /// The action as a policy document spells it.
32 #[must_use]
33 pub fn as_str(self) -> &'static str {
34 match self {
35 WriteAction::Create => "create",
36 WriteAction::Update => "update",
37 WriteAction::Delete => "delete",
38 }
39 }
40}
41
42/// One field's value before and after a write, at a dotted path such as
43/// `"displayName"` or `"profile.bio"`.
44///
45/// `before` and `after` are `None` when the path was absent on that side —
46/// absence is a value, not a missing entry. This is what lets a rule like
47/// *"displayName may not change to a different value"* resolve without an
48/// action special case: it is `before.is_some() && after != before`. A
49/// create has `before: None` everywhere, so it never trips such a rule —
50/// correct, there is no prior identity to protect. A delete has
51/// `after: None` everywhere; whether that counts as a change to the path
52/// is the evaluator's reading of [`WriteAction`], which is carried
53/// alongside the diff for exactly that decision.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Change<'a> {
56 /// The field path this change is at, passed through exactly as the
57 /// caller supplied it.
58 ///
59 /// What a path segment *is* — where one ends, what a `.` or a `[0]`
60 /// means, whether a literal dot in a field name is possible — is
61 /// per-engine, not this crate's to decide or canonicalize. An
62 /// evaluator reads this string with whatever segmentation its own
63 /// engine uses; this crate does not reinterpret it before handing it
64 /// over. (The applicability index's own `path_prefixes` matching does
65 /// split on `.` internally, and treats an `[index]` subscript as a
66 /// segment boundary too, so a prefix naming a field matches the
67 /// elements of that field when it holds an array. That is this
68 /// crate's own declarative convention for *routing* — a cheap,
69 /// structural pre-filter over which policies are even asked — not a
70 /// claim about what `path` means to the evaluator a matched policy
71 /// names.)
72 pub path: &'a str,
73 /// The value before the write, or `None` if the path was absent.
74 pub before: Option<&'a serde_json::Value>,
75 /// The value after the write, or `None` if the path is now absent.
76 pub after: Option<&'a serde_json::Value>,
77}
78
79/// What changed in a write, as the applicability index's key.
80///
81/// The diff has to be computed to evaluate anything, so diffing *is* the
82/// indexing step: a write's changed paths are walked through the tree once,
83/// and a write matching no policy has paid only for that walk.
84///
85/// The contract is exact: `changes` holds every path whose value differs
86/// between before and after, where absence is a value — so a create lists
87/// every path it introduces, a delete lists every path it removes, and
88/// there is no case where an evaluator sees an empty diff for a write that
89/// changed something. The server computes this, holding both sides of the
90/// write already, so it is enforced once in the write path rather than
91/// trusted from whatever produced the write.
92///
93/// This is a minimal, storage-agnostic view, deliberately not the richer
94/// diff type that `didbot-data`'s merkle search tree produces. That type
95/// carries proof material this crate has no use for, and coupling the
96/// policy core to one storage engine's diff shape would be a dependency in
97/// the wrong direction.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub struct Diff<'a> {
100 /// Every path that changed, each with its value before and after.
101 pub changes: &'a [Change<'a>],
102}
103
104/// One of the four account kinds this build recognizes.
105///
106/// Mirrors `bot.did.registration`'s `actor` union and
107/// `didbot-pds::kind::AccountKind` exactly, but is declared independently
108/// rather than reused from that crate — the same reasoning as [`Diff`]'s
109/// doc comment on not coupling this crate to one storage engine's shape,
110/// applied to an account-kind enum instead of a diff type. A fifth kind
111/// added to the lexicon is a silent gap here until this enum is edited to
112/// match; nothing checks the two stay in step automatically.
113///
114/// Agents are the *typical* `did.bot` subject, not the only one: a policy
115/// or an evaluator that quietly assumes every subject is an agent is wrong
116/// about the other three, which is exactly what made `AgentAttributes` the
117/// wrong name for [`SubjectAttributes`] in an earlier draft of this type.
118///
119/// This type is deliberately closed — the four kinds this build knows how
120/// to reason about by name — and is not, by itself, what a subject's kind
121/// is typed as. That is [`SubjectKind`], below, because the union it names
122/// is open.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub enum AccountKind {
125 /// A harness session with a model and a parent.
126 Agent,
127 /// A CI identity bound to a workflow.
128 Pipeline,
129 /// A machine. No model, no harness, no parent.
130 Host,
131 /// This software itself.
132 Service,
133}
134
135impl AccountKind {
136 /// The kind as `bot.did.registration` spells it.
137 #[must_use]
138 pub fn as_str(self) -> &'static str {
139 match self {
140 AccountKind::Agent => "agent",
141 AccountKind::Pipeline => "pipeline",
142 AccountKind::Host => "host",
143 AccountKind::Service => "service",
144 }
145 }
146}
147
148/// What kind of account a subject is, including a kind this build has never
149/// heard of.
150///
151/// `bot.did.registration`'s `actor` union is declared `"closed": false`: a
152/// fifth kind can appear in a record this server did not write, admitted by
153/// a future version of some deployment's software or a fork's. A type that
154/// could only ever be one of [`AccountKind`]'s four variants would have
155/// nowhere to put that value, forcing a choice between failing to parse the
156/// record, or coercing an unrecognized kind into a known one — silently
157/// making a claim about it nobody established. [`Unknown`](Self::Unknown)
158/// exists so neither happens: the string is preserved and the subject still
159/// routes, and see [`KindSet`](crate::policy::KindSet)'s doc comment for
160/// what an applicability declaration is and is not allowed to conclude from
161/// one.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum SubjectKind<'a> {
164 /// One of the four kinds this build recognizes by name.
165 Known(AccountKind),
166 /// A kind this build does not recognize, carrying the string the
167 /// record named it with.
168 Unknown(&'a str),
169}
170
171impl<'a> SubjectKind<'a> {
172 /// The kind as the record spells it, known or not.
173 #[must_use]
174 pub fn as_str(self) -> &'a str {
175 match self {
176 SubjectKind::Known(kind) => kind.as_str(),
177 SubjectKind::Unknown(kind) => kind,
178 }
179 }
180}
181
182/// Attributes about the writing account a policy may need beyond its DID,
183/// explicit and extensible rather than a bag.
184///
185/// This is the contract between this crate, the write path that populates
186/// it, and the evaluators that read it: an evaluator names the attribute it
187/// needs from here, and the write path knows, from this same type, what it
188/// is obliged to fill in. Every attribute past `kind` is kind-dependent —
189/// a host has no model to report, the way it has no harness and no parent —
190/// so `None` here means "this kind does not have one, or the write path
191/// could not resolve it for this write", never an error an evaluator needs
192/// to handle specially; it is a normal, expected value to see.
193///
194/// Adding an allowlist-shaped policy — *"displayName may be set only to the
195/// account's handle or a small fixed list of values"* — does not reintroduce
196/// an allow: it is expressed as *deny if the new value matches none of
197/// them*, read off [`Change::after`](crate::subject::Change::after) and
198/// this handle, which subtracts from what is permitted exactly like every
199/// other policy. Monotonicity does not distinguish an allowlist from a
200/// blocklist; both are deny rules, just aimed at different values.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct SubjectAttributes<'a> {
203 /// What kind of account this subject is — one of the four this build
204 /// recognizes, or a kind it does not. See [`SubjectKind`].
205 pub kind: SubjectKind<'a>,
206 /// This account's current handle, if the write path resolved one for
207 /// this write and this kind has one.
208 pub handle: Option<&'a str>,
209 /// How many edges lie between this account and the deployment's
210 /// operator: 1 for an account the operator runs directly, 2 for one
211 /// such an account created, and so on. `None` when the write path did
212 /// not resolve it.
213 pub depth: Option<u32>,
214}
215
216/// Facts about this deployment a policy may reference, independent of the
217/// account or the write — a different kind of fact from
218/// [`SubjectAttributes`], which is why it is its own type rather than a
219/// field folded into that one. An evaluator asking for the PDS's own DID or
220/// hostname is asking a different question than one asking about the
221/// account, and naming them separately keeps that visible at the call site
222/// — for a rule like *"deny a handle that is not under this deployment's
223/// zone"*.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
225pub struct DeploymentAttributes<'a> {
226 /// This PDS's own DID.
227 pub pds_did: &'a str,
228 /// This PDS's own hostname.
229 pub pds_hostname: &'a str,
230 /// The DID of the account that vouches for this deployment. `None`
231 /// until this server has observed its operator's claim, and on every
232 /// subject but a write, which is the only one a rule reads it from
233 /// today.
234 pub operator_did: Option<&'a str>,
235}
236
237/// Facts that do not belong to the account or the write, but that a
238/// stateless predicate is still allowed to reference — grouped into one
239/// field per [`Subject`] variant rather than left flat, since the field
240/// list otherwise grows by one every time a new universal fact is added.
241///
242/// # Why `now` lives here instead of an evaluator calling a clock
243///
244/// Time is an input, never an ambient read: nothing in this crate lets an
245/// evaluator call `SystemTime::now()` or its equivalent, for the same
246/// reason [`evaluate`](crate::outcome::Evaluator::evaluate) takes no store
247/// handle — a capability not reachable from the arguments cannot be reached
248/// at all. Passing `now` explicitly instead means:
249///
250/// - An evaluator implementing a time-shaped rule stays a pure function of
251/// its inputs, testable by constructing a `Subject` with the instant a
252/// test wants, never by sleeping or mocking a global clock.
253/// - The instant a decision used is exactly what was passed in, so the
254/// write path can record it in the evaluation log. `plan/policy.md`
255/// already says decisions are not reproducible and the log records the
256/// decision rather than re-deriving it; a time-dependent rule is a
257/// second, independent reason that holds, and the recorded instant is
258/// what makes such a decision explicable later.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub struct Universal<'a> {
261 /// Facts about this deployment. See [`DeploymentAttributes`].
262 pub deployment: DeploymentAttributes<'a>,
263 /// The instant this subject is being evaluated at, supplied by the
264 /// caller rather than read from a clock inside an evaluator.
265 pub now: OffsetDateTime,
266}
267
268/// What a policy's declared lookup found in somebody else's repository.
269///
270/// A policy declares the records its statements read in its own `lookups`
271/// list. The write path reads each one before the write is judged and hands
272/// the answers over here, so an engine reads them as ordinary fields of the
273/// subject and [`Evaluator::evaluate`](crate::outcome::Evaluator::evaluate)
274/// still takes a subject and nothing else. See `docs/write-pipeline.md` for
275/// where the reading happens.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub struct Lookup<'a> {
278 /// The name the policy's `lookups` entry gave this record, which is
279 /// what a statement calls it.
280 pub name: &'a str,
281 /// The DID of the repository that was read.
282 pub account: &'a str,
283 /// The handle the written record itself calls that account by, when it
284 /// carries one: a mention's text. Nothing checks the DID answers to it,
285 /// so it is for telling the writer who their own record named.
286 pub handle: Option<&'a str>,
287 /// The at-uri that was read, so a refusal can carry something a person
288 /// can open. A lookup with no `rkey` reads a collection, and this is
289 /// the collection's uri.
290 pub uri: &'a str,
291 /// What was there. See [`Found`].
292 pub found: Found<'a>,
293}
294
295/// What reading one declared lookup established.
296///
297/// Three cases, and the third is the one that decides the design: a reader
298/// that turned "somebody's server did not answer" into a value would have a
299/// rule refuse, or permit, on a fact nobody established. So the reader says
300/// it does not know, and each statement declares what an unknown means for
301/// itself.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum Found<'a> {
304 /// The records found: an array, each element `{"uri": …, "value": …}`.
305 /// A lookup naming one record finds at most one; a lookup naming a
306 /// collection finds every record in it. One shape means a statement
307 /// reads both the same way, and each uri is what a refusal carries so
308 /// a person can open the record it came from.
309 Records(&'a serde_json::Value),
310 /// The repository answered, and holds nothing there.
311 Nothing,
312 /// Neither was established, carrying the short word the reader named
313 /// the case with: not fetched, past its deadline, refused,
314 /// unresolvable, unreachable.
315 Unknown(&'a str),
316}
317
318/// What one policy is asked to decide about.
319///
320/// A client appears on all three variants, so a rule denying a client
321/// answers the same way at sign-in, at token issue, and at the write. The
322/// write matters most: a token issued before a denial still reaches it.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum Subject<'a> {
325 /// A write to this account's own repository. Typically an agent's, but
326 /// see [`AccountKind`] — the field is named for what it is, an account
327 /// DID, not for the kind that happens to be the common case.
328 Write {
329 /// The account whose repository this is.
330 account: &'a str,
331 /// Other attributes of that account a policy may reference — see
332 /// [`SubjectAttributes`].
333 attributes: SubjectAttributes<'a>,
334 /// Deployment and clock facts a policy may reference — see
335 /// [`Universal`].
336 universal: Universal<'a>,
337 /// The application whose OAuth token presented the write. `None`
338 /// when the account wrote with its own credential.
339 client_id: Option<&'a ClientId>,
340 /// The collection (NSID) being written.
341 collection: &'a str,
342 /// Create, update or delete — see [`WriteAction`].
343 action: WriteAction,
344 /// What changed.
345 diff: Diff<'a>,
346 /// The whole record as it was before this write, or `None` when the
347 /// key held nothing.
348 ///
349 /// [`Diff::changes`] is the index key and says what *moved*; this is
350 /// the record itself. An evaluator that reads fields rather than
351 /// changed paths reads them from here, because a record cannot be
352 /// recovered from a diff: a path string is not a parse of the key
353 /// that produced it, so two different fields can spell the same
354 /// path, and an update's diff names only the fields it touched.
355 before: Option<&'a serde_json::Value>,
356 /// The whole record as this write leaves it, or `None` for a delete.
357 /// See [`before`](Self::Write::before).
358 after: Option<&'a serde_json::Value>,
359 /// What the policies applicable to this write declared they read,
360 /// and what was found. See [`Lookup`]. Empty when no applicable
361 /// policy declares one, which is the ordinary case.
362 lookups: &'a [Lookup<'a>],
363 },
364 /// A blob uploaded to this account. It is judged when the upload
365 /// starts, on what the request claims; at checkpoints while the body
366 /// arrives, each of which can refuse the upload but not admit it; and
367 /// once the bytes are in and before they are stored. A fact the first
368 /// judgment does not have is `None` — what is not known yet cannot deny.
369 Blob {
370 /// The account the blob is uploaded to.
371 account: &'a str,
372 /// Other attributes of that account — see [`SubjectAttributes`].
373 attributes: SubjectAttributes<'a>,
374 /// Deployment and clock facts — see [`Universal`].
375 universal: Universal<'a>,
376 /// The application whose OAuth token presented the upload. `None`
377 /// when the account uploaded with its own credential.
378 client_id: Option<&'a ClientId>,
379 /// The MIME type the uploader declared.
380 mime_type: &'a str,
381 /// The MIME type read from the blob's own bytes. `None` before the
382 /// body is read.
383 sniffed: Option<&'a str>,
384 /// The blob's size in bytes. Before the body, the length the request
385 /// declared, or `None` when it declared none. While the body
386 /// arrives, that length, or the bytes received so far when there is
387 /// none. After, what arrived.
388 size: Option<u64>,
389 },
390 /// A pushed authorization request, judged on the application and the
391 /// scopes it asks for, before any account is known.
392 ///
393 /// It names no account because it is asked before the request's
394 /// `login_hint` is resolved, which is what keeps a client the gate
395 /// refuses indistinguishable from a hint naming nobody — so only a
396 /// policy bound to the whole server reaches it. A rule about one
397 /// account is asked where the account is known: [`Subject::Token`], on
398 /// every token issue and every rotation, and [`Subject::Write`].
399 Grant {
400 /// Deployment and clock facts a policy may reference — see
401 /// [`Universal`].
402 universal: Universal<'a>,
403 /// The application requesting the grant.
404 client_id: &'a ClientId,
405 /// The scopes it is asking for.
406 requested_scopes: &'a [&'a str],
407 },
408 /// A token about to be issued to an application for an account: an
409 /// authorization code exchanged, or a refresh token rotated.
410 Token {
411 /// The account the token would authenticate as.
412 account: &'a str,
413 /// Other attributes of that account — see [`SubjectAttributes`].
414 attributes: SubjectAttributes<'a>,
415 /// Deployment and clock facts — see [`Universal`].
416 universal: Universal<'a>,
417 /// The application the token would be issued to.
418 client_id: &'a ClientId,
419 /// The scopes the token would carry.
420 scopes: &'a [&'a str],
421 },
422 /// Where a new account lands: `account.create`, asked once for each
423 /// pool and zone a create could land in, before a name is made.
424 Create {
425 /// The account creating it. The operator's own create names the
426 /// operator's DID, which this server does not host.
427 account: &'a str,
428 /// Other attributes of the creating account, when this server
429 /// hosts it — see [`SubjectAttributes`].
430 attributes: Option<SubjectAttributes<'a>>,
431 /// Deployment and clock facts — see [`Universal`].
432 universal: Universal<'a>,
433 /// What the new account is.
434 kind: SubjectKind<'a>,
435 /// How the create asked for its name.
436 asked: Asked,
437 /// The pool the name would come from.
438 pool: &'a str,
439 /// The zone it would sit under.
440 zone: &'a str,
441 /// The whole name, when the create gave one. A name the pool makes
442 /// does not exist yet when this is asked.
443 name: Option<&'a str>,
444 /// The name's label under `zone`, when the create gave a name.
445 label: Option<&'a str>,
446 },
447}
448
449/// How a create asked for its account's name.
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum Asked {
452 /// A whole name, taken exactly or refused.
453 Name,
454 /// A pool, and optional fragments the pool fills its name with.
455 Pool,
456 /// Nothing: the first pool policy permits makes the name.
457 Nothing,
458}
459
460impl Asked {
461 /// How a policy reads it: `name`, `pool` or `none`.
462 #[must_use]
463 pub const fn as_str(self) -> &'static str {
464 match self {
465 Asked::Name => "name",
466 Asked::Pool => "pool",
467 Asked::Nothing => "none",
468 }
469 }
470}
471
472impl Subject<'_> {
473 /// The application presenting this subject, if one is.
474 pub fn client_id(&self) -> Option<&ClientId> {
475 match self {
476 Subject::Write { client_id, .. } | Subject::Blob { client_id, .. } => *client_id,
477 Subject::Grant { client_id, .. } | Subject::Token { client_id, .. } => Some(client_id),
478 Subject::Create { .. } => None,
479 }
480 }
481
482 /// What this write's applicable policies read from other repositories.
483 /// Empty for every subject that is not a write, since only a write
484 /// names anybody.
485 pub fn lookups(&self) -> &[Lookup<'_>] {
486 match self {
487 Subject::Write { lookups, .. } => lookups,
488 _ => &[],
489 }
490 }
491
492 /// Deployment and clock facts, common to every variant — see
493 /// [`Universal`].
494 pub fn universal(&self) -> &Universal<'_> {
495 match self {
496 Subject::Write { universal, .. }
497 | Subject::Blob { universal, .. }
498 | Subject::Grant { universal, .. }
499 | Subject::Token { universal, .. }
500 | Subject::Create { universal, .. } => universal,
501 }
502 }
503}