Skip to main content

cheers_server/
knock.rs

1//! Knock on the issuer side: online knocks, offers, and reconciliation of
2//! offline `Admit`s (`knock.md`, "Reconciliation", "Sybil controls";
3//! R734-F4).
4//!
5//! Every path ends in the same tuple: the requester's key holds the relation
6//! on the resource, `granted_by` the approver (the user when the approver
7//! proved one through a standing binding or an access token, else the
8//! approver's own key), and the lease the admission carried. An online
9//! knock-and-admit and an uploaded `Admit` for the same grant write the same
10//! row.
11//!
12//! **The admit ability** is [`AdmissionPolicy::admitters`], read from the
13//! live policy row and checked against live tuples with
14//! [`SchemaRegistry::holds`] (implies-closure included). The edge reads the
15//! same field off the signed snapshot, whose members are closure-expanded at
16//! mint, so both answer the same question the same way.
17//!
18//! **Every path is evaluated under the live policy** with
19//! [`evaluate_admit`]: an online admission obeys the dial exactly as an
20//! offline one does, so `closed` stops online knocks too (an operator can
21//! still write tuples through the ownership routes).
22//!
23//! **Renewal.** Writing an admitted tuple revokes any live *leased* tuple the
24//! requester already holds for the same relation on the same resource, so a
25//! re-admit (a `Knock` naming the prior `Admit` in `renews`, or simply a
26//! fresh `Admit`) replaces the old lease with the new one. A standing
27//! (unleased) tuple is never replaced; the admission returns it as is.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex, PoisonError};
31
32use async_trait::async_trait;
33use base64::engine::general_purpose::URL_SAFE_NO_PAD;
34use base64::Engine;
35use cheers_core::{
36    evaluate_admit, Admit, AdmissionPath, AdmissionPolicy, AdmissionRefusal, AdmitFacts, CodecError, Confirmation, Knock,
37    KnockError, LeaseRequest, Offer, PeerKey, PrincipalId, PrincipalKind, Revoked, SchemaRegistry, StoreError, UserId,
38};
39use cheers_verify::{verify_device_artifact, PasetoV4PublicVerifier, RevocationReader, StandingError, StandingVerifier};
40use serde::{Deserialize, Serialize};
41
42use crate::codec::PasetoV4SecretMinter;
43use crate::ownership::{NewOwnership, OwnershipRow, OwnershipStore, OwnershipTuples, TupleLease};
44use crate::revocation::RevocationWriter;
45
46/// Sybil bounds and lifetimes for the knock routes (`knock.md`, "Sybil
47/// controls"). The rate limits are enforced by the HTTP layer
48/// (`cheers-axum`), the rest here.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct KnockConfig {
51    /// Seconds a pending knock waits before it lapses.
52    pub pending_ttl: i64,
53    /// Pending knocks one resource holds at most.
54    pub pending_cap: usize,
55    /// `POST /knock` per source (client address) per minute.
56    pub knocks_per_source_per_minute: u32,
57    /// `POST /knock` per resource per minute.
58    pub knocks_per_resource_per_minute: u32,
59    /// Seconds an online `Offer` stays redeemable.
60    pub offer_ttl: i64,
61}
62
63impl Default for KnockConfig {
64    fn default() -> Self {
65        Self {
66            pending_ttl: 24 * 60 * 60,
67            pending_cap: 64,
68            knocks_per_source_per_minute: 10,
69            knocks_per_resource_per_minute: 60,
70            offer_ttl: 60 * 60,
71        }
72    }
73}
74
75/// A knock waiting for an approver.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct PendingKnock {
78    pub id: String,
79    pub resource_kind: String,
80    pub resource_id: String,
81    pub requester: PrincipalId,
82    pub relation: String,
83    pub label: String,
84    /// The user the requester's standing binding proved, if it presented one.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub requester_user: Option<UserId>,
87    /// The prior `Admit` jti this knock renews.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub renews: Option<String>,
90    /// The signed `Knock`, verbatim.
91    pub token: String,
92    pub created_at: i64,
93    pub expires_at: i64,
94}
95
96/// An online offer as the issuer recorded it.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct StoredOffer {
99    pub jti: String,
100    pub resource_kind: String,
101    pub resource_id: String,
102    pub relation: String,
103    pub created_by: PrincipalId,
104    pub max_uses: u32,
105    pub created_at: i64,
106    pub exp: i64,
107}
108
109/// What [`KnockStore::queue_knock`] did.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Queued {
112    Queued,
113    /// The requester's earlier pending knock on this resource was replaced.
114    Replaced,
115    /// The resource already holds `cap` other pending knocks.
116    Full,
117}
118
119/// What [`KnockStore::redeem_offer`] did.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Redeemed {
122    /// A new redemption by this key.
123    Fresh(StoredOffer),
124    /// This key had already redeemed it.
125    Again(StoredOffer),
126    Exhausted,
127    Expired,
128    Unknown,
129}
130
131/// An admission's recorded outcome, by jti.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case", tag = "outcome")]
134pub enum Admission {
135    Accepted { ownership_id: String },
136    Refused { refusal: String },
137}
138
139/// Persistence for knocks, offers and admission outcomes (migration 0017).
140#[async_trait]
141pub trait KnockStore: Send + Sync {
142    /// Queue `knock`. Pending knocks on its resource that lapsed at `now` are
143    /// dropped first; the requester's own earlier one is replaced in place;
144    /// otherwise it is refused ([`Queued::Full`]) when `cap` others wait.
145    async fn queue_knock(&self, knock: &PendingKnock, cap: usize, now: i64) -> Result<Queued, StoreError>;
146
147    /// Unlapsed pending knocks on one resource, oldest first.
148    async fn pending_knocks(&self, kind: &str, id: &str, now: i64) -> Result<Vec<PendingKnock>, StoreError>;
149
150    /// One unlapsed pending knock by id.
151    async fn pending_knock(&self, id: &str, now: i64) -> Result<Option<PendingKnock>, StoreError>;
152
153    /// Remove a pending knock (it was answered). Idempotent.
154    async fn drop_knock(&self, id: &str) -> Result<(), StoreError>;
155
156    async fn put_offer(&self, offer: &StoredOffer) -> Result<(), StoreError>;
157
158    /// Redeem `jti` for `redeemer`, atomically against `max_uses` (distinct
159    /// keys) and `exp`.
160    async fn redeem_offer(&self, jti: &str, redeemer: &PrincipalId, now: i64) -> Result<Redeemed, StoreError>;
161
162    async fn admission(&self, jti: &str) -> Result<Option<Admission>, StoreError>;
163
164    /// Record `outcome` for `jti` unless one is recorded already; returns the
165    /// outcome that stands (the earlier one on a race).
166    async fn record_admission(&self, jti: &str, outcome: &Admission, now: i64) -> Result<Admission, StoreError>;
167}
168
169#[async_trait]
170impl<T: KnockStore + ?Sized> KnockStore for Arc<T> {
171    async fn queue_knock(&self, knock: &PendingKnock, cap: usize, now: i64) -> Result<Queued, StoreError> {
172        (**self).queue_knock(knock, cap, now).await
173    }
174    async fn pending_knocks(&self, kind: &str, id: &str, now: i64) -> Result<Vec<PendingKnock>, StoreError> {
175        (**self).pending_knocks(kind, id, now).await
176    }
177    async fn pending_knock(&self, id: &str, now: i64) -> Result<Option<PendingKnock>, StoreError> {
178        (**self).pending_knock(id, now).await
179    }
180    async fn drop_knock(&self, id: &str) -> Result<(), StoreError> {
181        (**self).drop_knock(id).await
182    }
183    async fn put_offer(&self, offer: &StoredOffer) -> Result<(), StoreError> {
184        (**self).put_offer(offer).await
185    }
186    async fn redeem_offer(&self, jti: &str, redeemer: &PrincipalId, now: i64) -> Result<Redeemed, StoreError> {
187        (**self).redeem_offer(jti, redeemer, now).await
188    }
189    async fn admission(&self, jti: &str) -> Result<Option<Admission>, StoreError> {
190        (**self).admission(jti).await
191    }
192    async fn record_admission(&self, jti: &str, outcome: &Admission, now: i64) -> Result<Admission, StoreError> {
193        (**self).record_admission(jti, outcome, now).await
194    }
195}
196
197/// In-process [`KnockStore`]. Clones share one table; each call takes one
198/// lock, so every operation is atomic.
199#[derive(Debug, Clone, Default)]
200pub struct MemoryKnockStore(Arc<Mutex<MemoryKnocks>>);
201
202#[derive(Debug, Default)]
203struct MemoryKnocks {
204    pending: Vec<PendingKnock>,
205    offers: HashMap<String, StoredOffer>,
206    redemptions: HashMap<String, Vec<PrincipalId>>,
207    admissions: HashMap<String, Admission>,
208}
209
210impl MemoryKnockStore {
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    fn lock(&self) -> std::sync::MutexGuard<'_, MemoryKnocks> {
216        self.0.lock().unwrap_or_else(PoisonError::into_inner)
217    }
218}
219
220#[async_trait]
221impl KnockStore for MemoryKnockStore {
222    async fn queue_knock(&self, knock: &PendingKnock, cap: usize, now: i64) -> Result<Queued, StoreError> {
223        let mut t = self.lock();
224        let on = |p: &PendingKnock| p.resource_kind == knock.resource_kind && p.resource_id == knock.resource_id;
225        t.pending.retain(|p| !(on(p) && p.expires_at <= now));
226        let replaced = t.pending.iter().any(|p| on(p) && p.requester == knock.requester);
227        let others = t.pending.iter().filter(|p| on(p) && p.requester != knock.requester).count();
228        if others >= cap {
229            return Ok(Queued::Full);
230        }
231        t.pending.retain(|p| !(on(p) && p.requester == knock.requester));
232        t.pending.push(knock.clone());
233        Ok(if replaced { Queued::Replaced } else { Queued::Queued })
234    }
235    async fn pending_knocks(&self, kind: &str, id: &str, now: i64) -> Result<Vec<PendingKnock>, StoreError> {
236        let mut out: Vec<_> = self
237            .lock()
238            .pending
239            .iter()
240            .filter(|p| p.resource_kind == kind && p.resource_id == id && p.expires_at > now)
241            .cloned()
242            .collect();
243        out.sort_by(|a, b| (a.created_at, &a.id).cmp(&(b.created_at, &b.id)));
244        Ok(out)
245    }
246    async fn pending_knock(&self, id: &str, now: i64) -> Result<Option<PendingKnock>, StoreError> {
247        Ok(self.lock().pending.iter().find(|p| p.id == id && p.expires_at > now).cloned())
248    }
249    async fn drop_knock(&self, id: &str) -> Result<(), StoreError> {
250        self.lock().pending.retain(|p| p.id != id);
251        Ok(())
252    }
253    async fn put_offer(&self, offer: &StoredOffer) -> Result<(), StoreError> {
254        let mut t = self.lock();
255        if t.offers.contains_key(&offer.jti) {
256            return Err(StoreError::Conflict);
257        }
258        t.offers.insert(offer.jti.clone(), offer.clone());
259        Ok(())
260    }
261    async fn redeem_offer(&self, jti: &str, redeemer: &PrincipalId, now: i64) -> Result<Redeemed, StoreError> {
262        let mut t = self.lock();
263        let Some(offer) = t.offers.get(jti).cloned() else {
264            return Ok(Redeemed::Unknown);
265        };
266        let used = t.redemptions.entry(jti.to_owned()).or_default();
267        if used.contains(redeemer) {
268            return Ok(Redeemed::Again(offer));
269        }
270        if offer.exp <= now {
271            return Ok(Redeemed::Expired);
272        }
273        if used.len() >= offer.max_uses as usize {
274            return Ok(Redeemed::Exhausted);
275        }
276        used.push(redeemer.clone());
277        Ok(Redeemed::Fresh(offer))
278    }
279    async fn admission(&self, jti: &str) -> Result<Option<Admission>, StoreError> {
280        Ok(self.lock().admissions.get(jti).cloned())
281    }
282    async fn record_admission(&self, jti: &str, outcome: &Admission, _now: i64) -> Result<Admission, StoreError> {
283        Ok(self.lock().admissions.entry(jti.to_owned()).or_insert_with(|| outcome.clone()).clone())
284    }
285}
286
287/// Why a knock operation failed.
288#[derive(Debug, thiserror::Error)]
289pub enum KnockFlowError {
290    #[error("artifact refused: {0}")]
291    Artifact(#[from] CodecError),
292    #[error("artifact invalid: {0}")]
293    Invalid(#[from] KnockError),
294    #[error(transparent)]
295    Standing(#[from] StandingError),
296    #[error("admit names authority {got}, expected {expected}")]
297    WrongAuthority { expected: String, got: String },
298    #[error("admit expired at {exp}")]
299    Expired { exp: i64 },
300    #[error("{0} does not hold the admit ability on this resource")]
301    NotAnApprover(PrincipalId),
302    #[error("this resource has too many pending knocks")]
303    PendingFull,
304    #[error("no such pending knock")]
305    UnknownKnock,
306    #[error("no such offer")]
307    UnknownOffer,
308    #[error("offer used up")]
309    OfferExhausted,
310    #[error("offer expired")]
311    OfferExpired,
312    #[error("knock does not match the offer: {0}")]
313    OfferMismatch(&'static str),
314    #[error("admission refused: {0}")]
315    Refused(#[from] AdmissionRefusal),
316    #[error(transparent)]
317    Store(#[from] StoreError),
318}
319
320/// A tuple an admission wrote (or found).
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct Admitted {
323    /// The admission's jti: the uploaded `Admit`'s, `knock:<id>` for an
324    /// online knock, `offer:<jti>:<key>` for a redemption. A later `Knock`
325    /// may name it in `renews`.
326    pub jti: String,
327    pub row: OwnershipRow,
328}
329
330/// What reconciling an uploaded `Admit` decided.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub enum Reconciled {
333    Accepted(Admitted),
334    /// Refused and recorded as `Revoked::Jti`, so edges drop it.
335    Refused { jti: String, refusal: String },
336}
337
338/// The issuer side of knock (module docs).
339pub struct KnockAuthority<Rd> {
340    schema: Arc<SchemaRegistry>,
341    ownership: Arc<dyn OwnershipStore>,
342    knocks: Arc<dyn KnockStore>,
343    revocations: Arc<dyn RevocationWriter>,
344    standing: StandingVerifier<Rd>,
345    minter: PasetoV4SecretMinter,
346    own_key: PasetoV4PublicVerifier,
347    issuer: String,
348    kid: String,
349    config: KnockConfig,
350}
351
352impl<Rd> std::fmt::Debug for KnockAuthority<Rd> {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.debug_struct("KnockAuthority").field("issuer", &self.issuer).field("config", &self.config).finish_non_exhaustive()
355    }
356}
357
358fn mint_id() -> String {
359    let mut bytes = [0u8; 16];
360    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
361    URL_SAFE_NO_PAD.encode(bytes)
362}
363
364fn peer_key(key: &PrincipalId) -> Option<PeerKey> {
365    let bytes: [u8; 32] = URL_SAFE_NO_PAD.decode(key.id.as_bytes()).ok()?.try_into().ok()?;
366    Some(PeerKey::ed25519(bytes))
367}
368
369impl<Rd: RevocationReader + Send + Sync> KnockAuthority<Rd> {
370    /// `standing` verifies the standing bindings approvers and requesters
371    /// present; it must trust this issuer. `minter`/`kid` sign online offers.
372    #[allow(clippy::too_many_arguments)]
373    pub fn new(
374        schema: Arc<SchemaRegistry>,
375        ownership: Arc<dyn OwnershipStore>,
376        knocks: Arc<dyn KnockStore>,
377        revocations: Arc<dyn RevocationWriter>,
378        standing: StandingVerifier<Rd>,
379        minter: PasetoV4SecretMinter,
380        issuer: impl Into<String>,
381        kid: impl Into<String>,
382    ) -> Result<Self, CodecError> {
383        let public: [u8; 32] = minter.secret_key_bytes()[32..].try_into().map_err(|_| CodecError::Malformed)?;
384        Ok(Self {
385            schema,
386            ownership,
387            knocks,
388            revocations,
389            standing,
390            own_key: PasetoV4PublicVerifier::from_public_key(&public)?,
391            minter,
392            issuer: issuer.into(),
393            kid: kid.into(),
394            config: KnockConfig::default(),
395        })
396    }
397
398    pub fn with_config(mut self, config: KnockConfig) -> Self {
399        self.config = config;
400        self
401    }
402
403    pub fn config(&self) -> &KnockConfig {
404        &self.config
405    }
406
407    /// The admitter relation `principal` holds on `(kind, id)` under the live
408    /// policy row and live tuples, if any. `None` also when the resource has
409    /// no policy or the policy names no admitters.
410    pub async fn admit_relation(&self, principal: &PrincipalId, kind: &str, id: &str, now: i64) -> Result<Option<String>, StoreError> {
411        let Some(policy) = self.ownership.admission_policy(kind, id).await? else {
412            return Ok(None);
413        };
414        let tuples = OwnershipTuples::at(&*self.ownership, now);
415        for relation in &policy.admitters {
416            if self.schema.holds(&tuples, principal, kind, id, relation).await? {
417                return Ok(Some(relation.clone()));
418            }
419        }
420        Ok(None)
421    }
422
423    async fn require_admitter(&self, principal: &PrincipalId, kind: &str, id: &str, now: i64) -> Result<(), KnockFlowError> {
424        match self.admit_relation(principal, kind, id, now).await? {
425            Some(_) => Ok(()),
426            None => Err(KnockFlowError::NotAnApprover(principal.clone())),
427        }
428    }
429
430    /// The user a standing binding proves `key` belongs to.
431    async fn bound_user(&self, key: &PrincipalId, binding: &str, now: i64) -> Result<UserId, KnockFlowError> {
432        let peer = peer_key(key).ok_or_else(|| KnockError::NotAKey(key.clone()))?;
433        Ok(self.standing.verify_standing_at(binding, &peer, now).await?.binding.sub)
434    }
435
436    /// Who approves for an approver key: the key itself when it holds an
437    /// admitter relation, else the user its binding names when that user
438    /// does. The answer is the tuple's `granted_by`.
439    async fn approver_of(
440        &self,
441        key: &PrincipalId,
442        binding: Option<&str>,
443        kind: &str,
444        id: &str,
445        now: i64,
446    ) -> Result<PrincipalId, KnockFlowError> {
447        if key.kind == PrincipalKind::Key && self.admit_relation(key, kind, id, now).await?.is_some() {
448            return Ok(key.clone());
449        }
450        let Some(binding) = binding else {
451            return Err(KnockFlowError::NotAnApprover(key.clone()));
452        };
453        let user = PrincipalId::from(&self.bound_user(key, binding, now).await?);
454        self.require_admitter(&user, kind, id, now).await?;
455        Ok(user)
456    }
457
458    async fn policy(&self, kind: &str, id: &str) -> Result<Option<AdmissionPolicy>, StoreError> {
459        self.ownership.admission_policy(kind, id).await
460    }
461
462    /// `POST /knock`: verify the signed `Knock` and queue it.
463    pub async fn knock(&self, token: &str, now: i64) -> Result<(PendingKnock, Queued), KnockFlowError> {
464        let knock: Knock = verify_device_artifact(token)?;
465        knock.validate()?;
466        let requester_user = match &knock.standing {
467            Some(binding) => Some(self.bound_user(&knock.requester, binding, now).await?),
468            None => None,
469        };
470        let pending = PendingKnock {
471            id: mint_id(),
472            resource_kind: knock.kind,
473            resource_id: knock.id,
474            requester: knock.requester,
475            relation: knock.relation,
476            label: knock.label,
477            requester_user,
478            renews: knock.renews,
479            token: token.to_owned(),
480            created_at: now,
481            expires_at: now.saturating_add(self.config.pending_ttl),
482        };
483        match self.knocks.queue_knock(&pending, self.config.pending_cap, now).await? {
484            Queued::Full => Err(KnockFlowError::PendingFull),
485            q => Ok((pending, q)),
486        }
487    }
488
489    /// `GET` pending knocks: only for a caller holding the admit ability.
490    pub async fn pending(&self, caller: &PrincipalId, kind: &str, id: &str, now: i64) -> Result<Vec<PendingKnock>, KnockFlowError> {
491        self.require_admitter(caller, kind, id, now).await?;
492        Ok(self.knocks.pending_knocks(kind, id, now).await?)
493    }
494
495    /// `POST /knock/{id}/admit`: `caller` (an authenticated user) admits a
496    /// pending knock at `confirmation`, with `lease` seconds or the path
497    /// default.
498    pub async fn admit_knock(
499        &self,
500        caller: &PrincipalId,
501        knock_id: &str,
502        confirmation: Confirmation,
503        lease: Option<i64>,
504        now: i64,
505    ) -> Result<Admitted, KnockFlowError> {
506        let pending = self.knocks.pending_knock(knock_id, now).await?.ok_or(KnockFlowError::UnknownKnock)?;
507        let (kind, id) = (pending.resource_kind.as_str(), pending.resource_id.as_str());
508        self.require_admitter(caller, kind, id, now).await?;
509        let facts = AdmitFacts {
510            relation: &pending.relation,
511            path: if confirmation == Confirmation::Scan { AdmissionPath::Scan } else { AdmissionPath::Knock },
512            confirmation: Some(confirmation),
513            requester_is_user: pending.requester_user.is_some(),
514        };
515        let policy = self.policy(kind, id).await?;
516        let resolved = evaluate_admit(policy.as_ref(), &facts, now, lease.map(LeaseRequest::Length))?;
517        let jti = format!("knock:{}", pending.id);
518        let admitted = self
519            .write(&jti, &pending.requester, kind, id, &pending.relation, caller, TupleLease { iat: now, lease: resolved }, now)
520            .await?;
521        self.knocks.drop_knock(&pending.id).await?;
522        Ok(admitted)
523    }
524
525    /// `POST /offer`: `caller` mints an issuer-signed `Offer` of `relation`
526    /// on `(kind, id)` for up to `max_uses` keys.
527    pub async fn offer(
528        &self,
529        caller: &PrincipalId,
530        kind: &str,
531        id: &str,
532        relation: &str,
533        max_uses: u32,
534        now: i64,
535    ) -> Result<(Offer, String), KnockFlowError> {
536        self.require_admitter(caller, kind, id, now).await?;
537        let offer = Offer {
538            issuer: self.issuer.clone(),
539            signer: None,
540            kind: kind.to_owned(),
541            id: id.to_owned(),
542            relation: relation.to_owned(),
543            max_uses: max_uses.max(1),
544            jti: mint_id(),
545            iat: now,
546            exp: now.saturating_add(self.config.offer_ttl),
547        };
548        self.knocks
549            .put_offer(&StoredOffer {
550                jti: offer.jti.clone(),
551                resource_kind: offer.kind.clone(),
552                resource_id: offer.id.clone(),
553                relation: offer.relation.clone(),
554                created_by: caller.clone(),
555                max_uses: offer.max_uses,
556                created_at: now,
557                exp: offer.exp,
558            })
559            .await?;
560        let token = self.minter.mint_artifact(&offer, &self.kid)?;
561        Ok((offer, token))
562    }
563
564    /// `POST /offer/redeem`: a key proves itself with a signed `Knock` for
565    /// the offer's grant and redeems the offer. The offer's creator must
566    /// still hold the admit ability, and the live policy is applied on the
567    /// `offer` path.
568    pub async fn redeem_offer(&self, offer_token: &str, knock_token: &str, now: i64) -> Result<Admitted, KnockFlowError> {
569        let offer: Offer = self.own_key.verify_artifact(offer_token)?;
570        let knock: Knock = verify_device_artifact(knock_token)?;
571        knock.validate()?;
572        if (knock.kind.as_str(), knock.id.as_str()) != (offer.kind.as_str(), offer.id.as_str()) {
573            return Err(KnockFlowError::OfferMismatch("resource"));
574        }
575        if knock.relation != offer.relation {
576            return Err(KnockFlowError::OfferMismatch("relation"));
577        }
578        let jti = format!("offer:{}:{}", offer.jti, knock.requester.id);
579        let stored = match self.knocks.redeem_offer(&offer.jti, &knock.requester, now).await? {
580            Redeemed::Fresh(s) => s,
581            Redeemed::Again(_) => {
582                if let Some(Admission::Accepted { ownership_id }) = self.knocks.admission(&jti).await? {
583                    if let Some(row) = self.ownership.get(&ownership_id).await? {
584                        return Ok(Admitted { jti, row });
585                    }
586                }
587                return Err(KnockFlowError::OfferExhausted);
588            }
589            Redeemed::Exhausted => return Err(KnockFlowError::OfferExhausted),
590            Redeemed::Expired => return Err(KnockFlowError::OfferExpired),
591            Redeemed::Unknown => return Err(KnockFlowError::UnknownOffer),
592        };
593        let (kind, id) = (stored.resource_kind.as_str(), stored.resource_id.as_str());
594        self.require_admitter(&stored.created_by, kind, id, now).await?;
595        let requester_user = match &knock.standing {
596            Some(binding) => Some(self.bound_user(&knock.requester, binding, now).await?),
597            None => None,
598        };
599        let facts = AdmitFacts {
600            relation: &stored.relation,
601            path: AdmissionPath::Offer,
602            confirmation: Some(Confirmation::Accept),
603            requester_is_user: requester_user.is_some(),
604        };
605        let policy = self.policy(kind, id).await?;
606        let resolved = evaluate_admit(policy.as_ref(), &facts, now, None)?;
607        self.write(&jti, &knock.requester, kind, id, &stored.relation, &stored.created_by, TupleLease { iat: now, lease: resolved }, now)
608            .await
609    }
610
611    /// `POST /admit`: reconcile an uploaded offline `Admit`. Accepted iff its
612    /// approver holds the admit ability NOW (live tuples, live policy) and the
613    /// live policy admits it; then the tuple is written with the `Admit`'s
614    /// own lease. Otherwise its jti is revoked so edges drop it. Idempotent
615    /// per jti: a repeat upload returns the recorded outcome. An artifact
616    /// that does not verify, or has expired, is an error and records nothing.
617    pub async fn reconcile(&self, token: &str, now: i64) -> Result<Reconciled, KnockFlowError> {
618        let admit: Admit = verify_device_artifact(token)?;
619        admit.validate()?;
620        if admit.authority != self.issuer {
621            return Err(KnockFlowError::WrongAuthority { expected: self.issuer.clone(), got: admit.authority });
622        }
623        if let Some(prior) = self.knocks.admission(&admit.jti).await? {
624            return self.recorded(admit.jti, prior).await;
625        }
626        let exp = admit.lease.exp().unwrap_or(now);
627        if exp <= now {
628            return Err(KnockFlowError::Expired { exp });
629        }
630        match self.judge(&admit, now).await {
631            Ok(granted_by) => {
632                let lease = TupleLease { iat: admit.iat, lease: admit.lease };
633                let admitted = self
634                    .write(&admit.jti, &admit.requester, &admit.kind, &admit.id, &admit.relation, &granted_by, lease, now)
635                    .await?;
636                Ok(Reconciled::Accepted(admitted))
637            }
638            Err(e @ (KnockFlowError::NotAnApprover(_) | KnockFlowError::Refused(_) | KnockFlowError::Standing(_))) => {
639                let refusal = e.to_string();
640                let stood = self.knocks.record_admission(&admit.jti, &Admission::Refused { refusal }, now).await?;
641                if matches!(stood, Admission::Refused { .. }) {
642                    self.revocations.revoke(&Revoked::jti(admit.jti.clone(), admit.lease.exp())).await?;
643                }
644                self.recorded(admit.jti, stood).await
645            }
646            Err(e) => Err(e),
647        }
648    }
649
650    /// The approver (`granted_by`) if `admit` is admissible now.
651    async fn judge(&self, admit: &Admit, now: i64) -> Result<PrincipalId, KnockFlowError> {
652        let granted_by = self.approver_of(&admit.approver, admit.approver_binding.as_deref(), &admit.kind, &admit.id, now).await?;
653        let requester_user = match &admit.requester_binding {
654            Some(binding) => Some(self.bound_user(&admit.requester, binding, now).await?),
655            None => None,
656        };
657        let facts = AdmitFacts {
658            relation: &admit.relation,
659            path: admit.path(),
660            confirmation: Some(admit.confirmation),
661            requester_is_user: requester_user.is_some(),
662        };
663        let policy = self.policy(&admit.kind, &admit.id).await?;
664        evaluate_admit(policy.as_ref(), &facts, admit.iat, Some(LeaseRequest::Exact(admit.lease)))?;
665        Ok(granted_by)
666    }
667
668    async fn recorded(&self, jti: String, outcome: Admission) -> Result<Reconciled, KnockFlowError> {
669        match outcome {
670            Admission::Accepted { ownership_id } => {
671                let row = self
672                    .ownership
673                    .get(&ownership_id)
674                    .await?
675                    .ok_or_else(|| StoreError::Backend(format!("admission {jti} names missing tuple {ownership_id}")))?;
676                Ok(Reconciled::Accepted(Admitted { jti, row }))
677            }
678            Admission::Refused { refusal } => Ok(Reconciled::Refused { jti, refusal }),
679        }
680    }
681
682    /// Write the admitted tuple (module docs, "Renewal") and record it
683    /// against `jti`.
684    #[allow(clippy::too_many_arguments)]
685    async fn write(
686        &self,
687        jti: &str,
688        requester: &PrincipalId,
689        kind: &str,
690        id: &str,
691        relation: &str,
692        granted_by: &PrincipalId,
693        lease: TupleLease,
694        now: i64,
695    ) -> Result<Admitted, KnockFlowError> {
696        if let Some(prior) = self.knocks.admission(jti).await? {
697            return match self.recorded(jti.to_owned(), prior).await? {
698                Reconciled::Accepted(a) => Ok(a),
699                Reconciled::Refused { refusal, .. } => Err(StoreError::Backend(format!("admission {jti} was refused: {refusal}")).into()),
700            };
701        }
702        let held: Vec<OwnershipRow> = self
703            .ownership
704            .list_for_principal(requester)
705            .await?
706            .into_iter()
707            .filter(|r| r.resource_kind == kind && r.resource_id == id && r.relationship == relation && r.is_live_at(now))
708            .collect();
709        let row = match held.iter().find(|r| r.lease.is_none()) {
710            Some(standing) => standing.clone(),
711            None => {
712                for old in &held {
713                    self.ownership.revoke_by_id(&old.id, now).await?;
714                }
715                let new = NewOwnership::new(requester.clone(), kind, id, relation, granted_by.clone(), None)
716                    .map_err(|e| StoreError::Backend(e.to_string()))?
717                    .with_lease(Some(lease));
718                self.ownership.insert(&new, now).await?.row
719            }
720        };
721        let ours = Admission::Accepted { ownership_id: row.id.clone() };
722        let stood = self.knocks.record_admission(jti, &ours, now).await?;
723        if stood != ours && row.lease.is_some() {
724            // Lost a race to a concurrent upload of the same jti: its tuple stands.
725            self.ownership.revoke_by_id(&row.id, now).await?;
726        }
727        match self.recorded(jti.to_owned(), stood).await? {
728            Reconciled::Accepted(a) => Ok(a),
729            Reconciled::Refused { refusal, .. } => Err(StoreError::Backend(format!("admission {jti} was refused: {refusal}")).into()),
730        }
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::ownership::MemoryOwnershipStore;
738    use crate::revocation::MemoryRevocationStore;
739    use crate::snapshot::SnapshotIssuer;
740    use crate::standing::{MemoryBindingSequenceStore, StandingBinder};
741    use cheers_core::{
742        AdmissionMode, AdmitSource, DeviceId, LeaseState, RelationDef, ResourceSchema, ScopeRegistry, SignedArtifact,
743    };
744    use cheers_verify::{
745        artifact_hash, AdmitAuthority, IssuerAdmitAuthority, IssuerTrust, ReplicatedRevocations, SnapshotVerifier,
746    };
747    use pasetors::keys::{AsymmetricKeyPair, Generate};
748    use pasetors::version4::{PublicToken, V4};
749
750    const ISS: &str = "https://c.test";
751    const IAT: i64 = 1_000_000;
752    const DAY: i64 = 24 * 60 * 60;
753
754    const NAMESPACE: ResourceSchema = ResourceSchema {
755        kind: "namespace",
756        relations: &[
757            RelationDef { name: "owner", membership: true, implies: &["admin"], scopes: &[], grants: &["owner", "admin", "member", "guest"] },
758            RelationDef { name: "admin", membership: true, implies: &["member"], scopes: &[], grants: &["admin", "member", "guest"] },
759            RelationDef { name: "member", membership: true, implies: &["guest"], scopes: &[], grants: &[] },
760            RelationDef { name: "guest", membership: true, implies: &[], scopes: &[], grants: &[] },
761        ],
762        kind_relations: &[],
763    };
764
765    fn schema() -> Arc<SchemaRegistry> {
766        Arc::new(SchemaRegistry::build(&[NAMESPACE], &ScopeRegistry::builder().build().unwrap()).unwrap())
767    }
768
769    fn key_of(k: &AsymmetricKeyPair<V4>) -> PrincipalId {
770        PrincipalId::from_public_key(k.public.as_bytes().try_into().unwrap())
771    }
772
773    fn device_sign<T: SignedArtifact + Serialize>(k: &AsymmetricKeyPair<V4>, payload: &T) -> String {
774        let body = serde_json::to_vec(payload).unwrap();
775        PublicToken::sign(&k.secret, &body, Some(br#"{"kid":"device"}"#), Some(T::IMPLICIT_ASSERTION)).unwrap()
776    }
777
778    struct Rig {
779        store: MemoryOwnershipStore,
780        revocations: MemoryRevocationStore,
781        auth: KnockAuthority<Arc<ReplicatedRevocations>>,
782        snapshots: SnapshotIssuer,
783        edge: IssuerAdmitAuthority<Arc<ReplicatedRevocations>>,
784        /// alice's device key and its standing binding; alice owns ed.
785        device: AsymmetricKeyPair<V4>,
786        binding: String,
787        guest: AsymmetricKeyPair<V4>,
788    }
789
790    fn rig_with(config: KnockConfig) -> Rig {
791        let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
792        let bytes: [u8; 64] = minter.secret_key_bytes().try_into().unwrap();
793        let again = || PasetoV4SecretMinter::from_secret_key(&bytes).unwrap();
794        let trust = IssuerTrust::pinned(ISS, verifier);
795        let replica = Arc::new(ReplicatedRevocations::new(trust.clone()));
796        let store = MemoryOwnershipStore::new();
797        let revocations = MemoryRevocationStore::new();
798        let auth = KnockAuthority::new(
799            schema(),
800            Arc::new(store.clone()),
801            Arc::new(MemoryKnockStore::new()),
802            Arc::new(revocations.clone()),
803            StandingVerifier::new(trust.clone(), replica.clone()),
804            minter,
805            ISS,
806            "k1",
807        )
808        .unwrap()
809        .with_config(config);
810        let device = AsymmetricKeyPair::<V4>::generate().unwrap();
811        let binder = StandingBinder::new(MemoryBindingSequenceStore::new(), again(), ISS, "k1");
812        let peer = PeerKey::ed25519(device.public.as_bytes().try_into().unwrap());
813        let binding = pollster::block_on(binder.mint(UserId::new("alice"), DeviceId::new("d-alice"), peer, IAT)).unwrap().token;
814        let r = Rig {
815            snapshots: SnapshotIssuer::new(store.clone(), schema(), again(), ISS, "k1"),
816            edge: IssuerAdmitAuthority::new(
817                StandingVerifier::new(trust.clone(), replica.clone()),
818                SnapshotVerifier::new(trust, replica),
819            ),
820            store,
821            revocations,
822            auth,
823            device,
824            binding,
825            guest: AsymmetricKeyPair::<V4>::generate().unwrap(),
826        };
827        pollster::block_on(async {
828            let own = NewOwnership::new(PrincipalId::user("alice"), "namespace", "ed", "owner", PrincipalId::service("seed"), None).unwrap();
829            r.store.insert(&own, 10).await.unwrap();
830            let policy = AdmissionPolicy::new(AdmissionMode::Knock, "guest").with_admitters(["admin"]);
831            r.store.set_admission_policy("namespace", "ed", Some(&policy), 10).await.unwrap();
832        });
833        r
834    }
835
836    fn rig() -> Rig {
837        rig_with(KnockConfig::default())
838    }
839
840    impl Rig {
841        fn knock(&self, who: &AsymmetricKeyPair<V4>, label: &str) -> String {
842            device_sign(
843                who,
844                &Knock {
845                    requester: key_of(who),
846                    kind: "namespace".into(),
847                    id: "ed".into(),
848                    relation: "guest".into(),
849                    nonce: "n".into(),
850                    label: label.into(),
851                    standing: None,
852                    renews: None,
853                    iat: IAT,
854                },
855            )
856        }
857
858        fn admit(&self, jti: &str, lease: cheers_core::Lease) -> String {
859            device_sign(
860                &self.device,
861                &Admit {
862                    approver: key_of(&self.device),
863                    approver_binding: Some(self.binding.clone()),
864                    authority: ISS.into(),
865                    requester: key_of(&self.guest),
866                    requester_binding: None,
867                    kind: "namespace".into(),
868                    id: "ed".into(),
869                    relation: "guest".into(),
870                    source: AdmitSource::Knock(artifact_hash("knock")),
871                    confirmation: Confirmation::Accept,
872                    epoch: 1,
873                    jti: jti.into(),
874                    iat: IAT,
875                    lease,
876                },
877            )
878        }
879
880        fn alice(&self) -> PrincipalId {
881            PrincipalId::user("alice")
882        }
883    }
884
885    fn week() -> cheers_core::Lease {
886        cheers_core::Lease::new(IAT, IAT + DAY, Some(IAT + 7 * DAY)).unwrap()
887    }
888
889    fn same_grant(a: &OwnershipRow, b: &OwnershipRow) {
890        assert_eq!(
891            (&a.subject, &a.resource_kind, &a.resource_id, &a.relationship, &a.granted_by, &a.on_behalf_of, a.lease),
892            (&b.subject, &b.resource_kind, &b.resource_id, &b.relationship, &b.granted_by, &b.on_behalf_of, b.lease)
893        );
894    }
895
896    #[test]
897    fn an_online_knock_and_an_offline_admit_write_the_same_tuple() {
898        let r = rig();
899        pollster::block_on(async {
900            let (pending, q) = r.auth.knock(&r.knock(&r.guest, "phone"), IAT).await.unwrap();
901            assert_eq!(q, Queued::Queued);
902            let online = r.auth.admit_knock(&r.alice(), &pending.id, Confirmation::Accept, Some(7 * DAY), IAT).await.unwrap();
903            assert_eq!(online.jti, format!("knock:{}", pending.id));
904            assert_eq!(online.row.granted_by, r.alice());
905            assert_eq!(online.row.lease, Some(TupleLease { iat: IAT, lease: week() }));
906            // The knock was answered.
907            assert!(r.auth.pending(&r.alice(), "namespace", "ed", IAT).await.unwrap().is_empty());
908            // Undo it, then reconcile an offline Admit for the same grant.
909            r.store.revoke_by_id(&online.row.id, IAT).await.unwrap();
910            let Reconciled::Accepted(offline) = r.auth.reconcile(&r.admit("admit-1", week()), IAT).await.unwrap() else {
911                panic!("refused")
912            };
913            same_grant(&online.row, &offline.row);
914            // Idempotent per jti.
915            let again = r.auth.reconcile(&r.admit("admit-1", week()), IAT + 5).await.unwrap();
916            assert_eq!(again, Reconciled::Accepted(offline.clone()));
917            assert_eq!(r.store.list_for_resource("namespace", "ed").await.unwrap().iter().filter(|x| x.lease.is_some()).count(), 1);
918        });
919    }
920
921    #[test]
922    fn an_admit_from_a_since_demoted_approver_is_refused_and_revoked() {
923        let r = rig();
924        pollster::block_on(async {
925            let owner = r.store.list_for_principal(&r.alice()).await.unwrap();
926            r.store.revoke_by_id(&owner[0].id, IAT).await.unwrap();
927            let out = r.auth.reconcile(&r.admit("admit-2", week()), IAT).await.unwrap();
928            assert!(matches!(&out, Reconciled::Refused { jti, .. } if jti == "admit-2"), "{out:?}");
929            let set = r.revocations.snapshot().await.unwrap();
930            assert!(set.revoked.contains(&Revoked::jti("admit-2", Some(IAT + 7 * DAY))));
931            assert_eq!(r.auth.reconcile(&r.admit("admit-2", week()), IAT).await.unwrap(), out);
932            assert!(r.store.list_for_resource("namespace", "ed").await.unwrap().is_empty());
933        });
934    }
935
936    #[test]
937    fn a_second_pending_knock_replaces_the_first_and_the_cap_holds() {
938        let r = rig_with(KnockConfig { pending_cap: 1, ..KnockConfig::default() });
939        pollster::block_on(async {
940            let (first, _) = r.auth.knock(&r.knock(&r.guest, "one"), IAT).await.unwrap();
941            let (second, q) = r.auth.knock(&r.knock(&r.guest, "two"), IAT + 1).await.unwrap();
942            assert_eq!(q, Queued::Replaced);
943            let pending = r.auth.pending(&r.alice(), "namespace", "ed", IAT + 1).await.unwrap();
944            assert_eq!(pending.iter().map(|p| (&p.id, p.label.as_str())).collect::<Vec<_>>(), [(&second.id, "two")]);
945            assert!(matches!(
946                r.auth.admit_knock(&r.alice(), &first.id, Confirmation::Accept, None, IAT + 1).await,
947                Err(KnockFlowError::UnknownKnock)
948            ));
949            let other = AsymmetricKeyPair::<V4>::generate().unwrap();
950            assert!(matches!(r.auth.knock(&r.knock(&other, "three"), IAT + 2).await, Err(KnockFlowError::PendingFull)));
951            // Only an admitter sees the queue.
952            assert!(matches!(
953                r.auth.pending(&PrincipalId::user("mallory"), "namespace", "ed", IAT).await,
954                Err(KnockFlowError::NotAnApprover(_))
955            ));
956            // A pending knock lapses.
957            let ttl = KnockConfig::default().pending_ttl;
958            assert!(r.auth.pending(&r.alice(), "namespace", "ed", IAT + 1 + ttl).await.unwrap().is_empty());
959        });
960    }
961
962    #[test]
963    fn a_week_long_admit_reconciles_to_a_tuple_that_warns_and_lapses_with_it() {
964        let r = rig();
965        let guest = key_of(&r.guest);
966        pollster::block_on(async {
967            let Reconciled::Accepted(a) = r.auth.reconcile(&r.admit("admit-3", week()), IAT).await.unwrap() else {
968                panic!("refused")
969            };
970            assert_eq!(a.row.lease.map(|l| l.lease), Some(week()));
971            let holds = |now| {
972                let tuples = OwnershipTuples::at(&r.store, now);
973                let schema = schema();
974                let guest = guest.clone();
975                async move { schema.holds(&tuples, &guest, "namespace", "ed", "guest").await.unwrap() }
976            };
977            assert!(holds(IAT + 7 * DAY - 1).await);
978            assert!(!holds(IAT + 7 * DAY).await);
979            // The mint carries the lease; the edge reports Warning from the
980            // same refresh_after and drops the guest at the same exp.
981            let s = r.snapshots.mint("namespace", "ed", IAT + DAY).await.unwrap();
982            let m = s.snapshot.members.iter().find(|m| m.principal == guest).unwrap();
983            assert_eq!(m.lease, Some(week()));
984            r.edge.hold_snapshot(&s.token).await.unwrap();
985            let held = r.edge.snapshots().verify_snapshot_at(&s.token, IAT + DAY - 1).await.unwrap();
986            assert_eq!(held.member_lease(&guest, "guest", IAT + DAY - 1), Some(LeaseState::Current));
987            assert_eq!(held.member_lease(&guest, "guest", IAT + DAY), Some(LeaseState::Warning { exp: Some(IAT + 7 * DAY) }));
988            assert!(!r.edge.snapshots().verify_snapshot_at(&s.token, IAT + 7 * DAY).await.unwrap().holds(&guest, "guest"));
989            // Past exp the mint stops listing it.
990            let late = r.snapshots.mint("namespace", "ed", IAT + 7 * DAY).await.unwrap();
991            assert!(!late.snapshot.members.iter().any(|m| m.principal == guest));
992            // The standing owner carries no lease.
993            assert!(late.snapshot.members.iter().filter(|m| m.principal == r.alice()).all(|m| m.lease.is_none()));
994        });
995    }
996
997    #[test]
998    fn edge_and_server_agree_on_an_admitter_by_implication() {
999        let r = rig();
1000        pollster::block_on(async {
1001            // alice holds owner only; admin (the admitter relation) by implication.
1002            assert_eq!(r.auth.admit_relation(&r.alice(), "namespace", "ed", IAT).await.unwrap().as_deref(), Some("admin"));
1003            let s = r.snapshots.mint("namespace", "ed", IAT).await.unwrap();
1004            r.edge.hold_snapshot(&s.token).await.unwrap();
1005            let approval = r.edge.approval_at(&key_of(&r.device), Some(&r.binding), "namespace", "ed", IAT).await.unwrap();
1006            assert_eq!(approval.relation, "admin");
1007            assert_eq!(approval.user, Some(UserId::new("alice")));
1008            // And both refuse a non-holder.
1009            assert_eq!(r.auth.admit_relation(&PrincipalId::user("bob"), "namespace", "ed", IAT).await.unwrap(), None);
1010            let stranger = AsymmetricKeyPair::<V4>::generate().unwrap();
1011            assert!(r.edge.approval_at(&key_of(&stranger), None, "namespace", "ed", IAT).await.is_err());
1012        });
1013    }
1014
1015    #[test]
1016    fn a_renewing_knock_replaces_the_lease() {
1017        let r = rig();
1018        pollster::block_on(async {
1019            let Reconciled::Accepted(first) = r.auth.reconcile(&r.admit("admit-4", week()), IAT).await.unwrap() else {
1020                panic!("refused")
1021            };
1022            let renew = device_sign(
1023                &r.guest,
1024                &Knock {
1025                    requester: key_of(&r.guest),
1026                    kind: "namespace".into(),
1027                    id: "ed".into(),
1028                    relation: "guest".into(),
1029                    nonce: "n2".into(),
1030                    label: "phone".into(),
1031                    standing: None,
1032                    renews: Some("admit-4".into()),
1033                    iat: IAT + 6 * DAY,
1034                },
1035            );
1036            let (pending, _) = r.auth.knock(&renew, IAT + 6 * DAY).await.unwrap();
1037            assert_eq!(pending.renews.as_deref(), Some("admit-4"));
1038            let renewed = r.auth.admit_knock(&r.alice(), &pending.id, Confirmation::Accept, None, IAT + 6 * DAY).await.unwrap();
1039            assert_eq!(renewed.row.lease.and_then(|l| l.lease.exp()), Some(IAT + 13 * DAY));
1040            let live: Vec<_> = r.store.list_for_resource("namespace", "ed").await.unwrap().into_iter().filter(|x| x.lease.is_some()).collect();
1041            assert_eq!(live.len(), 1);
1042            assert_ne!(live[0].id, first.row.id);
1043        });
1044    }
1045
1046    #[test]
1047    fn an_offer_is_redeemed_once_per_key_up_to_max_uses() {
1048        let r = rig();
1049        pollster::block_on(async {
1050            assert!(matches!(
1051                r.auth.offer(&PrincipalId::user("bob"), "namespace", "ed", "guest", 1, IAT).await,
1052                Err(KnockFlowError::NotAnApprover(_))
1053            ));
1054            let (offer, token) = r.auth.offer(&r.alice(), "namespace", "ed", "guest", 1, IAT).await.unwrap();
1055            assert_eq!(offer.max_uses, 1);
1056            let a = r.auth.redeem_offer(&token, &r.knock(&r.guest, "phone"), IAT).await.unwrap();
1057            assert_eq!(a.row.granted_by, r.alice());
1058            assert_eq!(a.row.lease.and_then(|l| l.lease.exp()), Some(IAT + 30 * DAY));
1059            assert_eq!(r.auth.redeem_offer(&token, &r.knock(&r.guest, "phone"), IAT + 1).await.unwrap(), a);
1060            let other = AsymmetricKeyPair::<V4>::generate().unwrap();
1061            assert!(matches!(
1062                r.auth.redeem_offer(&token, &r.knock(&other, "x"), IAT + 2).await,
1063                Err(KnockFlowError::OfferExhausted)
1064            ));
1065            // A forged offer does not verify.
1066            let forged = device_sign(&other, &offer);
1067            assert!(matches!(r.auth.redeem_offer(&forged, &r.knock(&other, "x"), IAT).await, Err(KnockFlowError::Artifact(_))));
1068        });
1069    }
1070
1071    #[test]
1072    fn closed_stops_online_knocks_too() {
1073        let r = rig();
1074        pollster::block_on(async {
1075            let closed = AdmissionPolicy::new(AdmissionMode::Closed, "guest").with_admitters(["admin"]);
1076            r.store.set_admission_policy("namespace", "ed", Some(&closed), IAT).await.unwrap();
1077            let (pending, _) = r.auth.knock(&r.knock(&r.guest, "phone"), IAT).await.unwrap();
1078            assert!(matches!(
1079                r.auth.admit_knock(&r.alice(), &pending.id, Confirmation::Accept, None, IAT).await,
1080                Err(KnockFlowError::Refused(AdmissionRefusal::Mode(AdmissionMode::Closed)))
1081            ));
1082        });
1083    }
1084}