Files
krow_backend/migrations/000004_auth_sessions.up.sql
2026-08-24 13:06:29 +05:30

120 lines
6.0 KiB
SQL

-- ============================================================================
-- Krow — authentication foundation
--
-- Phase 3B. This migration adds the two schema facts authentication needs and
-- nothing else:
--
-- 1. users.email is globally unique, because login identifies a user by
-- email alone.
-- 2. a `sessions` table, because sessions are server-side and opaque.
--
-- Deliberately NOT here, per the Phase 3B decisions:
-- roles, permissions, organization_members, credentials, refresh_tokens.
-- `users.role` is already the authorization field and `users.password_hash`
-- already exists; neither needs a table of its own.
--
-- No login, logout, middleware or enforcement ships with this migration. It is
-- schema only.
--
-- Target schema: public. No system schema is read or written.
-- ============================================================================
-- Atomicity comes from golang-migrate: the postgres driver sends this file as a
-- single simple query, which Postgres executes inside one implicit transaction.
-- Any failure below rolls the whole migration back.
SET search_path = public;
-- ── users.email — global uniqueness ─────────────────────────────────────────
--
-- 000001 constrains (org_id, email). That is the right key for a tenant-scoped
-- directory, and the wrong key for a login form: `POST /auth/login` will be
-- given an email and a password and nothing else, so an email that resolved to
-- two users in two organizations would have no single answer.
--
-- The column is `citext`, so this index is case-insensitive for free —
-- "Demo@Krow.app" and "demo@krow.app" collide, which is what a login form
-- needs. A plain btree over a citext column uses the type's own comparison; no
-- lower() expression is required, and using one here would in fact build a
-- *different*, case-sensitive index.
--
-- users_org_email_key is left in place. It is now implied by this index and
-- therefore redundant, but dropping it is a change to the existing table that
-- authentication does not need, and a redundant unique constraint costs one
-- index write per user row — of which there is currently one.
--
-- Verified before writing this migration: no two rows in the target database
-- share an email, so the index builds without a conflict.
CREATE UNIQUE INDEX users_email_global_key ON public.users (email);
COMMENT ON INDEX public.users_email_global_key IS
'Login identity. Email must resolve to exactly one user across every '
'organization, because the login form supplies no organization.';
-- ── sessions ────────────────────────────────────────────────────────────────
--
-- A session is a row, not a token payload. The browser holds an opaque random
-- string in an HttpOnly cookie; this table holds only SHA-256 of that string,
-- so a dump of this table cannot be replayed as a login.
--
-- token_hash is `text` holding lowercase hex rather than bytea: it is 64 ASCII
-- bytes either way after TOAST considerations, it is greppable in psql during
-- development, and it matches how password_hash is already stored. The CHECK
-- pins the format, so a caller cannot accidentally store a raw token here —
-- a raw token is base64url of 32 bytes and fails the pattern.
--
-- Two expiries, because Phase 3B decision 3 allows sliding expiry and also
-- requires that a session cannot live forever:
--
-- expires_at moves forward as the session is used (the sliding
-- window: 12 hours normally, 30 days with Remember Me).
-- absolute_expires_at is fixed at creation and never moves. Once it passes,
-- the session is dead no matter how recently it was
-- used, and the user authenticates again.
--
-- Without the second column the first can be slid indefinitely, which is
-- exactly the "session that lives forever" the decision rules out.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
token_hash text NOT NULL,
expires_at timestamptz NOT NULL,
absolute_expires_at timestamptz NOT NULL,
created_date timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
-- UNIQUE is implemented by PostgreSQL as a btree index on token_hash, which
-- is also the index every session lookup uses: authentication hashes the
-- cookie and probes this one column. It is both the uniqueness guarantee and
-- the lookup path; a second index on the same column would be dead weight.
CONSTRAINT sessions_token_hash_key UNIQUE (token_hash),
CONSTRAINT sessions_token_hash_sha256 CHECK (token_hash ~ '^[0-9a-f]{64}$'),
CONSTRAINT sessions_absolute_after_created CHECK (absolute_expires_at > created_date),
CONSTRAINT sessions_within_absolute CHECK (expires_at <= absolute_expires_at)
);
-- ON DELETE CASCADE, not SET NULL and not RESTRICT: a deleted user must not
-- leave a live session behind that still authenticates as them.
-- Revoking every session for one user, and the FK's own cascade check.
CREATE INDEX sessions_user_idx ON sessions (user_id);
-- The periodic sweep of dead rows. Ordered by the column it filters on.
CREATE INDEX sessions_expires_idx ON sessions (expires_at);
COMMENT ON TABLE sessions IS
'Server-side sessions. The raw token exists only in the client HttpOnly '
'cookie; this table stores SHA-256 of it and never the token itself.';
COMMENT ON COLUMN sessions.token_hash IS
'Lowercase hex SHA-256 of the session token. Never the token.';
COMMENT ON COLUMN sessions.expires_at IS
'Sliding expiry. Moved forward on use; never past absolute_expires_at.';
COMMENT ON COLUMN sessions.absolute_expires_at IS
'Hard ceiling, fixed at creation. A session cannot outlive it.';