1use av_identity::{RevocationStore, RevokeOutcome, RevokedBy};
10use av_state::StateStore;
11use std::sync::Arc;
12
13pub struct StateRevocationStore {
17 store: Arc<dyn StateStore>,
18 prefix: String,
19 instance_prefix: String,
20}
21
22impl StateRevocationStore {
23 pub fn new(store: Arc<dyn StateStore>) -> Self {
25 Self {
26 store,
27 prefix: "av:revoked:".into(),
28 instance_prefix: "av:revoked-instance:".into(),
29 }
30 }
31
32 pub fn with_namespace(store: Arc<dyn StateStore>, namespace: &str) -> Self {
35 Self {
36 store,
37 prefix: format!("av:revoked-{namespace}:"),
38 instance_prefix: format!("av:revoked-{namespace}-instance:"),
39 }
40 }
41
42 fn key(&self, jti: &str) -> String {
45 format!("{}{}", self.prefix, av_core::digest::sha256_hex(jti.as_bytes()))
46 }
47
48 fn issued_key(&self, issuer: &str, jti: &str) -> String {
49 format!(
52 "{}issuer:{}:{}",
53 self.prefix,
54 av_core::digest::sha256_hex(issuer.as_bytes()),
55 av_core::digest::sha256_hex(jti.as_bytes())
56 )
57 }
58
59 fn instance_key(&self, instance_uid: &str) -> String {
60 format!(
61 "{}{}",
62 self.instance_prefix,
63 av_core::digest::sha256_hex(instance_uid.as_bytes())
64 )
65 }
66}
67
68fn off_the_reactor<T>(call: impl FnOnce() -> T) -> T {
76 match tokio::runtime::Handle::try_current() {
77 Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
78 tokio::task::block_in_place(call)
79 }
80 _ => call(),
81 }
82}
83
84impl RevocationStore for StateRevocationStore {
85 fn revoke(&self, jti: &str, expires_at: u64) -> Result<(), String> {
86 self.try_revoke(jti, expires_at).map(|_| ())
87 }
88
89 fn try_revoke(&self, jti: &str, _expires_at: u64) -> Result<RevokeOutcome, String> {
90 off_the_reactor(|| self.store.add(&self.key(jti), 1))
91 .map(|marks| {
92 if marks == 1 {
93 RevokeOutcome::Revoked
94 } else {
95 RevokeOutcome::AlreadyRevoked
96 }
97 })
98 .map_err(|error| error.to_string())
99 }
100
101 fn try_revoke_token(&self, issuer: &str, jti: &str, _expires_at: u64) -> Result<RevokeOutcome, String> {
102 off_the_reactor(|| self.store.add(&self.issued_key(issuer, jti), 1))
103 .map(|marks| {
104 if marks == 1 {
105 RevokeOutcome::Revoked
106 } else {
107 RevokeOutcome::AlreadyRevoked
108 }
109 })
110 .map_err(|error| error.to_string())
111 }
112
113 fn check_token(&self, issuer: &str, jti: &str, uid: &str, iat: u64) -> Result<Option<RevokedBy>, String> {
114 let marks = off_the_reactor(|| self.store.get(&self.issued_key(issuer, jti)))
115 .map_err(|error| error.to_string())?;
116 if marks > 0 {
117 return Ok(Some(RevokedBy::Jti));
118 }
119 self.check(jti, uid, iat)
120 }
121
122 fn is_revoked(&self, jti: &str) -> Result<bool, String> {
123 off_the_reactor(|| self.store.get(&self.key(jti)))
124 .map(|marks| marks > 0)
125 .map_err(|error| error.to_string())
126 }
127
128 fn revoke_instance(&self, instance_uid: &str, cutoff: u64, _expires_at: u64) -> Result<(), String> {
129 let encoded = cutoff
132 .checked_add(1)
133 .ok_or_else(|| "instance cutoff overflow".to_owned())?;
134 off_the_reactor(|| self.store.fetch_max(&self.instance_key(instance_uid), encoded))
135 .map(|_| ())
136 .map_err(|error| error.to_string())
137 }
138
139 fn check(&self, jti: &str, instance_uid: &str, iat: u64) -> Result<Option<RevokedBy>, String> {
140 if self.is_revoked(jti)? {
141 return Ok(Some(RevokedBy::Jti));
142 }
143 let encoded = off_the_reactor(|| self.store.get(&self.instance_key(instance_uid)))
144 .map_err(|error| error.to_string())?;
145 Ok(encoded
146 .checked_sub(1)
147 .filter(|cutoff| iat <= *cutoff)
148 .map(|cutoff| RevokedBy::Instance { cutoff }))
149 }
150}
151
152const BREAKER_FAILURE_THRESHOLD: u32 = 3;
154const BREAKER_OPEN_TIME: std::time::Duration = std::time::Duration::from_secs(1);
156const LOCAL_REVOKED_MAX: usize = 100_000;
158const MAX_IN_FLIGHT_READS: usize = 32;
160
161trait RevocationClock: Send + Sync {
162 fn monotonic(&self) -> std::time::Instant;
163 fn epoch_seconds(&self) -> u64;
164}
165
166struct SystemRevocationClock;
167impl RevocationClock for SystemRevocationClock {
168 fn monotonic(&self) -> std::time::Instant {
169 std::time::Instant::now()
170 }
171 fn epoch_seconds(&self) -> u64 {
172 av_core::time::now_ms() / 1000
173 }
174}
175
176#[derive(Clone, Copy)]
177struct CachedRevocation {
178 cutoff: Option<u64>,
179 expires_at: u64,
180}
181
182#[derive(Default)]
183struct GuardState {
184 failures: u32,
185 open_until: Option<std::time::Instant>,
186 probe: Option<u64>,
187 next_probe: u64,
188 in_flight: usize,
189 last_success: Option<std::time::Instant>,
190 revoked: std::collections::HashMap<String, CachedRevocation>,
191}
192
193struct GuardMetrics {
194 ok: Arc<av_core::metrics::Counter>,
195 failed: Arc<av_core::metrics::Counter>,
196 short_circuited: Arc<av_core::metrics::Counter>,
197 local_revoked: Arc<av_core::metrics::Counter>,
198 opened: Arc<av_core::metrics::Counter>,
199 available: Arc<av_core::metrics::Gauge>,
200 entries: Arc<av_core::metrics::Gauge>,
201}
202
203pub struct GuardedRevocationStore {
213 inner: Arc<dyn RevocationStore>,
214 state: parking_lot::Mutex<GuardState>,
215 metrics: GuardMetrics,
216 namespace: &'static str,
217 clock: Arc<dyn RevocationClock>,
218}
219
220impl GuardedRevocationStore {
221 pub fn new(
224 inner: Arc<dyn RevocationStore>,
225 metrics: &av_core::metrics::Registry,
226 namespace: &'static str,
227 ) -> Self {
228 Self::with_clock(inner, metrics, namespace, Arc::new(SystemRevocationClock))
229 }
230
231 fn with_clock(
232 inner: Arc<dyn RevocationStore>,
233 registry: &av_core::metrics::Registry,
234 namespace: &'static str,
235 clock: Arc<dyn RevocationClock>,
236 ) -> Self {
237 let namespace = match namespace {
238 "nhi" => "nhi",
239 "exchanged" => "exchanged",
240 _ => "other",
241 };
242 let lookup = |outcome: &str| {
243 registry.counter(
244 &format!("av_revocation_lookups_total{{namespace=\"{namespace}\",outcome=\"{outcome}\"}}"),
245 "Revocation dependency lookup outcomes",
246 )
247 };
248 let metrics = GuardMetrics {
249 ok: lookup("ok"),
250 failed: lookup("failed"),
251 short_circuited: lookup("short_circuited"),
252 local_revoked: lookup("local_revoked"),
253 opened: registry.counter(
254 &format!("av_revocation_breaker_opened_total{{namespace=\"{namespace}\"}}"),
255 "Revocation dependency circuit openings",
256 ),
257 available: registry.gauge(
258 &format!("av_revocation_list_available{{namespace=\"{namespace}\"}}"),
259 "Whether the revocation dependency circuit is closed",
260 ),
261 entries: registry.gauge(
262 &format!("av_revocation_local_entries{{namespace=\"{namespace}\"}}"),
263 "Locally cached known revocations",
264 ),
265 };
266 metrics.available.set(1);
267 metrics.entries.set(0);
268 Self {
269 inner,
270 state: parking_lot::Mutex::new(GuardState::default()),
271 metrics,
272 namespace,
273 clock,
274 }
275 }
276
277 fn cache_key(kind: &str, id: &str) -> String {
278 format!("{kind}:{}", av_core::digest::sha256_hex(id.as_bytes()))
279 }
280
281 fn issued_cache_key(issuer: &str, jti: &str) -> String {
282 format!(
283 "issuer:{}:{}",
284 av_core::digest::sha256_hex(issuer.as_bytes()),
285 av_core::digest::sha256_hex(jti.as_bytes())
286 )
287 }
288
289 fn cached(&self, key: &str) -> Option<CachedRevocation> {
290 let now = self.clock.epoch_seconds();
291 let mut state = self.state.lock();
292 match state.revoked.get(key).copied() {
293 Some(entry) if entry.expires_at >= now => Some(entry),
294 Some(_) => {
295 state.revoked.remove(key);
296 self.metrics.entries.set(state.revoked.len() as u64);
297 None
298 }
299 None => None,
300 }
301 }
302
303 fn remember(&self, key: String, cutoff: Option<u64>, expires_at: u64) {
304 if expires_at < self.clock.epoch_seconds() {
305 return;
306 }
307 let mut state = self.state.lock();
308 if let Some(entry) = state.revoked.get_mut(&key) {
309 entry.expires_at = entry.expires_at.max(expires_at);
310 entry.cutoff = entry.cutoff.max(cutoff);
311 } else if state.revoked.len() < LOCAL_REVOKED_MAX {
312 state.revoked.insert(key, CachedRevocation { cutoff, expires_at });
313 }
314 self.metrics.entries.set(state.revoked.len() as u64);
315 }
316
317 fn retention_from_read(&self) -> u64 {
318 self.clock
319 .epoch_seconds()
320 .saturating_add(av_identity::MAX_TTL_SECS)
321 .saturating_add(2 * av_identity::validator::DEFAULT_LEEWAY_SECS)
322 }
323
324 fn begin_lookup(&self) -> Result<LookupPermit<'_>, String> {
325 let now = self.clock.monotonic();
326 let mut state = self.state.lock();
327 if state.in_flight >= MAX_IN_FLIGHT_READS
328 || state
329 .open_until
330 .is_some_and(|until| now < until || state.probe.is_some())
331 {
332 self.metrics.short_circuited.inc();
333 return Err("revocation dependency temporarily unavailable".into());
334 }
335 let probe = if state.open_until.is_some() {
336 state.next_probe = state.next_probe.wrapping_add(1);
337 let probe = state.next_probe;
338 state.probe = Some(probe);
339 Some(probe)
340 } else {
341 None
342 };
343 state.in_flight += 1;
344 Ok(LookupPermit {
345 guard: self,
346 probe,
347 finished: false,
348 })
349 }
350
351 fn record_result(&self, success: bool, probe: Option<u64>) {
352 let now = self.clock.monotonic();
353 let mut state = self.state.lock();
354 let owns_probe = probe.is_some() && state.probe == probe;
355 if owns_probe {
356 state.probe = None;
357 }
358 if success {
359 let recovered = state.open_until.take().is_some();
360 state.failures = 0;
361 state.last_success = Some(now);
362 self.metrics.available.set(1);
363 if recovered {
364 tracing::info!(
365 namespace = self.namespace,
366 "revocation dependency reachable again"
367 );
368 }
369 } else {
370 state.failures = state.failures.saturating_add(1);
371 if state.failures >= BREAKER_FAILURE_THRESHOLD || owns_probe {
372 if state.open_until.is_none() {
373 self.metrics.opened.inc();
374 tracing::error!(
375 namespace = self.namespace,
376 "revocation dependency circuit opened after repeated errors"
377 );
378 }
379 state.open_until = Some(now + BREAKER_OPEN_TIME);
380 self.metrics.available.set(0);
381 }
382 }
383 }
384
385 fn lookup<T>(&self, read: impl FnOnce(&dyn RevocationStore) -> Result<T, String>) -> Result<T, String> {
386 let mut permit = self.begin_lookup()?;
387 let result = read(self.inner.as_ref());
388 if result.is_ok() {
389 self.metrics.ok.inc();
390 } else {
391 self.metrics.failed.inc();
392 }
393 self.record_result(result.is_ok(), permit.probe);
394 permit.finished = true;
395 result
396 }
397}
398
399struct LookupPermit<'a> {
400 guard: &'a GuardedRevocationStore,
401 probe: Option<u64>,
402 finished: bool,
403}
404
405impl Drop for LookupPermit<'_> {
406 fn drop(&mut self) {
407 if !self.finished {
408 self.guard.metrics.failed.inc();
409 self.guard.record_result(false, self.probe);
410 }
411 let mut state = self.guard.state.lock();
412 state.in_flight = state.in_flight.saturating_sub(1);
413 }
414}
415
416impl RevocationStore for GuardedRevocationStore {
417 fn health(&self) -> Option<av_identity::RevocationHealth> {
418 let state = self.state.lock();
419 Some(av_identity::RevocationHealth {
420 available: state.open_until.is_none(),
421 local_entries: state.revoked.len(),
422 seconds_since_last_success: state
423 .last_success
424 .map(|last| self.clock.monotonic().saturating_duration_since(last).as_secs()),
425 })
426 }
427
428 fn sweep_expired(&self, now_s: u64) {
429 {
430 let mut state = self.state.lock();
431 state.revoked.retain(|_, entry| entry.expires_at >= now_s);
432 self.metrics.entries.set(state.revoked.len() as u64);
433 }
434 self.inner.sweep_expired(now_s);
435 }
436
437 fn revoke(&self, jti: &str, expires_at: u64) -> Result<(), String> {
438 self.try_revoke(jti, expires_at).map(|_| ())
439 }
440
441 fn try_revoke(&self, jti: &str, expires_at: u64) -> Result<RevokeOutcome, String> {
442 self.remember(Self::cache_key("jti", jti), None, expires_at);
443 let result = self.inner.try_revoke(jti, expires_at);
444 self.record_result(result.is_ok(), None);
445 result
446 }
447
448 fn try_revoke_token(&self, issuer: &str, jti: &str, expiry: u64) -> Result<RevokeOutcome, String> {
449 self.remember(Self::issued_cache_key(issuer, jti), None, expiry);
450 let result = self.inner.try_revoke_token(issuer, jti, expiry);
451 self.record_result(result.is_ok(), None);
452 result
453 }
454
455 fn check_token(&self, issuer: &str, jti: &str, uid: &str, iat: u64) -> Result<Option<RevokedBy>, String> {
456 let scoped = Self::issued_cache_key(issuer, jti);
457 let global = Self::cache_key("jti", jti);
458 if self.cached(&scoped).is_some() || self.cached(&global).is_some() {
459 self.metrics.local_revoked.inc();
460 return Ok(Some(RevokedBy::Jti));
461 }
462 let instance = Self::cache_key("instance", uid);
463 if let Some(cutoff) = self
464 .cached(&instance)
465 .and_then(|entry| entry.cutoff)
466 .filter(|cutoff| iat <= *cutoff)
467 {
468 self.metrics.local_revoked.inc();
469 return Ok(Some(RevokedBy::Instance { cutoff }));
470 }
471 let revoked = self.lookup(|inner| inner.check_token(issuer, jti, uid, iat))?;
472 match revoked {
473 Some(RevokedBy::Jti) => self.remember(scoped, None, self.retention_from_read()),
477 Some(RevokedBy::Instance { cutoff }) => {
478 self.remember(instance, Some(cutoff), self.retention_from_read())
479 }
480 None => {}
481 }
482 Ok(revoked)
483 }
484
485 fn is_revoked(&self, jti: &str) -> Result<bool, String> {
486 let key = Self::cache_key("jti", jti);
487 if self.cached(&key).is_some() {
488 self.metrics.local_revoked.inc();
489 return Ok(true);
490 }
491 let revoked = self.lookup(|inner| inner.is_revoked(jti))?;
492 if revoked {
493 self.remember(key, None, self.retention_from_read());
494 }
495 Ok(revoked)
496 }
497
498 fn revoke_instance(&self, uid: &str, cutoff: u64, expires_at: u64) -> Result<(), String> {
499 self.remember(Self::cache_key("instance", uid), Some(cutoff), expires_at);
500 let result = self.inner.revoke_instance(uid, cutoff, expires_at);
501 self.record_result(result.is_ok(), None);
502 result
503 }
504
505 fn check(&self, jti: &str, uid: &str, iat: u64) -> Result<Option<RevokedBy>, String> {
506 let jti_key = Self::cache_key("jti", jti);
507 if self.cached(&jti_key).is_some() {
508 self.metrics.local_revoked.inc();
509 return Ok(Some(RevokedBy::Jti));
510 }
511 let instance_key = Self::cache_key("instance", uid);
512 if let Some(cutoff) = self
513 .cached(&instance_key)
514 .and_then(|entry| entry.cutoff)
515 .filter(|cutoff| iat <= *cutoff)
516 {
517 self.metrics.local_revoked.inc();
518 return Ok(Some(RevokedBy::Instance { cutoff }));
519 }
520 let revoked = self.lookup(|inner| inner.check(jti, uid, iat))?;
521 match revoked {
522 Some(RevokedBy::Jti) => self.remember(jti_key, None, self.retention_from_read()),
523 Some(RevokedBy::Instance { cutoff }) => {
524 self.remember(instance_key, Some(cutoff), self.retention_from_read())
525 }
526 None => {}
527 }
528 Ok(revoked)
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
535
536 use super::*;
537 use av_state::{InMemoryStore, Spend, StateError, TrySpendOutcome};
538
539 #[test]
540 fn revoked_jti_is_visible_through_every_handle_on_the_same_store() {
541 let shared: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
542 let replica_a = StateRevocationStore::new(Arc::clone(&shared));
543 let replica_b = StateRevocationStore::new(Arc::clone(&shared));
544 assert!(!replica_b.is_revoked("jti-1").unwrap());
545 replica_a.revoke("jti-1", 0).unwrap();
546 assert!(
547 replica_b.is_revoked("jti-1").unwrap(),
548 "a revocation must reach every replica"
549 );
550 assert!(!replica_b.is_revoked("jti-2").unwrap());
551 }
552
553 fn round_trip(store: &StateRevocationStore, jti: &str) {
554 assert!(!store.is_revoked(jti).unwrap());
555 store.revoke(jti, 0).unwrap();
556 assert!(store.is_revoked(jti).unwrap());
557 }
558
559 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
560 async fn lookups_are_safe_on_a_multi_thread_worker_and_its_blocking_pool() {
561 let store = Arc::new(StateRevocationStore::new(Arc::new(InMemoryStore::new())));
562 round_trip(&store, "on-worker");
563 let pooled = Arc::clone(&store);
564 tokio::task::spawn_blocking(move || round_trip(&pooled, "on-blocking-pool"))
565 .await
566 .unwrap();
567 }
568
569 #[tokio::test(flavor = "current_thread")]
570 async fn lookups_are_safe_on_a_current_thread_runtime() {
571 let store = StateRevocationStore::new(Arc::new(InMemoryStore::new()));
572 round_trip(&store, "current-thread");
573 }
574
575 #[test]
576 fn lookups_are_safe_outside_any_runtime() {
577 let store = StateRevocationStore::new(Arc::new(InMemoryStore::new()));
578 round_trip(&store, "no-runtime");
579 }
580
581 #[test]
582 fn revoking_twice_is_harmless() {
583 let store = StateRevocationStore::new(Arc::new(InMemoryStore::new()));
584 store.revoke("jti", 0).unwrap();
585 store.revoke("jti", 0).unwrap();
586 assert!(store.is_revoked("jti").unwrap());
587 }
588
589 #[test]
590 fn keys_are_bounded_digests_of_the_jti() {
591 let store = StateRevocationStore::new(Arc::new(InMemoryStore::new()));
592 let long_jti = "x".repeat(10_000);
593 let key = store.key(&long_jti);
594 assert!(key.starts_with("av:revoked:"));
595 assert_eq!(key.len(), "av:revoked:".len() + 64);
596 assert_ne!(store.key("a"), store.key("b"));
597 assert_eq!(
598 store.instance_key(&long_jti).len(),
599 "av:revoked-instance:".len() + 64
600 );
601 }
602
603 struct FailingStore;
604
605 impl StateStore for FailingStore {
606 fn add(&self, _key: &str, _delta: u64) -> Result<u64, StateError> {
607 Err(StateError::Backend("redis unreachable".into()))
608 }
609 fn get(&self, _key: &str) -> Result<u64, StateError> {
610 Err(StateError::Backend("redis unreachable".into()))
611 }
612 fn try_spend_many(&self, _spends: &[Spend]) -> Result<TrySpendOutcome, StateError> {
613 Err(StateError::Backend("redis unreachable".into()))
614 }
615 fn remove(&self, _key: &str) {}
616 }
617
618 #[test]
619 fn backend_failures_surface_as_errors_not_as_not_revoked() {
620 let store = StateRevocationStore::new(Arc::new(FailingStore));
621 assert!(
622 store.revoke("jti", 0).is_err(),
623 "a lost revocation must be reported"
624 );
625 assert!(
626 store.is_revoked("jti").is_err(),
627 "an unreadable list must not read as 'not revoked'"
628 );
629 assert!(store.revoke_instance("instance", 1000, 2000).is_err());
630 assert!(store.check("jti", "instance", 999).is_err());
631 }
632
633 #[test]
634 fn instance_revocation_is_monotonic_shared_and_inclusive() {
635 let shared: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
636 let writer = StateRevocationStore::new(shared.clone());
637 let reader = StateRevocationStore::new(shared);
638 assert_eq!(reader.check("jti", "instance", 0).unwrap(), None);
639 writer.revoke_instance("instance", 1000, 2000).unwrap();
640 writer.revoke_instance("instance", 900, 1900).unwrap();
641 assert_eq!(
642 reader.check("jti", "instance", 1000).unwrap(),
643 Some(RevokedBy::Instance { cutoff: 1000 })
644 );
645 assert_eq!(reader.check("jti", "instance", 1001).unwrap(), None);
646 assert_eq!(reader.check("jti", "another", 1000).unwrap(), None);
647 writer.revoke_instance("epoch-zero", 0, 2000).unwrap();
648 assert_eq!(
649 reader.check("jti", "epoch-zero", 0).unwrap(),
650 Some(RevokedBy::Instance { cutoff: 0 })
651 );
652 assert!(writer.revoke_instance("overflow", u64::MAX, u64::MAX).is_err());
653 }
654
655 #[test]
656 fn revocation_namespaces_do_not_collide_and_repeated_writes_are_deduplicated() {
657 let shared: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
658 let upstream = StateRevocationStore::new(shared.clone());
659 let exchanged = StateRevocationStore::with_namespace(shared, "exchanged");
660 assert_eq!(
661 upstream.try_revoke("same-jti", 2000).unwrap(),
662 RevokeOutcome::Revoked
663 );
664 assert_eq!(
665 upstream.try_revoke("same-jti", 2000).unwrap(),
666 RevokeOutcome::AlreadyRevoked
667 );
668 assert!(!exchanged.is_revoked("same-jti").unwrap());
669 assert_eq!(
670 exchanged.try_revoke("same-jti", 2000).unwrap(),
671 RevokeOutcome::Revoked
672 );
673 }
674 struct TestClock {
675 epoch: std::time::Instant,
676 millis: std::sync::atomic::AtomicU64,
677 }
678 impl TestClock {
679 fn new() -> Self {
680 Self {
681 epoch: std::time::Instant::now(),
682 millis: std::sync::atomic::AtomicU64::new(0),
683 }
684 }
685 fn advance(&self, millis: u64) {
686 self.millis.fetch_add(millis, std::sync::atomic::Ordering::SeqCst);
687 }
688 }
689 impl RevocationClock for TestClock {
690 fn monotonic(&self) -> std::time::Instant {
691 self.epoch
692 + std::time::Duration::from_millis(self.millis.load(std::sync::atomic::Ordering::SeqCst))
693 }
694 fn epoch_seconds(&self) -> u64 {
695 1000 + self.millis.load(std::sync::atomic::Ordering::SeqCst) / 1000
696 }
697 }
698
699 type PausedLookup = (std::sync::mpsc::Sender<()>, Arc<std::sync::Barrier>);
700
701 #[derive(Default)]
702 struct TestDependency {
703 store: av_identity::InMemoryRevocationStore,
704 offline: std::sync::atomic::AtomicBool,
705 panic_next: std::sync::atomic::AtomicBool,
706 reads: std::sync::atomic::AtomicUsize,
707 writes: std::sync::atomic::AtomicUsize,
708 pause_next: parking_lot::Mutex<Option<PausedLookup>>,
709 }
710 impl TestDependency {
711 fn read(&self) -> Result<(), String> {
712 use std::sync::atomic::Ordering::SeqCst;
713 self.reads.fetch_add(1, SeqCst);
714 if let Some((entered, resume)) = self.pause_next.lock().take() {
715 entered.send(()).unwrap();
716 resume.wait();
717 }
718 assert!(!self.panic_next.swap(false, SeqCst), "injected dependency panic");
719 if self.offline.load(SeqCst) {
720 Err("dependency offline".into())
721 } else {
722 Ok(())
723 }
724 }
725 fn write(&self) -> Result<(), String> {
726 use std::sync::atomic::Ordering::SeqCst;
727 self.writes.fetch_add(1, SeqCst);
728 if self.offline.load(SeqCst) {
729 Err("dependency offline".into())
730 } else {
731 Ok(())
732 }
733 }
734 }
735 impl RevocationStore for TestDependency {
736 fn is_revoked(&self, jti: &str) -> Result<bool, String> {
737 self.read()?;
738 self.store.is_revoked(jti)
739 }
740 fn revoke(&self, jti: &str, expiry: u64) -> Result<(), String> {
741 self.try_revoke(jti, expiry).map(|_| ())
742 }
743 fn try_revoke(&self, jti: &str, expiry: u64) -> Result<RevokeOutcome, String> {
744 self.write()?;
745 self.store.try_revoke(jti, expiry)
746 }
747 fn try_revoke_token(&self, issuer: &str, jti: &str, expiry: u64) -> Result<RevokeOutcome, String> {
748 self.write()?;
749 self.store.try_revoke_token(issuer, jti, expiry)
750 }
751 fn check_token(
752 &self,
753 issuer: &str,
754 jti: &str,
755 uid: &str,
756 iat: u64,
757 ) -> Result<Option<RevokedBy>, String> {
758 self.read()?;
759 self.store.check_token(issuer, jti, uid, iat)
760 }
761 fn check(&self, jti: &str, uid: &str, iat: u64) -> Result<Option<RevokedBy>, String> {
762 self.read()?;
763 self.store.check(jti, uid, iat)
764 }
765 fn revoke_instance(&self, uid: &str, cutoff: u64, expiry: u64) -> Result<(), String> {
766 self.write()?;
767 self.store.revoke_instance(uid, cutoff, expiry)
768 }
769 }
770
771 fn guarded() -> (
772 Arc<GuardedRevocationStore>,
773 Arc<TestDependency>,
774 Arc<TestClock>,
775 av_core::metrics::Registry,
776 ) {
777 let inner = Arc::new(TestDependency::default());
778 let clock = Arc::new(TestClock::new());
779 let registry = av_core::metrics::Registry::new();
780 let guard = Arc::new(GuardedRevocationStore::with_clock(
781 inner.clone(),
782 ®istry,
783 "nhi",
784 clock.clone(),
785 ));
786 (guard, inner, clock, registry)
787 }
788
789 #[test]
790 fn availability_guard_opens_after_three_errors_and_recovers_after_one_probe() {
791 use std::sync::atomic::Ordering::SeqCst;
792 let (guard, inner, clock, _) = guarded();
793 inner.offline.store(true, SeqCst);
794 for _ in 0..3 {
795 assert!(guard.is_revoked("live").is_err());
796 }
797 assert_eq!(inner.reads.load(SeqCst), 3);
798 assert!(!guard.health().unwrap().available);
799 assert_eq!(guard.health().unwrap().seconds_since_last_success, None);
800 for _ in 0..20 {
801 assert!(guard.is_revoked("live").is_err());
802 }
803 assert_eq!(inner.reads.load(SeqCst), 3);
804 clock.advance(999);
805 assert!(guard.is_revoked("live").is_err());
806 clock.advance(1);
807 assert!(guard.is_revoked("live").is_err());
808 assert_eq!(inner.reads.load(SeqCst), 4);
809 inner.offline.store(false, SeqCst);
810 clock.advance(999);
811 assert!(guard.is_revoked("live").is_err());
812 clock.advance(1);
813 assert!(!guard.is_revoked("live").unwrap());
814 assert_eq!(inner.reads.load(SeqCst), 5);
815 assert!(guard.health().unwrap().available);
816 assert_eq!(guard.health().unwrap().seconds_since_last_success, Some(0));
817 assert_eq!(guard.metrics.failed.get(), 4);
818 assert_eq!(guard.metrics.opened.get(), 1);
819 }
820
821 #[test]
822 fn availability_guard_never_caches_a_live_answer_and_retains_positive_results() {
823 use std::sync::atomic::Ordering::SeqCst;
824 let (guard, inner, _, _) = guarded();
825 assert!(!guard.is_revoked("token").unwrap());
826 inner.store.revoke("token", 2000).unwrap();
827 assert!(guard.is_revoked("token").unwrap());
828 assert_eq!(inner.reads.load(SeqCst), 2);
829 inner.offline.store(true, SeqCst);
830 for _ in 0..4 {
831 assert!(guard.is_revoked("another").is_err());
832 }
833 assert!(guard.is_revoked("token").unwrap());
834 assert_eq!(inner.reads.load(SeqCst), 5);
835 assert_eq!(guard.metrics.local_revoked.get(), 1);
836 assert_eq!(guard.health().unwrap().local_entries, 1);
837 }
838
839 #[test]
840 fn failed_writes_still_deny_locally_but_never_report_success_and_writes_bypass_breaker() {
841 use std::sync::atomic::Ordering::SeqCst;
842 let (guard, inner, _, _) = guarded();
843 inner.offline.store(true, SeqCst);
844 assert!(guard.try_revoke("token", 2000).is_err());
845 assert!(guard.revoke_instance("instance", 1000, 2000).is_err());
846 assert!(guard.is_revoked("unrevoked").is_err());
847 assert!(!guard.health().unwrap().available);
848 assert!(guard.is_revoked("token").unwrap());
849 assert_eq!(
850 guard.check("other", "instance", 1000).unwrap(),
851 Some(RevokedBy::Instance { cutoff: 1000 })
852 );
853 assert!(guard.check("other", "instance", 1001).is_err());
854 inner.offline.store(false, SeqCst);
855 assert_eq!(guard.try_revoke("token", 2000).unwrap(), RevokeOutcome::Revoked);
856 assert_eq!(inner.writes.load(SeqCst), 3);
857 assert!(guard.health().unwrap().available);
858 assert_eq!(
859 guard.try_revoke("token", 2000).unwrap(),
860 RevokeOutcome::AlreadyRevoked
861 );
862 }
863
864 #[test]
865 fn availability_guard_admits_exactly_one_half_open_probe() {
866 use std::sync::atomic::Ordering::SeqCst;
867 let (guard, inner, clock, _) = guarded();
868 inner.offline.store(true, SeqCst);
869 for _ in 0..3 {
870 assert!(guard.is_revoked("a").is_err());
871 }
872 clock.advance(1000);
873 inner.offline.store(false, SeqCst);
874 let (entered, receiver) = std::sync::mpsc::channel();
875 let barrier = Arc::new(std::sync::Barrier::new(2));
876 *inner.pause_next.lock() = Some((entered, barrier.clone()));
877 let probe_guard = guard.clone();
878 let probe = std::thread::spawn(move || probe_guard.is_revoked("probe"));
879 receiver.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
880 for _ in 0..10 {
881 assert!(guard.is_revoked("waiting").is_err());
882 }
883 assert_eq!(inner.reads.load(SeqCst), 4);
884 barrier.wait();
885 assert!(!probe.join().unwrap().unwrap());
886 assert!(guard.health().unwrap().available);
887 }
888
889 #[test]
890 fn panic_does_not_strand_a_probe_or_its_concurrency_permit() {
891 use std::sync::atomic::Ordering::SeqCst;
892 let (guard, inner, clock, _) = guarded();
893 inner.offline.store(true, SeqCst);
894 for _ in 0..3 {
895 assert!(guard.is_revoked("a").is_err());
896 }
897 clock.advance(1000);
898 inner.panic_next.store(true, SeqCst);
899 assert!(
900 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| guard.is_revoked("probe"))).is_err()
901 );
902 assert_eq!(guard.state.lock().in_flight, 0);
903 assert!(guard.state.lock().probe.is_none());
904 clock.advance(1000);
905 inner.offline.store(false, SeqCst);
906 assert!(!guard.is_revoked("recovery").unwrap());
907 }
908
909 #[test]
910 fn positive_cache_is_bounded_and_expiry_retains_the_last_accepted_second() {
911 let (guard, _, clock, _) = guarded();
912 for i in 0..LOCAL_REVOKED_MAX {
913 guard.remember(format!("jti:{i}"), None, 1010);
914 }
915 guard.remember("overflow".into(), None, 1010);
916 assert_eq!(guard.health().unwrap().local_entries, LOCAL_REVOKED_MAX);
917 assert!(!guard.state.lock().revoked.contains_key("overflow"));
918 guard.remember("jti:0".into(), None, 1011);
919 clock.advance(10_000);
920 guard.sweep_expired(1010);
921 assert_eq!(guard.health().unwrap().local_entries, LOCAL_REVOKED_MAX);
922 clock.advance(1000);
923 guard.sweep_expired(1011);
924 assert_eq!(guard.health().unwrap().local_entries, 1);
925 clock.advance(1000);
926 guard.sweep_expired(1012);
927 assert_eq!(guard.health().unwrap().local_entries, 0);
928 }
929
930 #[test]
931 fn known_instance_cutoffs_remain_monotonic_and_namespaces_have_separate_health() {
932 let (guard, inner, _, registry) = guarded();
933 inner.store.revoke_instance("instance", 1000, 2000).unwrap();
934 assert_eq!(
935 guard.check("token", "instance", 999).unwrap(),
936 Some(RevokedBy::Instance { cutoff: 1000 })
937 );
938 guard.revoke_instance("instance", 900, 1900).unwrap();
939 assert_eq!(
940 guard.check("other", "instance", 1000).unwrap(),
941 Some(RevokedBy::Instance { cutoff: 1000 })
942 );
943 let other = GuardedRevocationStore::new(Arc::new(TestDependency::default()), ®istry, "exchanged");
944 inner.offline.store(true, std::sync::atomic::Ordering::SeqCst);
945 for _ in 0..3 {
946 assert!(guard.is_revoked("unknown").is_err());
947 }
948 assert!(!guard.health().unwrap().available);
949 assert!(other.health().unwrap().available);
950 assert_eq!(guard.metrics.available.get(), 0);
951 assert_eq!(other.metrics.available.get(), 1);
952 }
953
954 #[test]
955 fn simultaneous_backend_reads_are_bounded_before_the_circuit_opens() {
956 let (guard, _, _, _) = guarded();
957 let permits: Vec<_> = (0..MAX_IN_FLIGHT_READS)
958 .map(|_| guard.begin_lookup().unwrap())
959 .collect();
960 assert!(guard.begin_lookup().is_err());
961 assert_eq!(guard.state.lock().in_flight, MAX_IN_FLIGHT_READS);
962 for mut permit in permits {
963 permit.finished = true;
964 drop(permit);
965 }
966 assert_eq!(guard.state.lock().in_flight, 0);
967 assert!(guard.health().unwrap().available);
968 }
969
970 #[test]
971 fn scoped_rules_keep_issuer_and_global_domains_separate_in_shared_storage() {
972 let shared: Arc<dyn StateStore> = Arc::new(InMemoryStore::new());
973 let writer = StateRevocationStore::new(shared.clone());
974 let reader = StateRevocationStore::new(shared);
975 assert_eq!(
976 writer.try_revoke_token("issuer-a", "same-jti", 2000).unwrap(),
977 RevokeOutcome::Revoked
978 );
979 assert_eq!(
980 writer.try_revoke_token("issuer-a", "same-jti", 2000).unwrap(),
981 RevokeOutcome::AlreadyRevoked
982 );
983 assert_eq!(
984 reader.check_token("issuer-a", "same-jti", "uid", 1000).unwrap(),
985 Some(RevokedBy::Jti)
986 );
987 assert_eq!(
988 reader.check_token("issuer-b", "same-jti", "uid", 1000).unwrap(),
989 None
990 );
991 assert!(!reader.is_revoked("same-jti").unwrap());
992 assert_ne!(reader.issued_key("a:b", "c"), reader.issued_key("a", "b:c"));
993 writer.revoke("same-jti", 2000).unwrap();
994 assert_eq!(
995 reader.check_token("issuer-b", "same-jti", "uid", 1000).unwrap(),
996 Some(RevokedBy::Jti)
997 );
998 }
999
1000 #[test]
1001 fn scoped_cache_never_widens_local_or_remote_revocations_to_other_issuers() {
1002 use std::sync::atomic::Ordering::SeqCst;
1003 let (guard, inner, _, _) = guarded();
1004 assert_eq!(
1005 guard.check_token("issuer-a", "same-jti", "uid", 1000).unwrap(),
1006 None
1007 );
1008 inner
1009 .store
1010 .try_revoke_token("issuer-a", "same-jti", 2000)
1011 .unwrap();
1012 assert_eq!(
1013 guard.check_token("issuer-a", "same-jti", "uid", 1000).unwrap(),
1014 Some(RevokedBy::Jti)
1015 );
1016 assert_eq!(
1017 guard.check_token("issuer-b", "same-jti", "uid", 1000).unwrap(),
1018 None
1019 );
1020 assert!(!guard.is_revoked("same-jti").unwrap());
1021 inner.offline.store(true, SeqCst);
1022 assert!(guard.try_revoke_token("issuer-c", "failed-write", 2000).is_err());
1023 assert_eq!(
1024 guard
1025 .check_token("issuer-c", "failed-write", "uid", 1000)
1026 .unwrap(),
1027 Some(RevokedBy::Jti)
1028 );
1029 assert!(guard
1030 .check_token("issuer-d", "failed-write", "uid", 1000)
1031 .is_err());
1032 assert_eq!(
1033 guard.check_token("issuer-a", "same-jti", "uid", 1000).unwrap(),
1034 Some(RevokedBy::Jti)
1035 );
1036 assert!(guard.check_token("issuer-b", "same-jti", "uid", 1000).is_err());
1037 assert!(!guard.health().unwrap().available);
1038 inner.offline.store(false, SeqCst);
1039 guard.revoke("same-jti", 2000).unwrap();
1040 assert_eq!(
1041 guard.check_token("issuer-b", "same-jti", "uid", 1000).unwrap(),
1042 Some(RevokedBy::Jti)
1043 );
1044 }
1045}