1use 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
22const 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
32const CURRENT_TOKEN_INDEX: &str = "session_guard_tokens_current_idx";
34
35#[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 f.debug_struct("PgStore")
62 .field("connections", &self.pool.size())
63 .finish_non_exhaustive()
64 }
65}
66
67impl PgStore {
68 pub fn new(pool: PgPool) -> Self {
70 Self { pool }
71 }
72
73 pub async fn migrated(pool: PgPool) -> Result<Self, MigrateError> {
80 MIGRATOR.run(&pool).await?;
81 Ok(Self::new(pool))
82 }
83
84 pub fn migrator() -> &'static Migrator {
87 &MIGRATOR
88 }
89
90 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 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 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 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 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 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}