1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct KnockConfig {
51 pub pending_ttl: i64,
53 pub pending_cap: usize,
55 pub knocks_per_source_per_minute: u32,
57 pub knocks_per_resource_per_minute: u32,
59 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub requester_user: Option<UserId>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub renews: Option<String>,
90 pub token: String,
92 pub created_at: i64,
93 pub expires_at: i64,
94}
95
96#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Queued {
112 Queued,
113 Replaced,
115 Full,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Redeemed {
122 Fresh(StoredOffer),
124 Again(StoredOffer),
126 Exhausted,
127 Expired,
128 Unknown,
129}
130
131#[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#[async_trait]
141pub trait KnockStore: Send + Sync {
142 async fn queue_knock(&self, knock: &PendingKnock, cap: usize, now: i64) -> Result<Queued, StoreError>;
146
147 async fn pending_knocks(&self, kind: &str, id: &str, now: i64) -> Result<Vec<PendingKnock>, StoreError>;
149
150 async fn pending_knock(&self, id: &str, now: i64) -> Result<Option<PendingKnock>, StoreError>;
152
153 async fn drop_knock(&self, id: &str) -> Result<(), StoreError>;
155
156 async fn put_offer(&self, offer: &StoredOffer) -> Result<(), StoreError>;
157
158 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 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct Admitted {
323 pub jti: String,
327 pub row: OwnershipRow,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332pub enum Reconciled {
333 Accepted(Admitted),
334 Refused { jti: String, refusal: String },
336}
337
338pub 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 assert!(r.auth.pending(&r.alice(), "namespace", "ed", IAT).await.unwrap().is_empty());
908 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 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 assert!(matches!(
953 r.auth.pending(&PrincipalId::user("mallory"), "namespace", "ed", IAT).await,
954 Err(KnockFlowError::NotAnApprover(_))
955 ));
956 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 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 let late = r.snapshots.mint("namespace", "ed", IAT + 7 * DAY).await.unwrap();
991 assert!(!late.snapshot.members.iter().any(|m| m.principal == guest));
992 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 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 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 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}