The administrator interface needs organizations to manage, and the slice had none: users.organization_id was a bare UUID pointing at nothing, so there was no tenant to create, list, or validate an account against. Tenancy: - Organization record with name, slug, and status active|suspended|archived, matching the control-plane vocabulary rather than reusing the account one. - Reads are open to any administrator; every write is platform-admin only, so the tenant boundary stays under a single authority. An organization admin is pinned to the organization on their own principal. - A derived slug is normalized from the display name, but a slug the operator typed must already be canonical, so nothing is silently rewritten behind their back. archived is terminal. - GET /api/uadmin/summary for the dashboard, recomputed per request from the same scoped queries the list endpoints use, so a summary cannot disclose a number the caller could not reach by listing. Two integrity gaps closed: 1. Account creation accepted any UUID as an organization. It now requires the organization to exist and to be active. 2. Suspending an organization revoked member sessions but did not stop new logins, because each member's own account status stays active. Both authenticate and resolve_session now refuse a member of a non-active organization, and a missing organization record blocks access rather than granting it. A test asserts the member is still active yet locked out. Migration, still unapplied: section 4 creates organizations with CREATE TABLE IF NOT EXISTS so a control-plane table is skipped, never redefined, and the rollback drops it only when this migration actually created it. The membership divergence is recorded as the top open review question rather than decided: user_roles is the control plane's membership source, this phase uses a column, and the migration adds no membership column so the choice stays open. Serving: UMBRATRANS_STATIC_ROOT mounts the browser client at / after the API routes, so the session cookie and CSRF header work without CORS. HTML responses get their own CSP allowing only same-origin script and style, which is why the frontend carries no inline script or style. Verification: 204 tests pass (21 new organization tests plus the existing suite), ruff check and format clean. Also exercised against a real uvicorn server rather than only the test client: bootstrap, forced password change, organization creation, one-time password issue, member lockout on organization suspension, and rejection of an account assigned to a suspended tenant.
155 lines
7.1 KiB
PL/PgSQL
155 lines
7.1 KiB
PL/PgSQL
-- =====================================================================
|
|
-- Rollback for: UmbraTrans phase-1 identity and tenancy migration
|
|
-- (users.username, user_credentials, organizations,
|
|
-- additive session columns)
|
|
-- =====================================================================
|
|
--
|
|
-- STATUS: PROPOSED / NOT APPLIED / NOT APPROVED
|
|
--
|
|
-- This file has never been executed against any database.
|
|
--
|
|
-- DESTRUCTIVE. Read the warnings before running anything.
|
|
--
|
|
-- * DROP TABLE public.user_credentials DESTROYS every credential row that
|
|
-- exists at rollback time. Accounts created or re-enrolled after the
|
|
-- forward migration lose their only authentication material and cannot
|
|
-- sign in until an operator re-provisions them through the operator
|
|
-- path. There is no recovery short of restoring a backup.
|
|
-- * DROP TABLE public.organizations DESTROYS every tenant row. The DROP is
|
|
-- guarded below so it only fires when the forward migration actually
|
|
-- created the table, because the control plane very likely already had
|
|
-- one. If organizations pre-existed, its rows are left alone.
|
|
-- * DROP CONSTRAINT users_username_key / the backing index removes case
|
|
-- insensitive uniqueness. The username VALUES are kept by this rollback;
|
|
-- drop the column only if the mapping must be discarded.
|
|
-- * Dropping the additive session columns discards MFA, idle deadline, and
|
|
-- last-seen history. An application build that still writes those columns
|
|
-- will fail after the rollback, so roll the application back first.
|
|
--
|
|
-- PREREQUISITES
|
|
-- [ ] Forward migration 0001 confirmed applied (see its section 5 output)
|
|
-- [ ] Fresh pg_dump -Fc backup taken and checksummed AFTER the forward
|
|
-- migration, so the post-migration state is recoverable
|
|
-- [ ] Restore rehearsed on a scratch database
|
|
-- [ ] Application rolled back to a build that does not use the new columns
|
|
-- [ ] Maintenance window agreed (the sessions DDL takes ACCESS EXCLUSIVE)
|
|
--
|
|
-- ---------------------------------------------------------------------
|
|
-- 0. Transaction guard rails (same limits as the forward migration)
|
|
-- ---------------------------------------------------------------------
|
|
|
|
BEGIN;
|
|
|
|
SET LOCAL lock_timeout = '3s';
|
|
SET LOCAL statement_timeout = '60s';
|
|
SET LOCAL idle_in_transaction_session_timeout = '30s';
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 1. Record what will be lost, before it is lost
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- SELECT count(*) AS credentials_to_be_dropped FROM public.user_credentials;
|
|
-- SELECT credential_type, count(*) FROM public.user_credentials GROUP BY 1;
|
|
-- SELECT count(*) AS live_sessions FROM public.sessions WHERE revoked_at IS NULL;
|
|
-- SELECT count(*) AS organizations FROM public.organizations;
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 2. Additive session columns
|
|
-- ---------------------------------------------------------------------
|
|
-- Drop the indexes first; they are not required to drop the columns, but
|
|
-- removing them explicitly keeps the rollback symmetric with the forward file.
|
|
|
|
DROP INDEX IF EXISTS public.sessions_idle_deadline_at_idx;
|
|
DROP INDEX IF EXISTS public.sessions_user_id_idx;
|
|
|
|
ALTER TABLE public.sessions
|
|
DROP COLUMN IF EXISTS idle_deadline_at,
|
|
DROP COLUMN IF EXISTS mfa_verified_at,
|
|
DROP COLUMN IF EXISTS last_seen_at;
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 3. user_credentials
|
|
-- ---------------------------------------------------------------------
|
|
-- The table and its constraints disappear with it. Any credential that is not
|
|
-- recoverable from a backup is gone: re-provision affected accounts through
|
|
-- umbratrans-bootstrap-admin (operator path) after the rollback is verified.
|
|
|
|
DROP TABLE IF EXISTS public.user_credentials;
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 4. organizations
|
|
-- ---------------------------------------------------------------------
|
|
-- Dropped only when this migration created the table. The forward file uses
|
|
-- CREATE TABLE IF NOT EXISTS, so in the expected case the control plane owned
|
|
-- organizations first and its rows, slug constraint, and dependent projects
|
|
-- rows must survive the rollback untouched.
|
|
--
|
|
-- Drop the second guard's condition to force the drop. Do not do that without
|
|
-- a backup: projects.organization_id and user_roles.organization_id reference
|
|
-- this table.
|
|
|
|
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_status_check'
|
|
AND pg_get_constraintdef(c.oid) LIKE '%archived%'
|
|
) THEN
|
|
-- The forward migration's own check constraint is absent, which means
|
|
-- the table was not created by this migration.
|
|
RAISE NOTICE 'organizations was not created by 0001; leaving it in place';
|
|
ELSE
|
|
DROP INDEX IF EXISTS public.organizations_status_idx;
|
|
DROP TABLE IF EXISTS public.organizations;
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 5. users.username
|
|
-- ---------------------------------------------------------------------
|
|
-- Values are preserved by default. Uncomment the second statement only when
|
|
-- the mapping must be discarded as well.
|
|
|
|
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_username_key;
|
|
DROP INDEX IF EXISTS public.users_username_key;
|
|
|
|
-- ALTER TABLE public.users DROP COLUMN IF EXISTS username;
|
|
|
|
-- citext is left installed: dropping an extension can cascade to dependent
|
|
-- objects, and an unused extension is harmless. Remove it only in a separate,
|
|
-- explicitly reviewed change.
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- 6. POST-ROLLBACK VERIFICATION
|
|
-- ---------------------------------------------------------------------
|
|
--
|
|
-- \d+ public.users
|
|
-- \d+ public.sessions
|
|
--
|
|
-- SELECT to_regclass('public.user_credentials') AS user_credentials; -- NULL
|
|
-- SELECT count(*) FROM pg_policies WHERE schemaname = 'public'
|
|
-- AND tablename = 'user_credentials'; -- 0
|
|
-- SELECT count(*) FROM public.sessions WHERE idle_deadline_at IS NOT NULL; -- 0
|
|
-- -- either organizations is gone, or it is exactly as it was before the
|
|
-- -- forward migration; compare against the pre-flight row count
|
|
-- SELECT to_regclass('public.organizations') AS organizations;
|
|
|
|
-- -- the pre-existing schema must be unchanged apart from this rollback
|
|
-- SELECT column_name, data_type FROM information_schema.columns
|
|
-- WHERE table_schema = 'public' AND table_name IN ('users', 'sessions')
|
|
-- ORDER BY table_name, ordinal_position;
|
|
--
|
|
-- -- re-run the control-plane integrity checks that were captured before the
|
|
-- -- forward migration and compare row counts for organizations, projects,
|
|
-- -- roles, permissions, user_roles, invitations, and audit_events
|
|
--
|
|
-- Roll forward again only after a new review of 0001_phase1_identity.sql.
|
|
|
|
COMMIT;
|