009_auth.up.sql 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. -- 009_auth.up.sql
  2. -- M13a: Multi-tenant auth schema for authd (port 8804).
  3. --
  4. -- Tenancy model (per M13 survey, decision 1.1 = C, decision 2.1 = C hybrid):
  5. -- - One Postgres DB for everything (M11 already uses it for DLQ, deliveries).
  6. -- - Schema 'auth' is the namespace for authd tables.
  7. -- - A 'tenant' is a customer company. Each tenant has a 'tenant_slug'
  8. -- used in routes (e.g. /v1/tenants/acme-001/sources) and as a
  9. -- partition key for downstream queries.
  10. -- - A 'user' belongs to ONE tenant (super-admins have tenant_id=NULL
  11. -- and role='super_admin'; tenant-admins have role='tenant_admin').
  12. -- - 'global_id' on users is for SSO future (v2). For now it's
  13. -- just a stable opaque id we generate server-side.
  14. --
  15. -- Authentication (per survey 2.2 = A in-house JWT, 2.4 = B magic-link):
  16. -- - 'magic_links' stores the one-time tokens emailed to invited
  17. -- users. After use they're marked 'consumed_at'.
  18. -- - 'refresh_tokens' stores the long-lived tokens (7d TTL by default)
  19. -- that authd issues alongside the short-lived (15m) access JWT.
  20. -- The refresh token is a 256-bit random string, NOT a JWT — the
  21. -- server-side row is the source of truth (decision: refresh in
  22. -- server-side Postgres, rotated on use).
  23. -- - 'sessions' is the audit trail of every login/logout, used for
  24. -- security investigations ("who was logged in when X happened").
  25. --
  26. -- Audit (per survey: every service writes its own audit_log row):
  27. -- - 'audit_log' is shared across services. action is namespaced
  28. -- as 'auth.login', 'auth.logout', 'auth.invite', 'cert.issue',
  29. -- 'cert.revoke', etc.
  30. -- - actor_user_id is nullable for system actions (cron, service tokens).
  31. -- - payload is jsonb for service-specific structured data.
  32. --
  33. -- Surfaces: authd (CRUD users/magic_links/sessions/refresh_tokens + reads
  34. -- audit_log), admind (reads audit_log for the admin UI), and a
  35. -- 'verify_session' SQL function used by ingestd/admind to validate
  36. -- a presented access JWT against the refresh-token table (so we can
  37. -- revoke a session by deleting its row).
  38. CREATE SCHEMA IF NOT EXISTS auth;
  39. SET search_path TO auth, public;
  40. -- pgcrypto provides gen_random_bytes() and digest() (used by the
  41. -- refresh-token functions below for secure random + sha256).
  42. -- gen_random_uuid() comes from pgcrypto on older Postgres and is
  43. -- built-in on 13+, but the extension is harmless to enable.
  44. CREATE EXTENSION IF NOT EXISTS pgcrypto;
  45. -- ---------------------------------------------------------------------------
  46. -- Tenants
  47. -- ---------------------------------------------------------------------------
  48. CREATE TABLE IF NOT EXISTS tenants (
  49. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  50. slug TEXT NOT NULL UNIQUE
  51. CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'),
  52. display_name TEXT NOT NULL,
  53. status TEXT NOT NULL DEFAULT 'active'
  54. CHECK (status IN ('active', 'suspended', 'archived')),
  55. contact_email TEXT NOT NULL,
  56. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  57. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  58. archived_at TIMESTAMPTZ
  59. );
  60. CREATE INDEX IF NOT EXISTS tenants_status_idx ON tenants(status) WHERE status != 'archived';
  61. -- ---------------------------------------------------------------------------
  62. -- Users
  63. -- ---------------------------------------------------------------------------
  64. CREATE TABLE IF NOT EXISTS users (
  65. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  66. -- global_id is for SSO migration (v2). Until then, the per-tenant
  67. -- (id, tenant_id) pair is the unique key.
  68. global_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
  69. tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
  70. email TEXT NOT NULL,
  71. role TEXT NOT NULL
  72. CHECK (role IN ('super_admin', 'tenant_admin', 'viewer')),
  73. status TEXT NOT NULL DEFAULT 'pending'
  74. CHECK (status IN ('pending', 'active', 'disabled')),
  75. display_name TEXT,
  76. -- bcrypt of the password. NULL until the magic link is consumed.
  77. password_hash TEXT,
  78. last_login_at TIMESTAMPTZ,
  79. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  80. updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  81. disabled_at TIMESTAMPTZ
  82. );
  83. -- Email is unique per tenant (or unique globally for super_admins).
  84. -- Partial unique index for super_admins (tenant_id IS NULL).
  85. CREATE UNIQUE INDEX IF NOT EXISTS users_email_global_uniq
  86. ON users(email) WHERE tenant_id IS NULL;
  87. CREATE UNIQUE INDEX IF NOT EXISTS users_email_per_tenant_uniq
  88. ON users(email, tenant_id) WHERE tenant_id IS NOT NULL;
  89. CREATE INDEX IF NOT EXISTS users_tenant_idx ON users(tenant_id) WHERE tenant_id IS NOT NULL;
  90. CREATE INDEX IF NOT EXISTS users_status_idx ON users(status) WHERE status != 'active';
  91. -- ---------------------------------------------------------------------------
  92. -- Magic links (invite + password-set)
  93. -- ---------------------------------------------------------------------------
  94. CREATE TABLE IF NOT EXISTS magic_links (
  95. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  96. -- token_hash is sha256(token). The plaintext token is emailed and
  97. -- never stored. Lookup is by hash.
  98. token_hash BYTEA NOT NULL UNIQUE,
  99. user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  100. purpose TEXT NOT NULL
  101. CHECK (purpose IN ('invite', 'password_reset', 'mfa_reset')),
  102. expires_at TIMESTAMPTZ NOT NULL,
  103. consumed_at TIMESTAMPTZ,
  104. -- ip that consumed it (audit trail)
  105. consumed_ip INET,
  106. -- user-agent that consumed it (audit trail)
  107. consumed_ua TEXT,
  108. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  109. );
  110. CREATE INDEX IF NOT EXISTS magic_links_user_idx ON magic_links(user_id);
  111. CREATE INDEX IF NOT EXISTS magic_links_unconsumed_idx
  112. ON magic_links(expires_at) WHERE consumed_at IS NULL;
  113. -- ---------------------------------------------------------------------------
  114. -- Refresh tokens (server-side session table)
  115. -- ---------------------------------------------------------------------------
  116. CREATE TABLE IF NOT EXISTS refresh_tokens (
  117. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  118. -- token_hash is sha256(token). The plaintext is in the httpOnly
  119. -- cookie / mobile secure storage. Token is a 32-byte random hex string.
  120. token_hash BYTEA NOT NULL UNIQUE,
  121. user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  122. -- jti of the access JWT that this refresh was issued with. Used
  123. -- to correlate logs and to make rotation auditable.
  124. access_jti TEXT NOT NULL,
  125. -- The previous token's hash (if rotated). Lets us detect a stolen
  126. -- token being used after rotation.
  127. parent_hash BYTEA,
  128. expires_at TIMESTAMPTZ NOT NULL,
  129. revoked_at TIMESTAMPTZ,
  130. -- The 'family' id groups all rotations of one login. If a non-head
  131. -- token in a family is used, the family is killed (re-use detection).
  132. family_id UUID NOT NULL DEFAULT gen_random_uuid(),
  133. created_ip INET,
  134. created_ua TEXT,
  135. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
  136. );
  137. CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id);
  138. CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens(family_id);
  139. CREATE INDEX IF NOT EXISTS refresh_tokens_active_idx
  140. ON refresh_tokens(expires_at) WHERE revoked_at IS NULL;
  141. -- ---------------------------------------------------------------------------
  142. -- Sessions (read-only audit view of refresh_tokens, denormalized for
  143. -- fast "what devices is this user logged in from" queries).
  144. -- ---------------------------------------------------------------------------
  145. CREATE TABLE IF NOT EXISTS sessions (
  146. id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  147. user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  148. refresh_id UUID NOT NULL REFERENCES refresh_tokens(id) ON DELETE CASCADE,
  149. ip INET,
  150. user_agent TEXT,
  151. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  152. last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  153. revoked_at TIMESTAMPTZ
  154. );
  155. CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
  156. CREATE INDEX IF NOT EXISTS sessions_active_idx
  157. ON sessions(user_id) WHERE revoked_at IS NULL;
  158. -- ---------------------------------------------------------------------------
  159. -- Audit log
  160. -- ---------------------------------------------------------------------------
  161. CREATE TABLE IF NOT EXISTS audit_log (
  162. id BIGSERIAL PRIMARY KEY,
  163. occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  164. -- 'auth.login', 'auth.logout', 'auth.invite', 'auth.magic_consume',
  165. -- 'cert.issue', 'cert.revoke', 'tenant.create', 'user.disable', etc.
  166. action TEXT NOT NULL,
  167. actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
  168. actor_ip INET,
  169. actor_ua TEXT,
  170. -- The target of the action. For 'auth.invite' it's the invited
  171. -- user. For 'cert.revoke' it's the source/cert id. Free-form uuid.
  172. target_id UUID,
  173. -- Tenant scope (NULL for super-admin global actions).
  174. tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
  175. -- Action-specific structured data. Example for 'auth.login':
  176. -- {"email": "...", "success": true, "failure_reason": null}
  177. payload JSONB NOT NULL DEFAULT '{}'::jsonb
  178. );
  179. CREATE INDEX IF NOT EXISTS audit_log_action_time_idx
  180. ON audit_log(action, occurred_at DESC);
  181. CREATE INDEX IF NOT EXISTS audit_log_actor_idx
  182. ON audit_log(actor_user_id, occurred_at DESC)
  183. WHERE actor_user_id IS NOT NULL;
  184. CREATE INDEX IF NOT EXISTS audit_log_tenant_idx
  185. ON audit_log(tenant_id, occurred_at DESC)
  186. WHERE tenant_id IS NOT NULL;
  187. CREATE INDEX IF NOT EXISTS audit_log_target_idx
  188. ON audit_log(target_id) WHERE target_id IS NOT NULL;
  189. -- ---------------------------------------------------------------------------
  190. -- SQL functions
  191. -- ---------------------------------------------------------------------------
  192. -- generate_magic_link_token returns a 32-byte cryptographically random
  193. -- token, hex-encoded. Callers hash it before storing.
  194. CREATE OR REPLACE FUNCTION generate_magic_link_token()
  195. RETURNS TEXT
  196. LANGUAGE plpgsql
  197. SET search_path = auth, public
  198. AS $$
  199. DECLARE
  200. raw BYTEA;
  201. BEGIN
  202. raw := gen_random_bytes(32);
  203. RETURN encode(raw, 'hex');
  204. END;
  205. $$;
  206. -- issue_refresh_token: create a new refresh token row + the matching
  207. -- session row in one transaction. Returns the plaintext token (caller
  208. -- hashes it for the response and stores only the hash).
  209. CREATE OR REPLACE FUNCTION issue_refresh_token(
  210. p_user_id UUID,
  211. p_access_jti TEXT,
  212. p_ttl_seconds INTEGER,
  213. p_ip INET,
  214. p_ua TEXT
  215. )
  216. RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ)
  217. LANGUAGE plpgsql
  218. SET search_path = auth, public
  219. AS $$
  220. DECLARE
  221. v_raw BYTEA := gen_random_bytes(32);
  222. v_token TEXT := encode(v_raw, 'hex');
  223. v_hash BYTEA := digest(v_raw, 'sha256');
  224. v_new_id UUID;
  225. v_family UUID := gen_random_uuid();
  226. v_expires TIMESTAMPTZ := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
  227. BEGIN
  228. INSERT INTO auth.refresh_tokens
  229. (token_hash, user_id, access_jti, family_id, expires_at, created_ip, created_ua)
  230. VALUES
  231. (v_hash, p_user_id, p_access_jti, v_family, v_expires, p_ip, p_ua)
  232. RETURNING auth.refresh_tokens.id INTO v_new_id;
  233. INSERT INTO auth.sessions
  234. (user_id, refresh_id, ip, user_agent)
  235. VALUES
  236. (p_user_id, v_new_id, p_ip, p_ua);
  237. RETURN QUERY SELECT v_new_id, v_token, v_family, v_expires;
  238. END;
  239. $$;
  240. -- rotate_refresh_token: consume a refresh token and issue a new one
  241. -- in the same family. If the presented token was already revoked OR
  242. -- the family has a re-use signal, the whole family is killed.
  243. CREATE OR REPLACE FUNCTION rotate_refresh_token(
  244. p_presented_token TEXT,
  245. p_new_access_jti TEXT,
  246. p_ttl_seconds INTEGER,
  247. p_ip INET,
  248. p_ua TEXT
  249. )
  250. RETURNS TABLE (id UUID, token TEXT, family_id UUID, expires_at TIMESTAMPTZ, killed_family BOOLEAN)
  251. LANGUAGE plpgsql
  252. SET search_path = auth, public
  253. AS $$
  254. DECLARE
  255. v_presented_hash BYTEA := digest(decode(p_presented_token, 'hex'), 'sha256');
  256. v_old RECORD;
  257. v_raw BYTEA;
  258. v_token TEXT;
  259. v_hash BYTEA;
  260. v_new_id UUID;
  261. v_expires TIMESTAMPTZ;
  262. v_killed BOOLEAN := FALSE;
  263. BEGIN
  264. SELECT rt.id, rt.user_id, rt.family_id, rt.expires_at, rt.revoked_at, rt.parent_hash
  265. INTO v_old
  266. FROM auth.refresh_tokens rt
  267. WHERE rt.token_hash = v_presented_hash;
  268. IF NOT FOUND THEN
  269. -- Unknown token — likely forgeries or already cleaned up.
  270. RAISE EXCEPTION 'unknown_refresh_token' USING ERRCODE = '22023';
  271. END IF;
  272. IF v_old.expires_at < NOW() THEN
  273. RAISE EXCEPTION 'expired_refresh_token' USING ERRCODE = '22023';
  274. END IF;
  275. -- Re-use detection: if this token is already revoked OR has a
  276. -- child (parent_hash set), someone is replaying a stolen token.
  277. IF v_old.revoked_at IS NOT NULL OR v_old.parent_hash IS NOT NULL THEN
  278. -- Kill the whole family. Every token in it is suspect.
  279. UPDATE auth.refresh_tokens
  280. SET revoked_at = NOW()
  281. WHERE auth.refresh_tokens.family_id = v_old.family_id
  282. AND auth.refresh_tokens.revoked_at IS NULL;
  283. UPDATE auth.sessions
  284. SET revoked_at = NOW()
  285. WHERE auth.sessions.user_id = v_old.user_id
  286. AND auth.sessions.refresh_id IN (
  287. SELECT auth.refresh_tokens.id
  288. FROM auth.refresh_tokens
  289. WHERE auth.refresh_tokens.family_id = v_old.family_id
  290. )
  291. AND auth.sessions.revoked_at IS NULL;
  292. v_killed := TRUE;
  293. RAISE EXCEPTION 'refresh_token_reuse' USING ERRCODE = '22023';
  294. END IF;
  295. -- Revoke the old, issue the new
  296. UPDATE auth.refresh_tokens
  297. SET revoked_at = NOW()
  298. WHERE auth.refresh_tokens.id = v_old.id;
  299. v_raw := gen_random_bytes(32);
  300. v_token := encode(v_raw, 'hex');
  301. v_hash := digest(v_raw, 'sha256');
  302. v_expires := NOW() + (p_ttl_seconds || ' seconds')::INTERVAL;
  303. INSERT INTO auth.refresh_tokens
  304. (token_hash, user_id, access_jti, family_id, parent_hash, expires_at, created_ip, created_ua)
  305. VALUES
  306. (v_hash, v_old.user_id, p_new_access_jti, v_old.family_id, v_presented_hash, v_expires, p_ip, p_ua)
  307. RETURNING auth.refresh_tokens.id INTO v_new_id;
  308. UPDATE auth.sessions
  309. SET last_seen_at = NOW(),
  310. refresh_id = v_new_id,
  311. ip = p_ip,
  312. user_agent = p_ua
  313. WHERE refresh_id = v_old.id;
  314. RETURN QUERY SELECT v_new_id, v_token, v_old.family_id, v_expires, v_killed;
  315. END;
  316. $$;
  317. -- revoke_refresh_token: revoke a single token by plaintext.
  318. CREATE OR REPLACE FUNCTION revoke_refresh_token(p_token TEXT)
  319. RETURNS BOOLEAN
  320. LANGUAGE plpgsql
  321. SET search_path = auth, public
  322. AS $$
  323. DECLARE
  324. v_hash BYTEA := digest(decode(p_token, 'hex'), 'sha256');
  325. v_id UUID;
  326. BEGIN
  327. UPDATE auth.refresh_tokens
  328. SET revoked_at = NOW()
  329. WHERE token_hash = v_hash
  330. AND revoked_at IS NULL
  331. RETURNING auth.refresh_tokens.id INTO v_id;
  332. IF v_id IS NULL THEN
  333. RETURN FALSE;
  334. END IF;
  335. UPDATE auth.sessions
  336. SET revoked_at = NOW()
  337. WHERE refresh_id = v_id
  338. AND revoked_at IS NULL;
  339. RETURN TRUE;
  340. END;
  341. $$;
  342. -- ---------------------------------------------------------------------------
  343. -- updated_at triggers
  344. -- ---------------------------------------------------------------------------
  345. CREATE OR REPLACE FUNCTION auth_set_updated_at()
  346. RETURNS TRIGGER
  347. LANGUAGE plpgsql
  348. AS $$
  349. BEGIN
  350. NEW.updated_at := NOW();
  351. RETURN NEW;
  352. END;
  353. $$;
  354. DROP TRIGGER IF EXISTS tenants_set_updated_at ON tenants;
  355. CREATE TRIGGER tenants_set_updated_at
  356. BEFORE UPDATE ON tenants
  357. FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();
  358. DROP TRIGGER IF EXISTS users_set_updated_at ON users;
  359. CREATE TRIGGER users_set_updated_at
  360. BEFORE UPDATE ON users
  361. FOR EACH ROW EXECUTE FUNCTION auth_set_updated_at();