Skip to main content

didbot_policy_records/
set.rs

1//! Reading one observation of the operator's two collections into the
2//! declarations a tree is built from, and the checks that need nothing but
3//! the records themselves.
4//!
5//! `didbot-pds`'s `policy_bindings::resolve` runs [`read`], adds the checks
6//! that ask its account registry who is above whom, then runs [`compile`].
7//! A browser runs the same two without the registry.
8
9use std::collections::{BTreeMap, HashMap};
10
11use crate::AcrossBuilds;
12use crate::{BuiltinPolicy, LoadReport, ParsedPolicy};
13use didbot_policy::{ActionSet, Applicability, KindSet, PolicyTree, Revision, Scope, Staleness};
14use didbot_policy_cedar::{ActionName as CedarAction, Document as CedarDocument, LoadError};
15use didbot_policy_regex::RegexEvaluator;
16use serde_json::Value;
17use sha2::{Digest, Sha256};
18
19use crate::engine::engine_for;
20use crate::records::{
21    Action, Document, PolicyBinding, PolicyRecord, PolicyRef, BINDING_COLLECTION, POLICY_COLLECTION,
22};
23
24/// Both collections as one listing of one repository observed them,
25/// complete. Constructed only once both listings have been read to their
26/// last page: a poll that could read one and not the other has nothing to
27/// build from.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct Observed {
30    /// The DID whose repository both listings came from.
31    pub author: String,
32    /// Every `bot.did.policy` record, by record key.
33    pub policies: BTreeMap<String, Value>,
34    /// Every `bot.did.policyBinding` record, by record key.
35    pub bindings: BTreeMap<String, Value>,
36}
37
38/// One revision of the operator's records, as this deployment holds it.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct PolicyRevision {
41    /// Every policy record held, by record key.
42    pub policies: BTreeMap<String, PolicyRecord>,
43    /// Every binding record held, by record key.
44    pub bindings: BTreeMap<String, PolicyBinding>,
45    /// A digest over every record key and body, in both collections. Two
46    /// readers holding the same records agree on this string, so an
47    /// operator checks what a server enforces by comparing it.
48    pub digest: String,
49}
50
51impl PolicyRevision {
52    /// The digest [`PolicyRevision::digest`] holds for these records.
53    #[must_use]
54    pub fn digest_of(
55        policies: &BTreeMap<String, PolicyRecord>,
56        bindings: &BTreeMap<String, PolicyBinding>,
57    ) -> String {
58        let mut hasher = Sha256::new();
59        let mut line = |collection: &str, key: &str, body: &Value| {
60            hasher.update(collection.as_bytes());
61            hasher.update(b"\t");
62            hasher.update(key.as_bytes());
63            hasher.update(b"\t");
64            hasher.update(didbot_policy::sorted_json(body).as_bytes());
65            hasher.update(b"\n");
66        };
67        for (key, policy) in policies {
68            line(POLICY_COLLECTION, key, &policy.to_json());
69        }
70        for (key, binding) in bindings {
71            line(BINDING_COLLECTION, key, &binding.to_json());
72        }
73        hex::encode(hasher.finalize())
74    }
75}
76
77/// One record a build could not honour, and why.
78#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
79pub struct BuildFailure {
80    /// The collection the record is in.
81    pub collection: &'static str,
82    /// The record's key.
83    pub key: String,
84    /// Why the build refused it.
85    pub reason: String,
86}
87
88impl BuildFailure {
89    /// A `bot.did.policy` record the build refused.
90    pub fn policy(key: &str, reason: impl Into<String>) -> Self {
91        Self {
92            collection: POLICY_COLLECTION,
93            key: key.to_owned(),
94            reason: reason.into(),
95        }
96    }
97
98    /// A `bot.did.policyBinding` record the build refused.
99    pub fn binding(key: &str, reason: impl Into<String>) -> Self {
100        Self {
101            collection: BINDING_COLLECTION,
102            key: key.to_owned(),
103            reason: reason.into(),
104        }
105    }
106}
107
108impl std::fmt::Display for BuildFailure {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        write!(f, "{}/{}: {}", self.collection, self.key, self.reason)
111    }
112}
113
114/// A build refused: every record it could not honour. The set last built
115/// keeps serving.
116#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
117pub struct RejectedBuild {
118    /// Every refused record, in key order within each collection.
119    pub failures: Vec<BuildFailure>,
120}
121
122impl RejectedBuild {
123    /// `failures`, policies before bindings and each by key.
124    #[must_use]
125    pub fn of(mut failures: Vec<BuildFailure>) -> Self {
126        failures.sort_by(|a, b| {
127            (a.collection != POLICY_COLLECTION, &a.key)
128                .cmp(&(b.collection != POLICY_COLLECTION, &b.key))
129        });
130        Self { failures }
131    }
132}
133
134impl std::fmt::Display for RejectedBuild {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        write!(f, "{} record(s) refused", self.failures.len())?;
137        for failure in &self.failures {
138            write!(f, "; {failure}")?;
139        }
140        Ok(())
141    }
142}
143
144impl std::error::Error for RejectedBuild {}
145
146/// Why a policy record yields no declaration a tree can register.
147#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
148pub enum DeclarationError {
149    /// An action this server does not answer.
150    #[error("`{action}` is an action this server does not answer")]
151    Action {
152        /// Where it is in `actions`.
153        at: usize,
154        /// The action as written.
155        action: String,
156    },
157    /// A document whose `$type` no engine in this build reads.
158    #[error("`{0}` names an engine this server does not carry")]
159    Engine(String),
160    /// A `#regex` statement its engine will not compile.
161    #[error("statement {at}: {reason}")]
162    Statement {
163        /// Where it is in `statements`.
164        at: usize,
165        /// What the engine said.
166        reason: String,
167    },
168    /// A `#cedar` document that did not load, naming each statement at
169    /// fault.
170    #[error("statement {0}")]
171    Cedar(LoadError),
172    /// A statement that decides none of the actions the policy names.
173    #[error("statement {0} decides none of the actions this policy names")]
174    Undecided(StatementLabel),
175}
176
177impl DeclarationError {
178    /// Where in the record the failure is, spelled as the reader spells
179    /// its own paths.
180    #[must_use]
181    pub fn path(&self) -> String {
182        match self {
183            Self::Action { at, .. } => format!("actions[{at}]"),
184            Self::Engine(_) => "document.$type".to_owned(),
185            Self::Statement { at, .. } => StatementLabel::Indexed(*at).path(),
186            Self::Cedar(_) => CEDAR_SOURCE.to_owned(),
187            Self::Undecided(statement) => statement.path(),
188        }
189    }
190}
191
192/// Where a `#cedar` document's text is. Its statements share it.
193const CEDAR_SOURCE: &str = "document.source";
194
195/// One statement of a policy's document, as its declaration ids name it.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum StatementLabel {
198    /// A statement of a document that holds a list of them -- `#regex`,
199    /// `#count` -- by its index in `statements`.
200    Indexed(usize),
201    /// A `#cedar` statement, by its `@id`.
202    Cedar(String),
203}
204
205impl StatementLabel {
206    /// Where the statement is in the record, spelled as the reader spells
207    /// its own paths.
208    #[must_use]
209    pub fn path(&self) -> String {
210        match self {
211            Self::Indexed(at) => format!("document.statements[{at}]"),
212            Self::Cedar(_) => CEDAR_SOURCE.to_owned(),
213        }
214    }
215}
216
217impl std::fmt::Display for StatementLabel {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        match self {
220            Self::Indexed(at) => write!(f, "{at}"),
221            Self::Cedar(id) => f.write_str(id),
222        }
223    }
224}
225
226/// The record key a declaration id was minted from — see [`declarations`].
227#[must_use]
228pub fn key_of(id: &str) -> &str {
229    id.split('/').next().unwrap_or(id)
230}
231
232/// The statement of `document` a declaration id was minted from — see
233/// [`declarations`].
234#[must_use]
235pub fn statement_of(document: &Document, id: &str) -> Option<StatementLabel> {
236    let (_, statement_and_action) = id.split_once('/')?;
237    let (statement, _) = statement_and_action.rsplit_once('/')?;
238    match document {
239        Document::Cedar { .. } => Some(StatementLabel::Cedar(statement.to_owned())),
240        _ => statement.parse().ok().map(StatementLabel::Indexed),
241    }
242}
243
244/// Whether this server answers `action`. A policy naming any other action is
245/// refused whole, at [`DeclarationError::Action`].
246#[must_use]
247pub fn answers(action: &Action) -> bool {
248    applicability_of(action).is_some()
249}
250
251/// The applicability an action selects, or `None` for an action this
252/// server does not answer.
253fn applicability_of(action: &Action) -> Option<Applicability> {
254    match action {
255        Action::RecordWrite => Some(Applicability::Write {
256            kinds: KindSet::ANY,
257            collections: Scope::Any,
258            actions: ActionSet::ALL,
259            path_prefixes: Scope::Any,
260        }),
261        Action::BlobWrite => Some(Applicability::Blob),
262        Action::OauthAuthorize => Some(Applicability::Grant {
263            client_ids: Scope::Any,
264        }),
265        Action::OauthToken => Some(Applicability::Token {
266            client_ids: Scope::Any,
267        }),
268        Action::AccountCreate => Some(Applicability::Create),
269        Action::Other(_) => None,
270    }
271}
272
273/// One statement of a policy record, split out by its engine: what the
274/// tree registers, and which subject shapes it decides.
275struct Split {
276    label: StatementLabel,
277    predicate: Value,
278    writes: bool,
279    grants: bool,
280    tokens: bool,
281    blobs: bool,
282    creates: bool,
283    /// Whether the statement, asked about a write, judges the client the
284    /// write's token was issued to.
285    writes_by_client: bool,
286}
287
288/// A record's statements, each on its own, with the language that
289/// compiles them. Each engine splits its own document: the regex engine
290/// holds one compiled statement apiece, and the Cedar engine keys a
291/// refusal by the statement's `@id`, which the tree can only report if the
292/// statement is registered under it.
293fn split(record: &PolicyRecord) -> Result<(String, Vec<Split>), DeclarationError> {
294    let type_name = record.document.type_name();
295    let Some(engine) = engine_for(type_name) else {
296        return Err(DeclarationError::Engine(type_name.to_owned()));
297    };
298    let language = engine.language.name().to_owned();
299    match &record.document {
300        Document::Regex { statements } => {
301            let mut splits = Vec::new();
302            for (at, statement) in statements.iter().enumerate() {
303                let shapes = RegexEvaluator::shapes(statement)
304                    .map_err(|reason| DeclarationError::Statement { at, reason })?;
305                splits.push(Split {
306                    label: StatementLabel::Indexed(at),
307                    predicate: statement.clone(),
308                    writes: shapes.writes,
309                    grants: shapes.grants,
310                    tokens: shapes.tokens,
311                    blobs: shapes.blobs,
312                    creates: false,
313                    // A `denyClient`, the one statement answering sign-in
314                    // and token issue, judges a write by its client too.
315                    writes_by_client: shapes.writes && (shapes.grants || shapes.tokens),
316                });
317            }
318            Ok((language, splits))
319        }
320        Document::Cedar { source } => {
321            let document = CedarDocument::load(source, &crate::engine::shapes())
322                .map_err(DeclarationError::Cedar)?;
323            let splits = document
324                .statements()
325                .iter()
326                .map(|statement| {
327                    let writes = statement.actions().contains(&CedarAction::RecordWrite);
328                    Split {
329                        label: StatementLabel::Cedar(statement.id().to_owned()),
330                        predicate: serde_json::json!({ "source": statement.source() }),
331                        writes,
332                        grants: statement.actions().contains(&CedarAction::OauthAuthorize),
333                        tokens: statement.actions().contains(&CedarAction::OauthToken),
334                        blobs: statement.actions().contains(&CedarAction::BlobWrite),
335                        creates: statement.actions().contains(&CedarAction::AccountCreate),
336                        writes_by_client: writes && statement.reads_client(),
337                    }
338                })
339                .collect();
340            Ok((language, splits))
341        }
342        Document::Count { statements } => {
343            let mut splits = Vec::new();
344            for (at, statement) in statements.iter().enumerate() {
345                splits.push(Split {
346                    label: StatementLabel::Indexed(at),
347                    predicate: statement.clone(),
348                    writes: true,
349                    grants: false,
350                    tokens: false,
351                    blobs: false,
352                    creates: false,
353                    writes_by_client: false,
354                });
355            }
356            Ok((language, splits))
357        }
358        Document::Other { .. } => Err(DeclarationError::Engine(type_name.to_owned())),
359    }
360}
361
362/// One policy record as the tree registers it.
363#[derive(Debug, Clone)]
364pub struct Declared {
365    /// One declaration per statement per action the statement can decide,
366    /// each with its own evaluator. The id is `{key}/{statement}/{action}`,
367    /// so [`key_of`] and [`statement_of`] recover the record and statement
368    /// from what the tree reports.
369    pub policies: Vec<(String, ParsedPolicy)>,
370    /// Whether the record denies a client before the write and not at it:
371    /// some statement is declared for sign-in or token issue, and none
372    /// declared for `record.write` judges the write's client. A write
373    /// statement judging any client counts, whichever clients it names.
374    pub spares_held_tokens: bool,
375}
376
377/// Declares `record`, written at `key`.
378///
379/// # Errors
380///
381/// [`DeclarationError`], for the first part of the record no declaration
382/// can be made from.
383pub fn declarations(key: &str, record: &PolicyRecord) -> Result<Declared, DeclarationError> {
384    let asked: Vec<(&Action, Applicability)> = record
385        .actions
386        .iter()
387        .enumerate()
388        .map(|(at, action)| {
389            applicability_of(action)
390                .map(|applicability| (action, applicability))
391                .ok_or_else(|| DeclarationError::Action {
392                    at,
393                    action: action.to_string(),
394                })
395        })
396        .collect::<Result<_, _>>()?;
397    let (language, statements) = split(record)?;
398    let mut declared = Vec::new();
399    let mut before_the_write = false;
400    let mut client_at_the_write = false;
401    for statement in statements {
402        let mut answers_one = false;
403        for (action, applicability) in &asked {
404            let answered = match applicability {
405                Applicability::Write { .. } => statement.writes,
406                Applicability::Grant { .. } => statement.grants,
407                Applicability::Token { .. } => statement.tokens,
408                Applicability::Blob => statement.blobs,
409                Applicability::Create => statement.creates,
410            };
411            if !answered {
412                continue;
413            }
414            answers_one = true;
415            match applicability {
416                Applicability::Write { .. } => client_at_the_write |= statement.writes_by_client,
417                Applicability::Grant { .. } | Applicability::Token { .. } => {
418                    before_the_write = true;
419                }
420                Applicability::Blob | Applicability::Create => {}
421            }
422            declared.push((
423                format!("{key}/{}/{action}", statement.label),
424                ParsedPolicy {
425                    applicability: applicability.clone(),
426                    observes: applicability.clone(),
427                    staleness: Staleness::Linearized,
428                    language: language.clone(),
429                    predicate: statement.predicate.clone(),
430                    revision: Revision(0),
431                    lookups: record.lookups.clone(),
432                },
433            ));
434        }
435        if !answers_one {
436            return Err(DeclarationError::Undecided(statement.label));
437        }
438    }
439    Ok(Declared {
440        policies: declared,
441        spares_held_tokens: before_the_write && !client_at_the_write,
442    })
443}
444
445/// Whether a binding written in `author`'s repository reaches `policy`. A
446/// binding reaches only the policies written beside it; `written` says
447/// whether a policy is written at a key there.
448///
449/// # Errors
450///
451/// Why it does not, as a build failure's reason.
452pub fn reach(
453    policy: &PolicyRef,
454    author: &str,
455    written: impl Fn(&str) -> bool,
456) -> Result<(), String> {
457    if policy.repository != author {
458        Err(format!(
459            "names `{policy}`, which is not in its own repository {author}; a binding reaches \
460             only the policies written beside it"
461        ))
462    } else if !written(&policy.key) {
463        Err(format!(
464            "names `{policy}`, and no policy is written at that key"
465        ))
466    } else {
467        Ok(())
468    }
469}
470
471/// What [`read`] made of one observation.
472#[derive(Debug, Clone, Default)]
473pub struct Read {
474    /// Every policy record the reader accepted, by record key.
475    pub policies: BTreeMap<String, PolicyRecord>,
476    /// Every binding record the reader accepted, by record key.
477    pub bindings: BTreeMap<String, PolicyBinding>,
478    /// Every declaration, by the id [`declarations`] minted for it.
479    pub declared: HashMap<String, ParsedPolicy>,
480    /// The ids each policy record was declared under, by record key.
481    pub ids: BTreeMap<String, Vec<String>>,
482    /// Every policy record that denies a client before the write and not
483    /// at it, by record key — see [`Declared::spares_held_tokens`].
484    pub spares_held_tokens: Vec<String>,
485    /// Every record refused so far.
486    pub failures: Vec<BuildFailure>,
487}
488
489/// Reads both collections, declares every policy, and checks that each
490/// binding reaches the policies it names.
491#[must_use]
492pub fn read(observed: &Observed) -> Read {
493    let mut read = Read::default();
494    for (key, value) in &observed.policies {
495        match PolicyRecord::from_json(value) {
496            Ok(record) => {
497                read.policies.insert(key.clone(), record);
498            }
499            Err(error) => read
500                .failures
501                .push(BuildFailure::policy(key, error.to_string())),
502        }
503    }
504    for (key, value) in &observed.bindings {
505        match PolicyBinding::from_json(value) {
506            Ok(record) => {
507                read.bindings.insert(key.clone(), record);
508            }
509            Err(error) => read
510                .failures
511                .push(BuildFailure::binding(key, error.to_string())),
512        }
513    }
514
515    // Every policy compiles, bound or not: an operator learns a document is
516    // unreadable when they write it, not when they first bind it.
517    for (key, record) in &read.policies {
518        match declarations(key, record) {
519            Ok(declared) => {
520                if declared.spares_held_tokens {
521                    read.spares_held_tokens.push(key.clone());
522                }
523                for (id, policy) in declared.policies {
524                    read.ids.entry(key.clone()).or_default().push(id.clone());
525                    read.declared.insert(id, policy);
526                }
527            }
528            Err(error) => read
529                .failures
530                .push(BuildFailure::policy(key, error.to_string())),
531        }
532    }
533
534    for (key, binding) in &read.bindings {
535        for policy in &binding.policies {
536            if let Err(reason) = reach(policy, &observed.author, |key| {
537                read.policies.contains_key(key)
538            }) {
539                read.failures.push(BuildFailure::binding(key, reason));
540            }
541        }
542    }
543    read
544}
545
546/// Compiles `declared` beside the two sources that are not the operator's,
547/// whole.
548///
549/// `across` must be the one a deployment holds for the length of its run:
550/// it is what lets a policy whose new revision will not compile keep
551/// enforcing the revision that did. See [`AcrossBuilds`].
552#[must_use]
553pub fn compile(
554    builtins: Vec<BuiltinPolicy>,
555    startup: &HashMap<String, ParsedPolicy>,
556    declared: &HashMap<String, ParsedPolicy>,
557    across: &AcrossBuilds,
558) -> (PolicyTree, LoadReport) {
559    crate::engine::build_tree(builtins, startup, declared, &across.last_good)
560}
561
562/// Every record a [`compile`] refused, under the record's own key.
563#[must_use]
564pub fn refused(load: &LoadReport) -> Vec<BuildFailure> {
565    let denied = load
566        .scoped_denials
567        .iter()
568        .map(|denial| BuildFailure::policy(key_of(&denial.id), denial.reason.clone()));
569    let malformed = load
570        .malformed
571        .iter()
572        .map(|malformed| BuildFailure::policy(key_of(malformed), malformed.clone()));
573    denied.chain(malformed).collect()
574}
575
576#[cfg(test)]
577mod tests {
578    use serde_json::json;
579
580    use super::{read, Observed, PolicyRevision};
581
582    /// The digest is a wire value: `bot.did.stats` publishes it and the
583    /// policy page computes it in wasm, from builds that ship apart. A
584    /// change to how records are read back or serialized moves it, and
585    /// shows here before an operator sees every server "behind".
586    #[test]
587    fn a_fixed_set_of_records_has_a_fixed_digest() {
588        let observed = Observed {
589            author: "did:example:operator".to_owned(),
590            policies: [(
591                "no-deletes".to_owned(),
592                json!({
593                    "$type": "bot.did.policy",
594                    "name": "no \"deletes\", café",
595                    "actions": ["record.write"],
596                    "document": {
597                        "$type": "bot.did.policy#regex",
598                        "statements": [{
599                            "kind": "denyDelete",
600                            "collection": "^app\\.bsky\\.feed\\.post$",
601                            "reason": "kept",
602                        }],
603                    },
604                    "createdAt": "2026-09-11T00:00:00.000Z",
605                }),
606            )]
607            .into(),
608            bindings: [(
609                "everyone".to_owned(),
610                json!({
611                    "$type": "bot.did.policyBinding",
612                    "policies": ["at://did:example:operator/bot.did.policy/no-deletes"],
613                    "subjects": ["did:web:pds.example.com"],
614                    "includes": ["subjects", "descendants"],
615                    "createdAt": "2026-09-11T00:00:00.000Z",
616                }),
617            )]
618            .into(),
619        };
620        let read = read(&observed);
621        assert!(read.failures.is_empty(), "{:?}", read.failures);
622        assert_eq!(
623            PolicyRevision::digest_of(&read.policies, &read.bindings),
624            "fc3ad976f309541b6768a49ef38ffff6e8b667ac8ef60609bddb9086a5340ee8"
625        );
626    }
627}