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