1#![forbid(unsafe_code)]
64
65mod client_id;
66mod evaluate;
67mod index;
68mod json;
69mod language;
70mod outcome;
71mod policy;
72mod subject;
73
74pub use client_id::{is_local_origin, ClientId, ClientIdError};
75pub use evaluate::{combine, evaluate, worst, Verdict};
76pub use index::{PolicyTree, PolicyTreeBuilder};
77pub use json::sorted_json;
78pub use language::{KnownLanguage, Language};
79pub use outcome::{
80 CompileOutcome, EvalError, Evaluator, Lifecycle, Observed, Outcome, PolicyLifecycle, Staleness,
81};
82pub use policy::{
83 ActionSet, Applicability, CompiledId, EvaluatorId, KindSet, PolicyDeclaration, PolicyId,
84 Predicate, Revision, Scope,
85};
86pub use subject::{
87 AccountKind, Asked, Change, DeploymentAttributes, Diff, Found, Lookup, Subject,
88 SubjectAttributes, SubjectKind, Universal, WriteAction,
89};
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use std::sync::atomic::{AtomicUsize, Ordering};
95 use std::sync::Arc;
96
97 struct Fixed {
100 outcome: Outcome,
101 calls: AtomicUsize,
102 }
103
104 impl Fixed {
105 fn new(outcome: Outcome) -> Self {
106 Fixed {
107 outcome,
108 calls: AtomicUsize::new(0),
109 }
110 }
111 }
112
113 impl Evaluator for Fixed {
114 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
115 CompileOutcome::Compiled(CompiledId(0))
116 }
117 fn evaluate(
118 &self,
119 _subject: &Subject<'_>,
120 _compiled: CompiledId,
121 ) -> Result<Outcome, EvalError> {
122 self.calls.fetch_add(1, Ordering::SeqCst);
123 Ok(self.outcome.clone())
124 }
125 fn observe(&self, _event: &Observed<'_>) {}
126 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
127 fn staleness(&self) -> Staleness {
128 Staleness::Linearized
129 }
130 }
131
132 struct FailsAtDecisionTime;
137
138 impl Evaluator for FailsAtDecisionTime {
139 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
140 CompileOutcome::Compiled(CompiledId(0))
141 }
142 fn evaluate(
143 &self,
144 _subject: &Subject<'_>,
145 _compiled: CompiledId,
146 ) -> Result<Outcome, EvalError> {
147 Err(EvalError::Failed("the evaluator is not answering".into()))
148 }
149 fn observe(&self, _event: &Observed<'_>) {}
150 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
151 fn staleness(&self) -> Staleness {
152 Staleness::Linearized
153 }
154 }
155
156 #[test]
163 fn an_evaluator_that_fails_denies_only_within_its_own_applicability() {
164 let mut builder = PolicyTree::builder();
165 let evaluator_id = builder.register_evaluator(Arc::new(FailsAtDecisionTime));
166 builder.add_policy(PolicyDeclaration {
167 applicability: Applicability::Write {
168 kinds: KindSet::ALL,
169 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
170 actions: ActionSet::ALL,
171 path_prefixes: Scope::Any,
172 },
173 observes: Applicability::Write {
174 kinds: KindSet::ALL,
175 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
176 actions: ActionSet::ALL,
177 path_prefixes: Scope::Any,
178 },
179 staleness: Staleness::Linearized,
180 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
181 revision: Revision(1),
182 });
183 let tree = builder.build();
184
185 let covered = write("app.bsky.feed.post", WriteAction::Create, &[]);
186 assert!(
187 matches!(
188 evaluate(&covered, &tree),
189 Verdict::Reject { ref reason, .. }
190 if reason.contains("the evaluator is not answering")
191 ),
192 "a failing evaluator must deny the writes it was routed, naming why"
193 );
194
195 let uncovered = write("bot.did.record", WriteAction::Create, &[]);
196 assert_eq!(
197 evaluate(&uncovered, &tree),
198 Verdict::Allow,
199 "one evaluator's failure must not widen into a blanket denial"
200 );
201 }
202
203 #[test]
211 fn decide_detailed_tells_a_decision_apart_from_a_substituted_refusal() {
212 let mut builder = PolicyTree::builder();
213 let working = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
214 reason: "no".into(),
215 })));
216 let failing = builder.register_evaluator(Arc::new(FailsAtDecisionTime));
217 let applicability = || Applicability::Write {
218 kinds: KindSet::ALL,
219 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
220 actions: ActionSet::ALL,
221 path_prefixes: Scope::Any,
222 };
223 let decided = builder.add_policy(PolicyDeclaration {
224 applicability: applicability(),
225 observes: applicability(),
226 staleness: Staleness::Linearized,
227 predicate: Predicate::Evaluator(working, CompiledId(0)),
228 revision: Revision(1),
229 });
230 let substituted = builder.add_policy(PolicyDeclaration {
231 applicability: applicability(),
232 observes: applicability(),
233 staleness: Staleness::Linearized,
234 predicate: Predicate::Evaluator(failing, CompiledId(0)),
235 revision: Revision(1),
236 });
237 let tree = builder.build();
238 let subject = write("app.bsky.feed.post", WriteAction::Create, &[]);
239
240 let (outcome, errored) = tree.decide_detailed(&subject, decided);
241 assert!(matches!(outcome, Outcome::Reject { ref reason } if reason == "no"));
242 assert!(
243 !errored,
244 "the evaluator's own decision is not a substituted refusal"
245 );
246 assert_eq!(tree.decide(&subject, decided), outcome);
247
248 let (outcome, errored) = tree.decide_detailed(&subject, substituted);
249 assert!(matches!(outcome, Outcome::Reject { .. }));
250 assert!(errored, "a failed evaluate() call is a substituted refusal");
251 assert_eq!(tree.decide(&subject, substituted), outcome);
252 }
253
254 fn dummy_value() -> &'static serde_json::Value {
257 static VALUE: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
258 VALUE.get_or_init(|| serde_json::Value::Bool(true))
259 }
260
261 fn test_universal() -> Universal<'static> {
264 Universal {
265 deployment: DeploymentAttributes {
266 pds_did: "did:web:test.example",
267 pds_hostname: "test.example",
268 operator_did: None,
269 },
270 now: time::macros::datetime!(2026 - 01 - 01 0:00 UTC),
271 }
272 }
273
274 fn write<'a>(collection: &'a str, action: WriteAction, paths: &'a [&'a str]) -> Subject<'a> {
278 let changes: Vec<Change<'a>> = paths
279 .iter()
280 .map(|&path| Change {
281 path,
282 before: None,
283 after: Some(dummy_value()),
284 })
285 .collect();
286 let changes: &'a [Change<'a>] = Box::leak(changes.into_boxed_slice());
287 Subject::Write {
288 account: "did:plc:agent",
289 attributes: SubjectAttributes {
290 kind: SubjectKind::Known(AccountKind::Agent),
291 handle: None,
292 depth: None,
293 },
294 universal: test_universal(),
295 client_id: None,
296 collection,
297 action,
298 diff: Diff { changes },
299 before: None,
300 after: None,
301 lookups: &[],
302 }
303 }
304
305 #[test]
309 fn empty_match_never_calls_an_evaluator() {
310 let denier = Arc::new(Fixed::new(Outcome::Reject {
311 reason: "no".into(),
312 }));
313 let mut builder = PolicyTree::builder();
314 let evaluator_id = builder.register_evaluator(denier.clone());
315 builder.add_policy(PolicyDeclaration {
316 applicability: Applicability::Write {
317 kinds: KindSet::ALL,
318 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
319 actions: ActionSet::ALL,
320 path_prefixes: Scope::Any,
321 },
322 observes: Applicability::Write {
323 kinds: KindSet::ALL,
324 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
325 actions: ActionSet::ALL,
326 path_prefixes: Scope::Any,
327 },
328 staleness: Staleness::Linearized,
329 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
330 revision: Revision(1),
331 });
332 let tree = builder.build();
333
334 let subject = write("bot.did.record", WriteAction::Create, &["track"]);
335 assert!(tree.decision_candidates(&subject).is_empty());
336
337 let verdict = evaluate(&subject, &tree);
338 assert_eq!(verdict, Verdict::Allow);
339 assert_eq!(denier.calls.load(Ordering::SeqCst), 0);
340 }
341
342 #[test]
345 fn a_wildcard_collection_policy_still_matches() {
346 let denier = Arc::new(Fixed::new(Outcome::Reject {
347 reason: "app denied".into(),
348 }));
349 let mut builder = PolicyTree::builder();
350 let evaluator_id = builder.register_evaluator(denier);
351 builder.add_policy(PolicyDeclaration {
352 applicability: Applicability::Write {
353 kinds: KindSet::ALL,
354 collections: Scope::Any,
355 actions: ActionSet::ALL,
356 path_prefixes: Scope::Any,
357 },
358 observes: Applicability::Write {
359 kinds: KindSet::ALL,
360 collections: Scope::Any,
361 actions: ActionSet::ALL,
362 path_prefixes: Scope::Any,
363 },
364 staleness: Staleness::Linearized,
365 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
366 revision: Revision(1),
367 });
368 let tree = builder.build();
369
370 let subject = write("bot.did.record", WriteAction::Create, &[]);
371 let verdict = evaluate(&subject, &tree);
372 assert!(matches!(verdict, Verdict::Reject { .. }));
373 }
374
375 #[test]
379 fn action_routing_is_exact() {
380 let delete_denier = Arc::new(Fixed::new(Outcome::Reject {
381 reason: "no deletes".into(),
382 }));
383 let mut builder = PolicyTree::builder();
384 let evaluator_id = builder.register_evaluator(delete_denier);
385 builder.add_policy(PolicyDeclaration {
386 applicability: Applicability::Write {
387 kinds: KindSet::ALL,
388 collections: Scope::Any,
389 actions: ActionSet::of(&[WriteAction::Delete]),
390 path_prefixes: Scope::Any,
391 },
392 observes: Applicability::Write {
393 kinds: KindSet::ALL,
394 collections: Scope::Any,
395 actions: ActionSet::of(&[WriteAction::Delete]),
396 path_prefixes: Scope::Any,
397 },
398 staleness: Staleness::Linearized,
399 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
400 revision: Revision(1),
401 });
402 let tree = builder.build();
403
404 let create = write("bot.did.record", WriteAction::Create, &[]);
405 assert_eq!(evaluate(&create, &tree), Verdict::Allow);
406
407 let delete = write("bot.did.record", WriteAction::Delete, &[]);
408 assert!(matches!(evaluate(&delete, &tree), Verdict::Reject { .. }));
409 }
410
411 #[test]
417 fn kind_routing_never_calls_an_evaluator_for_an_uncovered_kind() {
418 let agent_only = Arc::new(Fixed::new(Outcome::Reject {
419 reason: "agents only".into(),
420 }));
421 let mut builder = PolicyTree::builder();
422 let evaluator_id = builder.register_evaluator(agent_only.clone());
423 builder.add_policy(PolicyDeclaration {
424 applicability: Applicability::Write {
425 kinds: KindSet::of(&[AccountKind::Agent]),
426 collections: Scope::Any,
427 actions: ActionSet::ALL,
428 path_prefixes: Scope::Any,
429 },
430 observes: Applicability::Write {
431 kinds: KindSet::of(&[AccountKind::Agent]),
432 collections: Scope::Any,
433 actions: ActionSet::ALL,
434 path_prefixes: Scope::Any,
435 },
436 staleness: Staleness::Linearized,
437 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
438 revision: Revision(1),
439 });
440 let tree = builder.build();
441
442 let agent_write = write("bot.did.record", WriteAction::Create, &[]);
443 assert!(matches!(
444 evaluate(&agent_write, &tree),
445 Verdict::Reject { .. }
446 ));
447 assert_eq!(agent_only.calls.load(Ordering::SeqCst), 1);
448
449 let host_write = Subject::Write {
450 account: "did:plc:host",
451 attributes: SubjectAttributes {
452 kind: SubjectKind::Known(AccountKind::Host),
453 handle: None,
454 depth: None,
455 },
456 universal: test_universal(),
457 client_id: None,
458 collection: "bot.did.record",
459 action: WriteAction::Create,
460 diff: Diff { changes: &[] },
461 before: None,
462 after: None,
463 lookups: &[],
464 };
465 assert!(tree.decision_candidates(&host_write).is_empty());
466 assert_eq!(evaluate(&host_write, &tree), Verdict::Allow);
467 assert_eq!(agent_only.calls.load(Ordering::SeqCst), 1);
470 }
471
472 fn write_with_unknown_kind<'a>(collection: &'a str, kind_name: &'a str) -> Subject<'a> {
476 Subject::Write {
477 account: "did:plc:from-the-future",
478 attributes: SubjectAttributes {
479 kind: SubjectKind::Unknown(kind_name),
480 handle: None,
481 depth: None,
482 },
483 universal: test_universal(),
484 client_id: None,
485 collection,
486 action: WriteAction::Create,
487 diff: Diff { changes: &[] },
488 before: None,
489 after: None,
490 lookups: &[],
491 }
492 }
493
494 #[test]
501 fn an_unknown_kind_routes_past_a_policy_scoped_to_known_kinds() {
502 let known_only = Arc::new(Fixed::new(Outcome::Reject {
503 reason: "known kinds only".into(),
504 }));
505 let mut builder = PolicyTree::builder();
506 let evaluator_id = builder.register_evaluator(known_only.clone());
507 builder.add_policy(PolicyDeclaration {
508 applicability: Applicability::Write {
509 kinds: KindSet::ALL,
512 collections: Scope::Any,
513 actions: ActionSet::ALL,
514 path_prefixes: Scope::Any,
515 },
516 observes: Applicability::Write {
517 kinds: KindSet::ALL,
518 collections: Scope::Any,
519 actions: ActionSet::ALL,
520 path_prefixes: Scope::Any,
521 },
522 staleness: Staleness::Linearized,
523 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
524 revision: Revision(1),
525 });
526 let tree = builder.build();
527
528 let from_the_future = write_with_unknown_kind("bot.did.record", "oracle");
529 assert!(tree.decision_candidates(&from_the_future).is_empty());
530 assert_eq!(evaluate(&from_the_future, &tree), Verdict::Allow);
531 assert_eq!(known_only.calls.load(Ordering::SeqCst), 0);
532 }
533
534 #[test]
538 fn kind_set_any_reaches_an_unknown_kind() {
539 let any_kind = Arc::new(Fixed::new(Outcome::Reject {
540 reason: "no kind is exempt".into(),
541 }));
542 let mut builder = PolicyTree::builder();
543 let evaluator_id = builder.register_evaluator(any_kind.clone());
544 builder.add_policy(PolicyDeclaration {
545 applicability: Applicability::Write {
546 kinds: KindSet::ANY,
547 collections: Scope::Any,
548 actions: ActionSet::ALL,
549 path_prefixes: Scope::Any,
550 },
551 observes: Applicability::Write {
552 kinds: KindSet::ANY,
553 collections: Scope::Any,
554 actions: ActionSet::ALL,
555 path_prefixes: Scope::Any,
556 },
557 staleness: Staleness::Linearized,
558 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
559 revision: Revision(1),
560 });
561 let tree = builder.build();
562
563 let from_the_future = write_with_unknown_kind("bot.did.record", "oracle");
564 assert_eq!(tree.decision_candidates(&from_the_future).len(), 1);
565 assert!(matches!(
566 evaluate(&from_the_future, &tree),
567 Verdict::Reject { .. }
568 ));
569 assert_eq!(any_kind.calls.load(Ordering::SeqCst), 1);
570 }
571
572 #[test]
576 fn path_prefix_scoping() {
577 let denier = Arc::new(Fixed::new(Outcome::Reject {
578 reason: "displayName is locked".into(),
579 }));
580 let mut builder = PolicyTree::builder();
581 let evaluator_id = builder.register_evaluator(denier);
582 builder.add_policy(PolicyDeclaration {
583 applicability: Applicability::Write {
584 kinds: KindSet::ALL,
585 collections: Scope::Only(vec!["app.bsky.actor.profile".into()]),
586 actions: ActionSet::ALL,
587 path_prefixes: Scope::Only(vec!["displayName".into()]),
588 },
589 observes: Applicability::Write {
590 kinds: KindSet::ALL,
591 collections: Scope::Any,
592 actions: ActionSet::ALL,
593 path_prefixes: Scope::Any,
594 },
595 staleness: Staleness::Linearized,
596 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
597 revision: Revision(1),
598 });
599 let tree = builder.build();
600
601 let bio_edit = write("app.bsky.actor.profile", WriteAction::Update, &["bio"]);
602 assert_eq!(evaluate(&bio_edit, &tree), Verdict::Allow);
603
604 let name_edit = write(
605 "app.bsky.actor.profile",
606 WriteAction::Update,
607 &["displayName"],
608 );
609 assert!(matches!(
610 evaluate(&name_edit, &tree),
611 Verdict::Reject { .. }
612 ));
613 }
614
615 #[test]
622 fn a_path_prefix_reaches_its_field_s_array_elements() {
623 let denier = Arc::new(Fixed::new(Outcome::Reject {
624 reason: "tags are locked".into(),
625 }));
626 let mut builder = PolicyTree::builder();
627 let evaluator_id = builder.register_evaluator(denier.clone());
628 builder.add_policy(PolicyDeclaration {
629 applicability: Applicability::Write {
630 kinds: KindSet::ALL,
631 collections: Scope::Any,
632 actions: ActionSet::ALL,
633 path_prefixes: Scope::Only(vec!["tags".into()]),
634 },
635 observes: Applicability::Write {
636 kinds: KindSet::ALL,
637 collections: Scope::Any,
638 actions: ActionSet::ALL,
639 path_prefixes: Scope::Any,
640 },
641 staleness: Staleness::Linearized,
642 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
643 revision: Revision(1),
644 });
645 let tree = builder.build();
646
647 let scalar = write("app.bsky.actor.profile", WriteAction::Update, &["tags"]);
649 assert!(matches!(evaluate(&scalar, &tree), Verdict::Reject { .. }));
650
651 let element = write("app.bsky.actor.profile", WriteAction::Update, &["tags[0]"]);
654 assert!(
655 matches!(evaluate(&element, &tree), Verdict::Reject { .. }),
656 "a policy guarding `tags` must reach `tags[0]`"
657 );
658
659 let nested = write(
661 "app.bsky.actor.profile",
662 WriteAction::Update,
663 &["tags[2].label"],
664 );
665 assert!(matches!(evaluate(&nested, &tree), Verdict::Reject { .. }));
666
667 let sibling = write(
671 "app.bsky.actor.profile",
672 WriteAction::Update,
673 &["tagsExtra"],
674 );
675 assert_eq!(evaluate(&sibling, &tree), Verdict::Allow);
676 }
677
678 #[test]
682 fn a_path_prefix_may_name_one_element() {
683 let denier = Arc::new(Fixed::new(Outcome::Reject {
684 reason: "the first tag is locked".into(),
685 }));
686 let mut builder = PolicyTree::builder();
687 let evaluator_id = builder.register_evaluator(denier);
688 builder.add_policy(PolicyDeclaration {
689 applicability: Applicability::Write {
690 kinds: KindSet::ALL,
691 collections: Scope::Any,
692 actions: ActionSet::ALL,
693 path_prefixes: Scope::Only(vec!["tags[0]".into()]),
694 },
695 observes: Applicability::Write {
696 kinds: KindSet::ALL,
697 collections: Scope::Any,
698 actions: ActionSet::ALL,
699 path_prefixes: Scope::Any,
700 },
701 staleness: Staleness::Linearized,
702 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
703 revision: Revision(1),
704 });
705 let tree = builder.build();
706
707 let first = write("app.bsky.actor.profile", WriteAction::Update, &["tags[0]"]);
708 assert!(matches!(evaluate(&first, &tree), Verdict::Reject { .. }));
709 let second = write("app.bsky.actor.profile", WriteAction::Update, &["tags[1]"]);
710 assert_eq!(evaluate(&second, &tree), Verdict::Allow);
711 }
712
713 #[test]
716 fn observation_surface_is_independent_of_decision_surface() {
717 let watcher = Arc::new(Fixed::new(Outcome::Allow));
718 let mut builder = PolicyTree::builder();
719 let evaluator_id = builder.register_evaluator(watcher);
720 builder.add_policy(PolicyDeclaration {
721 applicability: Applicability::Write {
722 kinds: KindSet::ALL,
723 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
724 actions: ActionSet::ALL,
725 path_prefixes: Scope::Any,
726 },
727 observes: Applicability::Write {
728 kinds: KindSet::ALL,
729 collections: Scope::Any,
730 actions: ActionSet::ALL,
731 path_prefixes: Scope::Any,
732 },
733 staleness: Staleness::Linearized,
734 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
735 revision: Revision(1),
736 });
737 let tree = builder.build();
738
739 let subject = write("bot.did.record", WriteAction::Create, &[]);
740 assert!(tree.decision_candidates(&subject).is_empty());
741 assert_eq!(tree.observation_candidates(&subject).len(), 1);
742 }
743
744 #[test]
750 fn registration_order_only_changes_which_reason_is_reported() {
751 fn tree_with(first: &str, second: &str) -> PolicyTree {
752 let mut builder = PolicyTree::builder();
753 let e1 = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
754 reason: first.into(),
755 })));
756 let e2 = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
757 reason: second.into(),
758 })));
759 for evaluator in [e1, e2] {
760 builder.add_policy(PolicyDeclaration {
761 applicability: Applicability::Write {
762 kinds: KindSet::ALL,
763 collections: Scope::Any,
764 actions: ActionSet::ALL,
765 path_prefixes: Scope::Any,
766 },
767 observes: Applicability::Write {
768 kinds: KindSet::ALL,
769 collections: Scope::Any,
770 actions: ActionSet::ALL,
771 path_prefixes: Scope::Any,
772 },
773 staleness: Staleness::Linearized,
774 predicate: Predicate::Evaluator(evaluator, CompiledId(0)),
775 revision: Revision(1),
776 });
777 }
778 builder.build()
779 }
780
781 let subject = write("bot.did.record", WriteAction::Create, &[]);
782
783 let a = tree_with("a fires first", "b fires first");
784 assert!(matches!(
785 evaluate(&subject, &a),
786 Verdict::Reject { ref reason, .. } if reason == "a fires first"
787 ));
788
789 let b = tree_with("b fires first", "a fires first");
792 assert!(matches!(
793 evaluate(&subject, &b),
794 Verdict::Reject { ref reason, .. } if reason == "b fires first"
795 ));
796 }
797
798 #[test]
805 fn an_unusable_predicate_denies_only_within_its_own_applicability() {
806 let mut builder = PolicyTree::builder();
807 builder.add_policy(PolicyDeclaration {
808 applicability: Applicability::Write {
809 kinds: KindSet::ALL,
810 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
811 actions: ActionSet::ALL,
812 path_prefixes: Scope::Any,
813 },
814 observes: Applicability::Write {
815 kinds: KindSet::ALL,
816 collections: Scope::Only(vec!["app.bsky.feed.post".into()]),
817 actions: ActionSet::ALL,
818 path_prefixes: Scope::Any,
819 },
820 staleness: Staleness::Linearized,
821 predicate: Predicate::Unusable {
822 reason: "names an engine this build does not have".into(),
823 },
824 revision: Revision(1),
825 });
826 let tree = builder.build();
827
828 let covered = write("app.bsky.feed.post", WriteAction::Create, &[]);
830 assert!(matches!(
831 evaluate(&covered, &tree),
832 Verdict::Reject { ref reason, .. }
833 if reason.contains("names an engine this build does not have")
834 ));
835
836 let uncovered = write("bot.did.record", WriteAction::Create, &[]);
839 assert_eq!(evaluate(&uncovered, &tree), Verdict::Allow);
840 }
841
842 #[test]
847 fn an_unusable_predicate_never_downgrades_a_siblings_freeze() {
848 fn tree_with_unusable_and_freeze(unusable_first: bool) -> PolicyTree {
849 let applicability = || Applicability::Write {
850 kinds: KindSet::ALL,
851 collections: Scope::Any,
852 actions: ActionSet::ALL,
853 path_prefixes: Scope::Any,
854 };
855 let mut builder = PolicyTree::builder();
856 let freezer = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Freeze {
857 reason: "repeatedly tripped policy".into(),
858 })));
859 let mut add = |predicate: Predicate| {
860 builder.add_policy(PolicyDeclaration {
861 applicability: applicability(),
862 observes: applicability(),
863 staleness: Staleness::Linearized,
864 predicate,
865 revision: Revision(1),
866 });
867 };
868 let unusable = Predicate::Unusable {
869 reason: "predicate did not compile".into(),
870 };
871 if unusable_first {
872 add(unusable);
873 add(Predicate::Evaluator(freezer, CompiledId(0)));
874 } else {
875 add(Predicate::Evaluator(freezer, CompiledId(0)));
876 add(unusable);
877 }
878 builder.build()
879 }
880
881 let subject = write("bot.did.record", WriteAction::Create, &[]);
882 for unusable_first in [true, false] {
883 let tree = tree_with_unusable_and_freeze(unusable_first);
884 assert!(
885 matches!(
886 evaluate(&subject, &tree),
887 Verdict::Freeze { ref reason, .. }
888 if reason == "repeatedly tripped policy"
889 ),
890 "a freeze must outrank a broken sibling's refusal \
891 (unusable_first = {unusable_first})"
892 );
893 }
894 }
895
896 #[test]
900 fn unusable_predicate_has_no_evaluator() {
901 let mut builder = PolicyTree::builder();
902 let policy = builder.add_policy(PolicyDeclaration {
903 applicability: Applicability::Write {
904 kinds: KindSet::ALL,
905 collections: Scope::Any,
906 actions: ActionSet::ALL,
907 path_prefixes: Scope::Any,
908 },
909 observes: Applicability::Write {
910 kinds: KindSet::ALL,
911 collections: Scope::Any,
912 actions: ActionSet::ALL,
913 path_prefixes: Scope::Any,
914 },
915 staleness: Staleness::Linearized,
916 predicate: Predicate::Unusable {
917 reason: "bad syntax".into(),
918 },
919 revision: Revision(1),
920 });
921 let tree = builder.build();
922 assert!(tree.evaluator(policy).is_none());
923 }
924
925 struct NoRenaming;
931
932 impl Evaluator for NoRenaming {
933 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
934 CompileOutcome::Compiled(CompiledId(0))
935 }
936 fn evaluate(
937 &self,
938 subject: &Subject<'_>,
939 _compiled: CompiledId,
940 ) -> Result<Outcome, EvalError> {
941 let Subject::Write { diff, .. } = subject else {
942 return Ok(Outcome::Allow);
943 };
944 for change in diff.changes {
945 if change.path == "displayName"
946 && change.before.is_some()
947 && change.after != change.before
948 {
949 return Ok(Outcome::Reject {
950 reason: "displayName may not change".into(),
951 });
952 }
953 }
954 Ok(Outcome::Allow)
955 }
956 fn observe(&self, _event: &Observed<'_>) {}
957 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
958 fn staleness(&self) -> Staleness {
959 Staleness::Linearized
960 }
961 }
962
963 fn tree_with_evaluator(evaluator: Arc<dyn Evaluator>) -> PolicyTree {
964 let mut builder = PolicyTree::builder();
965 let evaluator_id = builder.register_evaluator(evaluator);
966 builder.add_policy(PolicyDeclaration {
967 applicability: Applicability::Write {
968 kinds: KindSet::ALL,
969 collections: Scope::Any,
970 actions: ActionSet::ALL,
971 path_prefixes: Scope::Only(vec!["displayName".into()]),
972 },
973 observes: Applicability::Write {
974 kinds: KindSet::ALL,
975 collections: Scope::Any,
976 actions: ActionSet::ALL,
977 path_prefixes: Scope::Any,
978 },
979 staleness: Staleness::Linearized,
980 predicate: Predicate::Evaluator(evaluator_id, CompiledId(0)),
981 revision: Revision(1),
982 });
983 builder.build()
984 }
985
986 #[test]
987 fn before_after_resolves_create_and_delete_without_an_action_arm() {
988 let tree = tree_with_evaluator(Arc::new(NoRenaming));
989 let old = serde_json::Value::String("alice".into());
990 let new = serde_json::Value::String("bob".into());
991
992 let created = Change {
994 path: "displayName",
995 before: None,
996 after: Some(&new),
997 };
998 let subject = Subject::Write {
999 account: "did:plc:agent",
1000 attributes: SubjectAttributes {
1001 kind: SubjectKind::Known(AccountKind::Agent),
1002 handle: None,
1003 depth: None,
1004 },
1005 universal: test_universal(),
1006 client_id: None,
1007 collection: "app.bsky.actor.profile",
1008 action: WriteAction::Create,
1009 diff: Diff {
1010 changes: &[created],
1011 },
1012 before: None,
1013 after: None,
1014 lookups: &[],
1015 };
1016 assert_eq!(evaluate(&subject, &tree), Verdict::Allow);
1017
1018 let renamed = Change {
1020 path: "displayName",
1021 before: Some(&old),
1022 after: Some(&new),
1023 };
1024 let subject = Subject::Write {
1025 account: "did:plc:agent",
1026 attributes: SubjectAttributes {
1027 kind: SubjectKind::Known(AccountKind::Agent),
1028 handle: None,
1029 depth: None,
1030 },
1031 universal: test_universal(),
1032 client_id: None,
1033 collection: "app.bsky.actor.profile",
1034 action: WriteAction::Update,
1035 diff: Diff {
1036 changes: &[renamed],
1037 },
1038 before: None,
1039 after: None,
1040 lookups: &[],
1041 };
1042 assert!(matches!(evaluate(&subject, &tree), Verdict::Reject { .. }));
1043
1044 let deleted = Change {
1047 path: "displayName",
1048 before: Some(&old),
1049 after: None,
1050 };
1051 let subject = Subject::Write {
1052 account: "did:plc:agent",
1053 attributes: SubjectAttributes {
1054 kind: SubjectKind::Known(AccountKind::Agent),
1055 handle: None,
1056 depth: None,
1057 },
1058 universal: test_universal(),
1059 client_id: None,
1060 collection: "app.bsky.actor.profile",
1061 action: WriteAction::Delete,
1062 diff: Diff {
1063 changes: &[deleted],
1064 },
1065 before: None,
1066 after: None,
1067 lookups: &[],
1068 };
1069 assert!(matches!(evaluate(&subject, &tree), Verdict::Reject { .. }));
1070 }
1071
1072 struct HandleOnly;
1077
1078 impl Evaluator for HandleOnly {
1079 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
1080 CompileOutcome::Compiled(CompiledId(0))
1081 }
1082 fn evaluate(
1083 &self,
1084 subject: &Subject<'_>,
1085 _compiled: CompiledId,
1086 ) -> Result<Outcome, EvalError> {
1087 let Subject::Write {
1088 diff, attributes, ..
1089 } = subject
1090 else {
1091 return Ok(Outcome::Allow);
1092 };
1093 for change in diff.changes {
1094 if change.path != "displayName" {
1095 continue;
1096 }
1097 let Some(after) = change.after else {
1098 continue;
1099 };
1100 let allowed = attributes
1101 .handle
1102 .is_some_and(|handle| after.as_str() == Some(handle));
1103 if !allowed {
1104 return Ok(Outcome::Reject {
1105 reason: "displayName must be the account's handle".into(),
1106 });
1107 }
1108 }
1109 Ok(Outcome::Allow)
1110 }
1111 fn observe(&self, _event: &Observed<'_>) {}
1112 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
1113 fn staleness(&self) -> Staleness {
1114 Staleness::Linearized
1115 }
1116 }
1117
1118 #[test]
1119 fn an_allowlist_is_expressed_as_a_deny_rule() {
1120 let tree = tree_with_evaluator(Arc::new(HandleOnly));
1121 let handle_value = serde_json::Value::String("alice.did.bot".into());
1122 let other_value = serde_json::Value::String("someone-else".into());
1123
1124 let matches_handle = Change {
1125 path: "displayName",
1126 before: None,
1127 after: Some(&handle_value),
1128 };
1129 let subject = Subject::Write {
1130 account: "did:plc:agent",
1131 attributes: SubjectAttributes {
1132 kind: SubjectKind::Known(AccountKind::Agent),
1133 handle: Some("alice.did.bot"),
1134 depth: None,
1135 },
1136 universal: test_universal(),
1137 client_id: None,
1138 collection: "app.bsky.actor.profile",
1139 action: WriteAction::Update,
1140 diff: Diff {
1141 changes: &[matches_handle],
1142 },
1143 before: None,
1144 after: None,
1145 lookups: &[],
1146 };
1147 assert_eq!(evaluate(&subject, &tree), Verdict::Allow);
1148
1149 let does_not_match = Change {
1150 path: "displayName",
1151 before: None,
1152 after: Some(&other_value),
1153 };
1154 let subject = Subject::Write {
1155 account: "did:plc:agent",
1156 attributes: SubjectAttributes {
1157 kind: SubjectKind::Known(AccountKind::Agent),
1158 handle: Some("alice.did.bot"),
1159 depth: None,
1160 },
1161 universal: test_universal(),
1162 client_id: None,
1163 collection: "app.bsky.actor.profile",
1164 action: WriteAction::Update,
1165 diff: Diff {
1166 changes: &[does_not_match],
1167 },
1168 before: None,
1169 after: None,
1170 lookups: &[],
1171 };
1172 assert!(matches!(evaluate(&subject, &tree), Verdict::Reject { .. }));
1173 }
1174
1175 #[test]
1179 fn a_grant_policy_and_a_token_policy_are_asked_apart() {
1180 let client = ClientId::parse("https://client.example/metadata.json").unwrap();
1181 let mut builder = PolicyTree::builder();
1182 let deny = builder.register_evaluator(Arc::new(Fixed::new(Outcome::Reject {
1183 reason: "no".into(),
1184 })));
1185 let at_token = Applicability::Token {
1186 client_ids: Scope::Only(vec![client.as_str().to_owned()]),
1187 };
1188 builder.add_policy(PolicyDeclaration {
1189 applicability: at_token.clone(),
1190 observes: at_token,
1191 staleness: Staleness::Linearized,
1192 predicate: Predicate::Evaluator(deny, CompiledId(0)),
1193 revision: Revision(1),
1194 });
1195 let tree = builder.build();
1196
1197 let grant = Subject::Grant {
1198 universal: test_universal(),
1199 client_id: &client,
1200 requested_scopes: &["atproto"],
1201 };
1202 let token = Subject::Token {
1203 account: "did:plc:agent",
1204 attributes: SubjectAttributes {
1205 kind: SubjectKind::Known(AccountKind::Agent),
1206 handle: None,
1207 depth: None,
1208 },
1209 universal: test_universal(),
1210 client_id: &client,
1211 scopes: &["atproto"],
1212 };
1213 assert_eq!(evaluate(&grant, &tree), Verdict::Allow);
1214 assert!(matches!(evaluate(&token, &tree), Verdict::Reject { .. }));
1215 }
1216}