Skip to main content

didbot_policy_cedar/
shared.rs

1//! One request per subject, shared by every statement asked about it.
2//!
3//! The server registers each statement of a Cedar document with an
4//! evaluator of its own, and a write asks every one of them about the same
5//! subject in turn. Most of what judging costs is building Cedar's context
6//! and entities from the record, and that depends only on the subject and
7//! the shapes. So each thread keeps the request it prepared last, beside a
8//! copy of the subject it came from, and hands it to the next statement
9//! asked about an equal subject under the same shapes.
10//!
11//! An entry answers only a subject equal to its own in every field, so it
12//! only ever hands out the request that subject would have been built into
13//! anyway. Each thread has its own entry, so a write judged on one thread
14//! never reads another thread's. An entry is replaced whole, and only once
15//! the new request is built, so a panic partway leaves the previous entry,
16//! which still answers only its own subject.
17//!
18//! An entry holds the record the write carried, so it is kept for as long
19//! as the reuse it exists for can happen and no longer. [`KEEP`] is that
20//! long, and the next miss on any thread drops every entry past it — which
21//! is why each thread's entry is reachable from [`SLOTS`] rather than only
22//! from the thread itself. A subject bigger than [`KEEP_BELOW`] is judged
23//! from a request built the same way and then not kept at all, so what one
24//! thread holds between writes is bounded by that rather than by the
25//! largest record the process has ever judged.
26
27use std::sync::{Arc, Mutex, Weak};
28
29use didbot_policy::{
30    AccountKind, Asked, Change, ClientId, DeploymentAttributes, Diff, Found, Lookup, Subject,
31    SubjectAttributes, SubjectKind, Universal, WriteAction,
32};
33use serde_json::Value;
34use time::{Duration, OffsetDateTime};
35
36use crate::request::{AccountAttributes, Action, Operation, Prepared, Principal, Request};
37use crate::schema::Shapes;
38
39/// How long an entry is kept after the subject that filled it.
40///
41/// Measured on the clock the subjects themselves carry —
42/// `Universal::now`, the instant the caller is judging at — so this holds
43/// wherever this crate runs, the browser policy checker included.
44///
45/// A write asks every statement about one subject in one burst, so the
46/// reuse an entry exists for happens within microseconds of it being
47/// filled. Seconds rather than milliseconds because a write descheduled
48/// partway through its statements should still find its own entry;
49/// anything longer is a copy of an account's record held for no further
50/// gain.
51const KEEP: Duration = Duration::seconds(5);
52
53/// The largest subject an entry is kept for, measured over the strings and
54/// record bodies it owns.
55///
56/// Reuse within one write happens whatever the size — the request is built
57/// once either way. This is about what is still held afterwards: a record
58/// at the 1 MiB an atproto record can reach, multiplied by the worker
59/// threads a write can land on, is real memory on a 2 GiB host. 64 KiB
60/// covers an ordinary record comfortably and bounds the rest.
61const KEEP_BELOW: usize = 64 * 1024;
62
63/// One thread's entry, reachable from that thread and from [`SLOTS`].
64type Slot = Arc<Mutex<Option<Entry>>>;
65
66/// Every live thread's slot. Weak, so a thread that has ended takes its
67/// entry with it and leaves a link [`sweep`] drops.
68static SLOTS: Mutex<Vec<Weak<Mutex<Option<Entry>>>>> = Mutex::new(Vec::new());
69
70thread_local! {
71    static LAST: Slot = registered();
72}
73
74struct Entry {
75    /// Held, so no other `Shapes` can take this address while the entry
76    /// compares against it.
77    shapes: Arc<Shapes>,
78    subject: Owned,
79    prepared: Arc<Prepared>,
80    /// The instant the subject that filled this entry was judged at. See
81    /// [`KEEP`].
82    used: OffsetDateTime,
83}
84
85/// A slot of this thread's own, listed in [`SLOTS`].
86fn registered() -> Slot {
87    let slot: Slot = Arc::new(Mutex::new(None));
88    if let Ok(mut slots) = SLOTS.lock() {
89        slots.push(Arc::downgrade(&slot));
90    }
91    slot
92}
93
94/// Drops every entry filled more than [`KEEP`] before `now`, and forgets
95/// the slots of threads that have ended.
96///
97/// Run on a miss rather than on every call: a miss has just paid to build a
98/// Cedar request, so one lock per live thread beside it is not measurable.
99fn sweep(now: OffsetDateTime) {
100    let Ok(mut slots) = SLOTS.lock() else {
101        return;
102    };
103    slots.retain(|held| {
104        let Some(slot) = held.upgrade() else {
105            return false;
106        };
107        // `try_lock`: a thread busy in its own slot is left to the next
108        // sweep rather than blocking this one behind it.
109        if let Ok(mut slot) = slot.try_lock() {
110            if slot.as_ref().is_some_and(|entry| now - entry.used >= KEEP) {
111                // Taken rather than assigned, so the entry drops after the
112                // lock is released.
113                let _expired = slot.take();
114            }
115        }
116        true
117    });
118}
119
120/// The instant `subject` is being judged at.
121///
122/// Every subject carries one, so this is a clock that works the same in a
123/// server and in the browser policy checker, and one an entry can be aged
124/// against without reading a clock of this crate's own.
125fn judged_at(subject: &Subject<'_>) -> OffsetDateTime {
126    match subject {
127        Subject::Write { universal, .. }
128        | Subject::Blob { universal, .. }
129        | Subject::Grant { universal, .. }
130        | Subject::Token { universal, .. }
131        | Subject::Create { universal, .. } => universal.now,
132    }
133}
134
135/// The account as Cedar's principal, with what the subject says about it.
136fn principal<'a>(account: &'a str, attributes: SubjectAttributes<'a>) -> Principal<'a> {
137    Principal::Account {
138        did: account,
139        admitted_by: None,
140        attributes: AccountAttributes {
141            kind: Some(attributes.kind.as_str()),
142            handle: attributes.handle,
143            depth: attributes.depth,
144        },
145    }
146}
147
148/// A `record.write`'s operation, from the write's action.
149fn operation(action: WriteAction) -> Operation {
150    match action {
151        WriteAction::Create => Operation::Create,
152        WriteAction::Update => Operation::Update,
153        WriteAction::Delete => Operation::Delete,
154    }
155}
156
157/// The request `subject` asks, prepared against `shapes`.
158pub(crate) fn prepared(subject: &Subject<'_>, shapes: &Arc<Shapes>) -> Arc<Prepared> {
159    let now = judged_at(subject);
160    let held = LAST.try_with(|slot| {
161        let slot = slot
162            .lock()
163            .unwrap_or_else(std::sync::PoisonError::into_inner);
164        slot.as_ref()
165            .filter(|entry| Arc::ptr_eq(&entry.shapes, shapes) && entry.subject.is(subject))
166            .map(|entry| Arc::clone(&entry.prepared))
167    });
168    if let Ok(Some(prepared)) = held {
169        return prepared;
170    }
171    let prepared = Arc::new(with_request(subject, |request| request.prepare(shapes)));
172    tracing::trace!("prepared a cedar request for a new subject");
173    sweep(now);
174    let subject = Owned::of(subject);
175    let keep = subject.weight() <= KEEP_BELOW;
176    let entry = keep.then(|| Entry {
177        shapes: Arc::clone(shapes),
178        subject,
179        prepared: Arc::clone(&prepared),
180        used: now,
181    });
182    if !keep {
183        tracing::trace!("the subject is too big to keep its prepared request");
184    }
185    // The previous entry is handed back by `replace`, so it drops outside
186    // the lock. An oversized subject replaces it with nothing rather than
187    // leaving whatever was there.
188    let _previous = LAST.try_with(|slot| {
189        let mut slot = slot
190            .lock()
191            .unwrap_or_else(std::sync::PoisonError::into_inner);
192        std::mem::replace(&mut *slot, entry)
193    });
194    prepared
195}
196
197/// The request `subject` asks, handed to `f`. A write's `value` and
198/// `existing` are the records themselves, exactly as the store holds them.
199pub(crate) fn with_request<R>(subject: &Subject<'_>, f: impl FnOnce(&Request<'_>) -> R) -> R {
200    let request = match subject {
201        Subject::Write {
202            account,
203            attributes,
204            universal,
205            collection,
206            client_id,
207            action,
208            before,
209            after,
210            ..
211        } => Request {
212            principal: principal(account, *attributes),
213            now: universal.now,
214            action: Action::RecordWrite {
215                collection,
216                rkey: None,
217                space: None,
218                operation: operation(*action),
219                client: client_id.map(ClientId::as_str),
220                value: *after,
221                existing: *before,
222            },
223        },
224        Subject::Blob {
225            account,
226            attributes,
227            universal,
228            mime_type,
229            sniffed,
230            size,
231            ..
232        } => Request {
233            principal: principal(account, *attributes),
234            now: universal.now,
235            action: Action::BlobWrite {
236                mime_type,
237                // Both are carried by every judgment this engine answers —
238                // see `CedarEvaluator::evaluate`, which skips the one that
239                // runs before a blob's bytes are read.
240                sniffed: sniffed.unwrap_or_default(),
241                size: size.unwrap_or_default(),
242                space: None,
243            },
244        },
245        Subject::Grant {
246            universal,
247            client_id,
248            requested_scopes,
249        } => Request {
250            principal: Principal::Unknown,
251            now: universal.now,
252            action: Action::OauthAuthorize {
253                client_id: client_id.as_str(),
254                scopes: requested_scopes,
255            },
256        },
257        Subject::Token {
258            account,
259            attributes,
260            universal,
261            client_id,
262            scopes,
263        } => Request {
264            principal: principal(account, *attributes),
265            now: universal.now,
266            action: Action::OauthToken {
267                client_id: client_id.as_str(),
268                grant: None,
269                scopes,
270            },
271        },
272        Subject::Create {
273            account,
274            attributes,
275            universal,
276            kind,
277            asked,
278            pool,
279            zone,
280            name,
281            label,
282        } => Request {
283            // An account this server does not host, the operator, is still
284            // who is asking; it just has no attributes here.
285            principal: attributes.map_or(
286                Principal::Account {
287                    did: account,
288                    admitted_by: None,
289                    attributes: AccountAttributes::default(),
290                },
291                |attributes| principal(account, attributes),
292            ),
293            now: universal.now,
294            action: Action::AccountCreate {
295                kind: kind.as_str(),
296                asked: asked.as_str(),
297                pool,
298                zone,
299                name: *name,
300                label: *label,
301            },
302        },
303    };
304    f(&request)
305}
306
307/// A [`Subject`] copied out of the borrows it arrived in. Both directions
308/// name every field, so a field added to `Subject` does not compile here
309/// until it is copied and compared too.
310#[derive(Debug)]
311enum Owned {
312    Write {
313        account: String,
314        attributes: Attributes,
315        universal: Facts,
316        client_id: Option<ClientId>,
317        collection: String,
318        action: WriteAction,
319        changes: Vec<OwnedChange>,
320        before: Option<Value>,
321        after: Option<Value>,
322        lookups: Vec<OwnedLookup>,
323    },
324    Blob {
325        account: String,
326        attributes: Attributes,
327        universal: Facts,
328        client_id: Option<ClientId>,
329        mime_type: String,
330        sniffed: Option<String>,
331        size: Option<u64>,
332    },
333    Grant {
334        universal: Facts,
335        client_id: ClientId,
336        requested_scopes: Vec<String>,
337    },
338    Token {
339        account: String,
340        attributes: Attributes,
341        universal: Facts,
342        client_id: ClientId,
343        scopes: Vec<String>,
344    },
345    Create {
346        account: String,
347        attributes: Option<Attributes>,
348        universal: Facts,
349        kind: Kind,
350        asked: Asked,
351        pool: String,
352        zone: String,
353        name: Option<String>,
354        label: Option<String>,
355    },
356}
357
358#[derive(Debug)]
359struct Attributes {
360    kind: Kind,
361    handle: Option<String>,
362    depth: Option<u32>,
363}
364
365#[derive(Debug, Clone)]
366enum Kind {
367    Known(AccountKind),
368    Unknown(String),
369}
370
371#[derive(Debug)]
372struct Facts {
373    pds_did: String,
374    pds_hostname: String,
375    operator_did: Option<String>,
376    now: OffsetDateTime,
377}
378
379#[derive(Debug)]
380struct OwnedChange {
381    path: String,
382    before: Option<Value>,
383    after: Option<Value>,
384}
385
386#[derive(Debug)]
387struct OwnedLookup {
388    name: String,
389    account: String,
390    handle: Option<String>,
391    uri: String,
392    found: OwnedFound,
393}
394
395#[derive(Debug)]
396enum OwnedFound {
397    Records(Value),
398    Nothing,
399    Unknown(String),
400}
401
402impl Owned {
403    fn of(subject: &Subject<'_>) -> Self {
404        match *subject {
405            Subject::Write {
406                account,
407                attributes,
408                universal,
409                client_id,
410                collection,
411                action,
412                diff: Diff { changes },
413                before,
414                after,
415                lookups,
416            } => Owned::Write {
417                account: account.to_owned(),
418                attributes: Attributes::of(attributes),
419                universal: Facts::of(universal),
420                client_id: client_id.cloned(),
421                collection: collection.to_owned(),
422                action,
423                changes: changes
424                    .iter()
425                    .map(
426                        |&Change {
427                             path,
428                             before,
429                             after,
430                         }| OwnedChange {
431                            path: path.to_owned(),
432                            before: before.cloned(),
433                            after: after.cloned(),
434                        },
435                    )
436                    .collect(),
437                before: before.cloned(),
438                after: after.cloned(),
439                lookups: lookups
440                    .iter()
441                    .map(
442                        |&Lookup {
443                             name,
444                             account,
445                             handle,
446                             uri,
447                             found,
448                         }| OwnedLookup {
449                            name: name.to_owned(),
450                            account: account.to_owned(),
451                            handle: handle.map(str::to_owned),
452                            uri: uri.to_owned(),
453                            found: match found {
454                                Found::Records(records) => OwnedFound::Records(records.clone()),
455                                Found::Nothing => OwnedFound::Nothing,
456                                Found::Unknown(why) => OwnedFound::Unknown(why.to_owned()),
457                            },
458                        },
459                    )
460                    .collect(),
461            },
462            Subject::Blob {
463                account,
464                attributes,
465                universal,
466                client_id,
467                mime_type,
468                sniffed,
469                size,
470            } => Owned::Blob {
471                account: account.to_owned(),
472                attributes: Attributes::of(attributes),
473                universal: Facts::of(universal),
474                client_id: client_id.cloned(),
475                mime_type: mime_type.to_owned(),
476                sniffed: sniffed.map(ToOwned::to_owned),
477                size,
478            },
479            Subject::Grant {
480                universal,
481                client_id,
482                requested_scopes,
483            } => Owned::Grant {
484                universal: Facts::of(universal),
485                client_id: client_id.clone(),
486                requested_scopes: requested_scopes.iter().map(|s| (*s).to_owned()).collect(),
487            },
488            Subject::Token {
489                account,
490                attributes,
491                universal,
492                client_id,
493                scopes,
494            } => Owned::Token {
495                account: account.to_owned(),
496                attributes: Attributes::of(attributes),
497                universal: Facts::of(universal),
498                client_id: client_id.clone(),
499                scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
500            },
501            Subject::Create {
502                account,
503                attributes,
504                universal,
505                kind,
506                asked,
507                pool,
508                zone,
509                name,
510                label,
511            } => Owned::Create {
512                account: account.to_owned(),
513                attributes: attributes.map(Attributes::of),
514                universal: Facts::of(universal),
515                kind: Kind::of(kind),
516                asked,
517                pool: pool.to_owned(),
518                zone: zone.to_owned(),
519                name: name.map(str::to_owned),
520                label: label.map(str::to_owned),
521            },
522        }
523    }
524
525    /// The subject this was copied from, handed to `f`.
526    fn view<R>(&self, f: impl FnOnce(&Subject<'_>) -> R) -> R {
527        match self {
528            Owned::Write {
529                account,
530                attributes,
531                universal,
532                client_id,
533                collection,
534                action,
535                changes,
536                before,
537                after,
538                lookups,
539            } => {
540                let changes: Vec<Change<'_>> = changes
541                    .iter()
542                    .map(|change| Change {
543                        path: &change.path,
544                        before: change.before.as_ref(),
545                        after: change.after.as_ref(),
546                    })
547                    .collect();
548                let lookups: Vec<Lookup<'_>> = lookups
549                    .iter()
550                    .map(|lookup| Lookup {
551                        name: &lookup.name,
552                        account: &lookup.account,
553                        handle: lookup.handle.as_deref(),
554                        uri: &lookup.uri,
555                        found: match &lookup.found {
556                            OwnedFound::Records(records) => Found::Records(records),
557                            OwnedFound::Nothing => Found::Nothing,
558                            OwnedFound::Unknown(why) => Found::Unknown(why),
559                        },
560                    })
561                    .collect();
562                f(&Subject::Write {
563                    account,
564                    attributes: attributes.view(),
565                    universal: universal.view(),
566                    client_id: client_id.as_ref(),
567                    collection,
568                    action: *action,
569                    diff: Diff { changes: &changes },
570                    before: before.as_ref(),
571                    after: after.as_ref(),
572                    lookups: &lookups,
573                })
574            }
575            Owned::Blob {
576                account,
577                attributes,
578                universal,
579                client_id,
580                mime_type,
581                sniffed,
582                size,
583            } => f(&Subject::Blob {
584                account,
585                attributes: attributes.view(),
586                universal: universal.view(),
587                client_id: client_id.as_ref(),
588                mime_type,
589                sniffed: sniffed.as_deref(),
590                size: *size,
591            }),
592            Owned::Grant {
593                universal,
594                client_id,
595                requested_scopes,
596            } => {
597                let requested_scopes: Vec<&str> =
598                    requested_scopes.iter().map(String::as_str).collect();
599                f(&Subject::Grant {
600                    universal: universal.view(),
601                    client_id,
602                    requested_scopes: &requested_scopes,
603                })
604            }
605            Owned::Token {
606                account,
607                attributes,
608                universal,
609                client_id,
610                scopes,
611            } => {
612                let scopes: Vec<&str> = scopes.iter().map(String::as_str).collect();
613                f(&Subject::Token {
614                    account,
615                    attributes: attributes.view(),
616                    universal: universal.view(),
617                    client_id,
618                    scopes: &scopes,
619                })
620            }
621            Owned::Create {
622                account,
623                attributes,
624                universal,
625                kind,
626                asked,
627                pool,
628                zone,
629                name,
630                label,
631            } => f(&Subject::Create {
632                account,
633                attributes: attributes.as_ref().map(Attributes::view),
634                universal: universal.view(),
635                kind: kind.view(),
636                asked: *asked,
637                pool,
638                zone,
639                name: name.as_deref(),
640                label: label.as_deref(),
641            }),
642        }
643    }
644
645    fn is(&self, subject: &Subject<'_>) -> bool {
646        self.view(|own| own == subject)
647    }
648
649    /// Roughly how many bytes of text and record body this owns, for
650    /// [`KEEP_BELOW`].
651    ///
652    /// The variable part only: a `Subject`'s fixed fields are a handful of
653    /// words each, and what separates one subject from another by orders of
654    /// magnitude is the record a write carries.
655    fn weight(&self) -> usize {
656        match self {
657            Owned::Write {
658                account,
659                collection,
660                changes,
661                before,
662                after,
663                ..
664            } => {
665                account.len()
666                    + collection.len()
667                    + changes
668                        .iter()
669                        .map(|change| {
670                            change.path.len() + weigh(&change.before) + weigh(&change.after)
671                        })
672                        .sum::<usize>()
673                    + weigh(before)
674                    + weigh(after)
675            }
676            Owned::Blob {
677                account,
678                mime_type,
679                sniffed,
680                ..
681            } => account.len() + mime_type.len() + sniffed.as_ref().map_or(0, String::len),
682            Owned::Grant {
683                requested_scopes, ..
684            } => requested_scopes.iter().map(String::len).sum(),
685            Owned::Token {
686                account, scopes, ..
687            } => account.len() + scopes.iter().map(String::len).sum::<usize>(),
688            Owned::Create {
689                account,
690                pool,
691                zone,
692                name,
693                ..
694            } => account.len() + pool.len() + zone.len() + name.as_ref().map_or(0, String::len),
695        }
696    }
697}
698
699/// Roughly how many bytes of text `value` holds.
700///
701/// Not the allocator's answer — a `Value`'s own words are not counted, and
702/// neither is a map's or a vector's spare capacity. It is the record's size
703/// to within a constant factor, which is what [`KEEP_BELOW`] is a threshold
704/// on.
705fn weigh(value: &Option<Value>) -> usize {
706    fn of(value: &Value) -> usize {
707        match value {
708            Value::Null | Value::Bool(_) | Value::Number(_) => 8,
709            Value::String(text) => text.len(),
710            Value::Array(items) => items.iter().map(of).sum(),
711            Value::Object(fields) => fields
712                .iter()
713                .map(|(name, held)| name.len() + of(held))
714                .sum(),
715        }
716    }
717    value.as_ref().map_or(0, of)
718}
719
720impl Attributes {
721    fn of(
722        SubjectAttributes {
723            kind,
724            handle,
725            depth,
726        }: SubjectAttributes<'_>,
727    ) -> Self {
728        Self {
729            kind: Kind::of(kind),
730            handle: handle.map(str::to_owned),
731            depth,
732        }
733    }
734
735    fn view(&self) -> SubjectAttributes<'_> {
736        SubjectAttributes {
737            kind: self.kind.view(),
738            handle: self.handle.as_deref(),
739            depth: self.depth,
740        }
741    }
742}
743
744impl Kind {
745    fn of(kind: SubjectKind<'_>) -> Self {
746        match kind {
747            SubjectKind::Known(kind) => Kind::Known(kind),
748            SubjectKind::Unknown(kind) => Kind::Unknown(kind.to_owned()),
749        }
750    }
751
752    fn view(&self) -> SubjectKind<'_> {
753        match self {
754            Kind::Known(kind) => SubjectKind::Known(*kind),
755            Kind::Unknown(kind) => SubjectKind::Unknown(kind),
756        }
757    }
758}
759
760impl Facts {
761    fn of(
762        Universal {
763            deployment:
764                DeploymentAttributes {
765                    pds_did,
766                    pds_hostname,
767                    operator_did,
768                },
769            now,
770        }: Universal<'_>,
771    ) -> Self {
772        Self {
773            pds_did: pds_did.to_owned(),
774            pds_hostname: pds_hostname.to_owned(),
775            operator_did: operator_did.map(ToOwned::to_owned),
776            now,
777        }
778    }
779
780    fn view(&self) -> Universal<'_> {
781        Universal {
782            deployment: DeploymentAttributes {
783                pds_did: &self.pds_did,
784                pds_hostname: &self.pds_hostname,
785                operator_did: self.operator_did.as_deref(),
786            },
787            now: self.now,
788        }
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use std::collections::{BTreeMap, HashMap};
795    use std::panic::{catch_unwind, AssertUnwindSafe};
796
797    use didbot_policy::{CompileOutcome, CompiledId, Evaluator, Outcome};
798    use rand::rngs::StdRng;
799    use rand::seq::IndexedRandom;
800    use rand::{Rng, SeedableRng};
801    use serde_json::json;
802    use time::Duration;
803
804    use super::*;
805    use crate::{outcome, CedarEvaluator, Document, FieldType, Shape};
806
807    const POST: &str = "app.bsky.feed.post";
808    const PROFILE: &str = "app.bsky.actor.profile";
809    const REPO: &str = "sh.tangled.repo";
810    const APP: &str = "https://app.example/client-metadata.json";
811
812    /// The fixtures and the workshop's statements, each under an id no
813    /// other uses, so any few of them make a document. Between them they
814    /// fire, error at evaluation, need a principal the gate does not carry,
815    /// and read what only a shape types.
816    const STATEMENTS: &[&str] = &[
817        include_str!("../../didbot-pds/tests/fixtures/c1-no-posting.cedar"),
818        include_str!("../../didbot-pds/tests/fixtures/c2-one-client.cedar"),
819        include_str!("../../didbot-pds/tests/fixtures/c3-no-late-edits.cedar"),
820        include_str!("../../didbot-pds/tests/fixtures/c4-public-only.cedar"),
821        r#"@id("subtree")
822        @reason("Nothing under the operator posts.")
823        forbid(principal in Didbot::Account::"did:plc:operator", action == Didbot::Action::"record.write", resource)
824        when { resource.collection == "app.bsky.feed.post" };"#,
825        r#"@id("not-the-agent")
826        @reason("The agent signs in nowhere.")
827        forbid(principal == Didbot::Account::"did:plc:agent", action in [Didbot::Action::"oauth.authorize", Didbot::Action::"oauth.token"], resource);"#,
828        r#"@id("no-crypto-names")
829        @reason("Display names may not mention crypto.")
830        forbid(principal, action == Didbot::Action::"record.write", resource)
831        when { resource.collection == "app.bsky.actor.profile" && context has value && context.value.displayNmae like "*crypto*" };"#,
832        r#"@id("no-secret-repos")
833        @reason("Repositories may not be named secret.")
834        forbid(principal, action == Didbot::Action::"record.write", resource)
835        when { resource.collection == "sh.tangled.repo" && context has value && context.value.name == "secret" };"#,
836        r#"@id("no-app-writes")
837        @reason("This app may not write here.")
838        forbid(principal, action == Didbot::Action::"record.write", resource)
839        when { context has client && context.client.id like "https://app.example/*" };"#,
840        r#"@id("no-app-sign-in")
841        @reason("This app may not sign in here.")
842        forbid(principal, action == Didbot::Action::"oauth.authorize", resource)
843        when { resource.id like "https://app.example/*" };"#,
844        r#"@id("unguarded")
845        @reason("This app may not name repositories.")
846        forbid(principal, action == Didbot::Action::"record.write", resource)
847        when { resource.collection == "sh.tangled.repo" && context has value && context.client.id like "https://app.example/*" };"#,
848        r#"@id("no-chat-tokens")
849        @reason("Chat apps get no tokens.")
850        forbid(principal, action == Didbot::Action::"oauth.token", resource)
851        when { resource.id like "https://chat.example/*" };"#,
852        r#"@id("no-refresh")
853        @reason("Tokens are not refreshed.")
854        forbid(principal, action == Didbot::Action::"oauth.token", resource)
855        when { context.grant == "refresh_token" };"#,
856        r#"@id("no-secret-text")
857        @reason("Nothing says secret.")
858        forbid(principal, action == Didbot::Action::"record.write", resource)
859        when { context has value && context.value has text && context.value.text like "*secret*" };"#,
860    ];
861
862    fn now() -> OffsetDateTime {
863        time::macros::datetime!(2026-09-11 12:00 UTC)
864    }
865
866    /// Posts described, as the workshop describes them.
867    fn described() -> Arc<Shapes> {
868        let mut shapes = Shapes::new();
869        shapes
870            .describe(
871                POST,
872                Shape::new()
873                    .field("text", FieldType::String)
874                    .field("createdAt", FieldType::Datetime)
875                    .optional("langs", FieldType::Set(Box::new(FieldType::String))),
876            )
877            .expect("the post shape is a valid record type");
878        Arc::new(shapes)
879    }
880
881    /// One compiled document, and what judging it afresh needs.
882    struct Judge {
883        evaluator: Arc<CedarEvaluator>,
884        compiled: CompiledId,
885        document: Arc<Document>,
886        shapes: Arc<Shapes>,
887    }
888
889    impl Judge {
890        fn new(
891            evaluator: &Arc<CedarEvaluator>,
892            shapes: &Arc<Shapes>,
893            source: &str,
894        ) -> Option<Self> {
895            let compiled = match evaluator.compile(&json!({ "source": source })) {
896                CompileOutcome::Compiled(id) | CompileOutcome::CompiledWithWarnings(id, _) => id,
897                CompileOutcome::Rejected(_) => return None,
898            };
899            Some(Self {
900                evaluator: Arc::clone(evaluator),
901                compiled,
902                document: evaluator.document(compiled).expect("just compiled"),
903                shapes: Arc::clone(shapes),
904            })
905        }
906
907        /// Through the evaluator, sharing the request between statements.
908        /// An evaluation neither path can complete -- a statement's own
909        /// check failing, never this call's `compiled` lookup, which was
910        /// just compiled -- becomes the same `Outcome::Reject` the two
911        /// paths would refuse the write with, so they stay comparable.
912        fn evaluate(&self, subject: &Owned) -> Outcome {
913            subject
914                .view(|subject| self.evaluator.evaluate(subject, self.compiled))
915                .unwrap_or_else(|error| Outcome::Reject {
916                    reason: error.to_string(),
917                })
918        }
919
920        /// A request built for this call alone, the way every statement
921        /// built its own before they shared one.
922        fn fresh(&self, subject: &Owned) -> Outcome {
923            subject.view(|subject| {
924                outcome(with_request(subject, |request| {
925                    self.document.decide(request, &self.shapes)
926                }))
927                .unwrap_or_else(|error| Outcome::Reject {
928                    reason: error.to_string(),
929                })
930            })
931        }
932    }
933
934    /// Documents of one to three statements over both sets of shapes,
935    /// several to an evaluator, the way a server compiles them.
936    fn judges(rng: &mut StdRng) -> Vec<Judge> {
937        let mut judges = Vec::new();
938        for shapes in [described(), Arc::new(Shapes::new())] {
939            let loadable: Vec<&str> = STATEMENTS
940                .iter()
941                .copied()
942                .filter(|source| Document::load(source, &shapes).is_ok())
943                .collect();
944            assert!(loadable.len() >= STATEMENTS.len() - 1, "{loadable:?}");
945            for _ in 0..8 {
946                let evaluator = Arc::new(CedarEvaluator::new(Arc::clone(&shapes)));
947                for _ in 0..rng.random_range(1..=3) {
948                    let size = rng.random_range(1..=3);
949                    let source = loadable
950                        .choose_multiple(rng, size)
951                        .copied()
952                        .collect::<Vec<_>>();
953                    judges.extend(Judge::new(&evaluator, &shapes, &source.join("\n")));
954                }
955            }
956        }
957        judges
958    }
959
960    fn records() -> Vec<Value> {
961        let post = |text: &str, age: Duration| {
962            json!({
963                "text": text,
964                "createdAt": (now() - age).format(&time::format_description::well_known::Rfc3339).unwrap(),
965                "langs": ["en"],
966            })
967        };
968        vec![
969            post("hello", Duration::ZERO),
970            post("hello", Duration::days(2)),
971            post("secret plans", Duration::hours(1)),
972            json!({ "displayName": "Agent" }),
973            json!({ "displayName": "crypto fan" }),
974            json!({ "name": "secret" }),
975            json!({ "name": "open" }),
976            json!({ "text": "not a time", "createdAt": "yesterday" }),
977            // Cedar has no floats, so this record cannot become a context.
978            json!({ "text": "hello", "score": 1.5 }),
979        ]
980    }
981
982    fn leaves(path: &str, value: &Value, out: &mut BTreeMap<String, Value>) {
983        match value {
984            Value::Object(map) if !map.is_empty() => {
985                for (key, child) in map {
986                    let child_path = if path.is_empty() {
987                        key.clone()
988                    } else {
989                        format!("{path}.{key}")
990                    };
991                    leaves(&child_path, child, out);
992                }
993            }
994            Value::Array(items) if !items.is_empty() => {
995                for (index, child) in items.iter().enumerate() {
996                    leaves(&format!("{path}[{index}]"), child, out);
997                }
998            }
999            leaf => {
1000                out.insert(path.to_owned(), leaf.clone());
1001            }
1002        }
1003    }
1004
1005    fn diff(before: Option<&Value>, after: Option<&Value>) -> Vec<OwnedChange> {
1006        let (mut old, mut new) = (BTreeMap::new(), BTreeMap::new());
1007        if let Some(before) = before {
1008            leaves("", before, &mut old);
1009        }
1010        if let Some(after) = after {
1011            leaves("", after, &mut new);
1012        }
1013        let mut paths: Vec<&String> = old.keys().chain(new.keys()).collect();
1014        paths.sort_unstable();
1015        paths.dedup();
1016        paths
1017            .into_iter()
1018            .filter(|path| old.get(*path) != new.get(*path))
1019            .map(|path| OwnedChange {
1020                path: path.clone(),
1021                before: old.get(path).cloned(),
1022                after: new.get(path).cloned(),
1023            })
1024            .collect()
1025    }
1026
1027    fn client(id: &str) -> ClientId {
1028        ClientId::parse(id).expect("a canonical client id")
1029    }
1030
1031    fn facts(rng: &mut StdRng) -> Facts {
1032        let (pds_did, pds_hostname) = *[
1033            ("did:web:pds.example", "pds.example"),
1034            ("did:web:b.example", "b.example"),
1035        ]
1036        .choose(rng)
1037        .unwrap();
1038        Facts {
1039            pds_did: pds_did.to_owned(),
1040            pds_hostname: pds_hostname.to_owned(),
1041            operator_did: None,
1042            now: now()
1043                + *[Duration::ZERO, Duration::nanoseconds(1), Duration::days(1)]
1044                    .choose(rng)
1045                    .unwrap(),
1046        }
1047    }
1048
1049    fn attributes(rng: &mut StdRng) -> Attributes {
1050        Attributes {
1051            kind: [
1052                Kind::Known(AccountKind::Agent),
1053                Kind::Known(AccountKind::Host),
1054                Kind::Unknown("robot".to_owned()),
1055            ]
1056            .choose(rng)
1057            .unwrap()
1058            .clone(),
1059            handle: [None, Some("agent.example".to_owned())]
1060                .choose(rng)
1061                .unwrap()
1062                .clone(),
1063            depth: *[None, Some(1), Some(2)].choose(rng).unwrap(),
1064        }
1065    }
1066
1067    fn subject(rng: &mut StdRng, records: &[Value]) -> Owned {
1068        let clients = [
1069            APP,
1070            "https://other-app.example/client-metadata.json",
1071            "https://quernstone.example/client-metadata.json",
1072            "https://chat.example/app",
1073        ];
1074        let account = (*["did:plc:agent", "did:plc:other"].choose(rng).unwrap()).to_owned();
1075        let scopes = [&["atproto"][..], &["atproto", "transition:generic"], &[]]
1076            .choose(rng)
1077            .unwrap()
1078            .iter()
1079            .map(|s| (*s).to_owned())
1080            .collect();
1081        let client_id = client(clients.choose(rng).unwrap());
1082        match rng.random_range(0..6) {
1083            0 => Owned::Grant {
1084                universal: facts(rng),
1085                client_id,
1086                requested_scopes: scopes,
1087            },
1088            1 => Owned::Token {
1089                account,
1090                attributes: attributes(rng),
1091                universal: facts(rng),
1092                client_id,
1093                scopes,
1094            },
1095            _ => {
1096                let (before, after, action) = match rng.random_range(0..3) {
1097                    0 => (None, records.choose(rng), WriteAction::Create),
1098                    1 => (
1099                        records.choose(rng),
1100                        records.choose(rng),
1101                        WriteAction::Update,
1102                    ),
1103                    _ => (records.choose(rng), None, WriteAction::Delete),
1104                };
1105                Owned::Write {
1106                    account,
1107                    attributes: attributes(rng),
1108                    universal: facts(rng),
1109                    client_id: rng.random_bool(0.6).then_some(client_id),
1110                    collection: (*[POST, PROFILE, REPO, "app.example.other"]
1111                        .choose(rng)
1112                        .unwrap())
1113                    .to_owned(),
1114                    action,
1115                    changes: diff(before, after),
1116                    before: before.cloned(),
1117                    after: after.cloned(),
1118                    lookups: Vec::new(),
1119                }
1120            }
1121        }
1122    }
1123
1124    /// Which of the evaluator's answers a run has seen.
1125    #[derive(Default, Debug)]
1126    struct Seen {
1127        allowed: usize,
1128        forbidden: usize,
1129        not_carried: usize,
1130        errored: usize,
1131        not_built: usize,
1132    }
1133
1134    impl Seen {
1135        fn count(&mut self, outcome: &Outcome) {
1136            match outcome {
1137                Outcome::Allow => self.allowed += 1,
1138                Outcome::Reject { reason } if reason.contains("does not carry") => {
1139                    self.not_carried += 1;
1140                }
1141                Outcome::Reject { reason } if reason.contains("could not complete") => {
1142                    self.errored += 1;
1143                }
1144                Outcome::Reject { reason } if reason.contains("could not be built") => {
1145                    self.not_built += 1;
1146                }
1147                Outcome::Reject { .. } => self.forbidden += 1,
1148                Outcome::Freeze { .. } => panic!("a cedar document never freezes"),
1149            }
1150        }
1151
1152        fn every_kind(&self) -> bool {
1153            [
1154                self.allowed,
1155                self.forbidden,
1156                self.not_carried,
1157                self.errored,
1158                self.not_built,
1159            ]
1160            .iter()
1161            .all(|&n| n > 0)
1162        }
1163    }
1164
1165    /// Random documents asked about random subjects in a random order,
1166    /// mostly in runs on one subject the way a write asks every statement
1167    /// in turn. Every answer, reason text included, is the one a request
1168    /// built for that call alone gives.
1169    #[test]
1170    fn every_statement_answers_as_a_fresh_build_would() {
1171        let mut rng = StdRng::seed_from_u64(0x0c3d_a75e);
1172        let judges = judges(&mut rng);
1173        let records = records();
1174        let subjects: Vec<Owned> = (0..80).map(|_| subject(&mut rng, &records)).collect();
1175        let mut fresh = HashMap::new();
1176        let mut seen = Seen::default();
1177        let mut on = 0;
1178        for _ in 0..4000 {
1179            if rng.random_bool(0.2) {
1180                on = rng.random_range(0..subjects.len());
1181            }
1182            let at = rng.random_range(0..judges.len());
1183            let judge = &judges[at];
1184            let want = fresh
1185                .entry((at, on))
1186                .or_insert_with(|| judge.fresh(&subjects[on]));
1187            let got = judge.evaluate(&subjects[on]);
1188            assert_eq!(&got, want, "subject {:?}", subjects[on]);
1189            seen.count(&got);
1190        }
1191        assert!(seen.every_kind(), "{seen:?}");
1192    }
1193
1194    /// Two writes that differ only in the record's text, alternating on
1195    /// one thread: each is judged on its own text.
1196    #[test]
1197    fn a_request_never_answers_for_the_next_write() {
1198        let shapes = described();
1199        let evaluator = Arc::new(CedarEvaluator::new(Arc::clone(&shapes)));
1200        let judge = Judge::new(&evaluator, &shapes, STATEMENTS[13]).expect("loads");
1201        let write = |text: &str| Owned::Write {
1202            account: "did:plc:agent".to_owned(),
1203            attributes: Attributes {
1204                kind: Kind::Known(AccountKind::Agent),
1205                handle: None,
1206                depth: None,
1207            },
1208            universal: Facts {
1209                pds_did: "did:web:pds.example".to_owned(),
1210                pds_hostname: "pds.example".to_owned(),
1211                now: now(),
1212                operator_did: None,
1213            },
1214            client_id: None,
1215            collection: POST.to_owned(),
1216            action: WriteAction::Create,
1217            changes: diff(None, Some(&json!({ "text": text }))),
1218            before: None,
1219            after: Some(json!({ "text": text })),
1220            lookups: Vec::new(),
1221        };
1222        let (open, secret) = (write("hello"), write("secret plans"));
1223        for _ in 0..3 {
1224            assert_eq!(judge.evaluate(&open), Outcome::Allow);
1225            assert_eq!(
1226                judge.evaluate(&secret),
1227                Outcome::Reject {
1228                    reason: "Nothing says secret.".to_owned()
1229                }
1230            );
1231        }
1232    }
1233
1234    /// A create of `text`, as a subject.
1235    fn posting(text: &str) -> Owned {
1236        Owned::Write {
1237            account: "did:plc:agent".to_owned(),
1238            attributes: Attributes {
1239                kind: Kind::Known(AccountKind::Agent),
1240                handle: None,
1241                depth: None,
1242            },
1243            universal: Facts {
1244                pds_did: "did:web:pds.example".to_owned(),
1245                pds_hostname: "pds.example".to_owned(),
1246                now: now(),
1247                operator_did: None,
1248            },
1249            client_id: None,
1250            collection: POST.to_owned(),
1251            action: WriteAction::Create,
1252            changes: diff(None, Some(&json!({ "text": text }))),
1253            before: None,
1254            after: Some(json!({ "text": text })),
1255            lookups: Vec::new(),
1256        }
1257    }
1258
1259    /// A record past [`KEEP_BELOW`] is judged from a request built the same
1260    /// way, and is not what the thread goes on holding afterwards. An
1261    /// ordinary one still is, which is what the reuse is for.
1262    #[test]
1263    fn a_record_too_big_to_keep_is_judged_all_the_same() {
1264        let shapes = described();
1265        let ordinary = posting("hello");
1266        let first = ordinary.view(|subject| prepared(subject, &shapes));
1267        let again = ordinary.view(|subject| prepared(subject, &shapes));
1268        assert!(
1269            Arc::ptr_eq(&first, &again),
1270            "an ordinary write built its request twice"
1271        );
1272
1273        let huge = posting(&"o".repeat(KEEP_BELOW + 1));
1274        let first = huge.view(|subject| prepared(subject, &shapes));
1275        let again = huge.view(|subject| prepared(subject, &shapes));
1276        assert!(
1277            first.built.is_ok(),
1278            "a record too big to keep was not judged"
1279        );
1280        assert!(
1281            !Arc::ptr_eq(&first, &again),
1282            "a record too big to keep was kept anyway"
1283        );
1284    }
1285
1286    /// A worker that judged one write and then went quiet does not go on
1287    /// holding that write's record. The sweep any later miss runs is what
1288    /// takes it, from whichever thread that miss happened on.
1289    #[test]
1290    fn an_idle_workers_record_is_dropped_by_a_sweep_from_another_thread() {
1291        let shapes = described();
1292        let (ready, judged) = std::sync::mpsc::channel();
1293        let (stop, wait) = std::sync::mpsc::channel::<()>();
1294        let worker = std::thread::spawn(move || {
1295            let write = posting("a record nobody asks about again");
1296            let held = write.view(|subject| prepared(subject, &shapes));
1297            ready.send((held, LAST.with(Arc::clone))).expect("sent");
1298            // Alive, and judging nothing further.
1299            wait.recv().ok();
1300        });
1301
1302        let (held, slot) = judged.recv().expect("the worker judged its write");
1303        assert_eq!(
1304            Arc::strong_count(&held),
1305            2,
1306            "the worker did not keep the request it prepared"
1307        );
1308
1309        // This one entry is aged rather than the clock, so a sweep running
1310        // for some other test's write is not disturbed by this one.
1311        slot.lock()
1312            .expect("not poisoned")
1313            .as_mut()
1314            .expect("held")
1315            .used -= KEEP;
1316        sweep(now() + Duration::days(7));
1317        assert_eq!(
1318            Arc::strong_count(&held),
1319            1,
1320            "an idle worker still holds the record it judged"
1321        );
1322
1323        stop.send(()).ok();
1324        worker.join().expect("the worker finished");
1325    }
1326
1327    /// The same edit judged by two engines, one that types `createdAt`
1328    /// as a datetime and one that does not. Each gets the request its own
1329    /// shapes build, however the two alternate.
1330    #[test]
1331    fn engines_with_different_shapes_never_share_a_request() {
1332        let described = described();
1333        let undescribed = Arc::new(Shapes::new());
1334        let typed = Judge::new(
1335            &Arc::new(CedarEvaluator::new(Arc::clone(&described))),
1336            &described,
1337            STATEMENTS[2],
1338        )
1339        .expect("loads");
1340        let untyped = Judge::new(
1341            &Arc::new(CedarEvaluator::new(Arc::clone(&undescribed))),
1342            &undescribed,
1343            STATEMENTS[2],
1344        )
1345        .expect("loads");
1346        let records = records();
1347        let edit = Owned::Write {
1348            account: "did:plc:agent".to_owned(),
1349            attributes: Attributes {
1350                kind: Kind::Known(AccountKind::Agent),
1351                handle: None,
1352                depth: None,
1353            },
1354            universal: Facts {
1355                pds_did: "did:web:pds.example".to_owned(),
1356                pds_hostname: "pds.example".to_owned(),
1357                now: now(),
1358                operator_did: None,
1359            },
1360            client_id: None,
1361            collection: POST.to_owned(),
1362            action: WriteAction::Update,
1363            changes: diff(Some(&records[1]), Some(&records[0])),
1364            before: Some(records[1].clone()),
1365            after: Some(records[0].clone()),
1366            lookups: Vec::new(),
1367        };
1368        let (want_typed, want_untyped) = (typed.fresh(&edit), untyped.fresh(&edit));
1369        assert_ne!(want_typed, want_untyped, "the shapes change the answer");
1370        for _ in 0..3 {
1371            assert_eq!(typed.evaluate(&edit), want_typed);
1372            assert_eq!(untyped.evaluate(&edit), want_untyped);
1373        }
1374    }
1375
1376    /// Writes judged on eight threads at once, against the same
1377    /// evaluators: each thread's answers are its own subjects'. Each
1378    /// thread asks every statement about one write in turn, then asks each
1379    /// statement about every write in turn.
1380    #[test]
1381    fn concurrent_writes_are_each_judged_on_their_own_subject() {
1382        let mut rng = StdRng::seed_from_u64(0x7ead);
1383        let judges = judges(&mut rng);
1384        let records = records();
1385        let subjects: Vec<Owned> = (0..64).map(|_| subject(&mut rng, &records)).collect();
1386        let fresh: Vec<Vec<Outcome>> = subjects
1387            .iter()
1388            .map(|subject| judges.iter().map(|judge| judge.fresh(subject)).collect())
1389            .collect();
1390        std::thread::scope(|scope| {
1391            for thread in 0..8 {
1392                let (judges, subjects, fresh) = (&judges, &subjects, &fresh);
1393                let own: Vec<usize> = (thread..subjects.len()).step_by(8).collect();
1394                scope.spawn(move || {
1395                    for &on in &own {
1396                        for (at, judge) in judges.iter().enumerate() {
1397                            assert_eq!(judge.evaluate(&subjects[on]), fresh[on][at]);
1398                        }
1399                    }
1400                    for (at, judge) in judges.iter().enumerate() {
1401                        for &on in &own {
1402                            assert_eq!(judge.evaluate(&subjects[on]), fresh[on][at]);
1403                        }
1404                    }
1405                });
1406            }
1407        });
1408    }
1409
1410    /// A thread that panics partway through judging a write goes on to
1411    /// judge the next one on its own subject.
1412    #[test]
1413    fn a_panic_mid_judgment_leaves_nothing_behind_for_the_next_write() {
1414        let mut rng = StdRng::seed_from_u64(0x0bad);
1415        let judges = judges(&mut rng);
1416        let records = records();
1417        let subjects: Vec<Owned> = (0..16).map(|_| subject(&mut rng, &records)).collect();
1418        for pair in subjects.windows(2) {
1419            let (first, next) = (&pair[0], &pair[1]);
1420            let panicked = catch_unwind(AssertUnwindSafe(|| {
1421                judges[0].evaluate(first);
1422                panic!("a judgment dies partway");
1423            }));
1424            assert!(panicked.is_err());
1425            for judge in &judges {
1426                assert_eq!(judge.evaluate(next), judge.fresh(next));
1427                assert_eq!(judge.evaluate(first), judge.fresh(first));
1428            }
1429        }
1430    }
1431}