Skip to main content

av_harness/
revocation.rs

1//! Token revocation list kept in the harness state store (pillar 1).
2//!
3//! With `state_backend = "redis"`, every replica reads the same list, so a
4//! token revoked through one replica is refused by all of them on the next
5//! request. The single-process backend keeps
6//! [`av_identity::InMemoryRevocationStore`] instead, whose sweep bounds its
7//! memory; see `agentvisord`'s startup wiring.
8
9use av_identity::{RevocationStore, RevokeOutcome, RevokedBy};
10use av_state::StateStore;
11use std::sync::Arc;
12
13/// Revocation list backed by a [`StateStore`]. Each revoked JTI is one
14/// counter; the Redis backend expires it 24 h after the write, far past the
15/// 15-minute NHI lifetime, so the list cleans itself up.
16pub struct StateRevocationStore {
17    store: Arc<dyn StateStore>,
18    prefix: String,
19    instance_prefix: String,
20}
21
22impl StateRevocationStore {
23    /// Wrap the harness state store.
24    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    /// Isolate a harness-issued token namespace from upstream token IDs.
33    /// Namespace names are supplied by the application, never by token claims.
34    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    /// The key holds a digest of the JTI, not the JTI itself: the issuer
43    /// controls the claim's length and characters, the key must not.
44    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        // Separate from the global key's 64-digit suffix. Hash each component
50        // independently so no delimiter in either claim can alias another pair.
51        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
68/// Identity validation runs on tokio workers in several handlers, and a
69/// Redis lookup is a synchronous network round-trip that must never stall
70/// a worker (the rule `prepare_chat_nonblocking` enforces for budgets). On
71/// a multi-thread worker, `block_in_place` first hands the worker's other
72/// tasks to another thread. Anywhere else (the blocking pool, no runtime,
73/// or a current-thread runtime, where `block_in_place` would panic) the
74/// call just runs.
75fn 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        // Encode cutoff+1 so a revocation through epoch zero remains distinct
130        // from StateStore::get's missing-key sentinel of zero.
131        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
152/// Three consecutive dependency failures open the revocation circuit.
153const BREAKER_FAILURE_THRESHOLD: u32 = 3;
154/// An open circuit permits one recovery probe each second.
155const BREAKER_OPEN_TIME: std::time::Duration = std::time::Duration::from_secs(1);
156/// Bound memory independently of the number of token identifiers an issuer creates.
157const LOCAL_REVOKED_MAX: usize = 100_000;
158/// Bound simultaneous synchronous storage calls even before the first failures return.
159const 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
203/// Fail-closed protection for distributed revocation storage. Only confirmed
204/// revocations and locally requested revocations are cached. A successful
205/// "not revoked" answer is never cached, so replicas see later revocations on
206/// the next request. Failed writes still return errors to their callers.
207///
208/// Reads are limited to 32 concurrent dependency calls. After three consecutive
209/// errors, reads fail immediately for one second; then one caller probes
210/// recovery. Writes always reach the backing store, even while the circuit is
211/// open. Positive JTI and instance entries share a strict 100,000-entry bound.
212pub 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    /// Wrap a Redis-backed store. Namespace is `nhi` or `exchanged`; any other
222    /// caller-supplied name shares the fixed `other` metric label.
223    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            // The backing result may be scoped or global. Cache it only for
474            // this issuer, because widening a scoped denial would cross trust
475            // boundaries. Global writes still populate the global cache above.
476            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            &registry,
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()), &registry, "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}