1use std::future::Future;
28use std::num::NonZeroUsize;
29use std::pin::Pin;
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32
33use didbot_http::PrivateAddresses;
34use didbot_identity::DidDocument;
35use didbot_lexicon::lexicon::LexiconDoc;
36use didbot_repo::CommitKey;
37use serde_json::Value;
38use tokio::time::Instant;
41
42use crate::oauth::scope::{Include, Scope, ScopeSet, MAX_GRANT_ATOMS, MAX_GRANT_BYTES};
43
44const SCHEMA_COLLECTION: &str = "com.atproto.lexicon.schema";
46
47const RECORD_NOT_FOUND: &str = "RecordNotFound";
49
50pub const FRESH_FOR: Duration = Duration::from_secs(60 * 60);
56
57pub const KEEP_FOR: Duration = Duration::from_secs(90 * 24 * 60 * 60);
64
65pub const RETRY_AFTER: Duration = Duration::from_secs(60);
71
72pub const SET_DEADLINE: Duration = Duration::from_secs(4);
75
76pub const EXPANSION_DEADLINE: Duration = Duration::from_secs(8);
80
81pub const MAX_SETS: usize = 16;
84
85pub const CACHE_ENTRIES: usize = 256;
87
88pub const MAX_PROOF_BYTES: usize = 64 * 1024;
91
92const MAX_ERROR_BYTES: usize = 64 * 1024;
94
95#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
97pub enum Unresolved {
98 #[error("no lexicon authority: {0}")]
101 NoAuthority(String),
102 #[error("unreachable: {0}")]
104 Unreachable(String),
105 #[error("not a permission set: {0}")]
110 Invalid(String),
111 #[error("resolving it took longer than allowed")]
113 Deadline,
114}
115
116impl Unresolved {
117 fn answered(&self) -> bool {
120 matches!(self, Self::NoAuthority(_) | Self::Invalid(_))
121 }
122}
123
124pub type ResolveFuture<'a> =
127 Pin<Box<dyn Future<Output = Result<Vec<Value>, Unresolved>> + Send + 'a>>;
128
129pub trait Resolve: Send + Sync {
133 fn resolve<'a>(&'a self, nsid: &'a str) -> ResolveFuture<'a>;
135}
136
137pub struct Network {
139 doh: String,
140 private: PrivateAddresses,
141}
142
143impl Network {
144 pub fn new(doh: impl Into<String>, private: PrivateAddresses) -> Self {
149 Self {
150 doh: doh.into(),
151 private,
152 }
153 }
154
155 async fn authority(&self, nsid: &str) -> Result<String, Unresolved> {
160 let name = lexicon_name(nsid);
161 let records = didbot_http::doh::lookup(
162 &self.doh,
163 &name,
164 didbot_http::doh::TXT,
165 PrivateAddresses::Allowed,
166 )
167 .await
168 .map_err(|error| Unresolved::Unreachable(format!("{name}: {error}")))?;
169 let dids: Vec<&str> = records
170 .iter()
171 .filter_map(|record| record.strip_prefix("did="))
172 .collect();
173 let [did] = dids.as_slice() else {
174 return Err(Unresolved::NoAuthority(format!(
175 "{name} names {} DIDs, not one",
176 dids.len()
177 )));
178 };
179 didbot_identity::validate_did(did)
180 .map_err(|error| Unresolved::NoAuthority(format!("{name} names {did}: {error}")))?;
181 Ok((*did).to_owned())
182 }
183
184 async fn identity(&self, did: &str) -> Result<(String, CommitKey), Unresolved> {
187 let url = didbot_identity::document_url(did)
188 .map_err(|error| Unresolved::NoAuthority(error.to_string()))?;
189 let body = self.get(&url, didbot_http::MAX_JSON_BODY).await?;
190 let document: DidDocument = serde_json::from_slice(&body)
191 .map_err(|error| Unresolved::Invalid(format!("parsing {url}: {error}")))?;
192 if document.id != did {
193 return Err(Unresolved::Invalid(format!(
194 "the document at {url} is {:?}'s, not {did:?}'s",
195 document.id
196 )));
197 }
198 let endpoint = document
199 .pds_endpoint()
200 .ok_or_else(|| Unresolved::Invalid(format!("{did} names no #atproto_pds service")))?;
201 let key = document
202 .signing_key_multibase()
203 .ok_or_else(|| Unresolved::Invalid(format!("{did} names no #atproto key")))?;
204 let key = CommitKey::from_multibase(key)
205 .map_err(|error| Unresolved::Invalid(format!("{did}'s #atproto key: {error}")))?;
206 Ok((endpoint.to_owned(), key))
207 }
208
209 async fn record(
211 &self,
212 endpoint: &str,
213 did: &str,
214 key: &CommitKey,
215 nsid: &str,
216 ) -> Result<Vec<Value>, Unresolved> {
217 let mut url = url::Url::parse(&format!(
218 "{}/xrpc/com.atproto.sync.getRecord",
219 endpoint.trim_end_matches('/')
220 ))
221 .map_err(|error| Unresolved::Invalid(format!("{did}'s server {endpoint}: {error}")))?;
222 url.query_pairs_mut()
223 .append_pair("did", did)
224 .append_pair("collection", SCHEMA_COLLECTION)
225 .append_pair("rkey", nsid);
226 let absent =
227 || Unresolved::Invalid(format!("{did}'s repository holds no lexicon at {nsid}"));
228 let response = self.send(url.as_str()).await?;
229 if response.status() == reqwest::StatusCode::BAD_REQUEST {
230 let body = didbot_http::read_bounded(response, MAX_ERROR_BYTES)
231 .await
232 .unwrap_or_default();
233 let error = serde_json::from_slice::<Value>(&body)
234 .ok()
235 .and_then(|body| body.get("error")?.as_str().map(str::to_owned));
236 return Err(match error.as_deref() {
237 Some(RECORD_NOT_FOUND) => absent(),
238 _ => Unresolved::Unreachable(format!("{url} answered 400 {error:?}")),
239 });
240 }
241 let proof = Self::body(url.as_str(), response, MAX_PROOF_BYTES).await?;
242 let record = didbot_repo::read_record(&proof, did, key, SCHEMA_COLLECTION, nsid)
243 .map_err(|error| Unresolved::Invalid(format!("{did}'s proof of {nsid}: {error}")))?
244 .ok_or_else(absent)?;
245 permissions_of(record, nsid)
246 }
247
248 async fn get(&self, url: &str, max_bytes: usize) -> Result<Vec<u8>, Unresolved> {
251 let response = self.send(url).await?;
252 Self::body(url, response, max_bytes).await
253 }
254
255 async fn send(&self, url: &str) -> Result<reqwest::Response, Unresolved> {
257 let unreachable = Unresolved::Unreachable;
258 let parsed =
259 url::Url::parse(url).map_err(|error| unreachable(format!("{url}: {error}")))?;
260 let client = didbot_http::pinned_client(&parsed, self.private)
261 .await
262 .map_err(|error| unreachable(error.to_string()))?;
263 client
264 .get(parsed)
265 .send()
266 .await
267 .map_err(|error| unreachable(format!("fetching {url}: {error}")))
268 }
269
270 async fn body(
272 url: &str,
273 response: reqwest::Response,
274 max_bytes: usize,
275 ) -> Result<Vec<u8>, Unresolved> {
276 let unreachable = Unresolved::Unreachable;
277 let response = response
278 .error_for_status()
279 .map_err(|error| unreachable(format!("{url} answered: {error}")))?;
280 didbot_http::read_bounded(response, max_bytes)
281 .await
282 .map_err(|error| unreachable(format!("reading {url}: {error}")))
283 }
284}
285
286impl Resolve for Network {
287 fn resolve<'a>(&'a self, nsid: &'a str) -> ResolveFuture<'a> {
288 Box::pin(async move {
289 let did = self.authority(nsid).await?;
290 let (endpoint, key) = self.identity(&did).await?;
291 self.record(&endpoint, &did, &key, nsid).await
292 })
293 }
294}
295
296fn lexicon_name(nsid: &str) -> String {
299 let (authority, _) = nsid.rsplit_once('.').unwrap_or((nsid, ""));
300 let reversed: Vec<&str> = authority.rsplit('.').collect();
301 format!("_lexicon.{}", reversed.join("."))
302}
303
304fn permissions_of(record: Value, nsid: &str) -> Result<Vec<Value>, Unresolved> {
307 let lexicon: LexiconDoc = serde_json::from_value(record)
308 .map_err(|error| Unresolved::Invalid(format!("{nsid} is not a lexicon: {error}")))?;
309 if lexicon.lexicon != 1 || lexicon.id != nsid {
310 return Err(Unresolved::Invalid(format!(
311 "the record at {nsid} is lexicon {}, version {}",
312 lexicon.id, lexicon.lexicon
313 )));
314 }
315 if lexicon.main_type() != Some("permission-set") {
316 return Err(Unresolved::Invalid(format!(
317 "{nsid} defines no permission-set as main"
318 )));
319 }
320 lexicon
321 .main()
322 .and_then(|main| main.get("permissions"))
323 .and_then(Value::as_array)
324 .cloned()
325 .ok_or_else(|| Unresolved::Invalid(format!("{nsid} lists no permissions")))
326}
327
328struct Entry {
330 copy: Option<(Arc<Vec<Value>>, Instant)>,
332 tried: Instant,
334 failed: Option<Unresolved>,
336}
337
338impl Entry {
339 fn kept(&self, now: Instant) -> Option<Arc<Vec<Value>>> {
341 self.copy
342 .as_ref()
343 .filter(|(_, resolved)| now.duration_since(*resolved) < KEEP_FOR)
344 .map(|(copy, _)| copy.clone())
345 }
346}
347
348pub struct PermissionSets {
350 resolver: Arc<dyn Resolve>,
351 cache: Mutex<lru::LruCache<String, Entry>>,
352}
353
354impl PermissionSets {
355 pub fn new(resolver: Arc<dyn Resolve>) -> Self {
357 Self {
358 resolver,
359 cache: Mutex::new(lru::LruCache::new(
360 NonZeroUsize::new(CACHE_ENTRIES).expect("a constant above zero"),
361 )),
362 }
363 }
364
365 pub fn over_network(doh: impl Into<String>, private: PrivateAddresses) -> Self {
367 Self::new(Arc::new(Network::new(doh, private)))
368 }
369
370 pub async fn expand(&self, requested: &ScopeSet) -> ScopeSet {
378 let started = Instant::now();
379 let mut atoms: Vec<Scope> = requested
380 .0
381 .iter()
382 .filter(|atom| !matches!(atom, Scope::Include { .. }))
383 .cloned()
384 .collect();
385 let mut bytes: usize = atoms.iter().map(printed_len).sum();
386 let mut sets = 0;
387 for atom in &requested.0 {
388 let Scope::Include { nsid, aud } = atom else {
389 continue;
390 };
391 let granted = match self.include(nsid, aud.as_deref(), started, &mut sets).await {
392 Ok(granted) => granted,
393 Err(why) => {
394 tracing::debug!(include = %atom, reason = %why, "an included permission set grants nothing");
395 continue;
396 }
397 };
398 let added: usize = granted.iter().map(printed_len).sum();
399 if atoms.len() + granted.len() > MAX_GRANT_ATOMS || bytes + added > MAX_GRANT_BYTES {
400 tracing::debug!(
401 include = %atom,
402 atoms = granted.len(),
403 "an included permission set grants nothing: its atoms would pass what one grant may hold"
404 );
405 continue;
406 }
407 bytes += added;
408 atoms.extend(granted);
409 }
410 ScopeSet::new(atoms)
411 }
412
413 async fn include(
415 &self,
416 nsid: &str,
417 aud: Option<&str>,
418 started: Instant,
419 sets: &mut usize,
420 ) -> Result<Vec<Scope>, String> {
421 let include = Include::new(nsid, aud).map_err(|error| error.to_string())?;
422 *sets += 1;
423 if *sets > MAX_SETS {
424 return Err(format!("the request names more than {MAX_SETS} sets"));
425 }
426 let left = EXPANSION_DEADLINE.saturating_sub(started.elapsed());
427 if left.is_zero() {
428 return Err("the request's other sets used its time".to_owned());
429 }
430 let permissions = self
431 .permissions(include.nsid(), left.min(SET_DEADLINE))
432 .await
433 .map_err(|error| error.to_string())?;
434 Ok(include.grants(&permissions))
435 }
436
437 async fn permissions(
440 &self,
441 nsid: &str,
442 budget: Duration,
443 ) -> Result<Arc<Vec<Value>>, Unresolved> {
444 if let Some(answer) = self.cached(nsid, Instant::now()) {
445 return answer;
446 }
447 let began = Instant::now();
448 let outcome = tokio::time::timeout(budget, self.resolver.resolve(nsid)).await;
449 let resolved = match outcome {
450 Ok(resolved) => resolved,
451 Err(_) if budget < SET_DEADLINE => {
454 return self
455 .lock()
456 .peek(nsid)
457 .and_then(|entry| entry.kept(Instant::now()))
458 .ok_or(Unresolved::Deadline);
459 }
460 Err(_) => Err(Unresolved::Deadline),
461 };
462 tracing::debug!(
463 nsid,
464 outcome = resolved
465 .as_ref()
466 .map_or_else(ToString::to_string, |_| "resolved".to_owned()),
467 latency_ms = began.elapsed().as_millis(),
468 "resolved a permission set"
469 );
470 self.remember(nsid, resolved)
471 }
472
473 fn cached(&self, nsid: &str, now: Instant) -> Option<Result<Arc<Vec<Value>>, Unresolved>> {
476 let mut cache = self.lock();
477 let entry = cache.get(nsid)?;
478 let since = now.duration_since(entry.tried);
479 match &entry.failed {
480 None if since < FRESH_FOR => entry.kept(now).map(Ok),
481 Some(why) if since < RETRY_AFTER => Some(entry.kept(now).ok_or_else(|| why.clone())),
482 _ => None,
483 }
484 }
485
486 fn remember(
490 &self,
491 nsid: &str,
492 resolved: Result<Vec<Value>, Unresolved>,
493 ) -> Result<Arc<Vec<Value>>, Unresolved> {
494 let now = Instant::now();
495 let mut cache = self.lock();
496 let previous = cache.pop(nsid);
497 let (entry, answer) = match resolved {
498 Ok(permissions) => {
499 let permissions = Arc::new(permissions);
500 let entry = Entry {
501 copy: Some((permissions.clone(), now)),
502 tried: now,
503 failed: None,
504 };
505 (entry, Ok(permissions))
506 }
507 Err(why) => {
508 let copy = previous
509 .and_then(|entry| entry.copy)
510 .filter(|(_, resolved)| {
511 !why.answered() && now.duration_since(*resolved) < KEEP_FOR
512 });
513 let answer = copy
514 .as_ref()
515 .map(|(copy, _)| copy.clone())
516 .ok_or_else(|| why.clone());
517 let entry = Entry {
518 copy,
519 tried: now,
520 failed: Some(why),
521 };
522 (entry, answer)
523 }
524 };
525 cache.put(nsid.to_owned(), entry);
526 answer
527 }
528
529 fn lock(&self) -> std::sync::MutexGuard<'_, lru::LruCache<String, Entry>> {
530 self.cache
531 .lock()
532 .unwrap_or_else(|poisoned| poisoned.into_inner())
533 }
534}
535
536impl Default for PermissionSets {
537 fn default() -> Self {
540 Self::over_network(didbot_http::doh::CLOUDFLARE, PrivateAddresses::Refused)
541 }
542}
543
544fn printed_len(atom: &Scope) -> usize {
547 atom.to_string().len() + 1
548}
549
550#[cfg(test)]
551mod tests {
552 use std::collections::HashMap;
553 use std::sync::atomic::{AtomicUsize, Ordering};
554
555 use serde_json::json;
556
557 use super::*;
558
559 struct Script {
561 answers: Mutex<HashMap<String, Result<Vec<Value>, Unresolved>>>,
562 calls: AtomicUsize,
563 }
564
565 impl Script {
566 fn new() -> Arc<Self> {
567 Arc::new(Self {
568 answers: Mutex::new(HashMap::new()),
569 calls: AtomicUsize::new(0),
570 })
571 }
572
573 fn answer(&self, nsid: &str, answer: Result<Vec<Value>, Unresolved>) {
574 self.answers
575 .lock()
576 .expect("answers")
577 .insert(nsid.to_owned(), answer);
578 }
579
580 fn calls(&self) -> usize {
581 self.calls.load(Ordering::SeqCst)
582 }
583 }
584
585 impl Resolve for Script {
586 fn resolve<'a>(&'a self, nsid: &'a str) -> ResolveFuture<'a> {
587 Box::pin(async move {
588 self.calls.fetch_add(1, Ordering::SeqCst);
589 self.answers
590 .lock()
591 .expect("answers")
592 .get(nsid)
593 .cloned()
594 .unwrap_or_else(|| Err(Unresolved::NoAuthority(nsid.to_owned())))
595 })
596 }
597 }
598
599 fn repo(collection: &str) -> Vec<Value> {
600 vec![json!({
601 "type": "permission",
602 "resource": "repo",
603 "collection": [collection],
604 })]
605 }
606
607 fn scope(s: &str) -> ScopeSet {
608 ScopeSet::parse(s).expect("a scope")
609 }
610
611 #[test]
612 fn the_authority_is_the_nsid_less_its_name_reversed() {
613 assert_eq!(lexicon_name("app.bsky.authFullApp"), "_lexicon.bsky.app");
614 assert_eq!(
615 lexicon_name("edu.university.dept.lab.blogging.getBlogPost"),
616 "_lexicon.blogging.lab.dept.university.edu"
617 );
618 }
619
620 #[tokio::test]
623 async fn a_set_that_does_not_resolve_grants_nothing_and_fails_nothing() {
624 let script = Script::new();
625 script.answer("com.example.authFull", Ok(repo("com.example.post")));
626 let sets = PermissionSets::new(script.clone());
627 let expanded = sets
628 .expand(&scope(
629 "atproto include:com.example.authFull include:org.example.authGone \
630 include:not-an-nsid rpc:com.example.get?aud=*",
631 ))
632 .await;
633 assert_eq!(
634 expanded.to_string(),
635 "atproto repo:com.example.post rpc:com.example.get?aud=*"
636 );
637 assert_eq!(
638 script.calls(),
639 2,
640 "an include naming no NSID is not resolved"
641 );
642 }
643
644 #[tokio::test(start_paused = true)]
645 async fn a_resolved_set_is_reused_until_it_is_stale() {
646 let script = Script::new();
647 script.answer("com.example.authFull", Ok(repo("com.example.post")));
648 let sets = PermissionSets::new(script.clone());
649 let request = scope("include:com.example.authFull");
650
651 sets.expand(&request).await;
652 sets.expand(&request).await;
653 assert_eq!(script.calls(), 1);
654
655 script.answer("com.example.authFull", Ok(repo("com.example.like")));
656 tokio::time::advance(FRESH_FOR).await;
657 assert_eq!(
658 sets.expand(&request).await.to_string(),
659 "repo:com.example.like"
660 );
661 assert_eq!(script.calls(), 2, "a stale set is resolved again");
662 }
663
664 #[tokio::test(start_paused = true)]
668 async fn a_failed_refresh_keeps_the_last_copy_and_is_retried_later() {
669 let script = Script::new();
670 script.answer("com.example.authFull", Ok(repo("com.example.post")));
671 let sets = PermissionSets::new(script.clone());
672 let request = scope("include:com.example.authFull");
673 sets.expand(&request).await;
674
675 script.answer(
676 "com.example.authFull",
677 Err(Unresolved::Unreachable("down".to_owned())),
678 );
679 tokio::time::advance(FRESH_FOR).await;
680 assert_eq!(
681 sets.expand(&request).await.to_string(),
682 "repo:com.example.post"
683 );
684 assert_eq!(
685 sets.expand(&request).await.to_string(),
686 "repo:com.example.post"
687 );
688 assert_eq!(script.calls(), 2, "the failure is not retried at once");
689
690 tokio::time::advance(RETRY_AFTER).await;
691 sets.expand(&request).await;
692 assert_eq!(script.calls(), 3, "and is retried after a while");
693
694 tokio::time::advance(KEEP_FOR).await;
695 assert_eq!(
696 sets.expand(&request).await.to_string(),
697 "",
698 "a copy past its keep grants nothing"
699 );
700 }
701
702 #[tokio::test(start_paused = true)]
705 async fn an_authority_that_withdraws_a_set_ends_its_last_copy() {
706 let script = Script::new();
707 script.answer("com.example.authFull", Ok(repo("com.example.post")));
708 let sets = PermissionSets::new(script.clone());
709 let request = scope("include:com.example.authFull");
710 sets.expand(&request).await;
711
712 script.answer(
713 "com.example.authFull",
714 Err(Unresolved::Invalid("no record there".to_owned())),
715 );
716 tokio::time::advance(FRESH_FOR).await;
717 assert_eq!(sets.expand(&request).await.to_string(), "");
718 assert_eq!(
719 sets.expand(&request).await.to_string(),
720 "",
721 "nor while the answer is remembered"
722 );
723 }
724
725 #[tokio::test(start_paused = true)]
726 async fn a_set_that_never_resolved_is_not_asked_for_again_at_once() {
727 let script = Script::new();
728 let sets = PermissionSets::new(script.clone());
729 let request = scope("include:org.example.authGone");
730 sets.expand(&request).await;
731 sets.expand(&request).await;
732 assert_eq!(script.calls(), 1);
733 tokio::time::advance(RETRY_AFTER).await;
734 sets.expand(&request).await;
735 assert_eq!(script.calls(), 2);
736 }
737
738 #[tokio::test(start_paused = true)]
741 async fn a_slow_set_is_cut_off_at_its_deadline() {
742 struct Slow;
743 impl Resolve for Slow {
744 fn resolve<'a>(&'a self, _nsid: &'a str) -> ResolveFuture<'a> {
745 Box::pin(async {
746 tokio::time::sleep(Duration::from_secs(3600)).await;
747 Ok(Vec::new())
748 })
749 }
750 }
751 let sets = PermissionSets::new(Arc::new(Slow));
752 let started = Instant::now();
753 let expanded = sets
754 .expand(&scope(
755 "atproto include:com.example.authOne include:com.example.authTwo \
756 include:com.example.authThree",
757 ))
758 .await;
759 assert_eq!(expanded.to_string(), "atproto");
760 assert_eq!(started.elapsed(), EXPANSION_DEADLINE);
761 }
762
763 #[tokio::test]
764 async fn one_request_resolves_at_most_its_bound_of_sets() {
765 let script = Script::new();
766 let names: Vec<String> = (0..MAX_SETS + 4)
767 .map(|i| format!("include:com.example.auth{i}"))
768 .collect();
769 PermissionSets::new(script.clone())
770 .expand(&scope(&names.join(" ")))
771 .await;
772 assert_eq!(script.calls(), MAX_SETS);
773 }
774
775 #[tokio::test]
778 async fn a_set_past_what_a_grant_holds_grants_nothing() {
779 let script = Script::new();
780 let methods: Vec<String> = (0..MAX_GRANT_ATOMS)
781 .map(|i| format!("com.example.get{i}"))
782 .collect();
783 script.answer(
784 "com.example.authHuge",
785 Ok(vec![json!({
786 "type": "permission",
787 "resource": "rpc",
788 "lxm": methods,
789 "aud": "*",
790 })]),
791 );
792 script.answer("org.example.authSmall", Ok(repo("org.example.post")));
793 let expanded = PermissionSets::new(script)
794 .expand(&scope(
795 "atproto include:com.example.authHuge include:org.example.authSmall",
796 ))
797 .await;
798 assert_eq!(expanded.to_string(), "atproto repo:org.example.post");
799 }
800}