You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
243 lines
12 KiB
243 lines
12 KiB
-- Roll a PostgreSQL database back to the schema the Vaultwarden version *before* the Custom-role
|
|
-- change expects, so that older binary starts again. Read README.md in this directory first --
|
|
-- it lists exactly what is lost and how to run this safely.
|
|
--
|
|
-- PostgreSQL DDL is transactional, so this whole script either applies or it does not.
|
|
--
|
|
-- Everything runs inside one DO block against schema-qualified names. An unqualified relation is
|
|
-- resolved per statement through `search_path`, i.e. to the first schema that happens to contain a
|
|
-- matching name -- so a session with `search_path = decoy, real` could have the checks and the table
|
|
-- rewrite land in `decoy` while the ledger delete hits `real`, leaving the real database with a new
|
|
-- schema and a ledger claiming the old one. Resolving each relation once, requiring all of them to
|
|
-- live in the *same* namespace, and then addressing that namespace explicitly removes the ambiguity.
|
|
|
|
BEGIN;
|
|
|
|
DO $$
|
|
DECLARE
|
|
memberships regclass := to_regclass('users_organizations');
|
|
ledger regclass := to_regclass('__diesel_schema_migrations');
|
|
allowlist regclass := to_regclass('__vw_rollback_manager_allowlist');
|
|
history regclass := to_regclass('__vw_custom_role_history_verified');
|
|
ns oid;
|
|
ns_name text;
|
|
access_all_present int;
|
|
permission_columns int;
|
|
allowlist_columns int;
|
|
ledger_rows int;
|
|
future_rows int;
|
|
BEGIN
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- Bind the target. Read-only: this inspects the catalog and the migration ledger and changes
|
|
-- nothing, so a database this script does not fit keeps its exact state. The transaction would
|
|
-- roll back a mismatch anyway; this turns a raw "column does not exist" into a message that says
|
|
-- what to do, and it keeps all three backends' scripts symmetrical.
|
|
-- ---------------------------------------------------------------------------------------------
|
|
IF memberships IS NULL THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: no users_organizations table is '
|
|
'reachable through the current search_path. Connect to the database and '
|
|
'schema Vaultwarden uses.';
|
|
END IF;
|
|
|
|
IF ledger IS NULL THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: no __diesel_schema_migrations table '
|
|
'is reachable through the current search_path.';
|
|
END IF;
|
|
|
|
IF history IS NULL THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_custom_role_history_verified '
|
|
'does not exist, so this database was migrated by an earlier revision of the '
|
|
'Custom-role change, whose migrations had different effects. Start '
|
|
'Vaultwarden once and follow the recovery it prints before rolling back.';
|
|
END IF;
|
|
|
|
IF allowlist IS NULL THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist does '
|
|
'not exist. Which memberships come back as legacy Manager has to be decided '
|
|
'for this rollback -- an empty list is a valid answer and maps every Custom '
|
|
'member to plain User. See README.md, section "Choosing which members come '
|
|
'back as Manager".';
|
|
END IF;
|
|
|
|
SELECT relnamespace INTO ns FROM pg_class WHERE oid = memberships;
|
|
|
|
IF (SELECT relnamespace FROM pg_class WHERE oid = ledger) <> ns
|
|
OR (SELECT relnamespace FROM pg_class WHERE oid = allowlist) <> ns
|
|
OR (SELECT relnamespace FROM pg_class WHERE oid = history) <> ns THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: the tables this script needs resolve '
|
|
'to different schemas through the current search_path -- '
|
|
'users_organizations in "%", __diesel_schema_migrations in "%", '
|
|
'__vw_rollback_manager_allowlist in "%", '
|
|
'__vw_custom_role_history_verified in "%". Set search_path to exactly the '
|
|
'schema Vaultwarden uses and run this again.',
|
|
(SELECT nspname FROM pg_namespace WHERE oid = ns),
|
|
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.oid = ledger),
|
|
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.oid = allowlist),
|
|
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.oid = history);
|
|
END IF;
|
|
|
|
SELECT nspname INTO ns_name FROM pg_namespace WHERE oid = ns;
|
|
|
|
SELECT count(*) INTO access_all_present
|
|
FROM pg_attribute
|
|
WHERE attrelid = memberships
|
|
AND attnum > 0
|
|
AND NOT attisdropped
|
|
AND attname = 'access_all';
|
|
|
|
SELECT count(*) INTO permission_columns
|
|
FROM pg_attribute
|
|
WHERE attrelid = memberships
|
|
AND attnum > 0
|
|
AND NOT attisdropped
|
|
AND attname IN (
|
|
'manage_users', 'manage_groups', 'manage_policies',
|
|
'create_new_collections', 'edit_any_collection', 'delete_any_collection',
|
|
'access_event_logs', 'access_import_export', 'access_reports'
|
|
);
|
|
|
|
-- The allowlist is read by the role mapping below, so a hand-written table of the right name but
|
|
-- the wrong shape has to be caught here rather than mid-rewrite.
|
|
SELECT count(*) INTO allowlist_columns
|
|
FROM pg_attribute
|
|
WHERE attrelid = allowlist
|
|
AND attnum > 0
|
|
AND NOT attisdropped
|
|
AND attname = 'users_organizations_uuid';
|
|
|
|
EXECUTE format(
|
|
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version IN ('
|
|
'''20260630120000'', ''20260715120000'', ''20260716120000'', ''20260723120000'','
|
|
'''20260724120000'', ''20260724130000'', ''20260724140000'', ''20260809120000'','
|
|
'''20260810120000'')',
|
|
ns_name
|
|
) INTO ledger_rows;
|
|
|
|
EXECUTE format(
|
|
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260810120000''',
|
|
ns_name
|
|
) INTO future_rows;
|
|
|
|
IF access_all_present <> 0 THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: users_organizations.access_all still '
|
|
'exists. This database was either never upgraded past the Custom-role '
|
|
'migrations, or this script already ran.';
|
|
END IF;
|
|
|
|
IF permission_columns <> 9 THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected all nine Custom-role '
|
|
'permission columns on users_organizations, found %. The upgrade is '
|
|
'incomplete, so restore the backup taken before it and start over.',
|
|
permission_columns;
|
|
END IF;
|
|
|
|
IF allowlist_columns <> 1 THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist has '
|
|
'no users_organizations_uuid column. Create it as documented in README.md.';
|
|
END IF;
|
|
|
|
IF ledger_rows <> 9 THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected all nine Custom-role '
|
|
'migrations in __diesel_schema_migrations, found %. Schema and ledger '
|
|
'disagree, so restore the backup taken before the upgrade and start over.',
|
|
ledger_rows;
|
|
END IF;
|
|
|
|
IF future_rows <> 0 THEN
|
|
RAISE EXCEPTION 'Rollback refused, nothing was changed: % migration(s) newer than the '
|
|
'Custom-role change are recorded. This script does not know what they '
|
|
'changed, and removing only the nine Custom-role versions would leave the '
|
|
'ledger inconsistent. Use the rollback script shipped with that newer '
|
|
'version.',
|
|
future_rows;
|
|
END IF;
|
|
|
|
-- ---------------------------------------------------------------------------------------------
|
|
-- From here on the database is known to be in the state this script converts *from*, and every
|
|
-- statement addresses the one namespace bound above.
|
|
-- ---------------------------------------------------------------------------------------------
|
|
EXECUTE format(
|
|
'ALTER TABLE %I.users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE',
|
|
ns_name
|
|
);
|
|
|
|
-- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a
|
|
-- subset of what a Custom member holds -- it manages, and deletes, every collection reachable
|
|
-- through `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and
|
|
-- reads member and collection ACL details through `ManagerHeadersLoose`, none of which needs a
|
|
-- permission flag in the old schema -- so handing it out on anything less than a current,
|
|
-- deliberate decision would *grant* authority during a downgrade.
|
|
-- `__vw_custom_role_legacy_manager` is not that decision: it records who was a Manager before the
|
|
-- first upgrade and is never updated afterwards, so a member whose powers an owner has since
|
|
-- reduced would get all of them back.
|
|
--
|
|
-- Everything else becomes a plain User and keeps its per-collection assignments.
|
|
--
|
|
-- `access_all` follows the same mapping the down migrations use: everyone who reached every
|
|
-- collection keeps that reach, and a Custom member has to hold all three collection permissions
|
|
-- -- Edit-only must not silently turn into the legacy "manage all collections" authority, which
|
|
-- in that older schema also carried collection deletion. A member mapped to plain User never
|
|
-- keeps it: `User + access_all` is the one legacy state the upgrade refuses.
|
|
EXECUTE format(
|
|
'UPDATE %I.users_organizations SET access_all = TRUE WHERE atype IN (0, 1)', ns_name
|
|
);
|
|
EXECUTE format(
|
|
'UPDATE %I.users_organizations SET access_all = TRUE '
|
|
'WHERE atype = 4 '
|
|
' AND uuid IN (SELECT users_organizations_uuid FROM %I.__vw_rollback_manager_allowlist) '
|
|
' AND create_new_collections = TRUE '
|
|
' AND edit_any_collection = TRUE '
|
|
' AND delete_any_collection = TRUE',
|
|
ns_name, ns_name
|
|
);
|
|
|
|
-- The old server cannot load type 4.
|
|
EXECUTE format(
|
|
'UPDATE %I.users_organizations SET atype = 3 '
|
|
'WHERE atype = 4 '
|
|
' AND uuid IN (SELECT users_organizations_uuid FROM %I.__vw_rollback_manager_allowlist)',
|
|
ns_name, ns_name
|
|
);
|
|
EXECUTE format(
|
|
'UPDATE %I.users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4', ns_name
|
|
);
|
|
|
|
EXECUTE format(
|
|
'ALTER TABLE %I.users_organizations '
|
|
' DROP COLUMN manage_users, '
|
|
' DROP COLUMN manage_groups, '
|
|
' DROP COLUMN manage_policies, '
|
|
' DROP COLUMN create_new_collections, '
|
|
' DROP COLUMN edit_any_collection, '
|
|
' DROP COLUMN delete_any_collection, '
|
|
' DROP COLUMN access_event_logs, '
|
|
' DROP COLUMN access_import_export, '
|
|
' DROP COLUMN access_reports',
|
|
ns_name
|
|
);
|
|
|
|
-- Bookkeeping tables this feature may have left behind. A later re-upgrade rebuilds the
|
|
-- provenance record and the history marker from the very `atype = 3` rows this script just
|
|
-- restored, so the round trip converges.
|
|
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_same_run_0716', ns_name);
|
|
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_allow_custom_role_downgrade', ns_name);
|
|
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_ack_permanent_collection_authority', ns_name);
|
|
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_rollback_manager_allowlist', ns_name);
|
|
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_legacy_manager', ns_name);
|
|
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_history_verified', ns_name);
|
|
|
|
-- Finally forget the nine migrations, so the older binary does not see a ledger from the future
|
|
-- and a later upgrade applies them again from a clean state.
|
|
EXECUTE format(
|
|
'DELETE FROM %I.__diesel_schema_migrations WHERE version IN ('
|
|
'''20260630120000'', ''20260715120000'', ''20260716120000'', ''20260723120000'','
|
|
'''20260724120000'', ''20260724130000'', ''20260724140000'', ''20260809120000'','
|
|
'''20260810120000'')',
|
|
ns_name
|
|
);
|
|
END $$;
|
|
|
|
COMMIT;
|
|
|