//! CRUD over the `admin_sessions` table. //! //! The token never appears in this module — only its SHA-256 hash. The //! raw value lives in `auth::GeneratedToken` long enough to hit the //! cookie and the JSON response, then is forgotten. Lookups also filter //! expired rows at query time so a delayed prune sweep can never extend //! a session's life. use async_trait::async_trait; use chrono::{DateTime, Utc}; use picloud_shared::AdminUserId; use sqlx::PgPool; #[derive(Debug, thiserror::Error)] pub enum AdminSessionRepositoryError { #[error("database error: {0}")] Db(#[from] sqlx::Error), } /// Result of a session lookup. Includes the user id (for auth context), /// the existing `expires_at` (so the middleware can decide whether the /// sliding-window bump is worth a write), and the `absolute_expires_at` /// hard cap the bump must clamp at (C1). #[derive(Debug, Clone)] pub struct AdminSessionLookup { pub user_id: AdminUserId, pub expires_at: DateTime, pub absolute_expires_at: DateTime, } #[async_trait] pub trait AdminSessionRepository: Send + Sync { async fn create( &self, user_id: AdminUserId, token_hash: &str, expires_at: DateTime, absolute_expires_at: DateTime, ) -> Result<(), AdminSessionRepositoryError>; /// Look up a session by token hash. Returns `None` for missing or /// already-expired rows — either the sliding `expires_at` OR the /// absolute cap having passed (the query filters both). async fn lookup( &self, token_hash: &str, ) -> Result, AdminSessionRepositoryError>; /// Sliding-window bump. Sets `last_used_at = NOW()` and `expires_at` /// to the supplied value. async fn touch( &self, token_hash: &str, new_expires_at: DateTime, ) -> Result<(), AdminSessionRepositoryError>; async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError>; /// Delete every session belonging to a user. Used when the user is /// deactivated or has their password reset out-of-band — both /// invalidate all current logins for that account. async fn delete_for_user( &self, user_id: AdminUserId, ) -> Result; /// Sweep expired rows. The auth middleware filters expired rows on /// lookup, so this is just bounded-growth hygiene, not correctness. async fn prune_expired(&self) -> Result; } pub struct PostgresAdminSessionRepository { pool: PgPool, } impl PostgresAdminSessionRepository { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl AdminSessionRepository for PostgresAdminSessionRepository { async fn create( &self, user_id: AdminUserId, token_hash: &str, expires_at: DateTime, absolute_expires_at: DateTime, ) -> Result<(), AdminSessionRepositoryError> { sqlx::query( "INSERT INTO admin_sessions (token_hash, user_id, expires_at, absolute_expires_at) \ VALUES ($1, $2, $3, $4)", ) .bind(token_hash) .bind(user_id.into_inner()) .bind(expires_at) .bind(absolute_expires_at) .execute(&self.pool) .await?; Ok(()) } async fn lookup( &self, token_hash: &str, ) -> Result, AdminSessionRepositoryError> { let row: Option<(uuid::Uuid, DateTime, DateTime)> = sqlx::query_as( "SELECT user_id, expires_at, absolute_expires_at FROM admin_sessions \ WHERE token_hash = $1 AND expires_at > NOW() AND absolute_expires_at > NOW()", ) .bind(token_hash) .fetch_optional(&self.pool) .await?; Ok(row.map(|(uid, exp, abs)| AdminSessionLookup { user_id: uid.into(), expires_at: exp, absolute_expires_at: abs, })) } async fn touch( &self, token_hash: &str, new_expires_at: DateTime, ) -> Result<(), AdminSessionRepositoryError> { sqlx::query( "UPDATE admin_sessions SET last_used_at = NOW(), expires_at = $2 \ WHERE token_hash = $1", ) .bind(token_hash) .bind(new_expires_at) .execute(&self.pool) .await?; Ok(()) } async fn delete(&self, token_hash: &str) -> Result<(), AdminSessionRepositoryError> { sqlx::query("DELETE FROM admin_sessions WHERE token_hash = $1") .bind(token_hash) .execute(&self.pool) .await?; Ok(()) } async fn delete_for_user( &self, user_id: AdminUserId, ) -> Result { let res = sqlx::query("DELETE FROM admin_sessions WHERE user_id = $1") .bind(user_id.into_inner()) .execute(&self.pool) .await?; Ok(res.rows_affected()) } async fn prune_expired(&self) -> Result { let res = sqlx::query( "DELETE FROM admin_sessions \ WHERE expires_at <= NOW() OR absolute_expires_at <= NOW()", ) .execute(&self.pool) .await?; Ok(res.rows_affected()) } }