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