Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
352 lines
17 KiB
PL/PgSQL
352 lines
17 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 only; nothing else may be added to this file)
|
|
--
|
|
-- ADD COLUMN users.username (nullable, unique, citext)
|
|
-- ADD TABLE user_credentials (local_password / oidc credentials)
|
|
-- 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 organizations/projects/roles/permissions/invitations/audit_events
|
|
-- change. They are used unchanged.
|
|
-- * 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;
|
|
--
|
|
-- -- 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. 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
|
|
--
|
|
-- -- the new unique rule is enforced and case-insensitive
|
|
-- SELECT indexname, indexdef FROM pg_indexes
|
|
-- WHERE tablename = 'users' AND indexname = 'users_username_key';
|
|
--
|
|
-- -- 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;
|