authkestra_engine/store/mod.rs
1use async_trait::async_trait;
2use std::time::Duration;
3
4#[derive(Debug, thiserror::Error)]
5pub enum StoreError {
6 #[error("Internal store error: {0}")]
7 Internal(String),
8 #[error("Not found")]
9 NotFound,
10 #[error("Serialization error: {0}")]
11 Serialization(String),
12}
13
14#[async_trait]
15pub trait KvStore<T>: Send + Sync + 'static {
16 async fn get(&self, key: &str) -> Result<Option<T>, StoreError>;
17 async fn set(&self, key: &str, value: T, ttl: Duration) -> Result<(), StoreError>;
18 async fn delete(&self, key: &str) -> Result<(), StoreError>;
19}
20
21/// Backends that can atomically fetch-and-remove a value implement this.
22#[async_trait]
23pub trait AtomicConsume<T>: KvStore<T> {
24 async fn consume(&self, key: &str) -> Result<Option<T>, StoreError>;
25}
26
27/// Backends that can atomically insert a value only if no value is
28/// currently stored under `key` implement this.
29///
30/// This is the complement of [`AtomicConsume`], not a duplicate of it:
31/// `AtomicConsume` is for values the *server* creates and later atomically
32/// fetches-and-removes (an authorization code, say — the key is only ever
33/// seen after this server put it there). `AtomicInsert` is for values whose
34/// key is supplied by the *caller* and was never stored by this server
35/// first — the shape a replay guard needs, e.g. a DPoP proof's `jti`, which
36/// a client generates and this server has never seen before the first time
37/// it's presented.
38#[async_trait]
39pub trait AtomicInsert<T>: KvStore<T> {
40 /// Inserts `value` under `key` only if `key` does not already hold a
41 /// value. Returns `Ok(true)` if the insert happened (the key was
42 /// fresh) and `Ok(false)` if a value was already present (the key was
43 /// already claimed — e.g. a replay). Must be a single atomic
44 /// operation: a check-then-set built from `get` followed by `set` is a
45 /// TOCTOU race that defeats the entire purpose of a replay guard.
46 async fn insert_if_absent(
47 &self,
48 key: &str,
49 value: T,
50 ttl: Duration,
51 ) -> Result<bool, StoreError>;
52}
53
54/// Rounds a [`Duration`] up to the nearest whole second, flooring at 1.
55///
56/// Every [`AtomicInsert`] backend whose storage only expresses TTL in
57/// whole seconds (Redis's `EX`, and the `expires_at` column every SQL
58/// backend uses) needs this same conversion, and needs it to round up:
59/// `insert_if_absent`'s return value is a security-critical replay
60/// signal, not a best-effort cache write. Truncating instead (a plain
61/// `.as_secs()`, or `.as_secs().max(1)`) would silently disable the
62/// replay guard for any caller using a sub-second TTL (e.g. a DPoP
63/// freshness window configured in milliseconds) — a 1ms TTL would
64/// truncate to `expires_at == now`, meaning the row is already expired by
65/// the time anyone could observe it, and every subsequent call with the
66/// same key would also see no live entry and also report "fresh". This
67/// bug was found and fixed for the Redis backend first (authkestra#277
68/// review) and, having no shared definition to fall to, was independently
69/// re-introduced in each SQL backend rather than caught by one fix.
70pub fn ttl_ceil_secs(ttl: Duration) -> u64 {
71 ttl.as_secs()
72 .saturating_add(u64::from(ttl.subsec_nanos() > 0))
73 .max(1)
74}
75
76/// Backends that can atomically write a value under a primary key while
77/// also maintaining a secondary lookup key implement this.
78#[async_trait]
79pub trait IndexedKvStore<T>: KvStore<T> {
80 async fn set_indexed(
81 &self,
82 primary_key: &str,
83 secondary_key: &str,
84 value: T,
85 ttl: Duration,
86 ) -> Result<(), StoreError>;
87 async fn get_by_index(&self, secondary_key: &str) -> Result<Option<T>, StoreError>;
88}
89
90#[cfg(feature = "memory")]
91pub mod memory;
92
93#[cfg(feature = "redis")]
94pub mod redis;
95
96#[cfg(any(
97 feature = "sql-postgres",
98 feature = "sql-sqlite",
99 feature = "sql-mysql"
100))]
101pub mod sql;