first commit
This commit is contained in:
119
migrations/000004_auth_sessions.up.sql
Normal file
119
migrations/000004_auth_sessions.up.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- ============================================================================
|
||||
-- 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.';
|
||||
Reference in New Issue
Block a user