From 7a44cbf5a446ff2838bab3b68311091442edbb52 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Fri, 5 Jun 2026 23:17:24 +0200 Subject: [PATCH] feat(v1.1.8): app_user_sessions table + repo with sliding TTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-user session storage (migration 0027) mirrors admin_sessions but adds three things v1.1.8 needs: * app_id column + FK cascade — every v1.1+ table starts with app_id so cross-app isolation is bright at the SQL layer (lookup keys off the hash only, but defense-in-depth: a leaked row's session still scopes to its app on every read). * absolute_expires_at — hard cap on the sliding window (default 30d via PICLOUD_APP_USER_SESSION_ABSOLUTE_HOURS). Beyond this the user must re-login regardless of recent activity. * revoked_at — explicit revocation by token (logout) or per-user (admin revoke-sessions button, password reset). Lookups reject revoked rows immediately so revocation takes effect before the weekly GC sweep runs. The repo's gc() uses FOR UPDATE SKIP LOCKED matching the dead-letter and abandoned-executions sweep patterns; the GC wiring lands in a later commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrations/0027_app_user_sessions.sql | 35 +++ .../manager-core/src/app_user_session_repo.rs | 202 ++++++++++++++++++ crates/manager-core/src/lib.rs | 5 + 3 files changed, 242 insertions(+) create mode 100644 crates/manager-core/migrations/0027_app_user_sessions.sql create mode 100644 crates/manager-core/src/app_user_session_repo.rs diff --git a/crates/manager-core/migrations/0027_app_user_sessions.sql b/crates/manager-core/migrations/0027_app_user_sessions.sql new file mode 100644 index 0000000..e0358be --- /dev/null +++ b/crates/manager-core/migrations/0027_app_user_sessions.sql @@ -0,0 +1,35 @@ +-- v1.1.8 User Management — app-user sessions. +-- +-- Distinct from `admin_sessions`. Mirror the schema shape (token_hash +-- PK, user FK cascading) but add: +-- +-- * app_id — every v1.1+ data-plane table starts with the app_id +-- FK so cross-app isolation is bright at the SQL level +-- and ON DELETE CASCADE wipes sessions when an app is +-- deleted (in addition to the user-FK cascade). +-- * absolute_expires_at — hard cap on the sliding window. The +-- application caps new_expires_at at this value on each +-- touch; beyond it, force re-login. +-- * revoked_at — explicit revocation (admin "revoke all sessions" +-- button, password reset, logout). The lookup query +-- rejects revoked rows so a revoked session is dead +-- immediately, before the GC sweep runs. +-- +-- `token_hash` stores SHA-256(raw_token) as hex; the raw lives only in +-- the script's return value from `users::login` / `users::accept_invite` +-- and the realtime subscriber's Authorization header. + +CREATE TABLE app_user_sessions ( + token_hash TEXT PRIMARY KEY, + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + absolute_expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_app_user_sessions_user ON app_user_sessions (app_id, user_id); +CREATE INDEX idx_app_user_sessions_expiry + ON app_user_sessions (expires_at) WHERE revoked_at IS NULL; diff --git a/crates/manager-core/src/app_user_session_repo.rs b/crates/manager-core/src/app_user_session_repo.rs new file mode 100644 index 0000000..26c69e4 --- /dev/null +++ b/crates/manager-core/src/app_user_session_repo.rs @@ -0,0 +1,202 @@ +//! CRUD over the `app_user_sessions` table (v1.1.8 data-plane sessions). +//! +//! Mirrors the shape of `admin_session_repo` (token hash as PK, sliding +//! window, prune on expiry) with three additions: +//! +//! * `app_id` is part of every row so cross-app isolation is bright +//! at the SQL layer. Lookups by token hash still join on app_id — +//! a token issued for app A cannot be used to authorize a request +//! into app B even if the hash collides (which it won't, but the +//! defense-in-depth is cheap). +//! * `absolute_expires_at` is the hard cap. The repo writes whatever +//! `new_expires_at` it's handed on touch; the service computes the +//! min of (now + ttl, absolute_expires_at) so a session that's +//! been bumped a hundred times still dies at the absolute cap. +//! * `revoked_at` is explicit revocation. Lookups reject revoked +//! rows immediately so a "revoke all sessions" admin action takes +//! effect before the next GC sweep runs. +//! +//! The token never appears in this module — only the SHA-256 hash. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::{AppId, AppUserId}; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum AppUserSessionRepositoryError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +/// Result of a session lookup. The service uses `expires_at` / +/// `absolute_expires_at` to decide whether the bump is allowed. +#[derive(Debug, Clone)] +pub struct AppUserSessionLookup { + pub app_id: AppId, + pub user_id: AppUserId, + pub expires_at: DateTime, + pub absolute_expires_at: DateTime, +} + +#[async_trait] +pub trait AppUserSessionRepository: Send + Sync { + async fn create( + &self, + app_id: AppId, + user_id: AppUserId, + token_hash: &str, + expires_at: DateTime, + absolute_expires_at: DateTime, + ) -> Result<(), AppUserSessionRepositoryError>; + /// Look up a non-revoked session whose sliding window has not yet + /// expired. Returns `None` for missing, revoked, or expired rows. + /// The absolute cap is enforced by the caller (the value is returned + /// here so the service can compute the next expires_at). + async fn lookup( + &self, + token_hash: &str, + ) -> Result, AppUserSessionRepositoryError>; + /// Sliding-window bump. Service supplies new_expires_at clamped at + /// absolute_expires_at. + async fn touch( + &self, + token_hash: &str, + new_expires_at: DateTime, + ) -> Result<(), AppUserSessionRepositoryError>; + /// Explicit revocation by token (logout). + async fn revoke(&self, token_hash: &str) -> Result<(), AppUserSessionRepositoryError>; + /// Revoke every active session for a user — password reset, + /// admin "revoke all sessions" button. Returns rows affected so + /// the caller can log or surface the count. + async fn revoke_for_user( + &self, + app_id: AppId, + user_id: AppUserId, + ) -> Result; + /// GC sweep. Deletes rows that are either expired (sliding window + /// or absolute cap passed) or explicitly revoked. Same hygiene as + /// `admin_session_repo::prune_expired` plus the revocation path. + async fn gc(&self, batch_size: i64) -> Result; +} + +pub struct PostgresAppUserSessionRepository { + pool: PgPool, +} + +impl PostgresAppUserSessionRepository { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AppUserSessionRepository for PostgresAppUserSessionRepository { + async fn create( + &self, + app_id: AppId, + user_id: AppUserId, + token_hash: &str, + expires_at: DateTime, + absolute_expires_at: DateTime, + ) -> Result<(), AppUserSessionRepositoryError> { + sqlx::query( + "INSERT INTO app_user_sessions \ + (token_hash, app_id, user_id, expires_at, absolute_expires_at) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(token_hash) + .bind(app_id.into_inner()) + .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, AppUserSessionRepositoryError> { + let row: Option<(uuid::Uuid, uuid::Uuid, DateTime, DateTime)> = sqlx::query_as( + "SELECT app_id, user_id, expires_at, absolute_expires_at \ + FROM app_user_sessions \ + WHERE token_hash = $1 \ + AND revoked_at IS NULL \ + AND expires_at > NOW() \ + AND absolute_expires_at > NOW()", + ) + .bind(token_hash) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(app, user, exp, abs)| AppUserSessionLookup { + app_id: app.into(), + user_id: user.into(), + expires_at: exp, + absolute_expires_at: abs, + })) + } + + async fn touch( + &self, + token_hash: &str, + new_expires_at: DateTime, + ) -> Result<(), AppUserSessionRepositoryError> { + sqlx::query( + "UPDATE app_user_sessions \ + SET last_used_at = NOW(), expires_at = $2 \ + WHERE token_hash = $1 AND revoked_at IS NULL", + ) + .bind(token_hash) + .bind(new_expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn revoke(&self, token_hash: &str) -> Result<(), AppUserSessionRepositoryError> { + sqlx::query( + "UPDATE app_user_sessions SET revoked_at = NOW() \ + WHERE token_hash = $1 AND revoked_at IS NULL", + ) + .bind(token_hash) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn revoke_for_user( + &self, + app_id: AppId, + user_id: AppUserId, + ) -> Result { + let res = sqlx::query( + "UPDATE app_user_sessions SET revoked_at = NOW() \ + WHERE app_id = $1 AND user_id = $2 AND revoked_at IS NULL", + ) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } + + async fn gc(&self, batch_size: i64) -> Result { + let res = sqlx::query( + "DELETE FROM app_user_sessions WHERE token_hash IN ( \ + SELECT token_hash FROM app_user_sessions \ + WHERE expires_at <= NOW() \ + OR absolute_expires_at <= NOW() \ + OR revoked_at IS NOT NULL \ + LIMIT $1 \ + FOR UPDATE SKIP LOCKED \ + )", + ) + .bind(batch_size) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index e666dca..b6d3423 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod app_members_repo; pub mod app_repo; pub mod app_secrets_repo; pub mod app_user_repo; +pub mod app_user_session_repo; pub mod apps_api; pub mod auth; pub mod auth_api; @@ -81,6 +82,10 @@ pub use app_user_repo::{ AppUserCredentials, AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as AppUserListOpts, ListPage as AppUserListPage, PostgresAppUserRepository, }; +pub use app_user_session_repo::{ + AppUserSessionLookup, AppUserSessionRepository, AppUserSessionRepositoryError, + PostgresAppUserSessionRepository, +}; pub use admin_users_api::{admins_router, AdminsState}; pub use api::{admin_router, AdminState}; pub use api_key_repo::{