Skip to main content

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}