pas_external/session_version.rs
1//! Building blocks for `sv` claim validation
2//! (STANDARDS_AUTH_INVALIDATION §5).
3//!
4//! In 4.0.0 the validator itself moved into the middleware layer
5//! (see [`SvAwareSessionResolver`](crate::middleware::SvAwareSessionResolver))
6//! so consumers get sv enforcement by default. This module hosts the
7//! reusable primitives:
8//!
9//! - [`SessionVersionCache`] — pluggable cache trait (default impl:
10//! in-memory [`MemorySessionVersionCache`], 60 s TTL). Consumers with
11//! shared substrates (KVRocks, Redis) can implement this trait to
12//! converge break-glass across pods within network RTT instead of
13//! waiting for the per-pod TTL.
14//! - [`SV_CACHE_KEY_PREFIX`] / [`SV_CACHE_TTL`] — the cache contract
15//! constants. Match PAS's `crates/ppoppo-token::sv_cache_key` so a
16//! custom KVRocks cache adapter can read the same key namespace as
17//! PCS chat-auth.
18//!
19//! See [`SvAwareSessionResolver`](crate::middleware::SvAwareSessionResolver)
20//! for the resolver entry point.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use async_trait::async_trait;
27use tokio::sync::RwLock;
28
29/// Namespace prefix for cache keys. Matches chat-auth and is_admin caches.
30pub const SV_CACHE_KEY_PREFIX: &str = "sv:";
31
32/// TTL per `paseto-sv-claim.md §R5`. 60 s, non-configurable by design.
33pub const SV_CACHE_TTL: Duration = Duration::from_secs(60);
34
35/// Cache abstraction for `sv:{ppnum_id}` lookups.
36///
37/// Default implementation is [`MemorySessionVersionCache`]. Consumers
38/// that already run KVRocks/Redis can write their own adapter — the
39/// `get` / `set` contract is minimal.
40///
41/// `get` returns `None` on cache miss OR any transient backend error.
42/// `set` is best-effort and swallows failures internally (a failed set
43/// only costs us one extra fetch on the next validate).
44#[async_trait]
45pub trait SessionVersionCache: Send + Sync {
46 async fn get(&self, key: &str) -> Option<i64>;
47 async fn set(&self, key: &str, sv: i64, ttl: Duration);
48}
49
50/// In-memory [`SessionVersionCache`]. Default choice for SDK consumers.
51///
52/// `tokio::sync::RwLock<HashMap<String, (sv, Instant)>>` with lazy
53/// eviction on read: entries past their TTL are treated as miss.
54/// Production consumers with many pods may want to plug in a shared
55/// cache (Redis, KVRocks) so a break-glass on one pod converges on all
56/// pods within the same 60 s window; the in-memory default is per-pod.
57pub struct MemorySessionVersionCache {
58 inner: Arc<RwLock<HashMap<String, (i64, Instant)>>>,
59}
60
61impl MemorySessionVersionCache {
62 #[must_use]
63 pub fn new() -> Self {
64 Self {
65 inner: Arc::new(RwLock::new(HashMap::new())),
66 }
67 }
68}
69
70impl Default for MemorySessionVersionCache {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76#[async_trait]
77impl SessionVersionCache for MemorySessionVersionCache {
78 async fn get(&self, key: &str) -> Option<i64> {
79 let guard = self.inner.read().await;
80 let (sv, written_at) = guard.get(key)?;
81 if written_at.elapsed() >= SV_CACHE_TTL {
82 return None;
83 }
84 Some(*sv)
85 }
86
87 async fn set(&self, key: &str, sv: i64, _ttl: Duration) {
88 // TTL is governed by the SV_CACHE_TTL constant; ignore the param
89 // so callers can't accidentally drift this substrate's TTL away
90 // from the contract.
91 let mut guard = self.inner.write().await;
92 guard.insert(key.to_string(), (sv, Instant::now()));
93 }
94}
95
96#[cfg(test)]
97#[allow(clippy::unwrap_used)]
98mod tests {
99 use super::*;
100
101 #[tokio::test]
102 async fn memory_cache_respects_ttl() {
103 // Exercises MemorySessionVersionCache's lazy-eviction-on-read.
104 // Can't literally advance wall clock, so this only proves that
105 // within-TTL reads hit — the expiry branch is covered by
106 // construction (if written_at.elapsed() >= SV_CACHE_TTL → None).
107 let cache = MemorySessionVersionCache::new();
108 cache.set("sv:abc", 42, SV_CACHE_TTL).await;
109 assert_eq!(cache.get("sv:abc").await, Some(42));
110 assert_eq!(cache.get("sv:missing").await, None);
111 }
112
113 #[tokio::test]
114 async fn memory_cache_overwrite() {
115 // A second set with the same key replaces the prior value.
116 // Exercises the SvAwareSessionResolver refresh path that updates
117 // the cache after picking up a newer sv.
118 let cache = MemorySessionVersionCache::new();
119 cache.set("sv:xyz", 1, SV_CACHE_TTL).await;
120 cache.set("sv:xyz", 2, SV_CACHE_TTL).await;
121 assert_eq!(cache.get("sv:xyz").await, Some(2));
122 }
123}