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_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::{
|
||||||
|
|||||||
Reference in New Issue
Block a user