1use std::collections::{BTreeMap, HashMap};
37use std::sync::Arc;
38
39use didbot_pds::policy_records::{BINDING_COLLECTION, POLICY_COLLECTION};
40use didbot_pds::policy_tree::TreePolicyGate;
41use didbot_pds::{
42 ApplyEvent, AtUri, Boundary, Coverage, EmptiedSet, EnforcedBinding, EnforcedPolicy,
43 EnforcedSet, Observed, PolicyRevision, PolicySet, PolicyStore, PolicyStoreError, Registry,
44 RejectedBuild, ServerIdentity,
45};
46use didbot_policy_records::AcrossBuilds;
47use serde::Deserialize;
48use tracing::{debug, error, info, warn};
49
50const PAGE_SIZE: u32 = 100;
53
54const MAX_PAGES: u32 = 200;
63
64#[derive(Debug, thiserror::Error, Clone)]
68pub enum PollError {
69 #[error("fetching {url}: {message}")]
71 Fetch {
72 url: String,
74 message: String,
76 },
77 #[error("parsing response from {url}: {message}")]
79 BadResponse {
80 url: String,
82 message: String,
84 },
85 #[error("listing {url} was still paging after {pages} pages")]
88 TooManyPages {
89 url: String,
91 pages: u32,
93 },
94}
95
96#[derive(Debug, Deserialize)]
97struct ListRecordsResponse {
98 records: Vec<ListedRecord>,
99 cursor: Option<String>,
100}
101
102#[derive(Debug, Deserialize)]
103struct ListedRecord {
104 uri: String,
105 value: serde_json::Value,
106}
107
108pub async fn list_records(
117 client: &reqwest::Client,
118 endpoint: &str,
119 operator_did: &str,
120 collection: &str,
121) -> Result<HashMap<String, serde_json::Value>, PollError> {
122 let list_url = format!("{endpoint}/xrpc/com.atproto.repo.listRecords");
123 let mut out = HashMap::new();
124 let mut cursor: Option<String> = None;
125 let mut pages: u32 = 0;
126 loop {
127 let mut query = vec![
128 ("repo".to_string(), operator_did.to_string()),
129 ("collection".to_string(), collection.to_string()),
130 ("limit".to_string(), PAGE_SIZE.to_string()),
131 ];
132 if let Some(cursor) = &cursor {
133 query.push(("cursor".to_string(), cursor.clone()));
134 }
135 let response = client
136 .get(&list_url)
137 .query(&query)
138 .send()
139 .await
140 .map_err(|error| PollError::Fetch {
141 url: list_url.clone(),
142 message: error.to_string(),
143 })?
144 .error_for_status()
145 .map_err(|error| PollError::Fetch {
146 url: list_url.clone(),
147 message: error.to_string(),
148 })?;
149 let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
150 .await
151 .map_err(|error| PollError::BadResponse {
152 url: list_url.clone(),
153 message: error.to_string(),
154 })?;
155 let page: ListRecordsResponse =
156 serde_json::from_slice(&body).map_err(|error| PollError::BadResponse {
157 url: list_url.clone(),
158 message: error.to_string(),
159 })?;
160
161 let page_len = page.records.len();
162 for record in page.records {
163 out.insert(record.uri, record.value);
164 }
165
166 match page.cursor {
167 Some(next) if page_len as u32 == PAGE_SIZE => cursor = Some(next),
173 _ => break,
174 }
175 pages += 1;
176 if pages >= MAX_PAGES {
177 return Err(PollError::TooManyPages {
182 url: list_url,
183 pages: MAX_PAGES,
184 });
185 }
186 }
187 Ok(out)
188}
189
190#[derive(Debug, thiserror::Error)]
192pub enum PolicyPollError {
193 #[error("listing {collection}: {source}")]
195 Listing {
196 collection: &'static str,
198 #[source]
200 source: PollError,
201 },
202 #[error(transparent)]
204 Emptied(#[from] EmptiedSet),
205}
206
207#[derive(Debug, thiserror::Error)]
209pub enum RestoreError {
210 #[error(transparent)]
212 Unusable(#[from] PolicyStoreError),
213 #[error(
216 "the policy set kept in the data directory holds record(s) this build cannot honour, \
217 and starting without them would widen what an agent may do: {refused}"
218 )]
219 Unbuildable {
220 refused: RejectedBuild,
222 },
223 #[error(
226 "the policy set kept in the data directory was listed from {held} and this run's \
227 operator is {configured}: one of the two is wrong. Point --owner back at {held}, or \
228 delete the kept set to start from {configured}'s repository alone"
229 )]
230 AnotherOperator {
231 held: String,
233 configured: String,
235 },
236}
237
238pub struct PolicyPoll {
241 operator_did: String,
242 this_pds: String,
243 gate: Arc<TreePolicyGate>,
244 set: PolicySet,
245 store: Option<PolicyStore>,
249 across: AcrossBuilds,
252 declared_lookups: Arc<crate::DeclaredLookups>,
256}
257
258impl PolicyPoll {
259 pub fn new(
265 operator_did: impl Into<String>,
266 this_pds: impl Into<String>,
267 gate: Arc<TreePolicyGate>,
268 store: Option<PolicyStore>,
269 declared_lookups: Arc<crate::DeclaredLookups>,
270 ) -> Self {
271 Self {
272 operator_did: operator_did.into(),
273 this_pds: this_pds.into(),
274 gate,
275 set: PolicySet::new(),
276 store,
277 across: AcrossBuilds::new(),
278 declared_lookups,
279 }
280 }
281
282 pub fn restore(
293 &self,
294 registry: &dyn Registry,
295 ) -> Result<Option<Arc<PolicyRevision>>, RestoreError> {
296 let Some(store) = &self.store else {
297 return Ok(None);
298 };
299 let Some(held) = store.load()? else {
300 return Ok(None);
301 };
302 if held.observed.author != self.operator_did {
303 return Err(RestoreError::AnotherOperator {
304 held: held.observed.author,
305 configured: self.operator_did.clone(),
306 });
307 }
308 let bound = self
311 .build(&held.observed, registry, &PolicyRevision::default())
312 .expect("a build against nothing in force cannot empty a set");
313 if !bound.refused.is_empty() {
314 return Err(RestoreError::Unbuildable {
315 refused: RejectedBuild::of(bound.refused),
316 });
317 }
318 store.agrees(&held.digest, &bound.revision)?;
319 let (revision, _) = self.set.install(bound.revision, Vec::new());
320 let enforced = self.summarise(&revision, registry, Vec::new(), Vec::new());
321 info!(
322 digest = %revision.digest,
323 policies = revision.policies.len(),
324 bindings = revision.bindings.len(),
325 path = %store.path().display(),
326 "put the kept operator policy set back in force before the first poll"
327 );
328 self.declared_lookups.publish(bound.lookups);
329 self.gate.swap_bound(bound.tree, bound.bindings, enforced);
330 Ok(Some(revision))
331 }
332
333 pub fn rejected(&self) -> Option<Arc<RejectedBuild>> {
335 self.set.rejected()
336 }
337
338 pub fn enforced_set(&self) -> Option<Arc<EnforcedSet>> {
340 self.gate.enforced_set()
341 }
342
343 pub async fn poll_once(
347 &self,
348 client: &reqwest::Client,
349 endpoint: &str,
350 registry: &dyn Registry,
351 ) -> Result<Arc<PolicyRevision>, PolicyPollError> {
352 let mut listed = Vec::with_capacity(2);
353 for collection in [POLICY_COLLECTION, BINDING_COLLECTION] {
354 let listing = list_records(client, endpoint, &self.operator_did, collection)
355 .await
356 .map_err(|source| PolicyPollError::Listing { collection, source })?;
357 listed.push(by_record_key(listing));
358 }
359 let bindings = listed.pop().unwrap_or_default();
360 let policies = listed.pop().unwrap_or_default();
361 let observed = Observed {
362 author: self.operator_did.clone(),
363 policies,
364 bindings,
365 };
366
367 let in_force = self.set.current();
368 let bound = match self.build(&observed, registry, &in_force) {
369 Ok(bound) => bound,
370 Err(emptied) => {
371 warn!(
372 collection = emptied.collection,
373 held = emptied.held,
374 "the operator's repository listed no records where a set is in force; \
375 nothing installed and the set in force keeps serving"
376 );
377 self.set
378 .reject(RejectedBuild::of(vec![didbot_pds::BuildFailure {
379 collection: emptied.collection,
380 key: String::new(),
381 reason: emptied.to_string(),
382 }]));
383 return Err(PolicyPollError::Emptied(emptied));
384 }
385 };
386
387 for failure in &bound.carried {
388 warn!(
389 collection = failure.collection,
390 key = %failure.key,
391 reason = %failure.reason,
392 "a policy record was refused; the record in force at its key keeps serving"
393 );
394 }
395 for failure in &bound.refused {
396 warn!(
397 collection = failure.collection,
398 key = %failure.key,
399 reason = %failure.reason,
400 "a policy record was refused and nothing was in force at its key; every record \
401 beside it is installed"
402 );
403 }
404
405 let digest = bound.revision.digest.clone();
406 let refused = bound.refused.clone();
407 let carried = bound.carried.clone();
408 let (revision, events) = self.set.install(bound.revision, bound.refused);
409 for event in &events {
410 match event {
411 ApplyEvent::Added { collection, key }
412 | ApplyEvent::Updated { collection, key }
413 | ApplyEvent::Removed { collection, key } => {
414 debug!(collection, %key, ?event, "operator policy poll observed a change");
415 }
416 }
417 }
418 for key in &bound.report.unbound {
419 debug!(%key, "a policy no binding names reaches nobody");
420 }
421 for (binding, subject) in &bound.report.foreign_subjects {
422 debug!(%binding, %subject, "a binding names a subject that is not an account here; ignored");
423 }
424 for key in &bound.report.spares_held_tokens {
425 warn!(
426 %key,
427 "a policy denies a client before the write and not at it; tokens it already \
428 holds keep writing until they expire"
429 );
430 }
431 for fallback in &bound.load.last_good_fallbacks {
432 warn!(
433 id = %fallback.id,
434 enforced = ?fallback.enforced_revision,
435 failed = ?fallback.failed_revision,
436 reason = %fallback.reason,
437 "a policy's new revision will not compile; the revision that did keeps being \
438 enforced"
439 );
440 }
441 for warning in &bound.load.warnings {
442 warn!(
443 ?warning,
444 "a policy compiled with something the operator should see"
445 );
446 }
447 info!(
448 %digest,
449 policies = revision.policies.len(),
450 bindings = revision.bindings.len(),
451 bound = bound.bindings.len(),
452 refused = refused.len(),
453 carried = carried.len(),
454 "installed the operator's policy set"
455 );
456 let enforced = self.summarise(&revision, registry, refused, carried);
457 self.declared_lookups.publish(bound.lookups);
458 self.gate.swap_bound(bound.tree, bound.bindings, enforced);
459
460 if let Some(store) = &self.store {
468 if in_force.digest != revision.digest {
469 match store.save(&self.operator_did, &revision) {
470 Ok(()) => {
471 debug!(%digest, path = %store.path().display(), "kept the policy set in force");
472 }
473 Err(err) => error!(
474 %err,
475 %digest,
476 "the policy set in force could not be written down; a restart would come \
477 back on the set before it"
478 ),
479 }
480 }
481 }
482 Ok(revision)
483 }
484
485 fn build(
488 &self,
489 observed: &Observed,
490 registry: &dyn Registry,
491 in_force: &PolicyRevision,
492 ) -> Result<didbot_pds::BoundTree, EmptiedSet> {
493 let chain_of = |did: &str| registry.boundary(did).ok();
494 didbot_pds::policy_bindings::resolve(
495 observed,
496 ServerIdentity {
497 service_did: &self.this_pds,
498 operator_did: &self.operator_did,
499 },
500 &chain_of,
501 didbot_policy_records::builtin_policies(),
502 &HashMap::new(),
503 &self.across,
504 in_force,
505 )
506 }
507
508 fn summarise(
515 &self,
516 revision: &PolicyRevision,
517 registry: &dyn Registry,
518 refused: Vec<didbot_pds::BuildFailure>,
519 carried: Vec<didbot_pds::BuildFailure>,
520 ) -> EnforcedSet {
521 let boundaries: Vec<(String, Boundary)> = registry
522 .accounts()
523 .into_iter()
524 .map(|account| {
525 let did = account.did.as_str().to_owned();
526 let chain = registry
527 .boundary(&did)
528 .unwrap_or_else(|_| vec![did.clone()]);
529 (did, Boundary::of(chain))
530 })
531 .collect();
532 EnforcedSet {
533 digest: revision.digest.clone(),
534 policies: revision
535 .policies
536 .iter()
537 .map(|(key, record)| EnforcedPolicy {
538 key: key.clone(),
539 actions: record.actions.iter().map(ToString::to_string).collect(),
540 document: record.document.type_name().to_owned(),
541 })
542 .collect(),
543 bindings: revision
544 .bindings
545 .iter()
546 .map(|(key, record)| {
547 let coverage = Coverage::of(record);
548 EnforcedBinding {
549 key: key.clone(),
550 policies: record
551 .policies
552 .iter()
553 .map(|policy| policy.key.clone())
554 .collect(),
555 accounts: boundaries
556 .iter()
557 .filter(|(_, boundary)| coverage.covers(boundary, &self.this_pds))
558 .map(|(did, _)| did.clone())
559 .collect(),
560 }
561 })
562 .collect(),
563 refused,
564 carried,
565 }
566 }
567}
568
569fn by_record_key(
572 listing: HashMap<String, serde_json::Value>,
573) -> BTreeMap<String, serde_json::Value> {
574 listing
575 .into_iter()
576 .map(|(uri, value)| {
577 let key = AtUri::parse(&uri)
578 .ok()
579 .and_then(AtUri::rkey)
580 .unwrap_or_else(|| uri.rsplit('/').next().unwrap_or(&uri))
581 .to_owned();
582 (key, value)
583 })
584 .collect()
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use serde_json::json;
591 use std::sync::Arc;
592
593 const COLLECTION: &str = "bot.did.policy";
594
595 fn value(revision: u64) -> serde_json::Value {
596 json!({
597 "language": "regex",
598 "revision": revision,
599 "applicability": { "write": { "collections": ["c1"] } },
600 "predicate": {}
601 })
602 }
603
604 async fn fake_operator_pds(
607 pages: Vec<Vec<(&'static str, serde_json::Value)>>,
608 ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
609 use axum::extract::Query;
610 use axum::response::IntoResponse;
611 use axum::routing::get;
612 use axum::{Json, Router};
613 use std::sync::atomic::{AtomicUsize, Ordering};
614
615 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
616 .await
617 .expect("bind");
618 let addr = listener.local_addr().expect("local addr");
619
620 let pages = Arc::new(pages);
621 let page_index = Arc::new(AtomicUsize::new(0));
622
623 let router = Router::new().route(
624 "/xrpc/com.atproto.repo.listRecords",
625 get(move |Query(_params): Query<HashMap<String, String>>| {
626 let pages = pages.clone();
627 let page_index = page_index.clone();
628 async move {
629 let i = page_index.fetch_add(1, Ordering::SeqCst);
630 let page = pages.get(i).cloned().unwrap_or_default();
631 let records: Vec<_> = page
632 .iter()
633 .map(
634 |(uri, value)| json!({ "uri": uri, "cid": "bafyfake", "value": value }),
635 )
636 .collect();
637 let has_more = i + 1 < pages.len();
638 let body = if has_more {
639 json!({ "records": records, "cursor": format!("page-{}", i + 1) })
640 } else {
641 json!({ "records": records })
642 };
643 Json(body).into_response()
644 }
645 }),
646 );
647
648 let handle = tokio::spawn(async move {
649 axum::serve(listener, router).await.ok();
650 });
651 (addr, handle)
652 }
653
654 #[tokio::test(start_paused = true)]
658 async fn an_operator_host_that_never_answers_is_a_fetch_error_after_the_client_timeout() {
659 let addr = didbot_http::test_support::hung_server();
660 let endpoint = format!("http://{addr}");
661 let client = didbot_http::client();
662 let started = tokio::time::Instant::now();
663 let listing = list_records(&client, &endpoint, "did:web:op.example", COLLECTION);
664 let err = tokio::time::timeout(didbot_http::DEFAULT_TIMEOUT * 2, listing)
665 .await
666 .expect("the client's own timeout fires first")
667 .expect_err("nothing ever answers");
668 assert!(matches!(err, PollError::Fetch { .. }), "{err:?}");
669 assert_eq!(started.elapsed(), didbot_http::DEFAULT_TIMEOUT);
670 }
671
672 #[tokio::test]
673 async fn a_single_page_is_fetched_in_full() {
674 let (addr, server) =
675 fake_operator_pds(vec![vec![("at://op/bot.did.policy/a", value(1))]]).await;
676 let endpoint = format!("http://{addr}");
677 let client = didbot_http::client();
678
679 let listing = list_records(&client, &endpoint, "did:web:op.example", COLLECTION)
680 .await
681 .expect("should succeed");
682 assert_eq!(listing.len(), 1);
683 server.abort();
684 }
685
686 #[tokio::test]
687 async fn a_full_page_followed_by_a_short_page_is_paginated_through() {
688 let full_page: Vec<_> = (0..PAGE_SIZE)
689 .map(|i| {
690 let uri: &'static str =
691 Box::leak(format!("at://op/bot.did.policy/{i}").into_boxed_str());
692 (uri, value(1))
693 })
694 .collect();
695 let second_page = vec![("at://op/bot.did.policy/extra", value(1))];
696 let (addr, server) = fake_operator_pds(vec![full_page, second_page]).await;
697 let endpoint = format!("http://{addr}");
698 let client = didbot_http::client();
699
700 let listing = list_records(&client, &endpoint, "did:web:op.example", COLLECTION)
701 .await
702 .expect("should succeed");
703 assert_eq!(listing.len() as u32, PAGE_SIZE + 1);
704 server.abort();
705 }
706
707 async fn endless_operator_pds() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
711 use axum::extract::Query;
712 use axum::routing::get;
713 use axum::{Json, Router};
714 use std::sync::atomic::{AtomicU32, Ordering};
715
716 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
717 .await
718 .expect("bind");
719 let addr = listener.local_addr().expect("local addr");
720 let served = Arc::new(AtomicU32::new(0));
721 let router = Router::new().route(
722 "/xrpc/com.atproto.repo.listRecords",
723 get(move |Query(_): Query<HashMap<String, String>>| {
724 let counted = served.clone();
725 async move {
726 let page = counted.fetch_add(1, Ordering::SeqCst);
727 let records: Vec<_> = (0..PAGE_SIZE)
728 .map(|n| {
729 json!({
730 "uri": format!("at://op/bot.did.policy/p{page}-{n}"),
731 "cid": "bafyfake",
732 "value": value(1),
733 })
734 })
735 .collect();
736 Json(json!({ "records": records, "cursor": format!("page-{}", page + 1) }))
737 }
738 }),
739 );
740 let handle = tokio::spawn(async move {
741 axum::serve(listener, router).await.ok();
742 });
743 (addr, handle)
744 }
745
746 #[tokio::test]
750 async fn a_listing_that_never_ends_stops_at_the_cap_and_fails() {
751 let (addr, server) = endless_operator_pds().await;
752 let endpoint = format!("http://{addr}");
753 let client = didbot_http::client();
754 let error = list_records(&client, &endpoint, "did:web:op.example", COLLECTION)
755 .await
756 .expect_err("a listing that never ends is not an answer");
757 match error {
758 PollError::TooManyPages { pages, .. } => assert_eq!(pages, MAX_PAGES),
759 other => panic!("{other:?}"),
760 }
761 server.abort();
762 }
763}