495 lines
24 KiB
PL/PgSQL
495 lines
24 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);
|
|
|
|
-- Attaching the index is guarded, not unconditional, so the file can be
|
|
-- applied more than once. An index that is already attached to a constraint
|
|
-- cannot be attached again, and because this migration runs in one transaction
|
|
-- the resulting error would roll back every other change in the file, leaving a
|
|
-- half-applied schema rather than an obviously untouched one.
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint c
|
|
JOIN pg_class t ON t.oid = c.conrelid
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
WHERE n.nspname = 'public'
|
|
AND t.relname = 'users'
|
|
AND c.conname = 'users_username_key'
|
|
) THEN
|
|
ALTER TABLE public.users
|
|
ADD CONSTRAINT users_username_key
|
|
UNIQUE USING INDEX users_username_key;
|
|
RAISE NOTICE 'attached users_username_key to users';
|
|
ELSE
|
|
RAISE NOTICE 'users_username_key already present; leaving it alone';
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 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);
|
|
|
|
-- Attaching the index is guarded, not unconditional. When the control plane
|
|
-- already created organizations it also already created this constraint, and
|
|
-- ADD CONSTRAINT against an index that is already attached fails with "index is
|
|
-- already associated with a constraint". Found by running this migration against
|
|
-- the real schema: unguarded, that error aborts the whole transaction, so the
|
|
-- rest of the migration would silently not apply.
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint c
|
|
JOIN pg_class t ON t.oid = c.conrelid
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
WHERE n.nspname = 'public'
|
|
AND t.relname = 'organizations'
|
|
AND c.conname = 'organizations_slug_key'
|
|
) THEN
|
|
ALTER TABLE public.organizations
|
|
ADD CONSTRAINT organizations_slug_key
|
|
UNIQUE USING INDEX organizations_slug_key;
|
|
RAISE NOTICE 'attached organizations_slug_key to organizations';
|
|
ELSE
|
|
RAISE NOTICE 'organizations_slug_key already present; leaving it alone';
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
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;
|