| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- -- 009_auth.up.sql
- -- M13a: Multi-tenant auth schema for authd (port 8804).
- --
- -- Tenancy model (per M13 survey, decision 1.1 = C, decision 2.1 = C hybrid):
- -- - One Postgres DB for everything (M11 already uses it for DLQ, deliveries).
- -- - Schema 'auth' is the namespace for authd tables.
- -- - A 'tenant' is a customer company. Each tenant has a 'tenant_slug'
- -- used in routes (e.g. /v1/tenants/acme-001/sources) and as a
- -- partition key for downstream queries.
- -- - A 'user' belongs to ONE tenant (super-admins have tenant_id=NULL
- -- and role='super_admin'; tenant-admins have role='tenant_admin').
- -- - 'global_id' on users is for SSO future (v2). For now it's
- -- just a stable opaque id we generate server-side.
- --
- -- Authentication (per survey 2.2 = A in-house JWT, 2.4 = B magic-link):
- -- - 'magic_links' stores the one-time tokens emailed to invited
- -- users. After use they're marked 'consumed_at'.
- -- - 'refresh_tokens' stores the long-lived tokens (7d TTL by default)
- -- that authd issues alongside the short-lived (15m) access JWT.
- -- The refresh token is a 256-bit random string, NOT a JWT — the
- -- server-side row is the source of truth (decision: refresh in
- -- server-side Postgres, rotated on use).
- -- - 'sessions' is the audit trail of every login/logout, used for
- -- security investigations ("who was logged in when X happened").
- --
- -- Audit (per survey: every service writes its own audit_log row):
- -- - 'audit_log' is shared across services. action is namespaced
- -- as 'auth.login', 'auth.logout', 'auth.invite', 'cert.issue',
- -- 'cert.revoke', etc.
- -- - actor_user_id is nullable for system actions (cron, service tokens).
- -- - payload is jsonb for service-specific structured data.
- --
- -- Surfaces: authd (CRUD users/magic_links/sessions/refresh_tokens + reads
- -- audit_log), admind (reads audit_log for the admin UI), and a
- -- 'verify_session' SQL function used by ingestd/admind to validate
- -- a presented access JWT against the refresh-token table (so we can
- -- revoke a session by deleting its row).
- CREATE SCHEMA IF NOT EXISTS auth;
- SET search_path TO auth, public;
- -- pgcrypto provides gen_random_bytes() and digest() (used by the
- -- refresh-token functions below for secure random + sha256).
- -- gen_random_uuid() comes from pgcrypto on older Postgres and is
- -- built-in on 13+, but the extension is harmless to enable.
- CREATE EXTENSION IF NOT EXISTS pgcrypto;
- -- ---------------------------------------------------------------------------
- -- Tenants
- -- ---------------------------------------------------------------------------
- CREATE TABLE IF NOT EXISTS tenants (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- slug TEXT NOT NULL UNIQUE
- CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'),
- display_name TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'active'
- CHECK (status IN ('active', 'suspended', 'archived')),
- contact_email TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- archived_at TIMESTAMPTZ
- );
- CREATE INDEX IF NOT EXISTS tenants_status_idx ON tenants(status) WHERE status != 'archived';
- -- ---------------------------------------------------------------------------
- -- Users
- -- ---------------------------------------------------------------------------
- CREATE TABLE IF NOT EXISTS users (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- -- global_id is for SSO migration (v2). Until then, the per-tenant
- -- (id, tenant_id) pair is the unique key.
- global_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
- tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
- email TEXT NOT NULL,
- role TEXT NOT NULL
- CHECK (role IN ('super_admin', 'tenant_admin', 'viewer')),
- status TEXT NOT NULL DEFAULT 'pending'
- CHECK (status IN ('pending', 'active', 'disabled')),
- display_name TEXT,
- -- bcrypt of the password. NULL until the magic link is consumed.
- password_hash TEXT,
- last_login_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- disabled_at TIMESTAMPTZ
- );
- -- Email is unique per tenant (or unique globally for super_admins).
- -- Partial unique index for super_admins (tenant_id IS NULL).
- CREATE UNIQUE INDEX IF NOT EXISTS users_email_global_uniq
- ON users(email) WHERE tenant_id IS NULL;
- CREATE UNIQUE INDEX IF NOT EXISTS users_email_per_tenant_uniq
- ON users(email, tenant_id) WHERE tenant_id IS NOT NULL;
- CREATE INDEX IF NOT EXISTS users_tenant_idx ON users(tenant_id) WHERE tenant_id IS NOT NULL;
- CREATE INDEX IF NOT EXISTS users_status_idx ON users(status) WHERE status != 'active';
- -- ---------------------------------------------------------------------------
- -- Magic links (invite + password-set)
- -- ---------------------------------------------------------------------------
- CREATE TABLE IF NOT EXISTS magic_links (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- -- token_hash is sha256(token). The plaintext token is emailed and
- -- never stored. Lookup is by hash.
- token_hash BYTEA NOT NULL UNIQUE,
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- purpose TEXT NOT NULL
- CHECK (purpose IN ('invite', 'password_reset', 'mfa_reset')),
- expires_at TIMESTAMPTZ NOT NULL,
- consumed_at TIMESTAMPTZ,
- -- ip that consumed it (audit trail)
- consumed_ip INET,
- -- user-agent that consumed it (audit trail)
- consumed_ua TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- );
- CREATE INDEX IF NOT EXISTS magic_links_user_idx ON magic_links(user_id);
- CREATE INDEX IF NOT EXISTS magic_links_unconsumed_idx
- ON magic_links(expires_at) WHERE consumed_at IS NULL;
- -- ---------------------------------------------------------------------------
- -- Refresh tokens (server-side session table)
- -- ---------------------------------------------------------------------------
- CREATE TABLE IF NOT EXISTS refresh_tokens (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- -- token_hash is sha256(token). The plaintext is in the httpOnly
- -- cookie / mobile secure storage. Token is a 32-byte random hex string.
- token_hash BYTEA NOT NULL UNIQUE,
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- -- jti of the access JWT that this refresh was issued with. Used
- -- to correlate logs and to make rotation auditable.
- access_jti TEXT NOT NULL,
- -- The previous token's hash (if rotated). Lets us detect a stolen
- -- token being used after rotation.
- parent_hash BYTEA,
- expires_at TIMESTAMPTZ NOT NULL,
- revoked_at TIMESTAMPTZ,
- -- The 'family' id groups all rotations of one login. If a non-head
- -- token in a family is used, the family is killed (re-use detection).
- family_id UUID NOT NULL DEFAULT gen_random_uuid(),
- created_ip INET,
- created_ua TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- );
- CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id);
- CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens(family_id);
- CREATE INDEX IF NOT EXISTS refresh_tokens_active_idx
- ON refresh_tokens(expires_at) WHERE revoked_at IS NULL;
- -- ---------------------------------------------------------------------------
- -- Sessions (read-only audit view of refresh_tokens, denormalized for
- -- fast "what devices is this user logged in from" queries).
- -- ---------------------------------------------------------------------------
- CREATE TABLE IF NOT EXISTS sessions (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- refresh_id UUID NOT NULL REFERENCES refresh_tokens(id) ON DELETE CASCADE,
- ip INET,
- user_agent TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- revoked_at TIMESTAMPTZ
- );
- CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
- CREATE INDEX IF NOT EXISTS sessions_active_idx
- ON sessions(user_id) WHERE revoked_at IS NULL;
- -- ---------------------------------------------------------------------------
- -- Audit log
- -- ---------------------------------------------------------------------------
- CREATE TABLE IF NOT EXISTS audit_log (
- id BIGSERIAL PRIMARY KEY,
- occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- -- 'auth.login', 'auth.logout', 'auth.invite', 'auth.magic_consume',
- -- 'cert.issue', 'cert.revoke', 'tenant.create', 'user.disable', etc.
- action TEXT NOT NULL,
- actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
- actor_ip INET,
- actor_ua TEXT,
- -- The target of the action. For 'auth.invite' it's the invited
- -- user. For 'cert.revoke' it's the source/cert id. Free-form uuid.
- target_id UUID,
- -- Tenant scope (NULL for super-admin global actions).
- tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
- -- Action-specific structured data. Example for 'auth.login':
- -- {"email": "...", "success": true, "failure_reason": null}
- payload JSONB NOT NULL DEFAULT '{}'::jsonb
- );
- CREATE INDEX IF NOT EXISTS audit_log_action_time_idx
- ON audit_log(action, occurred_at DESC);
- CREATE INDEX IF NOT EXISTS audit_log_actor_idx
- ON audit_log(actor_user_id, occurred_at DESC)
- WHERE actor_user_id IS NOT NULL;
- CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
- ON audit_log(tenant_id, occurred_at DESC)
- WHERE tenant_id IS NOT NULL;
- CREATE INDEX IF NOT EXISTS audit_log_target_idx
- ON audit_log(target_id) WHERE target_id IS NOT NULL;
- -- ---------------------------------------------------------------------------
- -- SQL functions
- -- ---------------------------------------------------------------------------
- -- generate_magic_link_token returns a 32-byte cryptographically random
- -- token, hex-encoded. Callers hash it before storing.
- CREATE OR REPLACE FUNCTION generate_magic_link_token()
- RETURNS TEXT
- LANGUAGE plpgsql
- SET search_path = auth, public
- AS $$
- DECLARE
- raw BYTEA;
- BEGIN
- raw := gen_random_bytes(32);
- RETURN encode(raw, 'hex');
- END;
- $$;
- -- issue_refresh_token: create a new refresh token row + the matching
- -- session row in one transaction. Returns the plaintext token (caller
- -- hashes it for the response and stores only the hash).
- CREATE OR REPLACE FUNCTION issue_refresh_token(
- p_user_id UUID,
- p_access_jti TEXT,
- p_ttl_seconds INTEGER,
- p_ip INET,
- p_ua TEXT
- )
- RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ)
- LANGUAGE plpgsql
- SET search_path = auth, public
- AS $$
- DECLARE
- v_raw BYTEA := gen_random_bytes(32);
- v_token TEXT := encode(v_raw, 'hex');
- v_hash BYTEA := digest(v_raw, 'sha256');
- v_new_id UUID;
- v_family UUID := gen_random_uuid();
- v_expires TIMESTAMPTZ := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
- BEGIN
- INSERT INTO auth.refresh_tokens
- (token_hash, user_id, access_jti, family_id, expires_at, created_ip, created_ua)
- VALUES
- (v_hash, p_user_id, p_access_jti, v_family, v_expires, p_ip, p_ua)
- RETURNING auth.refresh_tokens.id INTO v_new_id;
- INSERT INTO auth.sessions
- (user_id, refresh_id, ip, user_agent)
- VALUES
- (p_user_id, v_new_id, p_ip, p_ua);
- RETURN QUERY SELECT v_new_id, v_token, v_family, v_expires;
- END;
- $$;
- -- rotate_refresh_token: consume a refresh token and issue a new one
- -- in the same family. If the presented token was already revoked OR
- -- the family has a re-use signal, the whole family is killed.
- CREATE OR REPLACE FUNCTION rotate_refresh_token(
- p_presented_token TEXT,
- p_new_access_jti TEXT,
- p_ttl_seconds INTEGER,
- p_ip INET,
- p_ua TEXT
- )
- RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ, killed_family BOOLEAN)
- LANGUAGE plpgsql
- SET search_path = auth, public
- AS $$
- DECLARE
- v_presented_hash BYTEA := digest(decode(p_presented_token, 'hex'), 'sha256');
- v_old RECORD;
- v_raw BYTEA;
- v_token TEXT;
- v_hash BYTEA;
- v_new_id UUID;
- v_expires TIMESTAMPTZ;
- v_killed BOOLEAN := FALSE;
- BEGIN
- SELECT rt.id, rt.user_id, rt.family_id, rt.expires_at, rt.revoked_at, rt.parent_hash
- INTO v_old
- FROM auth.refresh_tokens rt
- WHERE rt.token_hash = v_presented_hash;
- IF NOT FOUND THEN
- -- Unknown token — likely forgeries or already cleaned up.
- RAISE EXCEPTION 'unknown_refresh_token' USING ERRCODE = '22023';
- END IF;
- IF v_old.expires_at < NOW() THEN
- RAISE EXCEPTION 'expired_refresh_token' USING ERRCODE = '22023';
- END IF;
- -- Re-use detection: if this token is already revoked OR has a
- -- child (parent_hash set), someone is replaying a stolen token.
- IF v_old.revoked_at IS NOT NULL OR v_old.parent_hash IS NOT NULL THEN
- -- Kill the whole family. Every token in it is suspect.
- UPDATE auth.refresh_tokens
- SET revoked_at = NOW()
- WHERE auth.refresh_tokens.family_id = v_old.family_id
- AND auth.refresh_tokens.revoked_at IS NULL;
- UPDATE auth.sessions
- SET revoked_at = NOW()
- WHERE auth.sessions.user_id = v_old.user_id
- AND auth.sessions.refresh_id IN (
- SELECT auth.refresh_tokens.id
- FROM auth.refresh_tokens
- WHERE auth.refresh_tokens.family_id = v_old.family_id
- )
- AND auth.sessions.revoked_at IS NULL;
- v_killed := TRUE;
- RAISE EXCEPTION 'refresh_token_reuse' USING ERRCODE = '22023';
- END IF;
- -- Revoke the old, issue the new
- UPDATE auth.refresh_tokens
- SET revoked_at = NOW()
- WHERE auth.refresh_tokens.id = v_old.id;
- v_raw := gen_random_bytes(32);
- v_token := encode(v_raw, 'hex');
- v_hash := digest(v_raw, 'sha256');
- v_expires := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
- INSERT INTO auth.refresh_tokens
- (token_hash, user_id, access_jti, family_id, parent_hash, expires_at, created_ip, created_ua)
- VALUES
- (v_hash, v_old.user_id, p_new_access_jti, v_old.family_id, v_presented_hash, v_expires, p_ip, p_ua)
- RETURNING auth.refresh_tokens.id INTO v_new_id;
- UPDATE auth.sessions
- SET last_seen_at = NOW(),
- refresh_id = v_new_id,
- ip = p_ip,
- user_agent = p_ua
- WHERE refresh_id = v_old.id;
- RETURN QUERY SELECT v_new_id, v_token, v_old.family_id, v_expires, v_killed;
- END;
- $$;
- -- revoke_refresh_token: revoke a single token by plaintext.
- CREATE OR REPLACE FUNCTION revoke_refresh_token(p_token TEXT)
- RETURNS BOOLEAN
- LANGUAGE plpgsql
- SET search_path = auth, public
- AS $$
- DECLARE
- v_hash BYTEA := digest(decode(p_token, 'hex'), 'sha256');
- v_id UUID;
- BEGIN
- UPDATE auth.refresh_tokens
- SET revoked_at = NOW()
- WHERE token_hash = v_hash
- AND revoked_at IS NULL
- RETURNING auth.refresh_tokens.id INTO v_id;
- IF v_id IS NULL THEN
- RETURN FALSE;
- END IF;
- UPDATE auth.sessions
- SET revoked_at = NOW()
- WHERE refresh_id = v_id
- AND revoked_at IS NULL;
- RETURN TRUE;
- END;
- $$;
- -- ---------------------------------------------------------------------------
- -- updated_at triggers
- -- ---------------------------------------------------------------------------
- CREATE OR REPLACE FUNCTION auth_set_updated_at()
- RETURNS TRIGGER
- LANGUAGE plpgsql
- AS $$
- BEGIN
- NEW.updated_at := NOW();
- RETURN NEW;
- END;
- $$;
- DROP TRIGGER IF EXISTS tenants_set_updated_at ON tenants;
- CREATE TRIGGER tenants_set_updated_at
- BEFORE UPDATE ON tenants
- FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();
- DROP TRIGGER IF EXISTS users_set_updated_at ON users;
- CREATE TRIGGER users_set_updated_at
- BEFORE UPDATE ON users
- FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();
|