Skip to main content

didbot_policy_records/
records.rs

1//! The `bot.did.policy` and `bot.did.policyBinding` records, as didbot reads
2//! and writes them.
3//!
4//! Both records are written by an operator into the operator's own
5//! repository, which this server can read but never write to — the same
6//! asymmetry `didbot-pds`'s `operator::OperatorClaim` relies on. So, like that
7//! claim, each is parsed here by hand from a foreign repository's JSON, and
8//! a malformed record reads as no record at all rather than charitably. The
9//! failure carries a path, exactly as the lexicon checker's own
10//! [`InvalidRecord`] does, because the reader is an operator looking at
11//! their own record and the field that matters is where.
12//!
13//! # Two checks, one stricter than the other
14//!
15//! The lexicon documents under `lexicons/bot/did/` are the first check, run
16//! by [`didbot_schema::validate_shape`]. They bound lengths, check formats
17//! and require fields. This module is the second: it refuses a field
18//! neither record declares, because a field silently dropped is a narrowing
19//! the operator wrote and the server never applied. What the lexicon leaves
20//! open — an action outside its known values, a document of a `$type` it
21//! does not name — is carried here as written; whether this server can
22//! answer the action or read the document is the policy build's question,
23//! not the reader's. The one open value refused here is an `includes` word
24//! outside `subjects` and `descendants`: it names a set of accounts this
25//! server cannot enumerate, so no later step could apply it.
26
27use serde_json::{json, Map, Value};
28use time::format_description::well_known::Rfc3339;
29use time::OffsetDateTime;
30
31use didbot_schema::InvalidRecord;
32
33/// The collection a [`PolicyRecord`] is written under.
34pub const POLICY_COLLECTION: &str = didbot_lexicon::nsid::POLICY;
35
36/// The collection a [`PolicyBinding`] is written under.
37pub const BINDING_COLLECTION: &str = didbot_lexicon::nsid::POLICY_BINDING;
38
39/// The `$type` of a [`Document::Regex`].
40pub const REGEX_DOCUMENT: &str = "bot.did.policy#regex";
41
42/// The `$type` of a [`Document::Cedar`].
43pub const CEDAR_DOCUMENT: &str = "bot.did.policy#cedar";
44
45/// The `$type` of a [`Document::Count`].
46pub const COUNT_DOCUMENT: &str = "bot.did.policy#count";
47
48/// The fields a `bot.did.policy` record declares.
49const POLICY_FIELDS: &[&str] = &[
50    "name",
51    "description",
52    "actions",
53    "document",
54    "lookups",
55    "createdAt",
56];
57
58/// The fields one `lookups` entry declares.
59const LOOKUP_FIELDS: &[&str] = &["name", "accounts", "collection", "rkey"];
60
61/// The `accounts` word for the accounts the written record names.
62const NAMED: &str = "named";
63
64/// The `includes` word for the subjects themselves.
65const SUBJECTS: &str = "subjects";
66
67/// The `includes` word for every account admitted beneath a subject.
68const DESCENDANTS: &str = "descendants";
69
70/// The fields a `bot.did.policyBinding` record declares.
71const BINDING_FIELDS: &[&str] = &["policies", "subjects", "includes", "excludes", "createdAt"];
72
73/// One `bot.did.policy` record.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct PolicyRecord {
76    /// What a refusal calls this policy. The record key stands in when
77    /// absent — see [`PolicyRecord::display_name`].
78    pub name: Option<String>,
79    /// The operator's own note, never shown to an agent.
80    pub description: Option<String>,
81    /// The actions this policy is consulted on.
82    pub actions: Vec<Action>,
83    /// The policy document, typed by the engine that reads it.
84    pub document: Document,
85    /// The records in other repositories this policy's statements read,
86    /// each under the name a statement calls it by.
87    pub lookups: Vec<Lookup>,
88    /// When the operator wrote the record.
89    pub created_at: OffsetDateTime,
90}
91
92/// One action a policy is consulted on. Open: the lexicon names some and
93/// carries any other, so the reader does too.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub enum Action {
96    /// A record write by an account on the server.
97    RecordWrite,
98    /// A blob upload by an account on the server.
99    BlobWrite,
100    /// An application's authorization request.
101    OauthAuthorize,
102    /// An application's token request.
103    OauthToken,
104    /// Where a new account lands.
105    AccountCreate,
106    /// An action outside the lexicon's known values.
107    Other(String),
108}
109
110impl Action {
111    /// Reads an action as the wire spells it.
112    #[must_use]
113    pub fn parse(name: &str) -> Self {
114        match name {
115            "record.write" => Self::RecordWrite,
116            "blob.write" => Self::BlobWrite,
117            "oauth.authorize" => Self::OauthAuthorize,
118            "oauth.token" => Self::OauthToken,
119            "account.create" => Self::AccountCreate,
120            other => Self::Other(other.to_owned()),
121        }
122    }
123
124    /// The action as the wire spells it.
125    #[must_use]
126    pub fn as_str(&self) -> &str {
127        match self {
128            Self::RecordWrite => "record.write",
129            Self::BlobWrite => "blob.write",
130            Self::OauthAuthorize => "oauth.authorize",
131            Self::OauthToken => "oauth.token",
132            Self::AccountCreate => "account.create",
133            Self::Other(other) => other,
134        }
135    }
136}
137
138impl std::fmt::Display for Action {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.write_str(self.as_str())
141    }
142}
143
144/// A policy's document, by the `$type` that names its engine.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Document {
147    /// Statements in the regex engine's grammar, each opaque here: the
148    /// engine is what reads one.
149    Regex {
150        /// The statements, in the order written.
151        statements: Vec<Value>,
152    },
153    /// Cedar policy text.
154    Cedar {
155        /// The source.
156        source: String,
157    },
158    /// Statements in the counting engine's grammar, each opaque here: the
159    /// engine is what reads one.
160    Count {
161        /// The statements, in the order written.
162        statements: Vec<Value>,
163    },
164    /// A document of a `$type` outside the lexicon's union, carried by its
165    /// type alone: no engine here reads its fields.
166    Other {
167        /// The `$type` written.
168        r#type: String,
169    },
170}
171
172impl Document {
173    /// The `$type` this document was written under.
174    #[must_use]
175    pub fn type_name(&self) -> &str {
176        match self {
177            Self::Regex { .. } => REGEX_DOCUMENT,
178            Self::Cedar { .. } => CEDAR_DOCUMENT,
179            Self::Count { .. } => COUNT_DOCUMENT,
180            Self::Other { r#type } => r#type,
181        }
182    }
183
184    /// Parses a `document` union member. Paths in a failure are relative
185    /// to it.
186    fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
187        let object = object(value, "")?;
188        let type_name = string(object, "$type")?;
189        match type_name {
190            REGEX_DOCUMENT => {
191                only_fields(object, &["$type", "statements"], "a `#regex` document")?;
192                let statements = array(object, "statements")?.to_vec();
193                if statements.is_empty() {
194                    return Err(InvalidRecord::at("statements", "holds no statement"));
195                }
196                Ok(Self::Regex { statements })
197            }
198            CEDAR_DOCUMENT => {
199                only_fields(object, &["$type", "source"], "a `#cedar` document")?;
200                Ok(Self::Cedar {
201                    source: string(object, "source")?.to_owned(),
202                })
203            }
204            COUNT_DOCUMENT => {
205                only_fields(object, &["$type", "statements"], "a `#count` document")?;
206                let statements = array(object, "statements")?.to_vec();
207                if statements.is_empty() {
208                    return Err(InvalidRecord::at("statements", "holds no statement"));
209                }
210                Ok(Self::Count { statements })
211            }
212            other => Ok(Self::Other {
213                r#type: other.to_owned(),
214            }),
215        }
216    }
217
218    /// The document as it appears inside a record.
219    #[must_use]
220    pub fn to_json(&self) -> Value {
221        match self {
222            Self::Regex { statements } => json!({
223                "$type": REGEX_DOCUMENT,
224                "statements": statements,
225            }),
226            Self::Cedar { source } => json!({
227                "$type": CEDAR_DOCUMENT,
228                "source": source,
229            }),
230            Self::Count { statements } => json!({
231                "$type": COUNT_DOCUMENT,
232                "statements": statements,
233            }),
234            Self::Other { r#type } => json!({ "$type": r#type }),
235        }
236    }
237}
238
239/// One record a policy reads from a repository this server does not hold.
240///
241/// The declaration says where to look, never what to conclude: the write
242/// path reads it and hands the answer to the engine under [`name`](Self::name),
243/// and a statement decides what a value, an absence and an unknown each
244/// mean for it.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct Lookup {
247    /// What a statement calls this lookup's answer.
248    pub name: String,
249    /// Whose repositories to read.
250    pub accounts: Accounts,
251    /// The collection to read in each of them.
252    pub collection: String,
253    /// The record key to read, or `None` to read the whole collection.
254    pub rkey: Option<String>,
255}
256
257/// Whose repositories a [`Lookup`] reads. Open: the lexicon names one word
258/// and carries any other, so the reader does too, and a word this build
259/// does not know reads nothing.
260#[derive(Debug, Clone, PartialEq, Eq, Hash)]
261pub enum Accounts {
262    /// Every account the written record names.
263    Named,
264    /// A word outside the lexicon's known values.
265    Other(String),
266}
267
268impl Accounts {
269    /// Reads the word as the wire spells it.
270    #[must_use]
271    pub fn parse(word: &str) -> Self {
272        match word {
273            NAMED => Self::Named,
274            other => Self::Other(other.to_owned()),
275        }
276    }
277
278    /// The word as the wire spells it.
279    #[must_use]
280    pub fn as_str(&self) -> &str {
281        match self {
282            Self::Named => NAMED,
283            Self::Other(other) => other,
284        }
285    }
286}
287
288/// The `lookups` a record's JSON declares, read the way
289/// [`PolicyRecord::from_json`] reads them.
290///
291/// For a caller holding a record as JSON rather than as a [`PolicyRecord`]
292/// -- the built-ins, which are records written in Rust.
293///
294/// # Errors
295///
296/// See [`InvalidRecord`]: anything `lookups` may not hold.
297pub fn lookups_of(record: &Value) -> Result<Vec<Lookup>, InvalidRecord> {
298    let object = object(record, "")?;
299    match object.get("lookups") {
300        None => Ok(Vec::new()),
301        Some(_) => array(object, "lookups")?
302            .iter()
303            .enumerate()
304            .map(|(at, entry)| {
305                Lookup::from_json(entry).map_err(|error| error.under(&index("lookups", at)))
306            })
307            .collect(),
308    }
309}
310
311impl Lookup {
312    /// Parses one `lookups` entry. Paths in a failure are relative to it.
313    fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
314        let object = object(value, "")?;
315        only_fields(object, LOOKUP_FIELDS, "a `lookups` entry")?;
316        let name = string(object, "name")?;
317        if name.is_empty() {
318            return Err(InvalidRecord::at("name", "is empty"));
319        }
320        let collection = string(object, "collection")?;
321        didbot_lexicon::nsid_syntax::validate(collection)
322            .map_err(|error| InvalidRecord::at("collection", format!("is not an nsid: {error}")))?;
323        Ok(Self {
324            name: name.to_owned(),
325            accounts: Accounts::parse(string(object, "accounts")?),
326            collection: collection.to_owned(),
327            rkey: optional_string(object, "rkey")?.map(str::to_owned),
328        })
329    }
330
331    /// The entry as it appears inside a record.
332    #[must_use]
333    pub fn to_json(&self) -> Value {
334        let mut value = json!({
335            "name": self.name,
336            "accounts": self.accounts.as_str(),
337            "collection": self.collection,
338        });
339        if let Some(rkey) = &self.rkey {
340            value["rkey"] = Value::String(rkey.clone());
341        }
342        value
343    }
344
345    /// The at-uri this lookup reads in `did`'s repository: the record, or
346    /// the collection when it names no key.
347    #[must_use]
348    pub fn uri(&self, did: &str) -> String {
349        match &self.rkey {
350            Some(rkey) => format!("at://{did}/{}/{rkey}", self.collection),
351            None => format!("at://{did}/{}", self.collection),
352        }
353    }
354}
355
356impl PolicyRecord {
357    /// Parses a `bot.did.policy` record's `value` object.
358    pub fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
359        let object = object(value, "")?;
360        typed_as(object, POLICY_COLLECTION)?;
361        only_fields(object, POLICY_FIELDS, "a `bot.did.policy` record")?;
362
363        let actions = array(object, "actions")?
364            .iter()
365            .enumerate()
366            .map(|(at, entry)| {
367                let path = index("actions", at);
368                entry
369                    .as_str()
370                    .map(Action::parse)
371                    .ok_or_else(|| expected("a string", entry, &path))
372            })
373            .collect::<Result<Vec<_>, _>>()?;
374        if actions.is_empty() {
375            return Err(InvalidRecord::at("actions", "names no action"));
376        }
377
378        let document = Document::from_json(required(object, "document")?)
379            .map_err(|error| error.under("document"))?;
380
381        let lookups = match object.get("lookups") {
382            None => Vec::new(),
383            Some(_) => array(object, "lookups")?
384                .iter()
385                .enumerate()
386                .map(|(at, entry)| {
387                    Lookup::from_json(entry).map_err(|error| error.under(&index("lookups", at)))
388                })
389                .collect::<Result<Vec<_>, _>>()?,
390        };
391        let mut seen = std::collections::HashSet::new();
392        for (at, lookup) in lookups.iter().enumerate() {
393            if !seen.insert(lookup.name.as_str()) {
394                return Err(InvalidRecord::at(
395                    &index("lookups", at),
396                    format!(
397                        "names `{}`, which an earlier entry already names",
398                        lookup.name
399                    ),
400                ));
401            }
402        }
403
404        Ok(Self {
405            name: optional_string(object, "name")?.map(str::to_owned),
406            description: optional_string(object, "description")?.map(str::to_owned),
407            actions,
408            document,
409            lookups,
410            created_at: datetime(object, "createdAt")?,
411        })
412    }
413
414    /// The record body, field for field what [`Self::from_json`] reads.
415    #[must_use]
416    pub fn to_json(&self) -> Value {
417        let mut value = json!({
418            "$type": POLICY_COLLECTION,
419            "actions": self.actions.iter().map(Action::as_str).collect::<Vec<_>>(),
420            "document": self.document.to_json(),
421            "createdAt": didbot_schema::format::datetime::render(self.created_at),
422        });
423        if let Some(name) = &self.name {
424            value["name"] = Value::String(name.clone());
425        }
426        if let Some(description) = &self.description {
427            value["description"] = Value::String(description.clone());
428        }
429        if !self.lookups.is_empty() {
430            value["lookups"] = Value::Array(self.lookups.iter().map(Lookup::to_json).collect());
431        }
432        value
433    }
434
435    /// What a refusal calls this policy: its `name`, or `key` — the record
436    /// key it was written at — when it has none.
437    #[must_use]
438    pub fn display_name<'a>(&'a self, key: &'a str) -> &'a str {
439        self.name.as_deref().unwrap_or(key)
440    }
441}
442
443/// One `bot.did.policy` record a binding names, by the repository it is
444/// in and its record key: the parts of the `at://` URI it is written as.
445#[derive(Debug, Clone, PartialEq, Eq, Hash)]
446pub struct PolicyRef {
447    /// The DID of the repository the policy is written in.
448    pub repository: String,
449    /// The policy's record key in that repository.
450    pub key: String,
451}
452
453impl PolicyRef {
454    /// The URI this reference is written as.
455    #[must_use]
456    pub fn uri(&self) -> String {
457        format!("at://{}/{POLICY_COLLECTION}/{}", self.repository, self.key)
458    }
459}
460
461impl std::fmt::Display for PolicyRef {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        f.write_str(&self.uri())
464    }
465}
466
467/// One `bot.did.policyBinding` record.
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub struct PolicyBinding {
470    /// The policies bound, each by URI.
471    pub policies: Vec<PolicyRef>,
472    /// The accounts the binding starts from, by DID. A server's own
473    /// service DID stands for the whole server.
474    pub subjects: Vec<String>,
475    /// Which accounts, relative to the subjects, the policies reach.
476    pub includes: Includes,
477    /// Accounts the policies do not apply to, nor to anything admitted
478    /// beneath them.
479    pub excludes: Vec<String>,
480    /// When the operator wrote the record.
481    pub created_at: OffsetDateTime,
482}
483
484/// The set a binding's `includes` names. At least one member is set, and
485/// the set with every member set writes every word the reader accepts.
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub struct Includes {
488    /// The subjects themselves.
489    pub subjects: bool,
490    /// Every account admitted beneath a subject.
491    pub descendants: bool,
492}
493
494impl Includes {
495    /// Reads the `includes` array. Paths in a failure are relative to the
496    /// record.
497    fn from_json(object: &Map<String, Value>) -> Result<Self, InvalidRecord> {
498        let mut includes = Self {
499            subjects: false,
500            descendants: false,
501        };
502        for (at, entry) in array(object, "includes")?.iter().enumerate() {
503            let path = index("includes", at);
504            match entry.as_str() {
505                Some(SUBJECTS) => includes.subjects = true,
506                Some(DESCENDANTS) => includes.descendants = true,
507                Some(other) => {
508                    return Err(InvalidRecord::at(
509                        &path,
510                        format!("`{other}` is neither `{SUBJECTS}` nor `{DESCENDANTS}`"),
511                    ))
512                }
513                None => return Err(expected("a string", entry, &path)),
514            }
515        }
516        if !includes.subjects && !includes.descendants {
517            return Err(InvalidRecord::at("includes", "names nobody"));
518        }
519        Ok(includes)
520    }
521
522    /// The set as it appears inside a record.
523    #[must_use]
524    pub fn to_json(self) -> Value {
525        let mut members = Vec::new();
526        if self.subjects {
527            members.push(SUBJECTS);
528        }
529        if self.descendants {
530            members.push(DESCENDANTS);
531        }
532        json!(members)
533    }
534}
535
536impl PolicyBinding {
537    /// Parses a `bot.did.policyBinding` record's `value` object.
538    pub fn from_json(value: &Value) -> Result<Self, InvalidRecord> {
539        let object = object(value, "")?;
540        typed_as(object, BINDING_COLLECTION)?;
541        only_fields(object, BINDING_FIELDS, "a `bot.did.policyBinding` record")?;
542
543        let policies = array(object, "policies")?
544            .iter()
545            .enumerate()
546            .map(|(at, entry)| policy_ref(entry, &index("policies", at)))
547            .collect::<Result<Vec<_>, _>>()?;
548        if policies.is_empty() {
549            return Err(InvalidRecord::at("policies", "names no policy"));
550        }
551
552        let subjects = dids(object, "subjects")?;
553        if subjects.is_empty() {
554            return Err(InvalidRecord::at("subjects", "names nobody"));
555        }
556
557        let includes = Includes::from_json(object)?;
558        let excludes = if object.contains_key("excludes") {
559            dids(object, "excludes")?
560        } else {
561            Vec::new()
562        };
563
564        Ok(Self {
565            policies,
566            subjects,
567            includes,
568            excludes,
569            created_at: datetime(object, "createdAt")?,
570        })
571    }
572
573    /// The record body, field for field what [`Self::from_json`] reads.
574    #[must_use]
575    pub fn to_json(&self) -> Value {
576        let mut value = json!({
577            "$type": BINDING_COLLECTION,
578            "policies": self.policies.iter().map(PolicyRef::uri).collect::<Vec<_>>(),
579            "subjects": self.subjects,
580            "includes": self.includes.to_json(),
581            "createdAt": didbot_schema::format::datetime::render(self.created_at),
582        });
583        if !self.excludes.is_empty() {
584            value["excludes"] = json!(self.excludes);
585        }
586        value
587    }
588}
589
590// ---------------------------------------------------------------------------
591// JSON field readers, each naming the field it failed at
592// ---------------------------------------------------------------------------
593
594/// Refuses a `$type` naming anything but `collection`. A record listed out
595/// of a repository carries its collection as `$type`; a record body built
596/// by hand may omit it.
597fn typed_as(object: &Map<String, Value>, collection: &str) -> Result<(), InvalidRecord> {
598    match object.get("$type") {
599        None => Ok(()),
600        Some(Value::String(named)) if named == collection => Ok(()),
601        Some(other) => Err(InvalidRecord::at(
602            "$type",
603            format!("is {other}, not `{collection}`"),
604        )),
605    }
606}
607
608/// Refuses any field of `object` outside `declared` (and `$type`).
609fn only_fields(
610    object: &Map<String, Value>,
611    declared: &[&str],
612    what: &str,
613) -> Result<(), InvalidRecord> {
614    for name in object.keys() {
615        if name != "$type" && !declared.contains(&name.as_str()) {
616            return Err(InvalidRecord::at(
617                name,
618                format!("is not a field {what} declares"),
619            ));
620        }
621    }
622    Ok(())
623}
624
625fn expected(what: &str, found: &Value, path: &str) -> InvalidRecord {
626    InvalidRecord::at(path, format!("expected {what}, found {}", kind_of(found)))
627}
628
629fn object<'a>(value: &'a Value, path: &str) -> Result<&'a Map<String, Value>, InvalidRecord> {
630    value
631        .as_object()
632        .ok_or_else(|| expected("an object", value, path))
633}
634
635fn required<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a Value, InvalidRecord> {
636    object
637        .get(name)
638        .ok_or_else(|| InvalidRecord::at(name, "is required and is missing"))
639}
640
641fn string<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a str, InvalidRecord> {
642    let value = required(object, name)?;
643    value
644        .as_str()
645        .ok_or_else(|| expected("a string", value, name))
646}
647
648fn optional_string<'a>(
649    object: &'a Map<String, Value>,
650    name: &str,
651) -> Result<Option<&'a str>, InvalidRecord> {
652    match object.get(name) {
653        None => Ok(None),
654        Some(_) => string(object, name).map(Some),
655    }
656}
657
658fn array<'a>(object: &'a Map<String, Value>, name: &str) -> Result<&'a [Value], InvalidRecord> {
659    let value = required(object, name)?;
660    value
661        .as_array()
662        .map(Vec::as_slice)
663        .ok_or_else(|| expected("an array", value, name))
664}
665
666/// Reads an array of DIDs at `name`.
667fn dids(object: &Map<String, Value>, name: &str) -> Result<Vec<String>, InvalidRecord> {
668    array(object, name)?
669        .iter()
670        .enumerate()
671        .map(|(at, entry)| did(entry, &index(name, at)))
672        .collect()
673}
674
675fn did(value: &Value, path: &str) -> Result<String, InvalidRecord> {
676    let text = value
677        .as_str()
678        .ok_or_else(|| expected("a string", value, path))?;
679    didbot_identity::validate_did(text)
680        .map_err(|error| InvalidRecord::at(path, format!("is not a did: {error}")))?;
681    Ok(text.to_owned())
682}
683
684/// Reads one `policies` entry: an `at://` URI naming a `bot.did.policy`
685/// record by the DID of its repository and its record key.
686fn policy_ref(value: &Value, path: &str) -> Result<PolicyRef, InvalidRecord> {
687    let text = value
688        .as_str()
689        .ok_or_else(|| expected("a string", value, path))?;
690    let uri = didbot_schema::AtUri::parse(text)
691        .map_err(|error| InvalidRecord::at(path, format!("is not an at-uri: {error}")))?;
692    let repository = uri.authority();
693    if didbot_identity::validate_did(repository).is_err() {
694        return Err(InvalidRecord::at(
695            path,
696            format!("names its repository by the handle `{repository}`, not by did"),
697        ));
698    }
699    match uri.collection() {
700        Some(POLICY_COLLECTION) => {}
701        Some(other) => {
702            return Err(InvalidRecord::at(
703                path,
704                format!("names a `{other}` record, not a `{POLICY_COLLECTION}`"),
705            ))
706        }
707        None => {
708            return Err(InvalidRecord::at(
709                path,
710                format!("names a repository, not a `{POLICY_COLLECTION}` record"),
711            ))
712        }
713    }
714    let key = uri.rkey().ok_or_else(|| {
715        InvalidRecord::at(
716            path,
717            format!("names the `{POLICY_COLLECTION}` collection, not a record in it"),
718        )
719    })?;
720    Ok(PolicyRef {
721        repository: repository.to_owned(),
722        key: key.to_owned(),
723    })
724}
725
726fn datetime(object: &Map<String, Value>, name: &str) -> Result<OffsetDateTime, InvalidRecord> {
727    let text = string(object, name)?;
728    OffsetDateTime::parse(text, &Rfc3339)
729        .map_err(|error| InvalidRecord::at(name, format!("is not an RFC 3339 datetime: {error}")))
730}
731
732fn index(path: &str, at: usize) -> String {
733    format!("{path}[{at}]")
734}
735
736fn kind_of(value: &Value) -> &'static str {
737    match value {
738        Value::Null => "null",
739        Value::Bool(_) => "a boolean",
740        Value::Number(_) => "a number",
741        Value::String(_) => "a string",
742        Value::Array(_) => "an array",
743        Value::Object(_) => "an object",
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use didbot_schema::validate_shape;
751
752    /// The repository every fixture binding lives in.
753    const OPERATOR: &str = "did:web:operator.example.com";
754
755    /// The URI of `key` in the operator's `bot.did.policy` collection.
756    fn policy_uri(key: &str) -> String {
757        format!("at://{OPERATOR}/{POLICY_COLLECTION}/{key}")
758    }
759
760    /// A well-formed policy, as the operator's repository would hold it.
761    fn policy() -> Value {
762        json!({
763            "name": "fixed-display-name",
764            "description": "an agent keeps the display name it was given",
765            "actions": ["record.write"],
766            "document": {
767                "$type": REGEX_DOCUMENT,
768                "statements": [{
769                    "kind": "denyPathChange",
770                    "collection": "^app\\.bsky\\.actor\\.profile$",
771                    "path": "^displayName$",
772                    "reason": "the display name is fixed",
773                }],
774            },
775            "createdAt": "2026-08-25T10:00:00Z",
776        })
777    }
778
779    /// A well-formed binding, as the operator's repository would hold it.
780    fn binding() -> Value {
781        json!({
782            "policies": [policy_uri("fixed-display-name"), policy_uri("3jzfcijpj2z2a")],
783            "subjects": ["did:web:pool.agents.example.com"],
784            "includes": ["descendants"],
785            "excludes": ["did:web:kestrel.agents.example.com"],
786            "createdAt": "2026-08-25T10:00:00Z",
787        })
788    }
789
790    /// Both checks, in the order a record meets them.
791    fn accept_policy(value: &Value) -> PolicyRecord {
792        validate_shape(POLICY_COLLECTION, value).expect("satisfies the lexicon");
793        PolicyRecord::from_json(value).expect("parses")
794    }
795
796    fn accept_binding(value: &Value) -> PolicyBinding {
797        validate_shape(BINDING_COLLECTION, value).expect("satisfies the lexicon");
798        PolicyBinding::from_json(value).expect("parses")
799    }
800
801    fn without_type(mut value: Value) -> Value {
802        value.as_object_mut().expect("an object").remove("$type");
803        value
804    }
805
806    #[test]
807    fn a_policy_round_trips_through_its_wire_type() {
808        let parsed = accept_policy(&policy());
809        assert_eq!(parsed.actions, vec![Action::RecordWrite]);
810        assert!(
811            matches!(parsed.document, Document::Regex { ref statements } if statements.len() == 1)
812        );
813        assert_eq!(parsed.display_name("rkey"), "fixed-display-name");
814        let written = parsed.to_json();
815        assert_eq!(written["$type"], POLICY_COLLECTION);
816        assert_eq!(without_type(written.clone()), policy());
817        assert_eq!(accept_policy(&written), parsed);
818    }
819
820    #[test]
821    fn a_cedar_policy_round_trips_and_a_nameless_one_is_called_by_its_key() {
822        let value = json!({
823            "actions": ["record.write", "oauth.authorize"],
824            "document": {
825                "$type": CEDAR_DOCUMENT,
826                "source": "forbid(principal, action, resource);",
827            },
828            "createdAt": "2026-08-25T10:00:00Z",
829        });
830        let parsed = accept_policy(&value);
831        assert_eq!(
832            parsed.document,
833            Document::Cedar {
834                source: "forbid(principal, action, resource);".to_owned()
835            }
836        );
837        assert_eq!(parsed.display_name("3jzfcijpj2z2a"), "3jzfcijpj2z2a");
838        assert_eq!(without_type(parsed.to_json()), value);
839    }
840
841    #[test]
842    fn a_binding_round_trips_through_its_wire_type() {
843        let parsed = accept_binding(&binding());
844        assert_eq!(
845            parsed.policies[0],
846            PolicyRef {
847                repository: OPERATOR.to_owned(),
848                key: "fixed-display-name".to_owned()
849            }
850        );
851        assert_eq!(parsed.policies[0].uri(), policy_uri("fixed-display-name"));
852        assert_eq!(
853            parsed.includes,
854            Includes {
855                subjects: false,
856                descendants: true
857            }
858        );
859        assert_eq!(parsed.excludes.len(), 1);
860        let written = parsed.to_json();
861        assert_eq!(written["$type"], BINDING_COLLECTION);
862        assert_eq!(without_type(written.clone()), binding());
863        assert_eq!(accept_binding(&written), parsed);
864    }
865
866    #[test]
867    fn a_field_neither_record_declares_is_refused() {
868        let mut value = policy();
869        value["engine"] = json!("regex");
870        validate_shape(POLICY_COLLECTION, &value).expect("the lexicon's object is open");
871        let error = PolicyRecord::from_json(&value).expect_err("an undeclared field");
872        assert_eq!(error.path, "engine");
873
874        let mut value = binding();
875        value["audience"] = json!(["did:web:agents.example.com"]);
876        let error = PolicyBinding::from_json(&value).expect_err("an undeclared field");
877        assert_eq!(error.path, "audience");
878    }
879
880    #[test]
881    fn a_type_naming_another_collection_is_refused() {
882        let mut value = policy();
883        value["$type"] = json!(BINDING_COLLECTION);
884        let error = PolicyRecord::from_json(&value).expect_err("the wrong collection");
885        assert_eq!(error.path, "$type");
886    }
887
888    #[test]
889    fn an_action_outside_the_known_values_is_carried() {
890        let mut value = policy();
891        value["actions"] = json!(["record.write", "account.lifecycle"]);
892        let parsed = accept_policy(&value);
893        assert_eq!(
894            parsed.actions[1],
895            Action::Other("account.lifecycle".to_owned())
896        );
897        assert_eq!(parsed.actions[1].as_str(), "account.lifecycle");
898    }
899
900    /// A `lookups` entry round-trips, and a lookup with no `rkey` reads the
901    /// whole collection rather than a record named by the empty key.
902    #[test]
903    fn a_lookup_round_trips_and_says_which_uri_it_reads() {
904        let mut value = policy();
905        value["lookups"] = json!([
906            {
907                "name": "declaredAiPreference",
908                "accounts": "named",
909                "collection": "community.lexicon.preference.ai",
910            },
911            {
912                "name": "replyGate",
913                "accounts": "named",
914                "collection": "app.bsky.feed.threadgate",
915                "rkey": "3lbwaaaaaaa2a",
916            },
917        ]);
918        let parsed = accept_policy(&value);
919        assert_eq!(
920            parsed.lookups[0].uri("did:web:person.example"),
921            "at://did:web:person.example/community.lexicon.preference.ai"
922        );
923        assert_eq!(
924            parsed.lookups[1].uri("did:web:person.example"),
925            "at://did:web:person.example/app.bsky.feed.threadgate/3lbwaaaaaaa2a"
926        );
927        assert_eq!(parsed.lookups[0].accounts, Accounts::Named);
928        assert_eq!(without_type(parsed.to_json()), value);
929    }
930
931    /// Two entries under one name would leave a statement naming one of
932    /// them and getting whichever the write path attached last.
933    #[test]
934    fn two_lookups_under_one_name_are_refused() {
935        let mut value = policy();
936        let entry = json!({
937            "name": "declaredAiPreference",
938            "accounts": "named",
939            "collection": "community.lexicon.preference.ai",
940        });
941        value["lookups"] = json!([entry, entry]);
942        validate_shape(POLICY_COLLECTION, &value).expect("satisfies the lexicon");
943        let refused = PolicyRecord::from_json(&value).expect_err("two entries share a name");
944        assert_eq!(refused.path, "lookups[1]");
945    }
946
947    #[test]
948    fn a_policy_naming_no_action_is_refused_by_both_checks() {
949        let mut value = policy();
950        value["actions"] = json!([]);
951        assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
952        let error = PolicyRecord::from_json(&value).expect_err("no action");
953        assert_eq!(error.path, "actions");
954
955        value["actions"] = json!([7]);
956        let error = PolicyRecord::from_json(&value).expect_err("not a string");
957        assert_eq!(error.path, "actions[0]");
958    }
959
960    #[test]
961    fn a_document_without_a_type_is_refused_by_both_checks() {
962        let mut value = policy();
963        value["document"] = json!({ "statements": [{}] });
964        assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
965        let error = PolicyRecord::from_json(&value).expect_err("no $type");
966        assert_eq!(error.path, "document.$type");
967
968        value["document"] = json!("forbid(principal, action, resource);");
969        let error = PolicyRecord::from_json(&value).expect_err("not an object");
970        assert_eq!(error.path, "document");
971    }
972
973    #[test]
974    fn a_regex_document_holds_statements_and_nothing_else() {
975        let mut value = policy();
976        value["document"] = json!({ "$type": REGEX_DOCUMENT, "statements": [] });
977        assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
978        let error = PolicyRecord::from_json(&value).expect_err("no statements");
979        assert_eq!(error.path, "document.statements");
980
981        value["document"] = json!({ "$type": REGEX_DOCUMENT });
982        let error = PolicyRecord::from_json(&value).expect_err("statements missing");
983        assert_eq!(error.path, "document.statements");
984
985        value["document"] = json!({ "$type": REGEX_DOCUMENT, "statements": [{}], "source": "x" });
986        let error = PolicyRecord::from_json(&value).expect_err("a cedar field");
987        assert_eq!(error.path, "document.source");
988    }
989
990    #[test]
991    fn a_cedar_document_holds_its_source_and_nothing_else() {
992        let mut value = policy();
993        value["document"] = json!({ "$type": CEDAR_DOCUMENT });
994        assert!(validate_shape(POLICY_COLLECTION, &value).is_err());
995        let error = PolicyRecord::from_json(&value).expect_err("source missing");
996        assert_eq!(error.path, "document.source");
997
998        value["document"] = json!({ "$type": CEDAR_DOCUMENT, "source": "x", "statements": [] });
999        let error = PolicyRecord::from_json(&value).expect_err("a regex field");
1000        assert_eq!(error.path, "document.statements");
1001    }
1002
1003    #[test]
1004    fn a_document_of_an_unknown_type_is_carried_by_its_type() {
1005        let mut value = policy();
1006        value["document"] = json!({ "$type": "bot.did.policy#regex2", "statements": [{}] });
1007        let parsed = accept_policy(&value);
1008        assert_eq!(
1009            parsed.document,
1010            Document::Other {
1011                r#type: "bot.did.policy#regex2".to_owned()
1012            }
1013        );
1014        assert_eq!(parsed.document.type_name(), "bot.did.policy#regex2");
1015    }
1016
1017    /// A policy is named by the URI of a `bot.did.policy` record, with its
1018    /// repository given by DID. The lexicon checks only that it is a URI;
1019    /// the reader checks what it names.
1020    #[test]
1021    fn a_policy_is_named_by_the_uri_of_a_policy_record() {
1022        let mut value = binding();
1023        value["policies"] = json!(["fixed-display-name"]);
1024        assert!(validate_shape(BINDING_COLLECTION, &value).is_err());
1025        let error = PolicyBinding::from_json(&value).expect_err("a bare record key");
1026        assert_eq!(error.path, "policies[0]");
1027
1028        for (uri, names) in [
1029            (
1030                format!("at://{OPERATOR}/{BINDING_COLLECTION}/x"),
1031                BINDING_COLLECTION,
1032            ),
1033            (format!("at://{OPERATOR}"), "a repository"),
1034            (
1035                format!("at://{OPERATOR}/{POLICY_COLLECTION}"),
1036                "not a record",
1037            ),
1038            (
1039                format!("at://operator.example.com/{POLICY_COLLECTION}/x"),
1040                "handle",
1041            ),
1042        ] {
1043            value["policies"] = json!([uri]);
1044            validate_shape(BINDING_COLLECTION, &value).expect("a well-formed at-uri");
1045            let error = PolicyBinding::from_json(&value).expect_err(&uri);
1046            assert_eq!(error.path, "policies[0]", "{uri}");
1047            assert!(error.reason.contains(names), "{uri}: {}", error.reason);
1048        }
1049
1050        value["policies"] = json!(["at://did:plc:other/bot.did.policy/x"]);
1051        let parsed = accept_binding(&value);
1052        assert_eq!(parsed.policies[0].repository, "did:plc:other");
1053        assert_eq!(parsed.policies[0].key, "x");
1054
1055        value["policies"] = json!([]);
1056        let error = PolicyBinding::from_json(&value).expect_err("no policy");
1057        assert_eq!(error.path, "policies");
1058    }
1059
1060    #[test]
1061    fn a_subject_or_exclusion_that_is_not_a_did_is_refused_by_both_checks() {
1062        let mut value = binding();
1063        value["subjects"] = json!(["pool.agents.example.com"]);
1064        assert!(validate_shape(BINDING_COLLECTION, &value).is_err());
1065        let error = PolicyBinding::from_json(&value).expect_err("not a did");
1066        assert_eq!(error.path, "subjects[0]");
1067
1068        let mut value = binding();
1069        value["excludes"] = json!(["did:web:ok.example", "kestrel"]);
1070        let error = PolicyBinding::from_json(&value).expect_err("not a did");
1071        assert_eq!(error.path, "excludes[1]");
1072
1073        let mut value = binding();
1074        value["subjects"] = json!([]);
1075        let error = PolicyBinding::from_json(&value).expect_err("nobody");
1076        assert_eq!(error.path, "subjects");
1077    }
1078
1079    #[test]
1080    fn includes_is_a_non_empty_set_of_two_words() {
1081        let mut value = binding();
1082        value["includes"] = json!([]);
1083        assert!(validate_shape(BINDING_COLLECTION, &value).is_err());
1084        let error = PolicyBinding::from_json(&value).expect_err("nobody");
1085        assert_eq!(error.path, "includes");
1086
1087        value["includes"] = json!(["self"]);
1088        validate_shape(BINDING_COLLECTION, &value).expect("the lexicon leaves the words open");
1089        let error = PolicyBinding::from_json(&value).expect_err("an old word");
1090        assert_eq!(error.path, "includes[0]");
1091        assert!(error.reason.contains("`self`"), "{}", error.reason);
1092
1093        value["includes"] = json!(["subjects", "subjects"]);
1094        let parsed = accept_binding(&value);
1095        assert_eq!(
1096            parsed.includes,
1097            Includes {
1098                subjects: true,
1099                descendants: false
1100            }
1101        );
1102        assert_eq!(parsed.includes.to_json(), json!(["subjects"]));
1103
1104        value["includes"] = json!(["descendants", "subjects"]);
1105        let parsed = accept_binding(&value);
1106        assert_eq!(
1107            parsed.includes.to_json(),
1108            json!(["subjects", "descendants"])
1109        );
1110    }
1111
1112    #[test]
1113    fn a_binding_without_exclusions_writes_none() {
1114        let mut value = binding();
1115        value.as_object_mut().expect("an object").remove("excludes");
1116        let parsed = accept_binding(&value);
1117        assert!(parsed.excludes.is_empty());
1118        assert!(parsed.to_json().get("excludes").is_none());
1119    }
1120
1121    #[test]
1122    fn a_missing_required_field_names_itself() {
1123        for (collection, sample, field) in [
1124            (POLICY_COLLECTION, policy(), "actions"),
1125            (POLICY_COLLECTION, policy(), "document"),
1126            (POLICY_COLLECTION, policy(), "createdAt"),
1127            (BINDING_COLLECTION, binding(), "policies"),
1128            (BINDING_COLLECTION, binding(), "subjects"),
1129            (BINDING_COLLECTION, binding(), "includes"),
1130            (BINDING_COLLECTION, binding(), "createdAt"),
1131        ] {
1132            let mut value = sample;
1133            value.as_object_mut().expect("an object").remove(field);
1134            assert!(validate_shape(collection, &value).is_err(), "{field}");
1135            let error = match collection {
1136                POLICY_COLLECTION => PolicyRecord::from_json(&value).map(|_| ()),
1137                _ => PolicyBinding::from_json(&value).map(|_| ()),
1138            }
1139            .expect_err(field);
1140            assert_eq!(error.path, field);
1141        }
1142    }
1143}