feat(v1.1.8): app_user_sessions table + repo with sliding TTL

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-05 23:17:24 +02:00
parent 97546e2eb2
commit 7a44cbf5a4
3 changed files with 242 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<Utc>,
pub absolute_expires_at: DateTime<Utc>,
}
#[async_trait]
pub trait AppUserSessionRepository: Send + Sync {
async fn create(
&self,
app_id: AppId,
user_id: AppUserId,
token_hash: &str,
expires_at: DateTime<Utc>,
absolute_expires_at: DateTime<Utc>,
) -> 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<Option<AppUserSessionLookup>, 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<Utc>,
) -> 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<u64, AppUserSessionRepositoryError>;
/// 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<u64, AppUserSessionRepositoryError>;
}
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<Utc>,
absolute_expires_at: DateTime<Utc>,
) -> 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<Option<AppUserSessionLookup>, AppUserSessionRepositoryError> {
let row: Option<(uuid::Uuid, uuid::Uuid, DateTime<Utc>, DateTime<Utc>)> = 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<Utc>,
) -> 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<u64, AppUserSessionRepositoryError> {
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<u64, AppUserSessionRepositoryError> {
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())
}
}

View File

@@ -18,6 +18,7 @@ pub mod app_members_repo;
pub mod app_repo; pub mod app_repo;
pub mod app_secrets_repo; pub mod app_secrets_repo;
pub mod app_user_repo; pub mod app_user_repo;
pub mod app_user_session_repo;
pub mod apps_api; pub mod apps_api;
pub mod auth; pub mod auth;
pub mod auth_api; pub mod auth_api;
@@ -81,6 +82,10 @@ pub use app_user_repo::{
AppUserCredentials, AppUserRepository, AppUserRepositoryError, AppUserRow, AppUserCredentials, AppUserRepository, AppUserRepositoryError, AppUserRow,
ListOpts as AppUserListOpts, ListPage as AppUserListPage, PostgresAppUserRepository, 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 admin_users_api::{admins_router, AdminsState};
pub use api::{admin_router, AdminState}; pub use api::{admin_router, AdminState};
pub use api_key_repo::{ pub use api_key_repo::{