Skip to main content

session_guard/store/
postgres.rs

1//! PostgreSQL store on sqlx.
2
3use std::{fmt, sync::LazyLock};
4
5use chrono::{DateTime, TimeDelta, Utc};
6use sqlx::{
7    PgPool, Postgres, Row, Transaction,
8    migrate::{MigrateError, Migrator},
9    postgres::PgRow,
10    types::Json,
11};
12use uuid::Uuid;
13
14use super::{ClaimScope, SessionRecord, SessionStore, SessionUpdate, Supersede, TokenLookup, TokenRecord};
15use crate::{
16    error::StoreError,
17    event::{EventKind, SessionEvent, SessionOrigin},
18    fingerprint::Baseline,
19    token::TokenHash,
20};
21
22/// Table that records which session-guard migrations ran, kept apart from the app's
23/// `_sqlx_migrations` so the two migration sets never see each other as missing.
24const MIGRATIONS_TABLE: &str = "_session_guard_migrations";
25
26static MIGRATOR: LazyLock<Migrator> = LazyLock::new(|| {
27    let mut migrator = sqlx::migrate!();
28    migrator.dangerous_set_table_name(MIGRATIONS_TABLE);
29    migrator
30});
31
32/// Partial unique index allowing one current token per session.
33const CURRENT_TOKEN_INDEX: &str = "session_guard_tokens_current_idx";
34
35/// Stores sessions in PostgreSQL through a sqlx [`PgPool`]. Shared safely by any number of
36/// processes; rotations are single transactions with a conditional update, and a partial unique
37/// index guarantees at most one current token per session.
38///
39/// Tables are named `session_guard_*`. Create them with [`PgStore::migrated`], or run
40/// [`PgStore::migrator`] in your own deployment pipeline. Applied migrations are tracked in
41/// `_session_guard_migrations`, separate from your app's `_sqlx_migrations`.
42///
43/// ```no_run
44/// # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
45/// use session_guard::{Guard, PgStore};
46///
47/// let pool = sqlx::PgPool::connect("postgres://localhost/app").await?;
48/// let guard = Guard::new(PgStore::migrated(pool).await?);
49/// # let _ = guard;
50/// # Ok(())
51/// # }
52/// ```
53#[derive(Clone)]
54pub struct PgStore {
55    pool: PgPool,
56}
57
58impl fmt::Debug for PgStore {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        // The pool's own Debug prints connect options; keep credentials out of logs.
61        f.debug_struct("PgStore")
62            .field("connections", &self.pool.size())
63            .finish_non_exhaustive()
64    }
65}
66
67impl PgStore {
68    /// Wraps `pool` without touching the schema. Run [`PgStore::migrator`] first.
69    pub fn new(pool: PgPool) -> Self {
70        Self { pool }
71    }
72
73    /// Runs the embedded migrations, then wraps `pool`. Safe to call from many processes at
74    /// once and on every start: applied migrations are skipped.
75    ///
76    /// # Errors
77    ///
78    /// If a migration fails or the applied migrations do not match the embedded ones.
79    pub async fn migrated(pool: PgPool) -> Result<Self, MigrateError> {
80        MIGRATOR.run(&pool).await?;
81        Ok(Self::new(pool))
82    }
83
84    /// The embedded migrations, for running them in your own pipeline:
85    /// `PgStore::migrator().run(&pool).await?`.
86    pub fn migrator() -> &'static Migrator {
87        &MIGRATOR
88    }
89
90    /// The underlying pool.
91    pub fn pool(&self) -> &PgPool {
92        &self.pool
93    }
94
95    async fn rotate(
96        tx: &mut Transaction<'static, Postgres>,
97        update: &SessionUpdate,
98        rotation: &super::Rotation,
99    ) -> Result<bool, sqlx::Error> {
100        let (exact, issued_before) = match rotation.supersede {
101            Supersede::Exact(hash) => (Some(hash.as_bytes().to_vec()), None),
102            Supersede::CurrentIssuedBefore(at) => (None, Some(at)),
103            Supersede::Current => (None, None),
104        };
105        // The row lock on the current token makes concurrent callers wait; they re-check the
106        // condition against the committed row and find it superseded.
107        let superseded: Option<Vec<u8>> = sqlx::query_scalar(
108            "UPDATE session_guard_tokens SET superseded_at = $2
109             WHERE session_id = $1 AND superseded_at IS NULL
110               AND ($3::bytea IS NULL OR hash = $3)
111               AND ($4::timestamptz IS NULL OR issued_at < $4)
112             RETURNING hash",
113        )
114        .bind(update.session_id)
115        .bind(update.now)
116        .bind(exact)
117        .bind(issued_before)
118        .fetch_optional(&mut **tx)
119        .await?;
120        let Some(superseded) = superseded else {
121            return Ok(false);
122        };
123        let inserted = sqlx::query(
124            "INSERT INTO session_guard_tokens (hash, session_id, issued_at) VALUES ($1, $2, $3)
125             ON CONFLICT (hash) DO NOTHING",
126        )
127        .bind(&rotation.new_token.as_bytes()[..])
128        .bind(update.session_id)
129        .bind(update.now)
130        .execute(&mut **tx)
131        .await?;
132        if inserted.rows_affected() == 0 {
133            // The new hash already exists somewhere: keep the old token current.
134            sqlx::query("UPDATE session_guard_tokens SET superseded_at = NULL WHERE hash = $1")
135                .bind(superseded)
136                .execute(&mut **tx)
137                .await?;
138            return Ok(false);
139        }
140        sqlx::query("DELETE FROM session_guard_tokens WHERE session_id = $1 AND superseded_at < $2")
141            .bind(update.session_id)
142            .bind(rotation.prune_superseded_before)
143            .execute(&mut **tx)
144            .await?;
145        Ok(true)
146    }
147
148    async fn apply_in(tx: &mut Transaction<'static, Postgres>, update: &SessionUpdate) -> Result<bool, sqlx::Error> {
149        // Serialize updates of one session; also keeps a concurrent delete from racing the
150        // token insert.
151        let exists = sqlx::query("SELECT 1 FROM session_guard_sessions WHERE id = $1 FOR NO KEY UPDATE")
152            .bind(update.session_id)
153            .fetch_optional(&mut **tx)
154            .await?
155            .is_some();
156        if !exists {
157            return Ok(false);
158        }
159        let rotated = match &update.rotation {
160            Some(rotation) => Self::rotate(tx, update, rotation).await?,
161            None => false,
162        };
163        if let Some(hash) = update.mark_used {
164            sqlx::query(
165                "UPDATE session_guard_tokens SET first_used_at = $3
166                 WHERE hash = $1 AND session_id = $2 AND first_used_at IS NULL",
167            )
168            .bind(&hash.as_bytes()[..])
169            .bind(update.session_id)
170            .bind(update.now)
171            .execute(&mut **tx)
172            .await?;
173        }
174        sqlx::query(
175            "UPDATE session_guard_sessions SET
176                 last_seen_at = $2,
177                 baseline = COALESCE($3, baseline),
178                 step_up_required = COALESCE($4, step_up_required),
179                 authenticated_at = COALESCE($5, authenticated_at)
180             WHERE id = $1",
181        )
182        .bind(update.session_id)
183        .bind(update.now)
184        .bind(update.baseline.as_ref().map(Json))
185        .bind(update.step_up_required)
186        .bind(update.authenticated_at)
187        .execute(&mut **tx)
188        .await?;
189        Ok(rotated)
190    }
191}
192
193fn is_current_token_conflict(error: &sqlx::Error) -> bool {
194    matches!(error, sqlx::Error::Database(db) if db.constraint() == Some(CURRENT_TOKEN_INDEX))
195}
196
197fn decode_hash(bytes: &[u8]) -> Result<TokenHash, sqlx::Error> {
198    <[u8; 32]>::try_from(bytes)
199        .map(TokenHash::from_bytes)
200        .map_err(|_| sqlx::Error::Decode(format!("token hash has {} bytes, expected 32", bytes.len()).into()))
201}
202
203fn session_from_row(row: &PgRow) -> Result<SessionRecord, sqlx::Error> {
204    let Json(baseline): Json<Baseline> = row.try_get("baseline")?;
205    let origin: Option<Json<SessionOrigin>> = row.try_get("origin")?;
206    Ok(SessionRecord {
207        id: row.try_get("id")?,
208        subject: row.try_get("subject")?,
209        created_at: row.try_get("created_at")?,
210        authenticated_at: row.try_get("authenticated_at")?,
211        last_seen_at: row.try_get("last_seen_at")?,
212        expires_at: row.try_get("expires_at")?,
213        step_up_required: row.try_get("step_up_required")?,
214        baseline,
215        origin: origin.map(|Json(origin)| origin),
216    })
217}
218
219fn lookup_from_row(row: &PgRow) -> Result<TokenLookup, sqlx::Error> {
220    let hash: Vec<u8> = row.try_get("hash")?;
221    Ok(TokenLookup {
222        session: session_from_row(row)?,
223        token: TokenRecord {
224            hash: decode_hash(&hash)?,
225            issued_at: row.try_get("issued_at")?,
226            first_used_at: row.try_get("first_used_at")?,
227            superseded_at: row.try_get("superseded_at")?,
228        },
229        newer_token_used: row.try_get("newer_token_used")?,
230    })
231}
232
233impl SessionStore for PgStore {
234    async fn insert_session(&self, session: &SessionRecord, token: &TokenHash) -> Result<(), StoreError> {
235        let mut tx = self.pool.begin().await?;
236        sqlx::query(
237            "INSERT INTO session_guard_sessions
238                 (id, subject, created_at, authenticated_at, last_seen_at, expires_at, step_up_required, baseline,
239                  origin)
240             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
241        )
242        .bind(session.id)
243        .bind(&session.subject)
244        .bind(session.created_at)
245        .bind(session.authenticated_at)
246        .bind(session.last_seen_at)
247        .bind(session.expires_at)
248        .bind(session.step_up_required)
249        .bind(Json(&session.baseline))
250        .bind(session.origin.as_ref().map(Json))
251        .execute(&mut *tx)
252        .await?;
253        sqlx::query("INSERT INTO session_guard_tokens (hash, session_id, issued_at) VALUES ($1, $2, $3)")
254            .bind(&token.as_bytes()[..])
255            .bind(session.id)
256            .bind(session.created_at)
257            .execute(&mut *tx)
258            .await?;
259        tx.commit().await?;
260        Ok(())
261    }
262
263    async fn find_by_token(&self, token: &TokenHash) -> Result<Option<TokenLookup>, StoreError> {
264        let row = sqlx::query(
265            "SELECT s.id, s.subject, s.created_at, s.authenticated_at, s.last_seen_at, s.expires_at,
266                    s.step_up_required, s.baseline, s.origin,
267                    t.hash, t.issued_at, t.first_used_at, t.superseded_at,
268                    EXISTS (
269                        SELECT 1 FROM session_guard_tokens n
270                        WHERE n.session_id = t.session_id AND n.seq > t.seq AND n.first_used_at IS NOT NULL
271                    ) AS newer_token_used
272             FROM session_guard_tokens t
273             JOIN session_guard_sessions s ON s.id = t.session_id
274             WHERE t.hash = $1",
275        )
276        .bind(&token.as_bytes()[..])
277        .fetch_optional(&self.pool)
278        .await?;
279        Ok(row.as_ref().map(lookup_from_row).transpose()?)
280    }
281
282    async fn apply(&self, update: &SessionUpdate) -> Result<bool, StoreError> {
283        let mut tx = self.pool.begin().await?;
284        match Self::apply_in(&mut tx, update).await {
285            Ok(rotated) => {
286                tx.commit().await?;
287                Ok(rotated)
288            }
289            // Backstop: another writer made a token current first. Unreachable while every
290            // writer takes the session lock, but never let it surface as a second current token.
291            Err(e) if is_current_token_conflict(&e) => {
292                tx.rollback().await?;
293                Ok(false)
294            }
295            Err(e) => Err(e.into()),
296        }
297    }
298
299    async fn delete_session(&self, session_id: Uuid) -> Result<bool, StoreError> {
300        let done = sqlx::query("DELETE FROM session_guard_sessions WHERE id = $1")
301            .bind(session_id)
302            .execute(&self.pool)
303            .await?;
304        Ok(done.rows_affected() > 0)
305    }
306
307    async fn delete_subject_sessions(&self, subject: &str) -> Result<Vec<Uuid>, StoreError> {
308        let ids = sqlx::query_scalar("DELETE FROM session_guard_sessions WHERE subject = $1 RETURNING id")
309            .bind(subject)
310            .fetch_all(&self.pool)
311            .await?;
312        Ok(ids)
313    }
314
315    async fn list_subject_sessions(&self, subject: &str) -> Result<Vec<SessionRecord>, StoreError> {
316        let rows = sqlx::query(
317            "SELECT s.id, s.subject, s.created_at, s.authenticated_at, s.last_seen_at, s.expires_at,
318                    s.step_up_required, s.baseline, s.origin
319             FROM session_guard_sessions s
320             WHERE s.subject = $1",
321        )
322        .bind(subject)
323        .fetch_all(&self.pool)
324        .await?;
325        Ok(rows.iter().map(session_from_row).collect::<Result<_, _>>()?)
326    }
327
328    async fn delete_expired(&self, now: DateTime<Utc>, idle_cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
329        let done = sqlx::query("DELETE FROM session_guard_sessions WHERE expires_at <= $1 OR last_seen_at <= $2")
330            .bind(now)
331            .bind(idle_cutoff)
332            .execute(&self.pool)
333            .await?;
334        Ok(done.rows_affected())
335    }
336
337    async fn record_event(&self, event: &SessionEvent) -> Result<(), StoreError> {
338        let signals: Vec<&str> = event.signals.iter().map(|c| c.as_str()).collect();
339        sqlx::query(
340            "INSERT INTO session_guard_events
341                 (session_id, subject, kind, enforced, score, signals, ip, user_agent, client, created_at)
342             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
343        )
344        .bind(event.session_id)
345        .bind(&event.subject)
346        .bind(event.kind.as_str())
347        .bind(event.enforced)
348        .bind(event.score.map(i64::from))
349        .bind(signals)
350        .bind(event.ip)
351        .bind(event.user_agent.as_deref())
352        .bind(sqlx::types::Json(&event.client))
353        .bind(event.at)
354        .execute(&self.pool)
355        .await?;
356        Ok(())
357    }
358
359    async fn delete_events_before(&self, cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
360        let done = sqlx::query(
361            "WITH claims AS (DELETE FROM session_guard_event_claims WHERE claimed_at < $1),
362                  subject_claims AS (DELETE FROM session_guard_subject_claims WHERE claimed_at < $1)
363             DELETE FROM session_guard_events WHERE created_at < $1",
364        )
365        .bind(cutoff)
366        .execute(&self.pool)
367        .await?;
368        Ok(done.rows_affected())
369    }
370
371    async fn claim_event(
372        &self,
373        scope: ClaimScope<'_>,
374        kind: EventKind,
375        now: DateTime<Utc>,
376        window: TimeDelta,
377    ) -> Result<bool, StoreError> {
378        // A claim at or before `now - window` has lapsed. If that instant does not exist, no
379        // stored claim can have lapsed; NULL never compares true.
380        let lapsed_at = now.checked_sub_signed(window);
381        let query = match scope {
382            ClaimScope::Session(session_id) => sqlx::query(
383                "INSERT INTO session_guard_event_claims (session_id, kind, claimed_at) VALUES ($1, $2, $3)
384                 ON CONFLICT (session_id, kind) DO UPDATE SET claimed_at = EXCLUDED.claimed_at
385                 WHERE session_guard_event_claims.claimed_at <= $4
386                 RETURNING 1",
387            )
388            .bind(session_id),
389            ClaimScope::Subject(subject) => sqlx::query(
390                "INSERT INTO session_guard_subject_claims (subject, kind, claimed_at) VALUES ($1, $2, $3)
391                 ON CONFLICT (subject, kind) DO UPDATE SET claimed_at = EXCLUDED.claimed_at
392                 WHERE session_guard_subject_claims.claimed_at <= $4
393                 RETURNING 1",
394            )
395            .bind(subject),
396        };
397        let claimed = query
398            .bind(kind.as_str())
399            .bind(now)
400            .bind(lapsed_at)
401            .fetch_optional(&self.pool)
402            .await?;
403        Ok(claimed.is_some())
404    }
405}