Skip to main content

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}