didbot_policy_records/compile.rs
1//! Turning a batch of [`ParsedPolicy`] records into declarations registered
2//! on a [`PolicyTreeBuilder`] -- the step where "applicability parses, the
3//! predicate does not" gets its scoped, fail-closed treatment, by actually
4//! calling [`didbot_policy::Evaluator::compile`].
5//!
6//! # Why this is a separate step from parsing
7//!
8//! Reading a record cannot tell a well-formed predicate from a malformed
9//! one: `predicate` is opaque JSON, and only the evaluator implementation
10//! named by `language` knows its own syntax. So the
11//! distinction `plan/policy.md` draws --
12//! "applicability parses, the predicate does not (bad syntax, or it names
13//! an evaluator this build does not have)" -- can only be made here, once a
14//! [`Language`] is available to try, and it is made by actually compiling:
15//! `didbot_policy::Evaluator::compile` is validation, there is no separate
16//! validate step.
17//!
18//! This module is synchronous and does no I/O of its own: it is handed
19//! predicate documents and hands back [`Predicate`]s.
20//!
21//! # Fail-closed, scoped -- and the core already does the work
22//!
23//! `didbot_policy::Predicate` has exactly the state this needs:
24//! [`Predicate::Evaluator`] for a working one, [`Predicate::Unusable`] for
25//! "applicability is known-good, the predicate is not." When a predicate's
26//! `language` is not one any registered [`Language`] answers to, or the
27//! matching [`Language`]'s evaluator rejects it as malformed, this module
28//! registers the record's
29//! `applicability` and `observes` with [`Predicate::Unusable`] carrying the
30//! reason -- no evaluator, no separate "deny everything" implementation
31//! needed, because [`didbot_policy::PolicyTree::decide`] already turns
32//! `Unusable` into a scoped [`didbot_policy::Outcome::Reject`] on its own.
33//! The tree still routes exactly the writes this policy declared it covers
34//! there -- nothing outside its declared scope is touched, and nothing
35//! inside it is silently allowed by a predicate that could not be built.
36
37use std::collections::HashMap;
38use std::sync::Arc;
39
40use didbot_policy::{
41 CompileOutcome, Evaluator, PolicyDeclaration, PolicyId, PolicyTreeBuilder, Predicate,
42};
43
44use crate::record::ParsedPolicy;
45
46/// One evaluator language a build carries, and the bridge from this crate's
47/// opaque `predicate` payloads into it.
48///
49/// `didbot-policy-regex` is expected to answer to `"regex"`. Kept as a
50/// trait rather than a hard dependency on any specific evaluator crate: this
51/// crate's job is turning observed records into declarations the core's
52/// tree can be built from, not owning any one evaluator's compiled-predicate
53/// format. A build wires in every [`Language`] it ships by passing them to
54/// [`compile_policies`].
55pub trait Language {
56 /// The `language` name a record's wrapper names to select this
57 /// evaluator, e.g. `"regex"`. Matched exactly, case-sensitively.
58 fn name(&self) -> &str;
59
60 /// A fresh evaluator instance for this language, with nothing compiled
61 /// into it yet.
62 ///
63 /// The actual compiling happens afterward, via
64 /// [`didbot_policy::Evaluator::compile`] on the value this returns --
65 /// this method's whole job is handing back *something* that
66 /// implements the trait, so [`compile_policies`] never needs to know
67 /// any evaluator crate's concrete type. One call per policy this
68 /// language serves: `didbot-policy-regex`'s evaluator holds exactly one
69 /// compiled artifact, so this crate constructs one fresh instance per
70 /// predicate document rather than compiling several predicates into a
71 /// shared one.
72 ///
73 /// # Returning a shared instance is a promise about `CompiledId`
74 ///
75 /// A language backed by shared state may return clones of an `Arc` it
76 /// holds internally, but only if its evaluator mints a *distinct*
77 /// [`didbot_policy::CompiledId`] per `compile` and dispatches
78 /// `evaluate` on it. [`resolve_predicate`] calls `compile` once per
79 /// policy on whatever this hands back, so an evaluator that keeps one
80 /// artifact and ignores the `compiled` argument -- which the trait
81 /// permits, and `didbot-policy-regex` does -- has that artifact
82 /// overwritten by the next policy sharing the instance, and every
83 /// earlier policy then silently enforces the last one's predicate
84 /// instead of its own. That is a fail-open: a policy the tree still
85 /// routes writes to stops checking what it was written to check. An
86 /// evaluator with one artifact per instance must therefore get one
87 /// instance per policy, which is what a fresh construction here gives
88 /// it.
89 fn new_evaluator(&self) -> Arc<dyn Evaluator>;
90}
91
92/// One record this step could not compile into a live [`Evaluator`],
93/// alongside why. Its applicability was still registered, scoped, with
94/// [`Predicate::Unusable`] -- see this module's doc comment -- so this is a
95/// report for the operator, not a sign the record was dropped.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ScopedDenial {
98 /// The record's id, as passed to [`compile_policies`].
99 pub id: String,
100 /// The `language` the record named.
101 pub language: String,
102 /// Why compiling `predicate` for `language` failed -- either "no
103 /// registered `Language` answers to this name" or whatever the matching
104 /// evaluator's [`Evaluator::compile`] rejected it with.
105 pub reason: String,
106}
107
108/// One record [`Evaluator::compile`] accepted, but with something the
109/// operator should see -- see [`CompileOutcome::CompiledWithWarnings`].
110/// Never blocks the policy: it is registered exactly like a clean compile,
111/// this is purely a report.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct PolicyWarning {
114 /// The record's id, as passed to [`compile_policies`].
115 pub id: String,
116 /// The `language` the record named.
117 pub language: String,
118 /// What the evaluator wanted to say about its own compiled form.
119 pub warnings: Vec<String>,
120}
121
122/// What compiling one batch of records produced: every [`ScopedDenial`] and
123/// [`PolicyWarning`] hit along the way, for the operator-facing report, and
124/// the [`PolicyId`] each record was registered as.
125#[derive(Debug, Clone, Default)]
126pub struct CompileReport {
127 /// Records whose predicate could not be compiled at all.
128 pub denials: Vec<ScopedDenial>,
129 /// Records that compiled with something the operator should see.
130 pub warnings: Vec<PolicyWarning>,
131 /// The tree position each record was registered at, by its id, so a
132 /// caller can attach what the tree does not index on -- whom a policy
133 /// is bound to -- to the policy the tree names in a verdict.
134 pub registered: HashMap<String, PolicyId>,
135}
136
137/// Registers every policy in `records` against `builder`, in `records`'
138/// iteration order -- callers wanting a specific presentational order (see
139/// `plan/policy.md`'s "order is presentational, not semantic") should pass
140/// an already-ordered sequence.
141///
142/// Precedence is not this function's concern: it registers every source's
143/// policies into the same tree with no source privileged over another,
144/// because `plan/policy.md`'s deny-only monotonicity already makes that
145/// correct -- see this crate's top-level doc comment for why no precedence
146/// engine belongs here.
147pub fn compile_policies<'a>(
148 records: impl IntoIterator<Item = (&'a str, &'a ParsedPolicy)>,
149 languages: &[&dyn Language],
150 builder: &mut PolicyTreeBuilder,
151) -> CompileReport {
152 let mut report = CompileReport::default();
153 for (id, policy) in records {
154 let predicate = resolve_predicate(id, policy, languages, builder, &mut report);
155 let registered = register(builder, policy, predicate);
156 report.registered.insert(id.to_string(), registered);
157 }
158 report
159}
160
161/// Register `predicate` -- whatever [`resolve_predicate`] produced -- as
162/// one policy on `builder`, using `policy`'s own applicability, observation
163/// surface, staleness and revision.
164///
165/// Split out from [`resolve_predicate`] because `didbot-pds`'s
166/// last-good-revision loader needs to *decide*, from a resolved
167/// [`Predicate`], whether to register it or discard it and register a
168/// fallback [`ParsedPolicy`] instead -- see [`resolve_predicate`]'s doc
169/// comment. Registering unconditionally inside the resolve step would leave
170/// a rejected revision's own (unusable, but still applicability-scoped)
171/// declaration in the tree alongside the fallback's, denying more than the
172/// fallback alone ever would.
173pub fn register(
174 builder: &mut PolicyTreeBuilder,
175 policy: &ParsedPolicy,
176 predicate: Predicate,
177) -> PolicyId {
178 builder.add_policy(PolicyDeclaration {
179 applicability: policy.applicability.clone(),
180 observes: policy.observes.clone(),
181 staleness: policy.staleness,
182 predicate,
183 revision: policy.revision,
184 })
185}
186
187/// Resolve one record's [`Predicate`] -- a working [`Predicate::Evaluator`]
188/// or a scoped [`Predicate::Unusable`] -- without registering it on
189/// `builder` as a policy. Appends any [`ScopedDenial`] or [`PolicyWarning`]
190/// hit along the way to `report`.
191///
192/// Exposed (not just used internally by [`compile_policies`]) because
193/// `didbot-pds`'s loader drives this one record at a time for the operator
194/// source, so it can substitute a last-good [`ParsedPolicy`] and retry when
195/// this call resolves [`Predicate::Unusable`] for a record that previously
196/// compiled -- see `plan/policy.md`'s last-good-revision behaviour.
197/// `compile_policies` itself has no notion of "the previous revision of
198/// this same id"; that state lives with the caller, across rebuilds, not in
199/// this crate's stateless compiling step. A caller that resolves a
200/// predicate here and decides not to register it (because it is using a
201/// fallback instead) has still, harmlessly, registered any evaluator that
202/// predicate's compile step created via `builder.register_evaluator` --
203/// an unused registration costs a `Vec` slot nothing else ever reaches, not
204/// a wrong verdict.
205pub fn resolve_predicate(
206 id: &str,
207 policy: &ParsedPolicy,
208 languages: &[&dyn Language],
209 builder: &mut PolicyTreeBuilder,
210 report: &mut CompileReport,
211) -> Predicate {
212 let language = languages.iter().find(|l| l.name() == policy.language);
213 match language {
214 None => {
215 let reason = format!(
216 "{id}: no evaluator registered for language {:?}",
217 policy.language
218 );
219 report.denials.push(ScopedDenial {
220 id: id.to_string(),
221 language: policy.language.clone(),
222 reason: reason.clone(),
223 });
224 Predicate::Unusable { reason }
225 }
226 Some(language) => {
227 let evaluator = language.new_evaluator();
228 match evaluator.compile(&policy.predicate) {
229 CompileOutcome::Compiled(compiled_id) => {
230 let warnings: Vec<String> = local_spelling(&policy.predicate).collect();
231 if !warnings.is_empty() {
232 report.warnings.push(PolicyWarning {
233 id: id.to_string(),
234 language: policy.language.clone(),
235 warnings,
236 });
237 }
238 let evaluator_id = builder.register_evaluator(evaluator);
239 Predicate::Evaluator(evaluator_id, compiled_id)
240 }
241 CompileOutcome::CompiledWithWarnings(compiled_id, mut warnings) => {
242 warnings.extend(local_spelling(&policy.predicate));
243 report.warnings.push(PolicyWarning {
244 id: id.to_string(),
245 language: policy.language.clone(),
246 warnings,
247 });
248 let evaluator_id = builder.register_evaluator(evaluator);
249 Predicate::Evaluator(evaluator_id, compiled_id)
250 }
251 CompileOutcome::Rejected(reason) => {
252 let reason = format!(
253 "{id}: predicate rejected by language {:?}: {reason}",
254 policy.language
255 );
256 report.denials.push(ScopedDenial {
257 id: id.to_string(),
258 language: policy.language.clone(),
259 reason: reason.clone(),
260 });
261 Predicate::Unusable { reason }
262 }
263 }
264 }
265 }
266}
267
268/// What a predicate is told when it names `http://localhost` in a spelling
269/// no `client_id` is judged in.
270pub const LOCAL_SPELLING: &str =
271 "names `http://localhost` in a spelling no client_id is judged in: \
272 every local client_id is judged as `http://localhost/?redirect_uri=…&scope=…`, \
273 so this part matches no local client; `^http://localhost/` matches every one";
274
275/// [`LOCAL_SPELLING`], once, if any string in `predicate` names
276/// `http://localhost` followed by anything but `/` or a port.
277///
278/// `didbot_policy::ClientId` spells every local `client_id` from
279/// `http://localhost/?`, so `http://localhost` alone, or followed by `?`, `$`
280/// or a quote, matches none of them. A rule written that way denies nothing,
281/// which under default-allow says nothing either.
282fn local_spelling(predicate: &serde_json::Value) -> impl Iterator<Item = String> {
283 fn any_string(value: &serde_json::Value, found: &dyn Fn(&str) -> bool) -> bool {
284 match value {
285 serde_json::Value::String(text) => found(text),
286 serde_json::Value::Array(items) => items.iter().any(|item| any_string(item, found)),
287 serde_json::Value::Object(map) => map.values().any(|item| any_string(item, found)),
288 _ => false,
289 }
290 }
291 let names_none = |text: &str| {
292 text.match_indices("http://localhost").any(|(at, prefix)| {
293 let rest = &text[at + prefix.len()..];
294 !(rest.starts_with('/') || rest.starts_with("\\/") || rest.starts_with(':'))
295 })
296 };
297 any_string(predicate, &names_none)
298 .then(|| LOCAL_SPELLING.to_owned())
299 .into_iter()
300}
301
302/// Whether [`resolve_predicate`] produced a working [`Predicate::Evaluator`]
303/// -- with or without warnings -- for `id`, judged from what it appended to
304/// `report`. `didbot-pds`'s last-good-revision loader uses this to decide
305/// whether an id's *current* revision compiled, without duplicating
306/// `resolve_predicate`'s own compiling logic.
307#[must_use]
308pub fn compiled_successfully(report: &CompileReport, id: &str) -> bool {
309 !report.denials.iter().any(|denial| denial.id == id)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use didbot_policy::{
316 ActionSet, Applicability, CompiledId, EvalError, KindSet, Lifecycle, Observed, Outcome,
317 PolicyTree, Revision, Scope, Staleness, Subject,
318 };
319 use serde_json::json;
320
321 fn policy_with_language(language: &str) -> ParsedPolicy {
322 ParsedPolicy {
323 applicability: Applicability::Write {
324 kinds: KindSet::ALL,
325 collections: Scope::Any,
326 actions: ActionSet::ALL,
327 path_prefixes: Scope::Any,
328 },
329 observes: Applicability::Write {
330 kinds: KindSet::ALL,
331 collections: Scope::Any,
332 actions: ActionSet::ALL,
333 path_prefixes: Scope::Any,
334 },
335 staleness: Staleness::Linearized,
336 language: language.to_string(),
337 predicate: json!({}),
338 revision: Revision(0),
339 lookups: Vec::new(),
340 }
341 }
342
343 struct AlwaysAllow;
344 impl Evaluator for AlwaysAllow {
345 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
346 CompileOutcome::Compiled(CompiledId(0))
347 }
348 fn evaluate(
349 &self,
350 _subject: &Subject<'_>,
351 _compiled: CompiledId,
352 ) -> Result<Outcome, EvalError> {
353 Ok(Outcome::Allow)
354 }
355 fn observe(&self, _event: &Observed<'_>) {}
356 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
357 fn staleness(&self) -> Staleness {
358 Staleness::Linearized
359 }
360 }
361 struct AlwaysAllowLanguage;
362 impl Language for AlwaysAllowLanguage {
363 fn name(&self) -> &str {
364 "always-allow"
365 }
366 fn new_evaluator(&self) -> Arc<dyn Evaluator> {
367 Arc::new(AlwaysAllow)
368 }
369 }
370
371 struct AlwaysRejectsPredicate;
372 impl Evaluator for AlwaysRejectsPredicate {
373 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
374 CompileOutcome::Rejected("this predicate is never good enough".to_string())
375 }
376 fn evaluate(
377 &self,
378 _subject: &Subject<'_>,
379 _compiled: CompiledId,
380 ) -> Result<Outcome, EvalError> {
381 unreachable!("never compiled")
382 }
383 fn observe(&self, _event: &Observed<'_>) {}
384 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
385 fn staleness(&self) -> Staleness {
386 Staleness::Linearized
387 }
388 }
389 struct PickyLanguage;
390 impl Language for PickyLanguage {
391 fn name(&self) -> &str {
392 "picky"
393 }
394 fn new_evaluator(&self) -> Arc<dyn Evaluator> {
395 Arc::new(AlwaysRejectsPredicate)
396 }
397 }
398
399 struct WarnsOnCompile;
400 impl Evaluator for WarnsOnCompile {
401 fn compile(&self, _predicate: &serde_json::Value) -> CompileOutcome {
402 CompileOutcome::CompiledWithWarnings(
403 CompiledId(0),
404 vec!["vulnerable to homoglyphs, normalize first".to_string()],
405 )
406 }
407 fn evaluate(
408 &self,
409 _subject: &Subject<'_>,
410 _compiled: CompiledId,
411 ) -> Result<Outcome, EvalError> {
412 Ok(Outcome::Allow)
413 }
414 fn observe(&self, _event: &Observed<'_>) {}
415 fn lifecycle(&self, _event: &Lifecycle<'_>) {}
416 fn staleness(&self) -> Staleness {
417 Staleness::Linearized
418 }
419 }
420 struct WarnsLanguage;
421 impl Language for WarnsLanguage {
422 fn name(&self) -> &str {
423 "warns"
424 }
425 fn new_evaluator(&self) -> Arc<dyn Evaluator> {
426 Arc::new(WarnsOnCompile)
427 }
428 }
429
430 fn dummy_subject() -> Subject<'static> {
431 Subject::Write {
432 account: "did:plc:agent",
433 attributes: didbot_policy::SubjectAttributes {
434 kind: didbot_policy::SubjectKind::Known(didbot_policy::AccountKind::Agent),
435 handle: None,
436 depth: None,
437 },
438 universal: didbot_policy::Universal {
439 deployment: didbot_policy::DeploymentAttributes::default(),
440 now: time::macros::datetime!(2026 - 01 - 01 0:00 UTC),
441 },
442 client_id: None,
443 collection: "app.bsky.feed.post",
444 action: didbot_policy::WriteAction::Create,
445 diff: didbot_policy::Diff::default(),
446 before: None,
447 after: None,
448 lookups: &[],
449 }
450 }
451
452 #[test]
453 fn an_unknown_language_is_scoped_deny_not_dropped() {
454 let policy = policy_with_language("nobody-ships-this");
455 let mut builder = PolicyTree::builder();
456 let report = compile_policies([("id-1", &policy)], &[], &mut builder);
457 assert_eq!(report.denials.len(), 1);
458 assert_eq!(report.denials[0].language, "nobody-ships-this");
459
460 let tree = builder.build();
461 let subject = dummy_subject();
462 assert_eq!(tree.decision_candidates(&subject).len(), 1);
463 assert!(matches!(
464 didbot_policy::evaluate(&subject, &tree),
465 didbot_policy::Verdict::Reject { .. }
466 ));
467 }
468
469 #[test]
470 fn a_predicate_a_language_rejects_is_scoped_deny() {
471 let policy = policy_with_language("picky");
472 let mut builder = PolicyTree::builder();
473 let report = compile_policies(
474 [("id-1", &policy)],
475 &[&PickyLanguage as &dyn Language],
476 &mut builder,
477 );
478 assert_eq!(report.denials.len(), 1);
479 assert!(report.denials[0]
480 .reason
481 .contains("this predicate is never good enough"));
482
483 let tree = builder.build();
484 assert!(matches!(
485 didbot_policy::evaluate(&dummy_subject(), &tree),
486 didbot_policy::Verdict::Reject { .. }
487 ));
488 }
489
490 #[test]
491 fn a_matching_language_compiles_with_no_denial_reported() {
492 let policy = policy_with_language("always-allow");
493 let mut builder = PolicyTree::builder();
494 let report = compile_policies(
495 [("id-1", &policy)],
496 &[&AlwaysAllowLanguage as &dyn Language],
497 &mut builder,
498 );
499 assert!(report.denials.is_empty());
500 assert!(report.warnings.is_empty());
501
502 let tree = builder.build();
503 assert_eq!(
504 didbot_policy::evaluate(&dummy_subject(), &tree),
505 didbot_policy::Verdict::Allow
506 );
507 }
508
509 /// A compile that succeeds with warnings is registered exactly like a
510 /// clean compile -- the policy is not blocked -- but the warning
511 /// reaches the report, not silently dropped.
512 #[test]
513 fn a_compile_with_warnings_still_registers_and_reports_the_warning() {
514 let policy = policy_with_language("warns");
515 let mut builder = PolicyTree::builder();
516 let report = compile_policies(
517 [("id-1", &policy)],
518 &[&WarnsLanguage as &dyn Language],
519 &mut builder,
520 );
521 assert!(report.denials.is_empty());
522 assert_eq!(report.warnings.len(), 1);
523 assert!(report.warnings[0].warnings[0].contains("homoglyphs"));
524
525 let tree = builder.build();
526 assert_eq!(
527 didbot_policy::evaluate(&dummy_subject(), &tree),
528 didbot_policy::Verdict::Allow
529 );
530 }
531
532 /// A rule written against a local client as it was sent before
533 /// `ClientId` gave every one a single spelling compiles, matches nothing,
534 /// and says so. One that names `http://localhost/` or a port does not.
535 #[test]
536 fn a_predicate_naming_a_local_spelling_no_client_id_takes_is_warned_of() {
537 let mut builder = PolicyTree::builder();
538 let mut warned = |predicate: serde_json::Value| {
539 let mut policy = policy_with_language("always-allow");
540 policy.predicate = predicate;
541 let report = compile_policies(
542 [("id-1", &policy)],
543 &[&AlwaysAllowLanguage as &dyn Language],
544 &mut builder,
545 );
546 report
547 .warnings
548 .iter()
549 .any(|warning| warning.warnings.iter().any(|text| text == LOCAL_SPELLING))
550 };
551 for stale in [
552 json!({"clientId": "^http://localhost$"}),
553 json!({"clientId": "^http://localhost\\?redirect_uri="}),
554 json!({"source": "resource.id == \"http://localhost\""}),
555 json!({"deep": [{"source": "resource.id like \"http://localhost?*\""}]}),
556 ] {
557 assert!(warned(stale.clone()), "{stale}");
558 }
559 for current in [
560 json!({"clientId": "^http://localhost/"}),
561 json!({"clientId": "^http://localhost\\/"}),
562 json!({"source": "resource.id like \"http://localhost/*\" || resource.id like \"http://localhost:*\""}),
563 json!({"clientId": "^https://client\\.example/"}),
564 ] {
565 assert!(!warned(current.clone()), "{current}");
566 }
567 }
568}