1use std::collections::{HashMap, HashSet};
58use std::sync::{Arc, RwLock};
59
60use didbot_schema::format::datetime;
61use rand::rngs::OsRng;
62use rand::TryRngCore;
63use serde_json::{json, Value};
64use time::OffsetDateTime;
65use tokio::sync::Notify;
66
67use crate::oauth::client_metadata::{ClientKey, ClientMetadataDocument};
68use crate::oauth::consent::ConsentReference;
69use crate::oauth::scope::{Scope, ScopeSet};
70
71pub const DEFAULT_PENDING_PER_ACCOUNT: usize = 8;
81
82pub const DEFAULT_PENDING_TOTAL: usize = 512;
85
86const SEEN_CLIENTS_MAX: usize = 4096;
94
95pub const REMEMBERED_TTL: time::Duration = crate::oauth::token::REFRESH_TTL;
98
99const EPOCH_BYTES: usize = 8;
101
102#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Cursor {
117 pub epoch: String,
119 pub sequence: u64,
121}
122
123impl Cursor {
124 pub fn read(raw: Option<&str>, epoch: &str) -> u64 {
132 let Some((sent_epoch, sequence)) = raw.and_then(|raw| raw.split_once('.')) else {
133 return 0;
134 };
135 if sent_epoch != epoch {
136 return 0;
137 }
138 sequence.parse().unwrap_or(0)
139 }
140
141 pub fn wire(epoch: &str, sequence: u64) -> String {
143 format!("{epoch}.{sequence}")
144 }
145}
146
147pub const CEILING_RULE: &str = "scope-ceiling";
153
154#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Verdict {
162 Allow,
164 Narrow {
166 granted: ScopeSet,
168 cut: ScopeSet,
172 rule: String,
174 },
175 Deny {
177 reason: String,
179 rule: String,
181 },
182}
183
184impl Verdict {
185 pub fn kind(&self) -> &'static str {
187 match self {
188 Verdict::Allow => "allow",
189 Verdict::Narrow { .. } => "narrow",
190 Verdict::Deny { .. } => "deny",
191 }
192 }
193
194 pub fn granted(&self, requested: &ScopeSet) -> ScopeSet {
201 match self {
202 Verdict::Allow => requested.clone(),
203 Verdict::Narrow { granted, .. } => granted.clone(),
204 Verdict::Deny { .. } => ScopeSet::default(),
205 }
206 }
207
208 pub fn is_deny(&self) -> bool {
210 matches!(self, Verdict::Deny { .. })
211 }
212
213 fn to_json(&self) -> Value {
214 match self {
215 Verdict::Allow => json!({ "kind": "allow" }),
216 Verdict::Narrow { granted, cut, rule } => json!({
217 "kind": "narrow",
218 "granted": atoms(granted),
219 "cut": atoms(cut),
220 "rule": rule,
221 }),
222 Verdict::Deny { reason, rule } => {
223 json!({ "kind": "deny", "reason": reason, "rule": rule })
224 }
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum DecisionState {
246 Pending,
248 Approved,
250 Declined,
252 Expired,
254}
255
256impl DecisionState {
257 pub fn as_str(self) -> &'static str {
259 match self {
260 DecisionState::Pending => "pending",
261 DecisionState::Approved => "approved",
262 DecisionState::Declined => "declined",
263 DecisionState::Expired => "expired",
264 }
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Default)]
275pub struct ClientSelf {
276 pub name: Option<String>,
278 pub uri: Option<String>,
280 pub logo: Option<String>,
282}
283
284impl ClientSelf {
285 pub fn of(document: &ClientMetadataDocument) -> Self {
287 Self {
288 name: document.client_name.clone(),
289 uri: document.client_uri.clone(),
290 logo: document.logo_uri.clone(),
291 }
292 }
293}
294
295#[derive(Debug, Clone)]
297pub struct DecisionRecord {
298 pub request_uri: String,
300 pub account: String,
303 pub client_origin: String,
307 pub client_key: ClientKey,
310 pub first_time: bool,
321 pub client_self: ClientSelf,
324 pub requested: ScopeSet,
326 pub verdict: Verdict,
328 pub token: Option<ConsentReference>,
335 pub created_at: OffsetDateTime,
337 pub expires_at: OffsetDateTime,
341 pub state: DecisionState,
343 pub sequence: u64,
346}
347
348impl DecisionRecord {
349 pub fn granted(&self) -> ScopeSet {
351 self.verdict.granted(&self.requested)
352 }
353
354 pub fn is_live(&self, now: OffsetDateTime) -> bool {
356 self.state == DecisionState::Pending && now < self.expires_at
357 }
358
359 pub fn to_json(&self) -> Value {
364 let mut out = json!({
365 "requestUri": self.request_uri,
366 "account": self.account,
367 "client": {
368 "origin": self.client_origin,
369 "key": self.client_key.0,
370 "firstTime": self.first_time,
371 },
372 "requested": atoms(&self.requested),
373 "verdict": self.verdict.to_json(),
374 "createdAt": datetime::render(self.created_at),
375 "expiresAt": datetime::render(self.expires_at),
376 "state": self.state.as_str(),
377 });
378 if let Some(token) = &self.token {
379 out["token"] = json!(token.0);
380 }
381 out
382 }
383}
384
385fn atoms(scopes: &ScopeSet) -> Vec<String> {
387 scopes.0.iter().map(ToString::to_string).collect()
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct Remembered {
393 pub client_id: String,
395 pub granted: ScopeSet,
398 pub expires_at: OffsetDateTime,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
412pub enum DecisionRefused {
413 #[error("this server already has {0} authorization requests awaiting an answer")]
415 TooMany(usize),
416}
417
418#[derive(Debug, Clone)]
427pub struct Displaced {
428 pub request_uri: String,
430 pub token: Option<ConsentReference>,
432}
433
434#[derive(Debug, Clone)]
436pub struct Stored {
437 pub record: DecisionRecord,
439 pub displaced: Option<Displaced>,
442}
443
444pub trait DecisionStore: Send + Sync {
446 fn put(&self, record: DecisionRecord) -> Result<Stored, DecisionRefused>;
472
473 fn check_room(&self) -> Result<(), DecisionRefused>;
484
485 fn get(&self, request_uri: &str) -> Option<DecisionRecord>;
487
488 fn by_token(&self, token: &str) -> Option<DecisionRecord>;
491
492 fn pending_for(&self, account: &str, cursor: Option<&str>) -> (Vec<DecisionRecord>, String);
501
502 fn settle(&self, request_uri: &str, state: DecisionState) -> Option<DecisionRecord>;
510
511 fn approve(&self, request_uri: &str, redirect: Option<String>) -> Option<DecisionRecord>;
516
517 fn holds(&self, request_uri: &str) -> bool;
519
520 fn collect(&self, request_uri: &str) -> Option<String>;
522
523 fn sweep(&self, now: OffsetDateTime);
526
527 fn arrivals(&self) -> Arc<Notify>;
530
531 fn epoch(&self) -> String;
533
534 fn remember(
538 &self,
539 account: &str,
540 client_key: &ClientKey,
541 client_id: &str,
542 granted: ScopeSet,
543 now: OffsetDateTime,
544 );
545
546 fn remembered(
548 &self,
549 account: &str,
550 client_key: &ClientKey,
551 now: OffsetDateTime,
552 ) -> Option<Remembered>;
553
554 fn forget_client(&self, client_id: &str, account: Option<&str>) -> usize;
557
558 fn forget_account(&self, account: &str);
560
561 fn forget_all(&self);
563}
564
565#[derive(Debug)]
567pub struct MemoryDecisionStore {
568 inner: RwLock<Inner>,
569 per_account: usize,
570 total: usize,
571 arrivals: Arc<Notify>,
572 epoch: String,
573}
574
575#[derive(Debug, Default)]
576struct Inner {
577 records: HashMap<String, DecisionRecord>,
578 tokens: HashMap<String, String>,
579 redirects: HashMap<String, String>,
581 seen: HashSet<(String, String)>,
582 remembered: HashMap<(String, String), Remembered>,
585 sequence: u64,
586}
587
588impl Default for MemoryDecisionStore {
589 fn default() -> Self {
590 Self::with_bounds(DEFAULT_PENDING_PER_ACCOUNT, DEFAULT_PENDING_TOTAL)
591 }
592}
593
594impl MemoryDecisionStore {
595 pub fn new() -> Self {
597 Self::default()
598 }
599
600 pub fn with_bounds(per_account: usize, total: usize) -> Self {
603 let mut bytes = [0u8; EPOCH_BYTES];
604 OsRng
605 .try_fill_bytes(&mut bytes)
606 .expect("the OS random source is available");
607 Self {
608 inner: RwLock::new(Inner::default()),
609 per_account,
610 total,
611 arrivals: Arc::new(Notify::new()),
612 epoch: hex::encode(bytes),
613 }
614 }
615}
616
617impl Inner {
618 fn sweep(&mut self, now: OffsetDateTime) {
620 for record in self.records.values_mut() {
621 if record.state == DecisionState::Pending && now >= record.expires_at {
622 record.state = DecisionState::Expired;
623 }
624 }
625 self.records.retain(|_, record| now < record.expires_at);
626 let records = &self.records;
627 self.redirects
628 .retain(|request_uri, _| records.contains_key(request_uri));
629 let live: HashSet<String> = self
630 .records
631 .values()
632 .filter(|record| record.state == DecisionState::Pending)
633 .filter_map(|record| record.token.as_ref().map(|token| token.0.clone()))
634 .collect();
635 self.tokens.retain(|token, _| live.contains(token));
636 self.remembered
637 .retain(|_, remembered| now < remembered.expires_at);
638 }
639
640 fn live_for(&self, account: &str) -> usize {
643 self.records
644 .values()
645 .filter(|record| record.account == account && record.state == DecisionState::Pending)
646 .count()
647 }
648
649 fn displace_oldest_for(&mut self, account: &str) -> Option<Displaced> {
656 let oldest = self
657 .records
658 .values()
659 .filter(|record| record.account == account && record.state == DecisionState::Pending)
660 .min_by_key(|record| record.sequence)?
661 .request_uri
662 .clone();
663 let record = self.records.remove(&oldest)?;
664 if let Some(token) = &record.token {
665 self.tokens.remove(&token.0);
666 }
667 Some(Displaced {
668 request_uri: record.request_uri,
669 token: record.token,
670 })
671 }
672
673 fn has_room(&self, total: usize) -> bool {
676 self.records.len() < total
677 }
678
679 fn settle(
682 &mut self,
683 request_uri: &str,
684 state: DecisionState,
685 redirect: Option<String>,
686 now: OffsetDateTime,
687 ) -> Option<DecisionRecord> {
688 let record = self.records.get_mut(request_uri)?;
689 if !record.is_live(now) {
690 return None;
691 }
692 let settled = record.clone();
693 record.state = state;
694 if let Some(token) = &settled.token {
695 self.tokens.remove(&token.0);
696 }
697 if state == DecisionState::Approved {
698 if self.seen.len() >= SEEN_CLIENTS_MAX {
699 self.seen.clear();
700 }
701 self.seen
702 .insert((settled.account.clone(), settled.client_key.0.clone()));
703 if let Some(redirect) = redirect {
704 self.redirects.insert(request_uri.to_owned(), redirect);
705 }
706 }
707 Some(settled)
708 }
709}
710
711impl DecisionStore for MemoryDecisionStore {
712 fn put(&self, mut record: DecisionRecord) -> Result<Stored, DecisionRefused> {
713 let mut inner = self.inner.write().unwrap_or_else(|p| p.into_inner());
714 inner.sweep(OffsetDateTime::now_utc());
715
716 let displaced = (inner.live_for(&record.account) >= self.per_account)
719 .then(|| inner.displace_oldest_for(&record.account))
720 .flatten();
721 if displaced.is_none() && !inner.has_room(self.total) {
728 return Err(DecisionRefused::TooMany(self.total));
729 }
730
731 record.first_time = didbot_pds::policy::is_local_origin(&record.client_origin)
734 || !inner
735 .seen
736 .contains(&(record.account.clone(), record.client_key.0.clone()));
737
738 inner.sequence += 1;
739 record.sequence = inner.sequence;
740 if let Some(token) = &record.token {
741 inner
742 .tokens
743 .insert(token.0.clone(), record.request_uri.clone());
744 }
745 inner
746 .records
747 .insert(record.request_uri.clone(), record.clone());
748 drop(inner);
749 self.arrivals.notify_waiters();
750 Ok(Stored { record, displaced })
751 }
752
753 fn check_room(&self) -> Result<(), DecisionRefused> {
754 let mut inner = self.inner.write().unwrap_or_else(|p| p.into_inner());
755 inner.sweep(OffsetDateTime::now_utc());
756 if inner.has_room(self.total) {
757 Ok(())
758 } else {
759 Err(DecisionRefused::TooMany(self.total))
760 }
761 }
762
763 fn get(&self, request_uri: &str) -> Option<DecisionRecord> {
764 let inner = self.inner.read().unwrap_or_else(|p| p.into_inner());
765 inner.records.get(request_uri).cloned()
766 }
767
768 fn by_token(&self, token: &str) -> Option<DecisionRecord> {
769 let inner = self.inner.read().unwrap_or_else(|p| p.into_inner());
770 let request_uri = inner.tokens.get(token)?;
771 inner.records.get(request_uri).cloned()
772 }
773
774 fn pending_for(&self, account: &str, cursor: Option<&str>) -> (Vec<DecisionRecord>, String) {
775 let after = Cursor::read(cursor, &self.epoch);
776 let now = OffsetDateTime::now_utc();
777 let inner = self.inner.read().unwrap_or_else(|p| p.into_inner());
778 let mut out: Vec<DecisionRecord> = inner
779 .records
780 .values()
781 .filter(|record| {
782 record.account == account && record.sequence > after && record.is_live(now)
783 })
784 .cloned()
785 .collect();
786 out.sort_by_key(|record| record.sequence);
787 (out, Cursor::wire(&self.epoch, inner.sequence.max(after)))
788 }
789
790 fn settle(&self, request_uri: &str, state: DecisionState) -> Option<DecisionRecord> {
791 self.inner
792 .write()
793 .unwrap_or_else(|p| p.into_inner())
794 .settle(request_uri, state, None, OffsetDateTime::now_utc())
795 }
796
797 fn approve(&self, request_uri: &str, redirect: Option<String>) -> Option<DecisionRecord> {
798 self.inner
799 .write()
800 .unwrap_or_else(|p| p.into_inner())
801 .settle(
802 request_uri,
803 DecisionState::Approved,
804 redirect,
805 OffsetDateTime::now_utc(),
806 )
807 }
808
809 fn holds(&self, request_uri: &str) -> bool {
810 self.inner
811 .read()
812 .unwrap_or_else(|p| p.into_inner())
813 .redirects
814 .contains_key(request_uri)
815 }
816
817 fn collect(&self, request_uri: &str) -> Option<String> {
818 self.inner
819 .write()
820 .unwrap_or_else(|p| p.into_inner())
821 .redirects
822 .remove(request_uri)
823 }
824
825 fn sweep(&self, now: OffsetDateTime) {
826 self.inner
827 .write()
828 .unwrap_or_else(|p| p.into_inner())
829 .sweep(now);
830 }
831
832 fn arrivals(&self) -> Arc<Notify> {
833 self.arrivals.clone()
834 }
835
836 fn epoch(&self) -> String {
837 self.epoch.clone()
838 }
839
840 fn remember(
841 &self,
842 account: &str,
843 client_key: &ClientKey,
844 client_id: &str,
845 granted: ScopeSet,
846 now: OffsetDateTime,
847 ) {
848 let mut inner = self.inner.write().unwrap_or_else(|p| p.into_inner());
849 inner.sweep(now);
850 let key = (account.to_owned(), client_key.0.clone());
851 if inner.remembered.len() >= SEEN_CLIENTS_MAX && !inner.remembered.contains_key(&key) {
854 inner.remembered.clear();
855 }
856 inner.remembered.insert(
857 key,
858 Remembered {
859 client_id: client_id.to_owned(),
860 granted,
861 expires_at: now + REMEMBERED_TTL,
862 },
863 );
864 }
865
866 fn remembered(
867 &self,
868 account: &str,
869 client_key: &ClientKey,
870 now: OffsetDateTime,
871 ) -> Option<Remembered> {
872 let inner = self.inner.read().unwrap_or_else(|p| p.into_inner());
873 inner
874 .remembered
875 .get(&(account.to_owned(), client_key.0.clone()))
876 .filter(|remembered| now < remembered.expires_at)
877 .cloned()
878 }
879
880 fn forget_client(&self, client_id: &str, account: Option<&str>) -> usize {
881 let mut inner = self.inner.write().unwrap_or_else(|p| p.into_inner());
882 let before = inner.remembered.len();
883 inner.remembered.retain(|(did, _), remembered| {
884 remembered.client_id != client_id || account.is_some_and(|account| account != did)
885 });
886 before - inner.remembered.len()
887 }
888
889 fn forget_account(&self, account: &str) {
890 self.inner
891 .write()
892 .unwrap_or_else(|p| p.into_inner())
893 .remembered
894 .retain(|(did, _), _| did != account);
895 }
896
897 fn forget_all(&self) {
898 self.inner
899 .write()
900 .unwrap_or_else(|p| p.into_inner())
901 .remembered
902 .clear();
903 }
904}
905
906fn hard_blocked(atom: &Scope) -> bool {
927 matches!(
928 atom,
929 Scope::Transition(_) | Scope::Identity { .. } | Scope::Account { .. } | Scope::Unknown(_)
930 )
931}
932
933pub fn verdict_for(requested: &ScopeSet, ceiling: &ScopeSet, rule: &str) -> Verdict {
940 let (dropped, asked): (Vec<Scope>, Vec<Scope>) =
943 requested.0.iter().cloned().partition(hard_blocked);
944 let (granted, cut) = ScopeSet::new(asked).narrow(ceiling);
945 let cut = ScopeSet::new(cut.0.into_iter().chain(dropped).collect());
946 if granted.0.is_empty() {
947 return Verdict::Deny {
948 reason: format!("this account's ceiling grants none of `{requested}`"),
949 rule: rule.to_owned(),
950 };
951 }
952 if cut.0.is_empty() {
953 return Verdict::Allow;
954 }
955 Verdict::Narrow {
956 granted,
957 cut,
958 rule: rule.to_owned(),
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965 fn record(account: &str, token: Option<&str>) -> DecisionRecord {
966 let now = OffsetDateTime::now_utc();
967 DecisionRecord {
968 request_uri: format!("urn:{}", rand_suffix()),
969 account: account.to_owned(),
970 client_origin: "https://client.example".to_owned(),
971 client_key: ClientKey("abcd".to_owned()),
972 first_time: false,
973 client_self: ClientSelf::default(),
974 requested: ScopeSet::parse("atproto").unwrap(),
975 verdict: Verdict::Allow,
976 token: token.map(|t| ConsentReference(t.to_owned())),
977 created_at: now,
978 expires_at: now + time::Duration::minutes(2),
979 state: DecisionState::Pending,
980 sequence: 0,
981 }
982 }
983
984 fn rand_suffix() -> String {
985 use std::sync::atomic::{AtomicU64, Ordering};
986 static NEXT: AtomicU64 = AtomicU64::new(0);
987 NEXT.fetch_add(1, Ordering::Relaxed).to_string()
988 }
989
990 #[test]
995 fn an_account_at_its_bound_displaces_its_oldest_rather_than_refusing() {
996 let store = MemoryDecisionStore::with_bounds(2, 100);
997 let first = store
998 .put(record("did:web:a", Some("tok-1")))
999 .unwrap()
1000 .record;
1001 let second = store.put(record("did:web:a", None)).unwrap().record;
1002 let third = store.put(record("did:web:a", Some("tok-3"))).unwrap();
1003
1004 let displaced = third.displaced.expect("the oldest made way");
1005 assert_eq!(displaced.request_uri, first.request_uri);
1006 assert_eq!(displaced.token.map(|t| t.0), Some("tok-1".to_owned()));
1007 assert!(store.get(&first.request_uri).is_none());
1008 assert!(store.by_token("tok-1").is_none());
1009 assert!(store.get(&second.request_uri).is_some());
1010 assert!(store.by_token("tok-3").is_some());
1011
1012 let (pending, _) = store.pending_for("did:web:a", None);
1013 assert_eq!(pending.len(), 2, "the bound still holds: {pending:?}");
1014
1015 let other = store.put(record("did:web:b", None)).unwrap();
1018 assert!(other.displaced.is_none());
1019 }
1020
1021 #[test]
1025 fn the_newest_push_survives_a_stranger_filling_the_account() {
1026 let store = MemoryDecisionStore::with_bounds(2, 100);
1027 for _ in 0..8 {
1028 store.put(record("did:web:a", None)).unwrap();
1029 }
1030 let real = store.put(record("did:web:a", Some("mine"))).unwrap().record;
1031 let (pending, _) = store.pending_for("did:web:a", None);
1032 assert!(
1033 pending.iter().any(|r| r.request_uri == real.request_uri),
1034 "the account's own sign-in was crowded out: {pending:?}"
1035 );
1036 assert!(store.by_token("mine").is_some());
1037 }
1038
1039 #[test]
1046 fn a_full_store_still_answers_the_account_whose_own_slots_are_full() {
1047 let store = MemoryDecisionStore::with_bounds(2, 4);
1048 for account in ["did:web:a", "did:web:b"] {
1050 for _ in 0..2 {
1051 store.put(record(account, None)).unwrap();
1052 }
1053 }
1054 assert_eq!(
1055 store.put(record("did:web:c", None)).unwrap_err(),
1056 DecisionRefused::TooMany(4),
1057 "a third account asking for a slot this store does not have"
1058 );
1059
1060 let mine = store
1061 .put(record("did:web:a", Some("mine")))
1062 .expect("an account at its own bound displaces, full store or not");
1063 assert!(mine.displaced.is_some());
1064 assert!(store.by_token("mine").is_some());
1065 let (pending, _) = store.pending_for("did:web:a", None);
1066 assert_eq!(pending.len(), 2, "the account's own bound still holds");
1067 assert_eq!(
1068 store.pending_for("did:web:b", None).0.len(),
1069 2,
1070 "and the other account's records were left alone"
1071 );
1072 }
1073
1074 #[test]
1075 fn the_server_wide_bound_refuses_regardless_of_account() {
1076 let store = MemoryDecisionStore::with_bounds(100, 2);
1077 assert!(store.put(record("did:web:a", None)).is_ok());
1078 assert!(store.put(record("did:web:b", None)).is_ok());
1079 assert_eq!(
1080 store.put(record("did:web:c", None)).unwrap_err(),
1081 DecisionRefused::TooMany(2)
1082 );
1083 }
1084
1085 #[test]
1090 fn check_room_reads_the_total_bound_without_writing() {
1091 let store = MemoryDecisionStore::with_bounds(100, 1);
1092 assert!(store.check_room().is_ok());
1093 assert!(
1094 store.check_room().is_ok(),
1095 "checking room does not use it up"
1096 );
1097 store.put(record("did:web:a", None)).unwrap();
1098 assert_eq!(store.check_room().unwrap_err(), DecisionRefused::TooMany(1));
1099 }
1100
1101 #[test]
1104 fn a_record_settles_once() {
1105 let store = MemoryDecisionStore::new();
1106 let stored = store.put(record("did:web:a", Some("tok"))).unwrap().record;
1107 assert!(store
1108 .settle(&stored.request_uri, DecisionState::Approved)
1109 .is_some());
1110 assert!(store
1111 .settle(&stored.request_uri, DecisionState::Declined)
1112 .is_none());
1113 assert_eq!(
1114 store.get(&stored.request_uri).unwrap().state,
1115 DecisionState::Approved
1116 );
1117 assert!(store.by_token("tok").is_none());
1119 }
1120
1121 #[test]
1123 fn an_approval_holds_its_redirect_until_it_is_collected_once() {
1124 let store = MemoryDecisionStore::new();
1125 let stored = store.put(record("did:web:a", Some("tok"))).unwrap().record;
1126 assert!(!store.holds(&stored.request_uri));
1127 store
1128 .approve(
1129 &stored.request_uri,
1130 Some("https://client.example/cb?code=c".into()),
1131 )
1132 .expect("a live record approves");
1133 assert!(
1134 store
1135 .approve(
1136 &stored.request_uri,
1137 Some("https://elsewhere.example/".into())
1138 )
1139 .is_none(),
1140 "an approval settles once"
1141 );
1142 assert!(store.holds(&stored.request_uri));
1143 assert_eq!(
1144 store.collect(&stored.request_uri).as_deref(),
1145 Some("https://client.example/cb?code=c")
1146 );
1147 assert!(store.collect(&stored.request_uri).is_none());
1148 assert!(!store.holds(&stored.request_uri));
1149 }
1150
1151 #[test]
1155 fn an_answered_record_stays_until_its_request_expires() {
1156 let store = MemoryDecisionStore::new();
1157 let approved = store.put(record("did:web:a", Some("a"))).unwrap().record;
1158 let declined = store.put(record("did:web:a", Some("d"))).unwrap().record;
1159 store.approve(
1160 &approved.request_uri,
1161 Some("https://client.example/cb".into()),
1162 );
1163 store.settle(&declined.request_uri, DecisionState::Declined);
1164
1165 store.sweep(OffsetDateTime::now_utc());
1166 assert_eq!(
1167 store.get(&approved.request_uri).unwrap().state,
1168 DecisionState::Approved
1169 );
1170 assert_eq!(
1171 store.get(&declined.request_uri).unwrap().state,
1172 DecisionState::Declined
1173 );
1174
1175 store.sweep(declined.expires_at);
1176 assert!(store.get(&approved.request_uri).is_none());
1177 assert!(store.get(&declined.request_uri).is_none());
1178 assert!(store.collect(&approved.request_uri).is_none());
1179 }
1180
1181 #[test]
1185 fn a_push_past_the_bound_never_displaces_an_answer() {
1186 let store = MemoryDecisionStore::with_bounds(2, 100);
1187 let approved = store.put(record("did:web:a", Some("mine"))).unwrap().record;
1188 store.approve(
1189 &approved.request_uri,
1190 Some("https://client.example/cb".into()),
1191 );
1192 for _ in 0..4 {
1193 let stranger = store.put(record("did:web:a", None)).unwrap();
1194 if let Some(displaced) = stranger.displaced {
1195 assert_ne!(displaced.request_uri, approved.request_uri);
1196 }
1197 }
1198 let (pending, _) = store.pending_for("did:web:a", None);
1199 assert_eq!(pending.len(), 2, "the account's own bound still holds");
1200 assert_eq!(
1201 store.get(&approved.request_uri).unwrap().state,
1202 DecisionState::Approved
1203 );
1204 assert_eq!(
1205 store.collect(&approved.request_uri).as_deref(),
1206 Some("https://client.example/cb")
1207 );
1208 }
1209
1210 #[test]
1213 fn the_server_wide_bound_counts_answered_records() {
1214 let store = MemoryDecisionStore::with_bounds(100, 1);
1215 let answered = store.put(record("did:web:a", Some("tok"))).unwrap().record;
1216 store.settle(&answered.request_uri, DecisionState::Approved);
1217 assert_eq!(
1218 store.put(record("did:web:b", None)).unwrap_err(),
1219 DecisionRefused::TooMany(1)
1220 );
1221 assert_eq!(store.check_room().unwrap_err(), DecisionRefused::TooMany(1));
1222 }
1223
1224 #[test]
1225 fn a_settled_record_is_not_listed_and_the_cursor_still_advances() {
1226 let store = MemoryDecisionStore::new();
1227 let first = store.put(record("did:web:a", None)).unwrap().record;
1228 let (pending, cursor) = store.pending_for("did:web:a", None);
1229 assert_eq!(pending.len(), 1);
1230 store.settle(&first.request_uri, DecisionState::Declined);
1231 let (pending, next) = store.pending_for("did:web:a", None);
1232 assert!(pending.is_empty());
1233 assert_eq!(next, cursor);
1234 }
1235
1236 #[test]
1240 fn a_cursor_only_returns_what_arrived_after_it() {
1241 let store = MemoryDecisionStore::new();
1242 store.put(record("did:web:a", None)).unwrap();
1243 let (_, cursor) = store.pending_for("did:web:a", None);
1244 let second = store.put(record("did:web:a", None)).unwrap().record;
1245 let (pending, _) = store.pending_for("did:web:a", Some(&cursor));
1246 assert_eq!(pending.len(), 1);
1247 assert_eq!(pending[0].request_uri, second.request_uri);
1248 }
1249
1250 #[test]
1251 fn one_account_never_sees_another_s_record() {
1252 let store = MemoryDecisionStore::new();
1253 store.put(record("did:web:a", None)).unwrap();
1254 let (pending, _) = store.pending_for("did:web:b", None);
1255 assert!(pending.is_empty());
1256 }
1257
1258 #[test]
1259 fn an_expired_record_is_swept_and_never_listed() {
1260 let store = MemoryDecisionStore::new();
1261 let mut stale = record("did:web:a", Some("tok"));
1262 stale.expires_at = OffsetDateTime::now_utc() - time::Duration::seconds(1);
1263 let stored = store.put(stale).unwrap().record;
1264 let (pending, _) = store.pending_for("did:web:a", None);
1265 assert!(pending.is_empty());
1266 store.sweep(OffsetDateTime::now_utc());
1267 assert!(store.get(&stored.request_uri).is_none());
1268 assert!(store.by_token("tok").is_none());
1269 }
1270
1271 #[test]
1275 fn first_time_lasts_until_the_account_approves() {
1276 let store = MemoryDecisionStore::new();
1277 let push = |account: &str| store.put(record(account, None)).unwrap().record;
1278
1279 let declined = push("did:web:a");
1280 assert!(declined.first_time);
1281 assert!(push("did:web:a").first_time, "a second push");
1282 store.settle(&declined.request_uri, DecisionState::Declined);
1283 assert!(push("did:web:a").first_time, "after a decline");
1284
1285 let approved = push("did:web:a");
1286 assert!(approved.first_time);
1287 store.settle(&approved.request_uri, DecisionState::Approved);
1288 assert!(!push("did:web:a").first_time, "after an approval");
1289 assert!(push("did:web:b").first_time, "another account");
1290 }
1291
1292 #[test]
1296 fn a_remembered_approval_is_the_latest_grant_until_it_expires() {
1297 let store = MemoryDecisionStore::new();
1298 let key = ClientKey("abcd".to_owned());
1299 let now = OffsetDateTime::now_utc();
1300 let wide = ScopeSet::parse("atproto repo:*").unwrap();
1301 let narrow = ScopeSet::parse("atproto").unwrap();
1302
1303 store.remember("did:web:a", &key, "https://c.example", wide, now);
1304 store.remember("did:web:a", &key, "https://c.example", narrow.clone(), now);
1305 let remembered = store.remembered("did:web:a", &key, now).unwrap();
1306 assert_eq!(remembered.granted, narrow);
1307 assert!(store.remembered("did:web:b", &key, now).is_none());
1308
1309 let later = now + REMEMBERED_TTL;
1310 assert!(store
1311 .remembered("did:web:a", &key, later - time::Duration::seconds(1))
1312 .is_some());
1313 assert!(store.remembered("did:web:a", &key, later).is_none());
1314 }
1315
1316 #[test]
1319 fn forgetting_a_client_on_one_account_spares_the_rest() {
1320 let store = MemoryDecisionStore::new();
1321 let (one, other) = (ClientKey("one".to_owned()), ClientKey("other".to_owned()));
1322 let now = OffsetDateTime::now_utc();
1323 let granted = ScopeSet::parse("atproto").unwrap();
1324 for account in ["did:web:a", "did:web:b"] {
1325 store.remember(account, &one, "https://one.example", granted.clone(), now);
1326 store.remember(
1327 account,
1328 &other,
1329 "https://other.example",
1330 granted.clone(),
1331 now,
1332 );
1333 }
1334
1335 assert_eq!(
1336 store.forget_client("https://one.example", Some("did:web:a")),
1337 1
1338 );
1339 assert!(store.remembered("did:web:a", &one, now).is_none());
1340 assert!(store.remembered("did:web:a", &other, now).is_some());
1341 assert!(store.remembered("did:web:b", &one, now).is_some());
1342
1343 assert_eq!(store.forget_client("https://one.example", None), 1);
1344 assert!(store.remembered("did:web:b", &one, now).is_none());
1345 }
1346
1347 #[test]
1355 fn a_cursor_from_before_a_restart_lists_from_the_beginning() {
1356 let before = MemoryDecisionStore::new();
1357 before.put(record("did:web:a", None)).unwrap();
1358 before.put(record("did:web:a", None)).unwrap();
1359 before.put(record("did:web:a", None)).unwrap();
1360 let (seen, stale) = before.pending_for("did:web:a", None);
1361 assert_eq!(seen.len(), 3);
1362
1363 let after = MemoryDecisionStore::new();
1365 assert_ne!(before.epoch(), after.epoch());
1366 after.put(record("did:web:a", None)).unwrap();
1367
1368 let (pending, fresh) = after.pending_for("did:web:a", Some(&stale));
1369 assert_eq!(
1370 pending.len(),
1371 1,
1372 "a cursor from the old store stranded the caller: {pending:?}"
1373 );
1374 assert!(fresh.starts_with(&after.epoch()));
1375 let (pending, _) = after.pending_for("did:web:a", Some(&fresh));
1377 assert!(pending.is_empty());
1378 }
1379
1380 #[test]
1385 fn a_cursor_this_store_did_not_mint_is_read_as_the_beginning() {
1386 let store = MemoryDecisionStore::new();
1387 store.put(record("did:web:a", None)).unwrap();
1388 for raw in [
1389 "",
1390 "3",
1391 "not-a-cursor",
1392 "deadbeefdeadbeef.3",
1393 ".",
1394 "..",
1395 &format!("{}.notanumber", store.epoch()),
1396 ] {
1397 let (pending, _) = store.pending_for("did:web:a", Some(raw));
1398 assert_eq!(pending.len(), 1, "`{raw}` was not read as the beginning");
1399 }
1400 }
1401
1402 #[test]
1403 fn a_cursor_reads_back_the_sequence_it_carries() {
1404 assert_eq!(Cursor::read(Some("abc.7"), "abc"), 7);
1405 assert_eq!(Cursor::read(Some("abc.7"), "xyz"), 0);
1406 assert_eq!(Cursor::read(None, "abc"), 0);
1407 assert_eq!(Cursor::wire("abc", 7), "abc.7");
1408 }
1409
1410 #[test]
1411 fn a_ceiling_that_grants_everything_asked_for_allows() {
1412 let requested = ScopeSet::parse("atproto repo:app.bsky.feed.post").unwrap();
1413 let ceiling = ScopeSet::parse("atproto repo:*").unwrap();
1414 assert_eq!(
1415 verdict_for(&requested, &ceiling, CEILING_RULE),
1416 Verdict::Allow
1417 );
1418 }
1419
1420 #[test]
1421 fn a_ceiling_that_grants_nothing_denies() {
1422 let requested = ScopeSet::parse("repo:app.bsky.feed.post").unwrap();
1423 let verdict = verdict_for(&requested, &ScopeSet::default(), CEILING_RULE);
1424 assert!(verdict.is_deny());
1425 assert!(verdict.granted(&requested).0.is_empty());
1426 }
1427
1428 #[test]
1431 fn a_ceiling_that_grants_part_narrows_and_names_the_cut() {
1432 let requested = ScopeSet::parse("atproto repo:app.bsky.feed.post").unwrap();
1433 let ceiling = ScopeSet::parse("atproto").unwrap();
1434 let verdict = verdict_for(&requested, &ceiling, CEILING_RULE);
1435 let Verdict::Narrow { granted, cut, rule } = &verdict else {
1436 panic!("expected a narrowing, got {verdict:?}");
1437 };
1438 assert_eq!(granted.to_string(), "atproto");
1439 assert_eq!(cut.to_string(), "repo:app.bsky.feed.post");
1440 assert_eq!(rule, CEILING_RULE);
1441 assert_eq!(verdict.granted(&requested).to_string(), "atproto");
1442 }
1443
1444 #[test]
1449 fn a_request_naming_a_hard_blocked_atom_is_granted_the_rest() {
1450 let requested = ScopeSet::parse("atproto transition:generic").unwrap();
1451 let ceiling = ScopeSet::parse("atproto repo:* rpc:*").unwrap();
1452 let verdict = verdict_for(&requested, &ceiling, CEILING_RULE);
1453 let Verdict::Narrow { granted, cut, rule } = &verdict else {
1454 panic!("expected a narrowing, got {verdict:?}");
1455 };
1456 assert_eq!(granted.to_string(), "atproto");
1457 assert_eq!(cut.to_string(), "transition:generic");
1458 assert_eq!(rule, CEILING_RULE);
1459 }
1460
1461 #[test]
1466 fn every_hard_blocked_atom_is_cut_however_it_is_spelled() {
1467 let ceiling = ScopeSet::parse("atproto repo:* rpc:*").unwrap();
1468 for (atom, named) in [
1469 ("transition:generic", "transition:generic"),
1470 ("transition:chat.bsky", "transition:chat.bsky"),
1471 ("transition:email", "transition:email"),
1472 ("identity:*", "identity:*"),
1473 ("identity:*?", "identity:*"),
1474 ("identity?attr=*", "identity:*"),
1475 ("identity:handle", "identity:handle"),
1476 ("identity?attr=handle", "identity:handle"),
1477 ("identity:handle?action=manage", "identity:handle"),
1478 ("account:email", "account:email"),
1479 ("account:email?action=read", "account:email"),
1480 ("account:repo?action=manage", "account:repo?action=manage"),
1481 ("account:status", "account:status"),
1482 (
1483 "space:*?authority=*&action=read",
1484 "space:*?authority=*&action=read",
1485 ),
1486 ] {
1487 let requested =
1488 ScopeSet::parse(&format!("atproto repo:app.bsky.feed.post {atom}")).unwrap();
1489 let verdict = verdict_for(&requested, &ceiling, CEILING_RULE);
1490 let Verdict::Narrow { granted, cut, rule } = &verdict else {
1491 panic!("`{atom}` was not cut: {verdict:?}");
1492 };
1493 assert_eq!(
1494 granted.to_string(),
1495 "atproto repo:app.bsky.feed.post",
1496 "`{atom}`"
1497 );
1498 assert_eq!(cut.to_string(), named, "`{atom}`");
1499 assert_eq!(rule, CEILING_RULE);
1500 }
1501 }
1502
1503 #[test]
1506 fn a_ceiling_that_holds_a_hard_blocked_atom_still_grants_none() {
1507 let held = ScopeSet::parse(
1508 "atproto transition:generic transition:email identity:* account:email space:*",
1509 )
1510 .unwrap();
1511 let requested = ScopeSet::parse(
1512 "atproto transition:generic transition:email identity:handle account:email space:*",
1513 )
1514 .unwrap();
1515 let verdict = verdict_for(&requested, &held, CEILING_RULE);
1516 let Verdict::Narrow { granted, cut, .. } = &verdict else {
1517 panic!("expected a narrowing, got {verdict:?}");
1518 };
1519 assert_eq!(granted.to_string(), "atproto");
1520 assert_eq!(
1521 cut.to_string(),
1522 "transition:generic transition:email identity:handle account:email space:*"
1523 );
1524 }
1525
1526 #[test]
1529 fn a_request_of_only_hard_blocked_atoms_is_denied_by_the_ceiling() {
1530 let requested = ScopeSet::parse("transition:email account:email").unwrap();
1531 let ceiling = ScopeSet::parse("atproto repo:* rpc:*").unwrap();
1532 assert_eq!(
1533 verdict_for(&requested, &ceiling, CEILING_RULE),
1534 Verdict::Deny {
1535 reason: "this account's ceiling grants none of \
1536 `transition:email account:email`"
1537 .to_owned(),
1538 rule: CEILING_RULE.to_owned(),
1539 }
1540 );
1541 }
1542
1543 #[test]
1546 fn scopes_asked_for_beside_a_transition_scope_survive_its_drop() {
1547 let requested =
1548 ScopeSet::parse("atproto transition:generic repo:app.bsky.feed.post blob:image/png")
1549 .unwrap();
1550 let ceiling = ScopeSet::parse("atproto repo:* rpc:* blob:*/*").unwrap();
1551 let verdict = verdict_for(&requested, &ceiling, CEILING_RULE);
1552 assert_eq!(
1553 verdict.granted(&requested).to_string(),
1554 "atproto repo:app.bsky.feed.post blob:image/png"
1555 );
1556 }
1557
1558 #[test]
1562 fn a_request_wider_than_its_ceiling_narrows_to_the_ceiling() {
1563 let requested = ScopeSet::parse("atproto repo:* rpc:*").unwrap();
1564 let ceiling = ScopeSet::parse("atproto repo:*").unwrap();
1565 let verdict = verdict_for(&requested, &ceiling, CEILING_RULE);
1566 let Verdict::Narrow { granted, cut, .. } = &verdict else {
1567 panic!("expected a narrowing, got {verdict:?}");
1568 };
1569 assert_eq!(granted.to_string(), "atproto repo:*");
1570 assert_eq!(cut.to_string(), "rpc:*");
1571 }
1572
1573 #[test]
1579 fn a_request_narrower_than_its_ceiling_is_granted_at_the_form_it_asked_for() {
1580 let requested = ScopeSet::parse("repo:com.example.thing?action=create").unwrap();
1581 let ceiling = ScopeSet::parse("repo:*").unwrap();
1582 let verdict = verdict_for(&requested, &ceiling, CEILING_RULE);
1583 assert_eq!(verdict, Verdict::Allow);
1584 assert_eq!(
1585 verdict.granted(&requested).to_string(),
1586 "repo:com.example.thing?action=create"
1587 );
1588 }
1589
1590 #[test]
1594 fn the_wire_shape_carries_no_client_self_description() {
1595 let mut with_copy = record("did:web:a", Some("tok"));
1596 with_copy.client_self = ClientSelf {
1597 name: Some("Krillbridge".to_owned()),
1598 uri: Some("https://impersonator.example".to_owned()),
1599 logo: Some("https://impersonator.example/logo.png".to_owned()),
1600 };
1601 let text = with_copy.to_json().to_string();
1602 assert!(!text.contains("Krillbridge"));
1603 assert!(!text.contains("impersonator.example"));
1604 assert!(text.contains("https://client.example"));
1605 assert!(text.contains("\"firstTime\""));
1606 }
1607}