Skip to main content

didbot_serve/oauth/
decision.rs

1//! Decision records: what a pushed authorization request becomes, and what
2//! an agent answers.
3//!
4//! `plan/oauth.md`'s hard part is who consents. The answer this module
5//! carries is that the pushed request itself is the question: at PAR, once
6//! the client is known and `login_hint` has been resolved to an account,
7//! this server works out what it would grant and writes that down as a
8//! [`DecisionRecord`] the named account can read, approve or decline over
9//! `bot.did.*`. Nothing about it needs a browser, and nothing about it
10//! forbids one — `GET /oauth/authorize` renders the same record for a person.
11//!
12//! # What the record says, and what it keeps to itself
13//!
14//! [`DecisionRecord::to_json`] is the whole wire shape. The client's own copy
15//! of its name, its home page and its logo is kept ([`ClientSelf`]) and never
16//! serialized: `plan/app-allowlist.md`'s "record the client's own copy; do
17//! not show it", because showing an untrusted client's self-description is
18//! how impersonation works. What an agent sees about the client is the
19//! origin it served its metadata from, the content key of that metadata, and
20//! whether this account has approved that key before.
21//!
22//! # Narrowing is never silent
23//!
24//! A [`Verdict`] is one of three answers, and two of them say something was
25//! taken away. [`Verdict::Narrow`] names the grant *and* the atoms the
26//! ceiling did not give in full. See
27//! [`crate::oauth::scope::ScopeSet::narrow`].
28//!
29//! An approval covers what the verdict granted, and that is what the login
30//! is issued at. A token may do that set as the ceiling in force grants it
31//! ([`crate::oauth::token::in_force`]), so a ceiling tightened after the
32//! approval takes a granted atom back. A cut atom stays cut: widening the
33//! ceiling cannot add one to a login the agent approved without it.
34//!
35//! # Nothing here is durable
36//!
37//! A record lives as long as the pushed request it was minted for
38//! ([`crate::oauth::par::REQUEST_URI_TTL`]), answered or not, and is swept
39//! when it expires: `GET /oauth/authorize` answers from it until then. An
40//! approval whose code a browser carries holds its redirect here too, until
41//! the page hands it to a browser signed in as the account
42//! ([`DecisionStore::collect`]). [`MemoryDecisionStore`] is bounded twice
43//! over — per account and in total — because PAR is unauthenticated, so an
44//! unbounded queue of records would be a way to spend this server's memory
45//! without holding any credential.
46//!
47//! # A remembered approval
48//!
49//! The account's approval of a client is remembered, per account and
50//! [`ClientKey`], at the grant it approved, for [`REMEMBERED_TTL`]
51//! ([`DecisionStore::remember`]). `GET /oauth/authorize` reads it to confirm
52//! a later sign-in without asking; see
53//! [`crate::oauth::authorize::confirms_unasked`]. A confirmation does not
54//! write one, so it never renews itself. It lives in memory, so a restart
55//! forgets every one. See `plan/remembered-consent.md`.
56
57use 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
71/// How many decisions one account may have awaiting an answer.
72///
73/// A client that pushes a request and never comes back leaves one of these
74/// behind for two minutes. Eight is more than any honest sign-in flow needs
75/// at once and few enough that a spree against one account cannot crowd out
76/// every other account's decisions.
77///
78/// Past it, the account's oldest undecided record is displaced rather than
79/// the new push dropped — see [`DecisionStore::put`].
80pub const DEFAULT_PENDING_PER_ACCOUNT: usize = 8;
81
82/// How many decisions this server may hold in total. An answered one counts
83/// until its pushed request expires.
84pub const DEFAULT_PENDING_TOTAL: usize = 512;
85
86/// How many `(account, client key)` approvals [`MemoryDecisionStore`]
87/// remembers for `DecisionRecord::first_time`.
88///
89/// The set is cleared rather than trimmed when it fills, so every client
90/// looks new again for a while. That is the failing-safe direction: `true`
91/// tells an agent to look harder at a client it may in fact have seen, and
92/// `false` would tell it not to bother with one it has not.
93const SEEN_CLIENTS_MAX: usize = 4096;
94
95/// How long the account's approval of a client is remembered: as long as
96/// the login it approved can last.
97pub const REMEMBERED_TTL: time::Duration = crate::oauth::token::REFRESH_TTL;
98
99/// How many random bytes name one store's epoch; see [`Cursor`].
100const EPOCH_BYTES: usize = 8;
101
102/// A place in one store's arrival order, and which store's order it is.
103///
104/// `"<epoch>.<sequence>"` on the wire, opaque to a caller. The epoch is
105/// minted when the store is created, so a cursor a daemon is holding across
106/// a restart names an order that no longer exists and is read as "from the
107/// beginning" rather than as a sequence this store has not reached yet.
108///
109/// Without it a restart strands every client: the counter begins again at
110/// zero, a daemon holding `3` asks for everything after `3`, and the answer
111/// is empty for as long as it takes the new store to mint four records —
112/// which for a quiet account is never. The failure is silent on both sides,
113/// which is what makes it worth a self-describing cursor rather than a
114/// durable counter.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Cursor {
117    /// Which store's arrival order this names.
118    pub epoch: String,
119    /// How far into that order the caller has read.
120    pub sequence: u64,
121}
122
123impl Cursor {
124    /// Reads a cursor a caller sent back, against the epoch in force now.
125    ///
126    /// Anything that is not this store's order — a different epoch, a
127    /// malformed string, an absent cursor — is the beginning of this one.
128    /// A caller is never told its cursor was discarded, because there is
129    /// nothing it could usefully do differently: the answer it gets carries
130    /// the current epoch, and sending that back works.
131    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    /// The wire form.
142    pub fn wire(epoch: &str, sequence: u64) -> String {
143        format!("{epoch}.{sequence}")
144    }
145}
146
147/// The rule a ceiling narrowing or refusal is attributed to.
148///
149/// `plan/scope-policy.md`'s ceiling is not one of the operator's numbered
150/// policies — it is this server applying the enumeration it was configured
151/// with — so it names itself rather than borrowing a policy id.
152pub const CEILING_RULE: &str = "scope-ceiling";
153
154/// What this server decided about one authorization request.
155///
156/// Derived, not chosen: [`verdict_for`] computes it from the requested scope
157/// and the ceiling, and a [`didbot_pds::policy::PolicyGate`] refusal
158/// overrides it. A policy denies and never grants (`plan/policy.md`), so
159/// there is no variant here that widens anything.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Verdict {
162    /// The ceiling grants the request exactly as asked.
163    Allow,
164    /// The ceiling grants part of the request.
165    Narrow {
166        /// What the ceiling grants now.
167        granted: ScopeSet,
168        /// The requested atoms no granted atom covers in full — see
169        /// [`ScopeSet::narrow`]. Never part of the approval, so a login
170        /// approved against this verdict never carries one.
171        cut: ScopeSet,
172        /// What did the narrowing: [`CEILING_RULE`], or a policy's own id.
173        rule: String,
174    },
175    /// Nothing is granted.
176    Deny {
177        /// Why, in the refusing party's own words.
178        reason: String,
179        /// What refused: [`CEILING_RULE`], or a policy's own id.
180        rule: String,
181    },
182}
183
184impl Verdict {
185    /// The wire word for this verdict's kind.
186    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    /// What a token issued under the ceiling this verdict was reached with
195    /// would carry, given the scope that was requested.
196    ///
197    /// [`Verdict::Allow`] grants the request whole, which is what an empty
198    /// `cut` means; [`Verdict::Deny`] grants nothing, which is why an
199    /// approval of a denied record can never mint a code.
200    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    /// Whether this verdict leaves anything to approve.
209    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/// Where one decision record is in its life.
230///
231/// The whole state machine, in one place: a record is minted
232/// [`DecisionState::Pending`] at PAR and leaves that state exactly once —
233/// the named account approves it, the named account declines it, or its
234/// pushed request outlives its window and a sweep marks it
235/// [`DecisionState::Expired`]. There is no path back to `Pending` and no
236/// transition between the three terminal states. Only a `Pending` record is
237/// listed to an agent, and only a `Pending` record can be approved.
238///
239/// A record can also stop existing without reaching any of the three: the
240/// account's newest push displaces its oldest undecided record when the
241/// account is at its bound ([`DecisionStore::put`]). A displaced record is
242/// removed rather than settled, so everything downstream sees exactly what
243/// it sees for one that expired and was swept.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum DecisionState {
246    /// Minted, and nobody has answered.
247    Pending,
248    /// The named account approved it, and a code was issued.
249    Approved,
250    /// The named account said no.
251    Declined,
252    /// Nobody answered before the pushed request's window closed.
253    Expired,
254}
255
256impl DecisionState {
257    /// The wire word for this state.
258    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/// The client's own account of itself, kept for the audit trail and never
269/// serialized to any caller.
270///
271/// `plan/app-allowlist.md`: the profile says an untrusted client's
272/// `client_name`, `client_uri` and `logo_uri` are not displayed. Nothing in
273/// [`DecisionRecord::to_json`] reads this type.
274#[derive(Debug, Clone, PartialEq, Eq, Default)]
275pub struct ClientSelf {
276    /// The client's own name for itself.
277    pub name: Option<String>,
278    /// The client's own home page.
279    pub uri: Option<String>,
280    /// The client's own logo.
281    pub logo: Option<String>,
282}
283
284impl ClientSelf {
285    /// Reads the three fields out of a validated metadata document.
286    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/// One authorization request, as this server decided about it.
296#[derive(Debug, Clone)]
297pub struct DecisionRecord {
298    /// The pushed request this decides. The record's identity.
299    pub request_uri: String,
300    /// The account the request named, resolved at PAR. Only this account may
301    /// see, approve or decline the record.
302    pub account: String,
303    /// Where the client served its metadata document from — scheme, host and
304    /// port of `client_id`, which is the only part of it that is a fact
305    /// about the world.
306    pub client_origin: String,
307    /// The content key of that document; see
308    /// [`crate::oauth::client_metadata::ClientKey`].
309    pub client_key: ClientKey,
310    /// Whether this account has not approved a request from this client key
311    /// since this server started. Held in memory only, and forgotten
312    /// wholesale once this server has recorded more approvals than it keeps
313    /// track of — which makes every client look new again, the direction
314    /// that fails safe.
315    ///
316    /// Always `true` for a client at the local origin
317    /// ([`didbot_pds::policy::is_local_origin`]). Any process on that
318    /// client's machine can send the same `client_id`, so an earlier
319    /// approval says nothing about who sent this one.
320    pub first_time: bool,
321    /// The client's own copy of its name, home page and logo. Logged, never
322    /// serialized; see [`ClientSelf`].
323    pub client_self: ClientSelf,
324    /// The scope the client asked for.
325    pub requested: ScopeSet,
326    /// What this server decided.
327    pub verdict: Verdict,
328    /// The one-time reference the named account approves or declines with.
329    ///
330    /// The same [`ConsentReference`] `plan/oauth.md`'s consent seam already
331    /// mints, reused rather than joined by a second credential. Absent when
332    /// the verdict is [`Verdict::Deny`]: there is nothing to approve, so
333    /// nothing is minted that could approve it.
334    pub token: Option<ConsentReference>,
335    /// When this record was minted.
336    pub created_at: OffsetDateTime,
337    /// When it stops being answerable — the pushed request's own expiry,
338    /// which is always sooner than the consent reference's, so this is the
339    /// deadline that binds.
340    pub expires_at: OffsetDateTime,
341    /// Where it is in its life; see [`DecisionState`].
342    pub state: DecisionState,
343    /// Where it sits in this store's arrival order, for the long poll's
344    /// cursor. Assigned by [`DecisionStore::put`].
345    pub sequence: u64,
346}
347
348impl DecisionRecord {
349    /// What the ceiling granted of the request when this record was minted.
350    pub fn granted(&self) -> ScopeSet {
351        self.verdict.granted(&self.requested)
352    }
353
354    /// Whether this record is still answerable at `now`.
355    pub fn is_live(&self, now: OffsetDateTime) -> bool {
356        self.state == DecisionState::Pending && now < self.expires_at
357    }
358
359    /// The record as the daemon and the consent page read it.
360    ///
361    /// [`ClientSelf`] is not in here, and must not be: see this module's doc
362    /// and `plan/app-allowlist.md`.
363    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
385/// A scope set as the array of atoms the wire carries.
386fn atoms(scopes: &ScopeSet) -> Vec<String> {
387    scopes.0.iter().map(ToString::to_string).collect()
388}
389
390/// The account's latest approval of one client, remembered.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct Remembered {
393    /// The client it approved, so an operator's `end-logins` can name it.
394    pub client_id: String,
395    /// What it granted. A later sign-in is confirmed only at this grant or
396    /// a narrower one.
397    pub granted: ScopeSet,
398    /// When it is forgotten.
399    pub expires_at: OffsetDateTime,
400}
401
402/// Why a record could not be stored.
403///
404/// One reason, and it is server state — identity-independent, true for every
405/// caller alike — which is what makes it safe to answer truthfully from an
406/// unauthenticated endpoint: `oauth::par::ParError::TooManyPending`'s wire
407/// body exists in exactly one shape. An account's own bound never refuses a
408/// push, because a `403` that fires only for accounts already under load is
409/// a `login_hint` oracle by another name; it displaces that account's oldest
410/// undecided record instead — see [`DecisionStore::put`].
411#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
412pub enum DecisionRefused {
413    /// This server already holds as many undecided records as it may.
414    #[error("this server already has {0} authorization requests awaiting an answer")]
415    TooMany(usize),
416}
417
418/// The record one push displaced to make room for its own, and what must be
419/// undone for it.
420///
421/// The caller owns the undo because the state is the caller's: the pushed
422/// request lives in [`crate::oauth::par::ParStore`] and the reference in
423/// [`crate::oauth::consent::ConsentStore`], neither of which this module can
424/// reach. Leaving either behind is memory nothing will ever present,
425/// reachable from an unauthenticated endpoint.
426#[derive(Debug, Clone)]
427pub struct Displaced {
428    /// The pushed request the displaced record decided.
429    pub request_uri: String,
430    /// The reference that would have approved it, if it had one.
431    pub token: Option<ConsentReference>,
432}
433
434/// What [`DecisionStore::put`] stored, and what it displaced to store it.
435#[derive(Debug, Clone)]
436pub struct Stored {
437    /// The record as stored, with its `sequence` and `first_time` assigned.
438    pub record: DecisionRecord,
439    /// The account's oldest undecided record, if this push was over the
440    /// account's bound and displaced one.
441    pub displaced: Option<Displaced>,
442}
443
444/// Where decision records live between PAR and an answer.
445pub trait DecisionStore: Send + Sync {
446    /// Stores a freshly minted record, assigning its `sequence` and its
447    /// `first_time`, and hands back what was stored.
448    ///
449    /// Sweeps expired records first, so a bound is measured against what is
450    /// actually still answerable rather than against everything ever minted.
451    ///
452    /// **An account at its own bound displaces, it does not refuse.** PAR is
453    /// unauthenticated and `login_hint` names whoever the caller likes, so a
454    /// bound that dropped the newest push let a stranger keep an account from
455    /// signing in at all: fill its eight slots every two minutes and the
456    /// account's own sign-ins never reach it. Displacing the oldest undecided
457    /// record instead keeps the bound — the account still holds no more than
458    /// it may — while leaving an account's freshest attempt always answerable,
459    /// which is the one an agent is actually waiting on. What that costs is in
460    /// [`Displaced`], for the caller to undo.
461    ///
462    /// The server-wide bound still refuses a push that would grow this store;
463    /// see [`DecisionRefused`]. A displacing push does not grow it, and is
464    /// not measured against it: an account's own room is its own, so a full
465    /// store cannot take an account's freshest attempt away from it.
466    ///
467    /// The account's bound counts its undecided records, and only one of
468    /// those is ever displaced. An answer, and the redirect an approval
469    /// holds, stay until the pushed request expires; the server-wide bound
470    /// counts them.
471    fn put(&self, record: DecisionRecord) -> Result<Stored, DecisionRefused>;
472
473    /// Whether the server-wide bound has room right now, without writing
474    /// anything and without checking any account's own bound — there is no
475    /// account to check one against here.
476    ///
477    /// `oauth::par::push_for_unresolved_hint` calls this so a `login_hint`
478    /// naming nobody answers exactly the `403` a real account's push would
479    /// when this server itself is out of room — a fact about server load,
480    /// which is fine to say identically to everyone — without that push
481    /// ever costing the slot it checked for, since nothing is stored under
482    /// it either way.
483    fn check_room(&self) -> Result<(), DecisionRefused>;
484
485    /// One record by the pushed request it decides, whatever state it is in.
486    fn get(&self, request_uri: &str) -> Option<DecisionRecord>;
487
488    /// One record by the token that approves it, if that token is still the
489    /// token of a live record.
490    fn by_token(&self, token: &str) -> Option<DecisionRecord>;
491
492    /// This account's live records that arrived after `cursor`, oldest
493    /// first, and the cursor a caller should send next.
494    ///
495    /// The cursor advances past records for other accounts too, so a poll
496    /// that returns nothing still makes progress. A cursor this store did
497    /// not mint — see [`Cursor`] — is read as the beginning of its order,
498    /// so a client that outlived a restart is handed everything rather than
499    /// nothing.
500    fn pending_for(&self, account: &str, cursor: Option<&str>) -> (Vec<DecisionRecord>, String);
501
502    /// Moves a live record out of [`DecisionState::Pending`], returning it as
503    /// it was just before. `None` if it is unknown, expired or already
504    /// settled — which is what makes an approval single-use even before the
505    /// consent reference is consumed.
506    ///
507    /// An approval is what makes the client key no longer the account's
508    /// `first_time`.
509    fn settle(&self, request_uri: &str, state: DecisionState) -> Option<DecisionRecord>;
510
511    /// [`DecisionStore::settle`]s a live record as
512    /// [`DecisionState::Approved`] and holds `redirect` for a browser signed
513    /// in as the account, in one step, so no load of the page finds the
514    /// approval without it.
515    fn approve(&self, request_uri: &str, redirect: Option<String>) -> Option<DecisionRecord>;
516
517    /// Whether an approval holds a redirect for a browser.
518    fn holds(&self, request_uri: &str) -> bool;
519
520    /// Hands over the redirect an approval held for a browser, once.
521    fn collect(&self, request_uri: &str) -> Option<String>;
522
523    /// Drops every record whose pushed request has expired, answered or not,
524    /// and the redirect it held.
525    fn sweep(&self, now: OffsetDateTime);
526
527    /// Woken every time a record arrives, so a long poll does not have to
528    /// spin.
529    fn arrivals(&self) -> Arc<Notify>;
530
531    /// This store's arrival-order epoch; see [`Cursor`].
532    fn epoch(&self) -> String;
533
534    /// Remembers the account's approval of `client_key` at `granted`, until
535    /// [`REMEMBERED_TTL`] from `now`. Replaces the one it had, rather than
536    /// merging scopes into it.
537    fn remember(
538        &self,
539        account: &str,
540        client_key: &ClientKey,
541        client_id: &str,
542        granted: ScopeSet,
543        now: OffsetDateTime,
544    );
545
546    /// The account's live remembered approval of `client_key`, if any.
547    fn remembered(
548        &self,
549        account: &str,
550        client_key: &ClientKey,
551        now: OffsetDateTime,
552    ) -> Option<Remembered>;
553
554    /// Forgets the remembered approvals of `client_id`, on `account` or on
555    /// every account, and says how many it forgot.
556    fn forget_client(&self, client_id: &str, account: Option<&str>) -> usize;
557
558    /// Forgets every remembered approval the account made.
559    fn forget_account(&self, account: &str);
560
561    /// Forgets every remembered approval.
562    fn forget_all(&self);
563}
564
565/// The in-process decision store.
566#[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    /// The redirect each approval holds for a browser, by `request_uri`.
580    redirects: HashMap<String, String>,
581    seen: HashSet<(String, String)>,
582    /// Each account's remembered approval of a client, by the account's DID
583    /// and the client's content key.
584    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    /// An empty store with the default bounds.
596    pub fn new() -> Self {
597        Self::default()
598    }
599
600    /// An empty store with bounds of a deployment's own choosing; see
601    /// `didbot_config::sections::OAuthSection`.
602    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    /// Expires what has run out and forgets it, answered or not.
619    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    /// How many of this account's records await an answer. `sweep` has
641    /// already run, so each of them is still live.
642    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    /// Removes this account's oldest undecided record — arrival order, which
650    /// `sequence` is — and says what it was, for the caller to undo.
651    ///
652    /// An answered record is never the one displaced: the bound counts only
653    /// undecided records, and an answer stays for the authorize page to
654    /// report until its request expires.
655    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    /// The server-wide bound, read from one place so
674    /// [`DecisionStore::put`] and [`DecisionStore::check_room`] agree on it.
675    fn has_room(&self, total: usize) -> bool {
676        self.records.len() < total
677    }
678
679    /// [`DecisionStore::settle`], holding `redirect` when the answer is an
680    /// approval that carries one.
681    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        // One record in, at most one out: every put adds exactly one, so an
717        // account is only ever one over its bound by the time it is here.
718        let displaced = (inner.live_for(&record.account) >= self.per_account)
719            .then(|| inner.displace_oldest_for(&record.account))
720            .flatten();
721        // The server-wide bound is asked only of a push that will grow the
722        // store. A displacing push does not: it takes one of this account's
723        // own slots back first, so the count after is the count before.
724        // Asking first made a full store refuse the very account whose slots
725        // a stranger had filled — the lockout this displacement exists to
726        // close, reached by the other bound instead.
727        if displaced.is_none() && !inner.has_room(self.total) {
728            return Err(DecisionRefused::TooMany(self.total));
729        }
730
731        // Read, not recorded: a push is anyone's to make, naming any
732        // account. The account's own approval records it; see `settle`.
733        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        // Cleared rather than trimmed, as `seen` is: forgetting asks again,
852        // which is the failing-safe direction.
853        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
906/// Whether this atom is one this server never grants, whatever a ceiling
907/// says: `plan/scope-policy.md`'s hard-blocked capabilities, and any atom the
908/// grammar cannot read. [`verdict_for`] drops each from the request whole, so
909/// the rest can be granted and the agent sees it in `cut`.
910///
911/// `account` and `identity` are on that list because key rotation and
912/// migrating out are boundaries an operator draws deliberately rather than
913/// ones an app asks its way past. `blob` is not: an upload is checked
914/// against the grant at `uploadBlob` and judged by policy twice, so a
915/// `blob:` scope says what an app may upload rather than handing it the
916/// account. `transition:*` is on it for
917/// the opposite reason — it is not narrow but enormous, carrying records,
918/// blobs and RPC in one string, which is what makes every granular rule
919/// beside it decorative.
920///
921/// Deliberately here and not in [`crate::oauth::scope`]. That module is the
922/// grammar — what a scope string means, and how two of them overlap — and
923/// this is a policy about which of them this deployment will consider at
924/// all. A grammar that knew the list could not be read by anything that
925/// disagreed with it.
926fn hard_blocked(atom: &Scope) -> bool {
927    matches!(
928        atom,
929        Scope::Transition(_) | Scope::Identity { .. } | Scope::Account { .. } | Scope::Unknown(_)
930    )
931}
932
933/// The verdict a ceiling alone gives for one request.
934///
935/// `granted` empty is a refusal, an empty `cut` is an unqualified yes, and
936/// anything else is a narrowing that names what it took. A policy refusal is
937/// applied by the caller on top of this, because a policy can only ever make
938/// the answer worse.
939pub fn verdict_for(requested: &ScopeSet, ceiling: &ScopeSet, rule: &str) -> Verdict {
940    // Before the ceiling is consulted at all, and whole: a hard-blocked atom
941    // is cut as asked for, never projected onto the ceiling in pieces.
942    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    /// The bound `plan/oauth.md` needs because PAR is unauthenticated: one
991    /// account cannot leave more than its share of undecided records behind.
992    /// Past it the account's oldest makes way, so the bound holds and the
993    /// newest push is the one that survives.
994    #[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        // Another account is unaffected: the bound is per account, so a
1016        // spree against one cannot crowd out the rest.
1017        let other = store.put(record("did:web:b", None)).unwrap();
1018        assert!(other.displaced.is_none());
1019    }
1020
1021    /// **The lockout this closes.** A stranger fills an account's slots by
1022    /// naming it in `login_hint`. The account's own sign-in, pushed last,
1023    /// must still be the one an agent can answer.
1024    #[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    /// **The lockout the other bound reopened.** The server-wide bound was
1040    /// asked before an account's own slot was taken back, so once this
1041    /// store was full every account was refused — including the one whose
1042    /// eight slots a stranger had just filled, which is exactly the victim
1043    /// displacement exists for. A displacing push does not grow the store,
1044    /// so it is not measured against the store's total.
1045    #[test]
1046    fn a_full_store_still_answers_the_account_whose_own_slots_are_full() {
1047        let store = MemoryDecisionStore::with_bounds(2, 4);
1048        // Two accounts, two records each: this store is full.
1049        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    /// `check_room` reads the same bound `put` enforces, without writing —
1086    /// `oauth::par::push_for_unresolved_hint` needs exactly this: an answer
1087    /// about server load that costs nothing, since there is no account for
1088    /// a write to be attributed to.
1089    #[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    /// A record leaves `Pending` once. The second answer finds nothing,
1102    /// which is what stops an approval and a decline from both landing.
1103    #[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        // The token stops resolving the moment the record settles.
1118        assert!(store.by_token("tok").is_none());
1119    }
1120
1121    /// An approval holds its redirect for a browser, which takes it once.
1122    #[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    /// An answered record stays until its pushed request expires, so the
1152    /// authorize page can still say how the request was answered. Then it
1153    /// goes, with the redirect it held.
1154    #[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    /// A stranger filling an account's bound displaces its undecided
1182    /// records, never one the account answered, so the page still reports
1183    /// that answer and still holds the code it waits to hand over.
1184    #[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    /// The server-wide bound counts what the store holds, answered records
1211    /// included, so keeping them does not let the store outgrow it.
1212    #[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    /// A cursor is per store, not per account: a record minted for somebody
1237    /// else still moves it, so a caller polling with the cursor it was last
1238    /// given never re-reads what it has already seen.
1239    #[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    /// A client is new to an account until that account approves it. A
1272    /// push, which anyone may make naming any account, does not make it
1273    /// familiar, nor does a decline, nor another account's approval.
1274    #[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    /// A new approval replaces the grant remembered before it rather than
1293    /// adding to it, and each is forgotten [`REMEMBERED_TTL`] after the
1294    /// approval that wrote it.
1295    #[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    /// An operator ending a client's logins on one account forgets that
1317    /// account's approval of it, and no other account's or client's.
1318    #[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    /// **The e2e defect.** A daemon that outlives a restart holds a cursor
1348    /// from a store that no longer exists. The new store's counter begins at
1349    /// zero, so `sequence > after` matches nothing and the daemon is handed
1350    /// an empty list forever — silently, since an empty answer is also what
1351    /// "nothing new" looks like. An epoch in the cursor makes the stale one
1352    /// legible: it names an order this store never had, so it is read as the
1353    /// beginning of the current one.
1354    #[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        // The restart. A fresh store, a fresh epoch, counting from one.
1364        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        // And the cursor it was just handed works normally from here.
1376        let (pending, _) = after.pending_for("did:web:a", Some(&fresh));
1377        assert!(pending.is_empty());
1378    }
1379
1380    /// A cursor is opaque, so anything a caller can send that is not one of
1381    /// this store's has to be read as the beginning rather than refused or
1382    /// believed. Refusing would strand a client on a string it cannot fix;
1383    /// believing a bare number is what the epoch exists to stop.
1384    #[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    /// The verdict the whole design exists for: part of the request is
1429    /// granted, and the record says which part was not.
1430    #[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    /// **The e2e defect.** A hard-blocked atom is cut whole, and nothing is
1445    /// paid out of it. `transition:generic` overlaps the compiled-in
1446    /// ceiling's `repo:*` and `rpc:*`, and projecting it onto them would hand
1447    /// back the entire ceiling. The rest of the request is granted.
1448    #[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    /// Every atom this server never grants, in the spellings the permission
1462    /// spec gives it and some it does not, against a ceiling wide enough
1463    /// that only the drop can be cutting it. `blob:` is not on the list: an
1464    /// upload is checked against the grant and judged by policy.
1465    #[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    /// The drop does not depend on the ceiling leaving these atoms out: one
1504    /// that holds them grants none of them either.
1505    #[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    /// A request left with nothing grantable once those atoms are dropped is
1527    /// denied as a narrowing that grants nothing is.
1528    #[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    /// The granular scopes an app asks for beside `transition:generic` are
1544    /// granted on their own once it is dropped.
1545    #[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    /// The narrowing case the e2e run got right, kept as a regression: an
1559    /// ordinary request wider than its ceiling is met in part, and the part
1560    /// it did not get is named.
1561    #[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    /// A request *narrower* than its ceiling is allowed at the form it
1574    /// asked for, never widened to the ceiling's. The direction
1575    /// `ScopeSet::narrow`'s projection rule exists to fix: a grant is a
1576    /// narrowing of the request, so a `repo:*` ceiling cannot answer a
1577    /// request for one collection with every collection.
1578    #[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    /// `plan/app-allowlist.md`'s "record the client's own copy; do not show
1591    /// it", asserted on the one function that decides what leaves this
1592    /// server.
1593    #[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}