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;