Skip to main content

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}