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::{SessionRecord, SessionStore, SessionUpdate, Supersede, TokenLookup, TokenRecord};
15use crate::{
16 error::StoreError,
17 event::{EventKind, SessionEvent},
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 Ok(SessionRecord {
206 id: row.try_get("id")?,
207 subject: row.try_get("subject")?,
208 created_at: row.try_get("created_at")?,
209 authenticated_at: row.try_get("authenticated_at")?,
210 last_seen_at: row.try_get("last_seen_at")?,
211 expires_at: row.try_get("expires_at")?,
212 step_up_required: row.try_get("step_up_required")?,
213 baseline,
214 })
215}
216
217fn lookup_from_row(row: &PgRow) -> Result<TokenLookup, sqlx::Error> {
218 let hash: Vec<u8> = row.try_get("hash")?;
219 Ok(TokenLookup {
220 session: session_from_row(row)?,
221 token: TokenRecord {
222 hash: decode_hash(&hash)?,
223 issued_at: row.try_get("issued_at")?,
224 first_used_at: row.try_get("first_used_at")?,
225 superseded_at: row.try_get("superseded_at")?,
226 },
227 newer_token_used: row.try_get("newer_token_used")?,
228 })
229}
230
231impl SessionStore for PgStore {
232 async fn insert_session(&self, session: &SessionRecord, token: &TokenHash) -> Result<(), StoreError> {
233 let mut tx = self.pool.begin().await?;
234 sqlx::query(
235 "INSERT INTO session_guard_sessions
236 (id, subject, created_at, authenticated_at, last_seen_at, expires_at, step_up_required, baseline)
237 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
238 )
239 .bind(session.id)
240 .bind(&session.subject)
241 .bind(session.created_at)
242 .bind(session.authenticated_at)
243 .bind(session.last_seen_at)
244 .bind(session.expires_at)
245 .bind(session.step_up_required)
246 .bind(Json(&session.baseline))
247 .execute(&mut *tx)
248 .await?;
249 sqlx::query("INSERT INTO session_guard_tokens (hash, session_id, issued_at) VALUES ($1, $2, $3)")
250 .bind(&token.as_bytes()[..])
251 .bind(session.id)
252 .bind(session.created_at)
253 .execute(&mut *tx)
254 .await?;
255 tx.commit().await?;
256 Ok(())
257 }
258
259 async fn find_by_token(&self, token: &TokenHash) -> Result<Option<TokenLookup>, StoreError> {
260 let row = sqlx::query(
261 "SELECT s.id, s.subject, s.created_at, s.authenticated_at, s.last_seen_at, s.expires_at,
262 s.step_up_required, s.baseline,
263 t.hash, t.issued_at, t.first_used_at, t.superseded_at,
264 EXISTS (
265 SELECT 1 FROM session_guard_tokens n
266 WHERE n.session_id = t.session_id AND n.seq > t.seq AND n.first_used_at IS NOT NULL
267 ) AS newer_token_used
268 FROM session_guard_tokens t
269 JOIN session_guard_sessions s ON s.id = t.session_id
270 WHERE t.hash = $1",
271 )
272 .bind(&token.as_bytes()[..])
273 .fetch_optional(&self.pool)
274 .await?;
275 Ok(row.as_ref().map(lookup_from_row).transpose()?)
276 }
277
278 async fn apply(&self, update: &SessionUpdate) -> Result<bool, StoreError> {
279 let mut tx = self.pool.begin().await?;
280 match Self::apply_in(&mut tx, update).await {
281 Ok(rotated) => {
282 tx.commit().await?;
283 Ok(rotated)
284 }
285 Err(e) if is_current_token_conflict(&e) => {
288 tx.rollback().await?;
289 Ok(false)
290 }
291 Err(e) => Err(e.into()),
292 }
293 }
294
295 async fn delete_session(&self, session_id: Uuid) -> Result<bool, StoreError> {
296 let done = sqlx::query("DELETE FROM session_guard_sessions WHERE id = $1")
297 .bind(session_id)
298 .execute(&self.pool)
299 .await?;
300 Ok(done.rows_affected() > 0)
301 }
302
303 async fn delete_subject_sessions(&self, subject: &str) -> Result<u64, StoreError> {
304 let done = sqlx::query("DELETE FROM session_guard_sessions WHERE subject = $1")
305 .bind(subject)
306 .execute(&self.pool)
307 .await?;
308 Ok(done.rows_affected())
309 }
310
311 async fn list_subject_sessions(&self, subject: &str) -> Result<Vec<SessionRecord>, StoreError> {
312 let rows = sqlx::query(
313 "SELECT s.id, s.subject, s.created_at, s.authenticated_at, s.last_seen_at, s.expires_at,
314 s.step_up_required, s.baseline
315 FROM session_guard_sessions s
316 WHERE s.subject = $1",
317 )
318 .bind(subject)
319 .fetch_all(&self.pool)
320 .await?;
321 Ok(rows.iter().map(session_from_row).collect::<Result<_, _>>()?)
322 }
323
324 async fn delete_expired(&self, now: DateTime<Utc>, idle_cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
325 let done = sqlx::query("DELETE FROM session_guard_sessions WHERE expires_at <= $1 OR last_seen_at <= $2")
326 .bind(now)
327 .bind(idle_cutoff)
328 .execute(&self.pool)
329 .await?;
330 Ok(done.rows_affected())
331 }
332
333 async fn record_event(&self, event: &SessionEvent) -> Result<(), StoreError> {
334 let signals: Vec<&str> = event.signals.iter().map(|c| c.as_str()).collect();
335 sqlx::query(
336 "INSERT INTO session_guard_events
337 (session_id, subject, kind, enforced, score, signals, ip, user_agent, created_at)
338 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
339 )
340 .bind(event.session_id)
341 .bind(&event.subject)
342 .bind(event.kind.as_str())
343 .bind(event.enforced)
344 .bind(event.score.map(i64::from))
345 .bind(signals)
346 .bind(event.ip)
347 .bind(event.user_agent.as_deref())
348 .bind(event.at)
349 .execute(&self.pool)
350 .await?;
351 Ok(())
352 }
353
354 async fn delete_events_before(&self, cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
355 let done = sqlx::query(
356 "WITH claims AS (DELETE FROM session_guard_event_claims WHERE claimed_at < $1)
357 DELETE FROM session_guard_events WHERE created_at < $1",
358 )
359 .bind(cutoff)
360 .execute(&self.pool)
361 .await?;
362 Ok(done.rows_affected())
363 }
364
365 async fn claim_event(
366 &self,
367 session_id: Uuid,
368 kind: EventKind,
369 now: DateTime<Utc>,
370 window: TimeDelta,
371 ) -> Result<bool, StoreError> {
372 let lapsed_at = now.checked_sub_signed(window);
375 let claimed = sqlx::query(
376 "INSERT INTO session_guard_event_claims (session_id, kind, claimed_at) VALUES ($1, $2, $3)
377 ON CONFLICT (session_id, kind) DO UPDATE SET claimed_at = EXCLUDED.claimed_at
378 WHERE session_guard_event_claims.claimed_at <= $4
379 RETURNING 1",
380 )
381 .bind(session_id)
382 .bind(kind.as_str())
383 .bind(now)
384 .bind(lapsed_at)
385 .fetch_optional(&self.pool)
386 .await?;
387 Ok(claimed.is_some())
388 }
389}