session_guard/store/mod.rs
1//! The storage contract and the built-in stores.
2//!
3//! The guard holds all security logic; a store only persists records and performs two
4//! operations atomically: [`SessionStore::apply`] (rotate, mark used, update) and the lookups.
5//! Implement [`SessionStore`] for your own backend and run the
6//! `store_conformance!` (feature `testing`) suite against it.
7
8mod memory;
9#[cfg(feature = "postgres")]
10#[cfg_attr(docsrs, doc(cfg(feature = "postgres")))]
11mod postgres;
12#[cfg(feature = "valkey")]
13#[cfg_attr(docsrs, doc(cfg(feature = "valkey")))]
14mod valkey;
15
16use std::future::Future;
17
18use chrono::{DateTime, TimeDelta, Utc};
19use uuid::Uuid;
20
21/// The bucket a session id belongs to: its last byte. Sharded stores keep all of a session's
22/// records in this bucket, and the guard sets that byte when it creates the id.
23pub fn session_bucket(id: Uuid) -> u8 {
24 id.as_bytes()[15]
25}
26
27/// The bucket a token belongs to: the first byte of its hash. A session's tokens share its
28/// [`session_bucket`] when the store is [sharded](SessionStore::sharded).
29pub fn token_bucket(hash: &TokenHash) -> u8 {
30 hash.as_bytes()[0]
31}
32
33pub use memory::MemoryStore;
34#[cfg(feature = "postgres")]
35#[cfg_attr(docsrs, doc(cfg(feature = "postgres")))]
36pub use postgres::PgStore;
37#[cfg(feature = "valkey")]
38#[cfg_attr(docsrs, doc(cfg(feature = "valkey")))]
39pub use valkey::ValkeyStore;
40
41use crate::{
42 error::StoreError,
43 event::{EventKind, SessionEvent, SessionOrigin},
44 fingerprint::Baseline,
45 token::TokenHash,
46};
47
48/// A stored session.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct SessionRecord {
51 /// Session id. Safe to show and log; key your own per-session data by it.
52 pub id: Uuid,
53 /// The subject in canonical JSON form (see [`Subject`](crate::Subject)).
54 pub subject: String,
55 /// When the session was created.
56 pub created_at: DateTime<Utc>,
57 /// When the subject last proved who they are (login or completed step-up).
58 pub authenticated_at: DateTime<Utc>,
59 /// Last request, written at most once per
60 /// [`last_seen_write_interval`](crate::GuardConfig::last_seen_write_interval).
61 pub last_seen_at: DateTime<Utc>,
62 /// Absolute expiry.
63 pub expires_at: DateTime<Utc>,
64 /// Requests are rejected until step-up completes.
65 pub step_up_required: bool,
66 /// What the session's client looks like.
67 pub baseline: Baseline,
68 /// Where the session started. `None` for sessions created before 0.2.
69 pub origin: Option<SessionOrigin>,
70}
71
72/// A stored token.
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub struct TokenRecord {
75 /// SHA-256 of the token.
76 pub hash: TokenHash,
77 /// When it was issued.
78 pub issued_at: DateTime<Utc>,
79 /// When a client first presented it. `None` means its response may never have arrived.
80 pub first_used_at: Option<DateTime<Utc>>,
81 /// When a newer token replaced it. `None` for the session's current token.
82 pub superseded_at: Option<DateTime<Utc>>,
83}
84
85/// Result of [`SessionStore::find_by_token`].
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct TokenLookup {
88 /// The session the token belongs to.
89 pub session: SessionRecord,
90 /// The token itself.
91 pub token: TokenRecord,
92 /// Whether any token issued after this one in the same session has been presented
93 /// (`first_used_at` set).
94 pub newer_token_used: bool,
95}
96
97/// Which token a [`Rotation`] replaces. Each variant is a condition evaluated atomically
98/// against the session's current token; if it does not hold, nothing rotates.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Supersede {
101 /// Only if this exact token is still current. Two concurrent requests with the same token
102 /// both ask for this; exactly one wins.
103 Exact(TokenHash),
104 /// Only if the current token was issued strictly before this instant.
105 CurrentIssuedBefore(DateTime<Utc>),
106 /// Whatever token is current.
107 Current,
108}
109
110/// What an event claim is deduplicated by. See [`SessionStore::claim_event`].
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
112#[non_exhaustive]
113pub enum ClaimScope<'a> {
114 /// One session; used for unenforced events.
115 Session(Uuid),
116 /// Every session of a subject (canonical JSON); used for hook alerts.
117 Subject(&'a str),
118}
119
120/// Replace the current token with a new one.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct Rotation {
123 /// Hash of the new token; becomes current with `issued_at = now`.
124 pub new_token: TokenHash,
125 /// Which token is replaced (its `superseded_at` becomes `now`).
126 pub supersede: Supersede,
127 /// Delete this session's tokens superseded strictly before this instant.
128 pub prune_superseded_before: DateTime<Utc>,
129}
130
131/// Everything one request changes, applied atomically by [`SessionStore::apply`].
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct SessionUpdate {
134 /// The session to update.
135 pub session_id: Uuid,
136 /// The request time; `last_seen_at` becomes this.
137 pub now: DateTime<Utc>,
138 /// Set this token's `first_used_at` to `now` if it is still unset.
139 pub mark_used: Option<TokenHash>,
140 /// Replace the baseline.
141 pub baseline: Option<Baseline>,
142 /// Set the step-up flag.
143 pub step_up_required: Option<bool>,
144 /// Set `authenticated_at`.
145 pub authenticated_at: Option<DateTime<Utc>>,
146 /// Rotate the token.
147 pub rotation: Option<Rotation>,
148}
149
150impl SessionUpdate {
151 /// An update that only touches `last_seen_at`.
152 pub fn touch(session_id: Uuid, now: DateTime<Utc>) -> Self {
153 Self {
154 session_id,
155 now,
156 mark_used: None,
157 baseline: None,
158 step_up_required: None,
159 authenticated_at: None,
160 rotation: None,
161 }
162 }
163}
164
165/// Session persistence.
166///
167/// Every method must be safe to call concurrently from many processes. Each returned future
168/// must be `Send`. Timestamps may be stored at microsecond precision.
169pub trait SessionStore: Send + Sync + 'static {
170 /// Whether the store partitions data into [buckets](session_bucket) that must be co-located.
171 ///
172 /// When `true`, the guard gives every session an id whose [`session_bucket`] it picks, and
173 /// mines each of the session's tokens until its [`token_bucket`] is the same, so one session's
174 /// records always live together. The default, `false`, skips that work.
175 fn sharded(&self) -> bool {
176 false
177 }
178
179 /// Stores a new session with `token` as its current token (`issued_at = created_at`,
180 /// unused, not superseded).
181 fn insert_session(
182 &self,
183 session: &SessionRecord,
184 token: &TokenHash,
185 ) -> impl Future<Output = Result<(), StoreError>> + Send;
186
187 /// Finds the session holding `token`, current or superseded.
188 fn find_by_token(&self, token: &TokenHash) -> impl Future<Output = Result<Option<TokenLookup>, StoreError>> + Send;
189
190 /// Applies `update` atomically, in this order:
191 ///
192 /// 1. If `rotation` is set and its [`Supersede`] condition holds for the current token:
193 /// supersede it, insert the new token as current, prune old superseded tokens.
194 /// 2. Mark `mark_used` used if it belongs to the session and is unused; this includes the
195 /// token step 1 just inserted.
196 /// 3. Set `last_seen_at = now` and every `Some` field.
197 ///
198 /// A session must never have more than one current token. Returns whether the rotation
199 /// happened; a missing session is not an error and returns `false`.
200 fn apply(&self, update: &SessionUpdate) -> impl Future<Output = Result<bool, StoreError>> + Send;
201
202 /// Deletes a session and its tokens. Returns whether it existed.
203 fn delete_session(&self, session_id: Uuid) -> impl Future<Output = Result<bool, StoreError>> + Send;
204
205 /// Deletes every session of `subject` (canonical JSON). Returns the ids it deleted.
206 fn delete_subject_sessions(&self, subject: &str) -> impl Future<Output = Result<Vec<Uuid>, StoreError>> + Send;
207
208 /// Every session of `subject` (canonical JSON), including expired ones not yet purged.
209 fn list_subject_sessions(
210 &self,
211 subject: &str,
212 ) -> impl Future<Output = Result<Vec<SessionRecord>, StoreError>> + Send;
213
214 /// Deletes sessions with `expires_at <= now` or `last_seen_at <= idle_cutoff`. Returns how
215 /// many.
216 fn delete_expired(
217 &self,
218 now: DateTime<Utc>,
219 idle_cutoff: DateTime<Utc>,
220 ) -> impl Future<Output = Result<u64, StoreError>> + Send;
221
222 /// Appends a security event. Stores without an event log may discard it.
223 fn record_event(&self, event: &SessionEvent) -> impl Future<Output = Result<(), StoreError>> + Send;
224
225 /// Deletes events with `at < cutoff`. Returns how many. Also deletes event claims older
226 /// than `cutoff`.
227 fn delete_events_before(&self, cutoff: DateTime<Utc>) -> impl Future<Output = Result<u64, StoreError>> + Send;
228
229 /// Claims the right to report a `kind` event for `scope`, for deduplication across
230 /// processes. Succeeds (`true`) unless a claim for the same scope and kind was made after
231 /// `now - window`, and records `now` as the new claim when it does. Session and subject
232 /// claims are independent. Must be atomic across processes.
233 ///
234 /// The guard claims [`ClaimScope::Session`] before recording an unenforced event
235 /// (`shadow_dedup_window`) and [`ClaimScope::Subject`] before calling the hook for an
236 /// enforced one (`alert_dedup_window`).
237 ///
238 /// The default always succeeds, which leaves shadow deduplication to each process and
239 /// disables alert deduplication.
240 fn claim_event(
241 &self,
242 scope: ClaimScope<'_>,
243 kind: EventKind,
244 now: DateTime<Utc>,
245 window: TimeDelta,
246 ) -> impl Future<Output = Result<bool, StoreError>> + Send {
247 let _ = (scope, kind, now, window);
248 std::future::ready(Ok(true))
249 }
250}
251
252impl<T: SessionStore> SessionStore for std::sync::Arc<T> {
253 fn sharded(&self) -> bool {
254 (**self).sharded()
255 }
256
257 fn insert_session(
258 &self,
259 session: &SessionRecord,
260 token: &TokenHash,
261 ) -> impl Future<Output = Result<(), StoreError>> + Send {
262 (**self).insert_session(session, token)
263 }
264
265 fn find_by_token(&self, token: &TokenHash) -> impl Future<Output = Result<Option<TokenLookup>, StoreError>> + Send {
266 (**self).find_by_token(token)
267 }
268
269 fn apply(&self, update: &SessionUpdate) -> impl Future<Output = Result<bool, StoreError>> + Send {
270 (**self).apply(update)
271 }
272
273 fn delete_session(&self, session_id: Uuid) -> impl Future<Output = Result<bool, StoreError>> + Send {
274 (**self).delete_session(session_id)
275 }
276
277 fn delete_subject_sessions(&self, subject: &str) -> impl Future<Output = Result<Vec<Uuid>, StoreError>> + Send {
278 (**self).delete_subject_sessions(subject)
279 }
280
281 fn list_subject_sessions(
282 &self,
283 subject: &str,
284 ) -> impl Future<Output = Result<Vec<SessionRecord>, StoreError>> + Send {
285 (**self).list_subject_sessions(subject)
286 }
287
288 fn delete_expired(
289 &self,
290 now: DateTime<Utc>,
291 idle_cutoff: DateTime<Utc>,
292 ) -> impl Future<Output = Result<u64, StoreError>> + Send {
293 (**self).delete_expired(now, idle_cutoff)
294 }
295
296 fn record_event(&self, event: &SessionEvent) -> impl Future<Output = Result<(), StoreError>> + Send {
297 (**self).record_event(event)
298 }
299
300 fn delete_events_before(&self, cutoff: DateTime<Utc>) -> impl Future<Output = Result<u64, StoreError>> + Send {
301 (**self).delete_events_before(cutoff)
302 }
303
304 fn claim_event(
305 &self,
306 scope: ClaimScope<'_>,
307 kind: EventKind,
308 now: DateTime<Utc>,
309 window: TimeDelta,
310 ) -> impl Future<Output = Result<bool, StoreError>> + Send {
311 (**self).claim_event(scope, kind, now, window)
312 }
313}