Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0009_auth.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | db/migrations/0009_auth.sql |
| Original SHA-256 | 912f8bcc368e91f733aa2ebb8946b5330d0da12e53cd36fd3e9d8d1db7c12e6f |
| Git blob SHA | c84f61c660fc217855b1e2ebe99c0bffe5e8ba8f |
| Reading-copy SHA-256 | 912f8bcc368e91f733aa2ebb8946b5330d0da12e53cd36fd3e9d8d1db7c12e6f |
Every recorded occurrence
These 36 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / db/migrations/0009_auth.sql | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | w0-aedt-compliance | 39df94f92b2a33f852481fe1954dabed841524bb | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
| Janady13/missionmatch / db/migrations/0009_auth.sql | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓568 lines · 27.9 KB
-- +goose Up
-- Phase 2 Authentication and Accounts. Run as the BYPASSRLS migrator role.
--
-- Posture (see docs/STACK_DECISIONS.md section 7): the secret bearing tables (auth_credentials,
-- auth_sessions, mfa_totp, mfa_recovery_codes, user_invites) live OUTSIDE RLS and the app role gets
-- NO direct table grants on them. Every app access goes through a SECURITY DEFINER function owned by
-- the migrator with a locked search_path, EXECUTE granted narrowly. That restores DB side default
-- deny for the most sensitive data: the app role literally cannot SELECT a credential or session row,
-- so a forgotten predicate cannot leak another org's secrets. provision_org is migrator only.
--
-- Every per user table uses a COMPOSITE foreign key (organization_id, user_id) to users so the org on
-- the row is pinned to the user's real org by the database, closing cross org credential binding.
-- ---------------------------------------------------------------------------
-- users: gain the MFA enrollment flag and a GLOBALLY unique email. Identity is one human, one
-- account, one org, so login can resolve a user by email before the org context is known. The pre
-- existing per org unique index is kept (it is implied by the global one but other code may name it).
-- ---------------------------------------------------------------------------
ALTER TABLE users ADD COLUMN mfa_enrolled boolean NOT NULL DEFAULT false;
CREATE UNIQUE INDEX uq_users_global_email ON users (lower(email));
-- ---------------------------------------------------------------------------
-- auth_credentials: one password credential per user. OUTSIDE RLS, no app table grants. The composite
-- FK pins the org to the user's org; the global unique email reconciles with users so a second org
-- cannot shadow the first.
-- ---------------------------------------------------------------------------
CREATE TABLE auth_credentials (
user_id uuid PRIMARY KEY,
organization_id uuid NOT NULL,
email_normalized text NOT NULL,
password_hash text NOT NULL,
pepper_version int NOT NULL,
failed_streak int NOT NULL DEFAULT 0,
locked_until timestamptz,
password_changed_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_cred_email_global UNIQUE (email_normalized),
CONSTRAINT fk_cred_user_org FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE
);
-- ---------------------------------------------------------------------------
-- auth_sessions: opaque server side sessions, stored only as a token hash. OUTSIDE RLS, no app table
-- grants. organization_id is a convenience copy; auth_validate_session JOINs users and returns the
-- authoritative org, failing closed on a mismatch.
-- ---------------------------------------------------------------------------
CREATE TABLE auth_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash bytea NOT NULL UNIQUE,
user_id uuid NOT NULL,
organization_id uuid NOT NULL,
state text NOT NULL CHECK (state IN ('auth_pending', 'active')),
mfa_satisfied boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
idle_expires_at timestamptz NOT NULL,
absolute_expires_at timestamptz NOT NULL,
revoked_at timestamptz,
rotated_from uuid,
ip_address text,
user_agent text,
CONSTRAINT fk_session_user_org FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_auth_sessions_user_live ON auth_sessions (user_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_auth_sessions_sweep ON auth_sessions (absolute_expires_at);
-- ---------------------------------------------------------------------------
-- mfa_totp: one TOTP secret per user, AES 256 GCM encrypted at rest with a key_version for rotation.
-- last_used_step is the strictly monotonic replay marker. OUTSIDE RLS, no app table grants, reached
-- only by the auth definers (MFA enroll and verify run pre org on an auth_pending session).
-- ---------------------------------------------------------------------------
CREATE TABLE mfa_totp (
user_id uuid PRIMARY KEY,
organization_id uuid NOT NULL,
secret_ciphertext bytea NOT NULL,
secret_nonce bytea NOT NULL,
key_version int NOT NULL,
status text NOT NULL CHECK (status IN ('pending', 'active')),
last_used_step bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
activated_at timestamptz,
CONSTRAINT fk_mfa_user_org FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE
);
-- ---------------------------------------------------------------------------
-- mfa_recovery_codes: single use backup codes. code_hash is PBKDF2, lookup_tag is a non secret HMAC
-- so a recovery attempt runs at most one PBKDF2 verify. OUTSIDE RLS, no app table grants.
-- ---------------------------------------------------------------------------
CREATE TABLE mfa_recovery_codes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
organization_id uuid NOT NULL,
code_hash text NOT NULL,
pepper_version int NOT NULL,
lookup_tag bytea NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_recovery_user_org FOREIGN KEY (organization_id, user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_recovery_lookup_live ON mfa_recovery_codes (user_id, lookup_tag) WHERE used_at IS NULL;
-- ---------------------------------------------------------------------------
-- user_invites: single use, time boxed invite tokens (stored hashed) redeemed before the invitee has
-- a session. OUTSIDE RLS, no app table grants. role bounds are enforced in the API and re asserted at
-- accept; the CHECK permits owner only because the migrator provisioning path issues the owner's
-- activation through this same table.
-- ---------------------------------------------------------------------------
CREATE TABLE user_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
email_normalized text NOT NULL,
invited_user_id uuid NOT NULL,
token_hash bytea NOT NULL UNIQUE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'recruiter', 'member', 'viewer')),
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_invite_user_org FOREIGN KEY (organization_id, invited_user_id)
REFERENCES users (organization_id, id) ON DELETE CASCADE
);
-- ---------------------------------------------------------------------------
-- login_attempts: brute force counter. OUTSIDE RLS but the ONE auth table with a narrow app grant
-- (no secrets). A row is written for known AND unknown emails so lock behavior is not an enumeration
-- oracle.
-- ---------------------------------------------------------------------------
CREATE TABLE login_attempts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email_normalized text NOT NULL,
organization_id uuid,
ip_address text,
succeeded boolean NOT NULL,
reason text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_login_attempts_email ON login_attempts (email_normalized, created_at DESC);
CREATE INDEX idx_login_attempts_ip ON login_attempts (ip_address, created_at DESC);
-- ===========================================================================
-- SECURITY DEFINER functions. Owned by the migrator (this script runs as migrator), search_path
-- locked, EXECUTE revoked from PUBLIC below and granted narrowly.
-- ===========================================================================
-- +goose StatementBegin
CREATE FUNCTION auth_lookup_login(p_email text)
RETURNS TABLE (
user_id uuid, organization_id uuid, role text, user_status text,
password_hash text, pepper_version int, mfa_enrolled boolean,
mfa_status text, mfa_secret_ciphertext bytea, mfa_secret_nonce bytea,
mfa_key_version int, last_used_step bigint
)
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT u.id, u.organization_id, u.role, u.status,
c.password_hash, c.pepper_version, u.mfa_enrolled,
m.status, m.secret_ciphertext, m.secret_nonce, m.key_version, m.last_used_step
FROM users u
JOIN auth_credentials c ON c.user_id = u.id
LEFT JOIN mfa_totp m ON m.user_id = u.id
WHERE c.email_normalized = lower(p_email)
LIMIT 1;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_create_session(
p_token_hash bytea, p_user_id uuid, p_org_id uuid, p_state text,
p_mfa_satisfied boolean, p_idle_seconds int, p_absolute_seconds int, p_ip text, p_ua text)
RETURNS TABLE (session_id uuid, idle_expires_at timestamptz, absolute_expires_at timestamptz)
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
INSERT INTO auth_sessions (
token_hash, user_id, organization_id, state, mfa_satisfied,
idle_expires_at, absolute_expires_at, ip_address, user_agent)
VALUES (
p_token_hash, p_user_id, p_org_id, p_state, p_mfa_satisfied,
now() + make_interval(secs => p_idle_seconds),
now() + make_interval(secs => p_absolute_seconds), p_ip, p_ua)
RETURNING id, idle_expires_at, absolute_expires_at;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- The single atomic validate, slide, and revocation check. Returns the AUTHORITATIVE users.org (the
-- JOIN, never the stored session copy) and fails closed on a session.org to users.org mismatch, so a
-- moved org user's stale session is rejected, not silently re scoped. A revoked or expired row can
-- never be slid because the guard and the update are one statement. The org is returned for both
-- auth_pending and active sessions because the pre MFA enroll and verify flows need the user's own
-- org for their definer writes; the request resolver grants tenant context ONLY when state is active
-- and mfa_satisfied is true, and RunInTenantTx plus RLS still fail closed if that gate is ever missed.
CREATE FUNCTION auth_validate_session(p_token_hash bytea, p_idle_seconds int)
RETURNS TABLE (
session_id uuid, user_id uuid, organization_id uuid, role text,
state text, mfa_satisfied boolean, user_status text)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
#variable_conflict use_column
BEGIN
RETURN QUERY
UPDATE auth_sessions s
SET last_seen_at = now(),
idle_expires_at = LEAST(now() + make_interval(secs => p_idle_seconds), s.absolute_expires_at)
FROM users u
WHERE s.token_hash = p_token_hash
AND s.user_id = u.id
AND s.revoked_at IS NULL
AND now() < s.absolute_expires_at
AND now() < s.idle_expires_at
AND u.status = 'active'
AND s.organization_id = u.organization_id
RETURNING s.id, s.user_id, u.organization_id, u.role, s.state, s.mfa_satisfied, u.status;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Privilege transition: revoke the old token and mint a new one carrying the same user and org, in
-- one statement pair, so the pre transition token is invalid in the same request cycle.
CREATE FUNCTION auth_rotate_session(
p_old_token_hash bytea, p_new_token_hash bytea, p_new_state text, p_mfa_satisfied boolean,
p_idle_seconds int, p_absolute_seconds int, p_ip text, p_ua text)
RETURNS TABLE (session_id uuid, idle_expires_at timestamptz, absolute_expires_at timestamptz)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
#variable_conflict use_column
DECLARE v_old_id uuid; v_user uuid; v_org uuid;
BEGIN
UPDATE auth_sessions SET revoked_at = now()
WHERE token_hash = p_old_token_hash AND revoked_at IS NULL
RETURNING id, user_id, organization_id INTO v_old_id, v_user, v_org;
IF v_user IS NULL THEN
RETURN;
END IF;
RETURN QUERY
INSERT INTO auth_sessions (
token_hash, user_id, organization_id, state, mfa_satisfied,
idle_expires_at, absolute_expires_at, rotated_from, ip_address, user_agent)
VALUES (
p_new_token_hash, v_user, v_org, p_new_state, p_mfa_satisfied,
now() + make_interval(secs => p_idle_seconds),
now() + make_interval(secs => p_absolute_seconds), v_old_id, p_ip, p_ua)
RETURNING id, idle_expires_at, absolute_expires_at;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_revoke_session(p_token_hash bytea)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE n integer;
BEGIN
UPDATE auth_sessions SET revoked_at = now()
WHERE token_hash = p_token_hash AND revoked_at IS NULL;
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Cascade revoke. p_except_token_hash keeps the caller's current session alive on a password change.
CREATE FUNCTION auth_revoke_user_sessions(p_user_id uuid, p_except_token_hash bytea)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE n integer;
BEGIN
UPDATE auth_sessions SET revoked_at = now()
WHERE user_id = p_user_id AND revoked_at IS NULL
AND (p_except_token_hash IS NULL OR token_hash <> p_except_token_hash);
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_set_credential(p_user_id uuid, p_org_id uuid, p_email text, p_hash text, p_pepper_version int)
RETURNS void
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
INSERT INTO auth_credentials (user_id, organization_id, email_normalized, password_hash, pepper_version)
VALUES (p_user_id, p_org_id, lower(p_email), p_hash, p_pepper_version)
ON CONFLICT (user_id) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
pepper_version = EXCLUDED.pepper_version,
password_changed_at = now(),
updated_at = now();
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Read a credential by user id (for the authenticated password change path, where email is unknown
-- but the validated session gives the user id).
CREATE FUNCTION auth_get_credential(p_user_id uuid)
RETURNS TABLE (organization_id uuid, email_normalized text, password_hash text, pepper_version int)
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT organization_id, email_normalized, password_hash, pepper_version
FROM auth_credentials WHERE user_id = p_user_id;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Enroll writes a pending secret. The ON CONFLICT update is scoped to rows that are still pending, so
-- it can NEVER overwrite an already active authenticator. That closes the account takeover where an
-- attacker holding the password re enrolls their own secret from a pre MFA session; a genuine reset
-- must go through the authenticated admin auth_mfa_reset path. Returns true only when a pending row
-- was written.
CREATE FUNCTION auth_mfa_enroll(p_user_id uuid, p_org_id uuid, p_ciphertext bytea, p_nonce bytea, p_key_version int)
RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE n integer;
BEGIN
INSERT INTO mfa_totp (user_id, organization_id, secret_ciphertext, secret_nonce, key_version, status)
VALUES (p_user_id, p_org_id, p_ciphertext, p_nonce, p_key_version, 'pending')
ON CONFLICT (user_id) DO UPDATE
SET secret_ciphertext = EXCLUDED.secret_ciphertext,
secret_nonce = EXCLUDED.secret_nonce,
key_version = EXCLUDED.key_version,
status = 'pending',
last_used_step = 0,
created_at = now(),
activated_at = NULL
WHERE mfa_totp.status = 'pending';
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n > 0;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_mfa_activate(p_user_id uuid)
RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE n integer;
BEGIN
UPDATE mfa_totp SET status = 'active', activated_at = now()
WHERE user_id = p_user_id AND status = 'pending';
GET DIAGNOSTICS n = ROW_COUNT;
IF n = 0 THEN
RETURN false;
END IF;
UPDATE users SET mfa_enrolled = true, updated_at = now() WHERE id = p_user_id;
RETURN true;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_mfa_get(p_user_id uuid)
RETURNS TABLE (secret_ciphertext bytea, secret_nonce bytea, key_version int, status text, last_used_step bigint)
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT secret_ciphertext, secret_nonce, key_version, status, last_used_step
FROM mfa_totp WHERE user_id = p_user_id;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Monotonic advance: only moves the marker forward, so a consumed or earlier step cannot be reused.
CREATE FUNCTION auth_mfa_advance_step(p_user_id uuid, p_step bigint)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE n integer;
BEGIN
UPDATE mfa_totp SET last_used_step = p_step
WHERE user_id = p_user_id AND last_used_step < p_step;
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_mfa_reset(p_user_id uuid)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
BEGIN
DELETE FROM mfa_totp WHERE user_id = p_user_id;
DELETE FROM mfa_recovery_codes WHERE user_id = p_user_id;
UPDATE users SET mfa_enrolled = false, updated_at = now() WHERE id = p_user_id;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_recovery_store(p_user_id uuid, p_org_id uuid, p_hashes text[], p_versions int[], p_tags bytea[])
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
BEGIN
DELETE FROM mfa_recovery_codes WHERE user_id = p_user_id;
INSERT INTO mfa_recovery_codes (user_id, organization_id, code_hash, pepper_version, lookup_tag)
SELECT p_user_id, p_org_id, h, v, t
FROM unnest(p_hashes, p_versions, p_tags) AS x(h, v, t);
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_recovery_lookup(p_user_id uuid, p_tag bytea)
RETURNS TABLE (id uuid, code_hash text, pepper_version int)
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
SELECT id, code_hash, pepper_version
FROM mfa_recovery_codes
WHERE user_id = p_user_id AND lookup_tag = p_tag AND used_at IS NULL
LIMIT 1;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Atomic single use claim: a second concurrent claim of the same row affects zero rows.
CREATE FUNCTION auth_recovery_claim(p_id uuid)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE n integer;
BEGIN
UPDATE mfa_recovery_codes SET used_at = now() WHERE id = p_id AND used_at IS NULL;
GET DIAGNOSTICS n = ROW_COUNT;
RETURN n;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION auth_create_invite(
p_org_id uuid, p_email text, p_invited_user_id uuid, p_token_hash bytea,
p_role text, p_expires_seconds int, p_created_by uuid)
RETURNS uuid
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
INSERT INTO user_invites (
organization_id, email_normalized, invited_user_id, token_hash, role, expires_at, created_by)
VALUES (
p_org_id, lower(p_email), p_invited_user_id, p_token_hash, p_role,
now() + make_interval(secs => p_expires_seconds), p_created_by)
RETURNING id;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Accept asserts token.org == users.org AND the invite email matches the users row, takes the org
-- from the authoritative users row, creates the credential, and activates the user, all in one tx.
-- Any mismatch returns no rows so a tampered or replayed invite cannot plant a cross org login.
CREATE FUNCTION auth_accept_invite(p_token_hash bytea, p_password_hash text, p_pepper_version int)
RETURNS TABLE (user_id uuid, organization_id uuid)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
#variable_conflict use_column
DECLARE v_inv user_invites%ROWTYPE; v_user users%ROWTYPE;
BEGIN
SELECT * INTO v_inv FROM user_invites
WHERE token_hash = p_token_hash AND accepted_at IS NULL AND now() < expires_at;
IF v_inv.id IS NULL THEN
RETURN;
END IF;
SELECT * INTO v_user FROM users WHERE id = v_inv.invited_user_id;
IF v_user.id IS NULL
OR v_user.organization_id <> v_inv.organization_id
OR lower(v_user.email) <> v_inv.email_normalized THEN
RETURN;
END IF;
INSERT INTO auth_credentials (user_id, organization_id, email_normalized, password_hash, pepper_version)
VALUES (v_user.id, v_user.organization_id, lower(v_user.email), p_password_hash, p_pepper_version)
ON CONFLICT (user_id) DO UPDATE
SET password_hash = EXCLUDED.password_hash, pepper_version = EXCLUDED.pepper_version, updated_at = now();
UPDATE user_invites SET accepted_at = now() WHERE id = v_inv.id;
UPDATE users SET status = 'active', updated_at = now() WHERE id = v_user.id;
RETURN QUERY SELECT v_user.id, v_user.organization_id;
END;
$$;
-- +goose StatementEnd
-- +goose StatementBegin
-- Genesis: migrator only. Creates the org, the owner user (invited, MFA not yet enrolled), and the
-- owner's one time activation invite, in one tx. EXECUTE is revoked from app and PUBLIC below.
CREATE FUNCTION provision_org(
p_org_name text, p_org_slug text, p_owner_email text, p_owner_name text,
p_token_hash bytea, p_invite_expires_seconds int)
RETURNS TABLE (organization_id uuid, owner_user_id uuid, invite_id uuid)
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
#variable_conflict use_column
DECLARE v_org uuid; v_user uuid; v_inv uuid;
BEGIN
INSERT INTO organizations (name, slug) VALUES (p_org_name, p_org_slug) RETURNING id INTO v_org;
INSERT INTO users (organization_id, email, full_name, role, status, mfa_enrolled)
VALUES (v_org, p_owner_email, p_owner_name, 'owner', 'invited', false)
RETURNING id INTO v_user;
INSERT INTO user_invites (organization_id, email_normalized, invited_user_id, token_hash, role, expires_at, created_by)
VALUES (v_org, lower(p_owner_email), v_user, p_token_hash, 'owner',
now() + make_interval(secs => p_invite_expires_seconds), v_user)
RETURNING id INTO v_inv;
RETURN QUERY SELECT v_org, v_user, v_inv;
END;
$$;
-- +goose StatementEnd
-- ===========================================================================
-- Grants. The app role gets ONLY EXECUTE on the auth definers plus the login_attempts counter. By
-- default CREATE FUNCTION grants EXECUTE to PUBLIC, so each function is revoked from PUBLIC first.
-- ===========================================================================
GRANT SELECT, INSERT ON login_attempts TO missionmatch_app;
REVOKE EXECUTE ON FUNCTION auth_lookup_login(text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_create_session(bytea, uuid, uuid, text, boolean, int, int, text, text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_validate_session(bytea, int) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_rotate_session(bytea, bytea, text, boolean, int, int, text, text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_revoke_session(bytea) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_revoke_user_sessions(uuid, bytea) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_set_credential(uuid, uuid, text, text, int) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_get_credential(uuid) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_mfa_enroll(uuid, uuid, bytea, bytea, int) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_mfa_activate(uuid) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_mfa_get(uuid) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_mfa_advance_step(uuid, bigint) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_mfa_reset(uuid) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_recovery_store(uuid, uuid, text[], int[], bytea[]) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_recovery_lookup(uuid, bytea) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_recovery_claim(uuid) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_create_invite(uuid, text, uuid, bytea, text, int, uuid) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION auth_accept_invite(bytea, text, int) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION provision_org(text, text, text, text, bytea, int) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_lookup_login(text) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_create_session(bytea, uuid, uuid, text, boolean, int, int, text, text) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_validate_session(bytea, int) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_rotate_session(bytea, bytea, text, boolean, int, int, text, text) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_revoke_session(bytea) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_revoke_user_sessions(uuid, bytea) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_set_credential(uuid, uuid, text, text, int) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_get_credential(uuid) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_mfa_enroll(uuid, uuid, bytea, bytea, int) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_mfa_activate(uuid) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_mfa_get(uuid) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_mfa_advance_step(uuid, bigint) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_mfa_reset(uuid) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_recovery_store(uuid, uuid, text[], int[], bytea[]) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_recovery_lookup(uuid, bytea) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_recovery_claim(uuid) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_create_invite(uuid, text, uuid, bytea, text, int, uuid) TO missionmatch_app;
GRANT EXECUTE ON FUNCTION auth_accept_invite(bytea, text, int) TO missionmatch_app;
-- provision_org is intentionally NOT granted to missionmatch_app; the migrator executes it as owner.
-- +goose Down
DROP FUNCTION IF EXISTS provision_org(text, text, text, text, bytea, int);
DROP FUNCTION IF EXISTS auth_accept_invite(bytea, text, int);
DROP FUNCTION IF EXISTS auth_create_invite(uuid, text, uuid, bytea, text, int, uuid);
DROP FUNCTION IF EXISTS auth_recovery_claim(uuid);
DROP FUNCTION IF EXISTS auth_recovery_lookup(uuid, bytea);
DROP FUNCTION IF EXISTS auth_recovery_store(uuid, uuid, text[], int[], bytea[]);
DROP FUNCTION IF EXISTS auth_mfa_reset(uuid);
DROP FUNCTION IF EXISTS auth_mfa_advance_step(uuid, bigint);
DROP FUNCTION IF EXISTS auth_mfa_get(uuid);
DROP FUNCTION IF EXISTS auth_mfa_activate(uuid);
DROP FUNCTION IF EXISTS auth_mfa_enroll(uuid, uuid, bytea, bytea, int);
DROP FUNCTION IF EXISTS auth_get_credential(uuid);
DROP FUNCTION IF EXISTS auth_set_credential(uuid, uuid, text, text, int);
DROP FUNCTION IF EXISTS auth_revoke_user_sessions(uuid, bytea);
DROP FUNCTION IF EXISTS auth_revoke_session(bytea);
DROP FUNCTION IF EXISTS auth_rotate_session(bytea, bytea, text, boolean, int, int, text, text);
DROP FUNCTION IF EXISTS auth_validate_session(bytea, int);
DROP FUNCTION IF EXISTS auth_create_session(bytea, uuid, uuid, text, boolean, int, int, text, text);
DROP FUNCTION IF EXISTS auth_lookup_login(text);
DROP TABLE IF EXISTS login_attempts;
DROP TABLE IF EXISTS user_invites;
DROP TABLE IF EXISTS mfa_recovery_codes;
DROP TABLE IF EXISTS mfa_totp;
DROP TABLE IF EXISTS auth_sessions;
DROP TABLE IF EXISTS auth_credentials;
DROP INDEX IF EXISTS uq_users_global_email;
ALTER TABLE users DROP COLUMN IF EXISTS mfa_enrolled;