//! 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) /// and the existing `expires_at` so the middleware can decide whether /// the sliding window bump is worth a write. #[derive(Debug, Clone)] pub struct AdminSessionLookup { pub user_id: AdminUserId, pub expires_at: DateTime, } #[async_trait] pub trait AdminSessionRepository: Send + Sync { async fn create( &self, user_id: AdminUserId, token_hash: &str, expires_at: DateTime, ) -> Result<(), AdminSessionRepositoryError>; /// Look up a session by token hash. Returns `None` for missing or /// already-expired rows (the query filters them). 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, ) -> Result<(), AdminSessionRepositoryError> { sqlx::query( "INSERT INTO admin_sessions (token_hash, user_id, expires_at) \ VALUES ($1, $2, $3)", ) .bind(token_hash) .bind(user_id.into_inner()) .bind(expires_at) .execute(&self.pool) .await?; Ok(()) } async fn lookup( &self, token_hash: &str, ) -> Result, AdminSessionRepositoryError> { let row: Option<(uuid::Uuid, DateTime)> = sqlx::query_as( "SELECT user_id, expires_at FROM admin_sessions \ WHERE token_hash = $1 AND expires_at > NOW()", ) .bind(token_hash) .fetch_optional(&self.pool) .await?; Ok(row.map(|(uid, exp)| AdminSessionLookup { user_id: uid.into(), expires_at: exp, })) } 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()") .execute(&self.pool) .await?; Ok(res.rows_affected()) } }