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:
35
crates/manager-core/migrations/0027_app_user_sessions.sql
Normal file
35
crates/manager-core/migrations/0027_app_user_sessions.sql
Normal 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;
|
||||
202
crates/manager-core/src/app_user_session_repo.rs
Normal file
202
crates/manager-core/src/app_user_session_repo.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
Reference in New Issue
Block a user