-- ============================================================================ -- 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.';