Files
UmbraTrans/umbratrans_web/migrations/0001_phase1_identity.sql
umbra audio 31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned through user_roles with a
scope, and app.validate_user_role_scope() enforces that the role's scope
matches the assignment. Resolving one role per user is therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

Every operation runs in one transaction on one pooled connection and publishes
the caller with SET LOCAL app.current_user_id, so a returned connection cannot
carry one request's identity into the next. That is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

Production fails closed at two independent gates: the settings layer refuses
without a DSN, and the repository boundary refuses to build a PostgreSQL store
without one. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00

450 lines
22 KiB
PL/PgSQL

-- =====================================================================
-- UmbraTrans phase-1 identity migration (users.username, user_credentials,
-- additive session columns)
-- =====================================================================
--
-- STATUS: PROPOSED / NOT APPLIED / NOT APPROVED
--
-- This file has never been executed against any database. It was written for
-- review only. Do not run it until every gate in "PRE-FLIGHT GATES" below is
-- signed off by a human operator, and until the review questions in
-- migrations/README.md are answered against the *actual* production schema.
--
-- SCOPE (phase-1 identity and tenancy only; nothing else may be added here)
--
-- ADD COLUMN users.username (nullable, unique, citext)
-- ADD TABLE user_credentials (local_password / oidc credentials)
-- ADD TABLE organizations (tenants, additive; skipped if present)
-- ADD COLUMN sessions.mfa_verified_at (nullable)
-- ADD COLUMN sessions.idle_deadline_at (nullable)
-- ADD COLUMN sessions.last_seen_at (nullable, backfilled, then NOT NULL)
--
-- DELIBERATELY NOT IN THIS MIGRATION
--
-- * No project_membership table. user_roles stays the membership source
-- of truth.
-- * No users.organization_id column. The control plane derives membership
-- from user_roles; the application slice uses a column for phase 1 and
-- the divergence is an open review question, not a settled decision.
-- * No projects/roles/permissions/invitations/audit_events change. They are
-- used unchanged.
-- * No organization row is inserted. Section 4 creates the table if it is
-- absent and verifies it if it is present; it never seeds a tenant.
-- * No password, hash, token, or connection string may appear in this file.
-- * No backfill of existing accounts. Existing rows keep a NULL username
-- until an operator supplies an explicit mapping (see BACKFILL GATE).
-- * No MFA enforcement. mfa_verified_at is added for forward compatibility
-- and stays NULL.
-- * No OIDC subject column. The provider-subject mapping belongs to a later,
-- separately reviewed migration.
--
-- SAFETY RULES FOR THE REVIEWER AND THE OPERATOR
--
-- 1. Read the whole file, including the comments, before running anything.
-- 2. Take and restore-test a custom-format logical backup first
-- (pg_dump -Fc, restore into a scratch database, verify row counts).
-- 3. Run the PRE-FLIGHT VERIFICATION block and save its output. If any
-- assumption below is false, STOP and produce a new migration.
-- 4. The statements run in one transaction with a short lock_timeout and
-- statement_timeout so the live application cannot be blocked behind an
-- open transaction. If the timeout fires, the transaction rolls back.
-- 5. users.username is added as NULLABLE on purpose: a NOT NULL column with
-- no backfill would fail or force an invented identifier.
-- 6. user_credentials is created empty. No credential is inserted here.
-- Credentials are created by the application through the reviewed
-- service path only.
-- 7. The unique constraint on users.username is added as a UNIQUE INDEX
-- (CREATE UNIQUE INDEX, then ATTACH as a constraint) so the build does
-- not hold an ACCESS EXCLUSIVE lock for the duration of the scan.
-- 8. The sessions indexes are plain CREATE INDEX. For a large sessions
-- table, run them separately with CREATE INDEX CONCURRENTLY *outside*
-- this transaction, one statement per session, after verifying the table
-- size. See the note above section 4.
-- 9. Do not run this file with a superuser. Use the dedicated migration
-- role, and record the role used in the change ticket.
-- 10. RLS and GRANT statements are deliberately left as reviewed COMMENTs in
-- section 6: the production policy helper functions and role names are
-- unverified, and inventing them would be a BLOCKER, not a shortcut.
--
-- BACKUP/RESTORE GATE
-- [ ] pg_dump -Fc of umbracontrol completed and checksummed
-- [ ] restore into an isolated scratch database succeeded
-- [ ] restored row counts match for users, sessions, organizations, projects
-- [ ] rollback file reviewed and rehearsed against the scratch database
--
-- RLS TEST GATE (must pass before approval)
-- [ ] two organizations, two projects, two users each
-- [ ] a user in org A cannot read or write org B rows
-- [ ] a session row is readable only by its owner
-- [ ] the application role cannot escalate by setting a GUC
-- [ ] FORCE ROW LEVEL SECURITY is proven, not merely enabled
--
-- ---------------------------------------------------------------------
-- PRE-FLIGHT VERIFICATION (run read-only, save the output with the ticket)
-- ---------------------------------------------------------------------
--
-- SELECT current_database(), current_user, version();
--
-- -- users must exist with a uuid primary key named id
-- SELECT column_name, data_type, is_nullable
-- FROM information_schema.columns
-- WHERE table_schema = 'public' AND table_name = 'users'
-- ORDER BY ordinal_position;
--
-- -- sessions must exist; record its current columns
-- SELECT column_name, data_type, is_nullable
-- FROM information_schema.columns
-- WHERE table_schema = 'public' AND table_name = 'sessions'
-- ORDER BY ordinal_position;
--
-- -- citext availability for the unique username
-- SELECT name, default_version, installed_version FROM pg_available_extensions
-- WHERE name = 'citext';
--
-- -- current RLS posture of the tables this file touches
-- SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
-- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
-- WHERE n.nspname = 'public' AND c.relname IN ('users','sessions');
--
-- -- existing policies, so the additive columns inherit known-good rules
-- SELECT tablename, policyname, cmd, roles, qual, with_check
-- FROM pg_policies WHERE schemaname = 'public' AND tablename IN ('users','sessions');
--
-- -- confirm nothing named user_credentials already exists
-- SELECT to_regclass('public.user_credentials') AS user_credentials;
--
-- -- does public.organizations already exist? Section 4 is a no-op when it
-- -- does, so record its current shape and row count either way
-- SELECT to_regclass('public.organizations') AS organizations;
-- SELECT column_name, data_type, is_nullable
-- FROM information_schema.columns
-- WHERE table_schema = 'public' AND table_name = 'organizations'
-- ORDER BY ordinal_position;
-- SELECT count(*) FROM public.organizations;
--
-- -- row counts used as the post-apply baseline
-- SELECT (SELECT count(*) FROM users) AS users, (SELECT count(*) FROM sessions) AS sessions;
--
-- ---------------------------------------------------------------------
-- 0. Transaction guard rails
-- ---------------------------------------------------------------------
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '60s';
SET LOCAL idle_in_transaction_session_timeout = '30s';
-- ---------------------------------------------------------------------
-- 1. users.username
-- ---------------------------------------------------------------------
-- Assumptions: users.id is uuid; no existing column named username.
-- Existing rows are left with NULL: the new login identifier is introduced
-- only through an explicit, operator-approved mapping.
--
-- citext gives case-insensitive uniqueness, matching the application rule
-- that "Umbraadmin" and "umbraadmin" are the same account. If the extension
-- is unavailable, stop and use a lower(...) unique index instead; do not
-- substitute a plain text column with case-sensitive uniqueness.
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS username citext NULL;
COMMENT ON COLUMN public.users.username IS
'Case-insensitive canonical login identifier. NULL for accounts that have not been mapped yet.';
-- Build the unique index first (it takes a SHARE lock, not ACCESS EXCLUSIVE),
-- then attach it as a constraint so the rule is visible to the planner and to
-- future migrations. NULLs do not conflict, which is what allows the staged
-- backfill to run before the column becomes mandatory.
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key
ON public.users (username);
ALTER TABLE public.users
ADD CONSTRAINT users_username_key
UNIQUE USING INDEX users_username_key;
-- ---------------------------------------------------------------------
-- 2. user_credentials
-- ---------------------------------------------------------------------
-- One credential row per user in phase 1. The unique user_id is a deliberate
-- phase-1 constraint (one authentication mechanism at a time); relaxing it is
-- a later, separately reviewed change.
--
-- password_hash holds an Argon2id PHC string only, e.g. the argon2id variant
-- with m=65536, t=3, p=4. No plaintext password, temporary password, or
-- password-recovery value is ever stored in this table or anywhere else.
CREATE TABLE IF NOT EXISTS public.user_credentials (
id uuid PRIMARY KEY,
user_id uuid NOT NULL UNIQUE REFERENCES public.users (id) ON DELETE CASCADE,
credential_type text NOT NULL,
password_hash text NULL,
must_change_password boolean NOT NULL DEFAULT false,
failed_login_count integer NOT NULL DEFAULT 0,
locked_until timestamptz NULL,
last_login_at timestamptz NULL,
password_changed_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT user_credentials_type_check
CHECK (credential_type IN ('local_password', 'oidc')),
CONSTRAINT user_credentials_local_requires_hash
CHECK (credential_type <> 'local_password' OR password_hash IS NOT NULL),
CONSTRAINT user_credentials_oidc_forbids_hash
CHECK (credential_type <> 'oidc' OR password_hash IS NULL),
CONSTRAINT user_credentials_failed_login_count_check
CHECK (failed_login_count >= 0),
CONSTRAINT user_credentials_password_hash_format
CHECK (password_hash IS NULL OR password_hash ~ '^\$argon2(id|i|d)\$')
);
COMMENT ON TABLE public.user_credentials IS
'Authentication credentials, kept separate from the user profile. Rows are never returned by the API.';
COMMENT ON COLUMN public.user_credentials.password_hash IS
'Argon2id PHC hash. Never returned by an endpoint, never logged, never selected into a report.';
COMMENT ON COLUMN public.user_credentials.must_change_password IS
'True for temporary-password enrollment. Cleared only after a successful first-login change.';
-- Login path and lockout scans. Both are narrow and index-only.
CREATE INDEX IF NOT EXISTS user_credentials_type_idx
ON public.user_credentials (credential_type);
CREATE INDEX IF NOT EXISTS user_credentials_locked_until_idx
ON public.user_credentials (locked_until)
WHERE locked_until IS NOT NULL;
-- updated_at is maintained by the application on every write. If the
-- production schema already has a generic set_updated_at() trigger helper,
-- attach it here instead; do not assume one exists.
-- CREATE TRIGGER user_credentials_set_updated_at
-- BEFORE UPDATE ON public.user_credentials
-- FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- ---------------------------------------------------------------------
-- 3. sessions: additive columns
-- ---------------------------------------------------------------------
-- The session bearer token is unchanged: it stays random, and only its hash
-- is stored. The three columns below are what the phase-1 identity slice
-- needs; no existing column is dropped, renamed, or retyped, so an older
-- application build keeps working against this schema.
ALTER TABLE public.sessions
ADD COLUMN IF NOT EXISTS mfa_verified_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS idle_deadline_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS last_seen_at timestamptz NULL;
COMMENT ON COLUMN public.sessions.mfa_verified_at IS
'When the session completed MFA. NULL in phase 1; no MFA is enforced yet.';
COMMENT ON COLUMN public.sessions.idle_deadline_at IS
'Absolute idle deadline for the session. Sessions past it are rejected and revoked.';
COMMENT ON COLUMN public.sessions.last_seen_at IS
'Last time the session was used, for idle accounting and operator review.';
-- Backfill last_seen_at from an existing column only if one can be verified
-- in the pre-flight output. Uncomment exactly one source after confirming the
-- column exists; otherwise leave NULL and let the next write populate it.
-- UPDATE public.sessions SET last_seen_at = now() WHERE last_seen_at IS NULL;
-- Set idle deadlines for live sessions so the new column is never NULL for
-- an active row. The duration must match the reviewed application setting
-- (default 30 minutes idle, 12 hours absolute).
UPDATE public.sessions
SET idle_deadline_at = now() + interval '30 minutes'
WHERE idle_deadline_at IS NULL
AND revoked_at IS NULL;
ALTER TABLE public.sessions
ALTER COLUMN last_seen_at SET DEFAULT now();
-- Make last_seen_at NOT NULL only after the backfill is verified; a partial
-- backfill would fail here, which is the intended safety check.
-- ALTER TABLE public.sessions ALTER COLUMN last_seen_at SET NOT NULL;
CREATE INDEX IF NOT EXISTS sessions_user_id_idx
ON public.sessions (user_id);
CREATE INDEX IF NOT EXISTS sessions_idle_deadline_at_idx
ON public.sessions (idle_deadline_at)
WHERE revoked_at IS NULL;
-- ---------------------------------------------------------------------
-- 4. organizations
-- ---------------------------------------------------------------------
-- Assumptions: the control plane already has a public.organizations table
-- (see 001_control_plane.sql) with id, name, slug, status, created_at and
-- updated_at. If it already exists this section is a no-op: CREATE TABLE IF
-- NOT EXISTS skips it and the statements below verify the shape instead of
-- changing it. That is the expected case, because this file is additive by
-- design and must not redefine a table the audio system already reads.
--
-- The application needs organizations to exist as first-class rows for the
-- administrator interface to manage tenants and to validate an account's
-- organization assignment. Without this section that assignment pointed at
-- nothing at all.
--
-- Deliberately NOT changed here:
-- * users.organization_id is NOT added by this file. The control plane
-- derives organization membership from user_roles (scope='organization'),
-- not from a column on users. The application slice models membership as
-- a column for phase 1; reconciling the two is recorded as an open review
-- question in migrations/README.md rather than silently decided here.
-- * No organization row is inserted. Seeding a tenant is an operator action.
CREATE TABLE IF NOT EXISTS public.organizations (
id uuid PRIMARY KEY,
name text NOT NULL,
slug citext NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT organizations_status_check
CHECK (status IN ('active', 'suspended', 'archived')),
CONSTRAINT organizations_name_length_check
CHECK (char_length(name) BETWEEN 2 AND 120)
);
-- A duplicate-free tenant handle. Built then attached, so the scan does not
-- hold an ACCESS EXCLUSIVE lock for its duration.
CREATE UNIQUE INDEX IF NOT EXISTS organizations_slug_key
ON public.organizations (slug);
ALTER TABLE public.organizations
ADD CONSTRAINT organizations_slug_key
UNIQUE USING INDEX organizations_slug_key;
COMMENT ON TABLE public.organizations IS
'Tenants that own projects and accounts. Phase 1 inserts no rows; the control plane may already contain some.';
-- The administrator dashboard filters by status on every load.
CREATE INDEX IF NOT EXISTS organizations_status_idx
ON public.organizations (status);
-- ---------------------------------------------------------------------
-- 4b. audit_events: target lookup
-- ---------------------------------------------------------------------
-- The application records a target as the polymorphic (target_type,
-- target_id) pair the control plane already uses, and the administrator audit
-- view filters on it. audit_events_org_created_idx covers the organization and
-- created_at ordering, but nothing covers the target pair, so that filter would
-- be a sequential scan. Additive index only; no column or constraint changes.
CREATE INDEX IF NOT EXISTS audit_events_target_idx
ON public.audit_events (target_type, target_id, created_at DESC);
-- ---------------------------------------------------------------------
-- 5. RLS AND GRANTS (REVIEWED, NOT EXECUTED)
-- ---------------------------------------------------------------------
-- The statements below are intentionally provided as comments. The
-- production policy helper functions, the application role names, and the
-- existing policy conventions are UNVERIFIED in this workspace. Writing
-- executable GRANT/RLS against guessed identifiers is a BLOCKER.
--
-- Required before approval:
-- * Confirm the name of the "current user" helper (for example
-- app.current_user_id() or a request-scoped GUC) and prove it cannot be
-- set by a client-controlled value.
-- * Confirm the migration role and the application role names.
-- * Confirm the existing users/sessions policies so the additive columns
-- inherit the same guarantees.
--
-- Policies to add on user_credentials once the helper is verified:
--
-- ALTER TABLE public.user_credentials ENABLE ROW LEVEL SECURITY;
-- ALTER TABLE public.user_credentials FORCE ROW LEVEL SECURITY;
--
-- -- a user reads and updates only their own credential
-- CREATE POLICY user_credentials_self_read ON public.user_credentials
-- FOR SELECT USING (user_id = app.current_user_id());
-- CREATE POLICY user_credentials_self_update ON public.user_credentials
-- FOR UPDATE USING (user_id = app.current_user_id());
--
-- -- only the administrator service path inserts credentials, and it does so
-- -- as a privileged role that bypasses the per-user policies
--
-- Grants to review (role names must be confirmed, not guessed):
--
-- REVOKE ALL ON public.user_credentials FROM PUBLIC;
-- GRANT SELECT, INSERT, UPDATE ON public.user_credentials TO <app_role>;
-- -- No DELETE for the application role: credential removal is an
-- -- administrative, audited operation.
-- -- No column-level GRANT alternative is meaningful here: the application
-- -- must read password_hash to verify a local password. The control is that
-- -- no endpoint selects it, which the API tests assert.
--
-- On sessions, the new columns need no new policy: they inherit the existing
-- per-session row policies. Adding a policy that keys on a new column before
-- the existing ones are verified risks weakening them.
--
-- ---------------------------------------------------------------------
-- 5. POST-APPLY VERIFICATION (run immediately after COMMIT)
-- ---------------------------------------------------------------------
--
-- \d+ public.users
-- \d+ public.user_credentials
-- \d+ public.sessions
-- \d+ public.organizations
--
-- -- the new unique rule is enforced and case-insensitive
-- SELECT indexname, indexdef FROM pg_indexes
-- WHERE tablename = 'users' AND indexname = 'users_username_key';
--
-- -- the organizations slug rule survived, whether the table pre-existed or
-- -- was created by section 4
-- SELECT indexname, indexdef FROM pg_indexes
-- WHERE tablename = 'organizations' AND indexname = 'organizations_slug_key';
--
-- -- the status vocabulary the application expects is the one the check
-- -- constraint enforces
-- SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
-- WHERE conrelid = 'public.organizations'::regclass AND contype = 'c'
-- ORDER BY conname;
--
-- -- this migration inserted no tenant
-- SELECT count(*) FROM public.organizations;
--
-- -- the audit target index the administrator view depends on exists
-- SELECT indexname, indexdef FROM pg_indexes
-- WHERE tablename = 'audit_events' AND indexname = 'audit_events_target_idx';
--
-- -- no duplicate, non-null usernames can exist
-- SELECT lower(username::text) AS key, count(*)
-- FROM public.users WHERE username IS NOT NULL
-- GROUP BY 1 HAVING count(*) > 1;
--
-- -- the table is empty: this migration must not invent credentials
-- SELECT count(*) FROM public.user_credentials;
--
-- -- no live session is left without a deadline
-- SELECT count(*) FROM public.sessions
-- WHERE revoked_at IS NULL AND idle_deadline_at IS NULL;
--
-- -- RLS state matches the reviewed expectation
-- SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
-- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
-- WHERE n.nspname = 'public' AND c.relname = 'user_credentials';
--
-- -- the application role can verify a credential but cannot delete one
-- -- (run as the application role, not the migration role)
-- SELECT has_table_privilege('<app_role>', 'public.user_credentials', 'SELECT');
-- SELECT has_table_privilege('<app_role>', 'public.user_credentials', 'DELETE');
--
-- ---------------------------------------------------------------------
-- 6. ROLLBACK
-- ---------------------------------------------------------------------
-- Rehearse first, on the scratch database. Rolling back DESTROYS credential
-- rows: any account created after the migration would lose its only
-- authentication material and could not sign in until it is re-enrolled.
--
-- Take a fresh backup immediately before rolling back, then use
-- 0001_phase1_identity_rollback.sql in the same session settings
-- (lock_timeout, statement_timeout). The rollback is not online-safe for a
-- busy sessions table: prefer a maintenance window.
--
-- If the migration failed mid-transaction, nothing was applied: the
-- transaction rolled back and this file can be corrected and re-reviewed.
COMMIT;