120 lines
6.0 KiB
SQL
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.';
|