diff --git a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql index 4eb19e97..29b831e3 100644 --- a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql @@ -1,7 +1,23 @@ -- Nine independent Custom-role permissions cannot be represented losslessly by the legacy --- role/access_all schema. Always stop before any older down migration removes permission data. +-- role/access_all schema, so a revert is blocked here -- before any older down migration removes +-- permission data. +-- +-- It is an explicit, acknowledged decision though, not a dead end. Create the marker table below +-- while every Vaultwarden instance is stopped and this guard lets the revert through: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- Operators who only need the old server version to start again do not need Diesel at all -- +-- tools/custom_role_rollback/ has a self-contained script per backend. CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( blocked INTEGER NOT NULL PRIMARY KEY ); INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); +-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. +INSERT INTO __vw_custom_role_downgrade_guard (blocked) +SELECT 1 FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade' +); +DROP TABLE __vw_custom_role_downgrade_guard; diff --git a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql index 4eb19e97..24760fc4 100644 --- a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql @@ -1,7 +1,20 @@ -- Nine independent Custom-role permissions cannot be represented losslessly by the legacy --- role/access_all schema. Always stop before any older down migration removes permission data. +-- role/access_all schema, so a revert is blocked here -- before any older down migration removes +-- permission data. +-- +-- It is an explicit, acknowledged decision though, not a dead end. Create the marker table below +-- while every Vaultwarden instance is stopped and this guard lets the revert through: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- Operators who only need the old server version to start again do not need Diesel at all -- +-- tools/custom_role_rollback/ has a self-contained script per backend. CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( blocked INTEGER NOT NULL PRIMARY KEY ); INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); +-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. +INSERT INTO __vw_custom_role_downgrade_guard (blocked) +SELECT 1 +WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; +DROP TABLE __vw_custom_role_downgrade_guard; diff --git a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql index 4eb19e97..8d149ae3 100644 --- a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql @@ -1,7 +1,23 @@ -- Nine independent Custom-role permissions cannot be represented losslessly by the legacy --- role/access_all schema. Always stop before any older down migration removes permission data. +-- role/access_all schema, so a revert is blocked here -- before any older down migration removes +-- permission data. +-- +-- It is an explicit, acknowledged decision though, not a dead end. Create the marker table below +-- while every Vaultwarden instance is stopped and this guard lets the revert through: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- Operators who only need the old server version to start again do not need Diesel at all -- +-- tools/custom_role_rollback/ has a self-contained script per backend. CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( blocked INTEGER NOT NULL PRIMARY KEY ); INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); +-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. +INSERT INTO __vw_custom_role_downgrade_guard (blocked) +SELECT 1 +WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade' +); +DROP TABLE __vw_custom_role_downgrade_guard; diff --git a/tools/custom_role_rollback/README.md b/tools/custom_role_rollback/README.md new file mode 100644 index 00000000..e475ff5f --- /dev/null +++ b/tools/custom_role_rollback/README.md @@ -0,0 +1,77 @@ +# Rolling back the Custom-role change + +The Custom-role change removes the membership `access_all` column and adds nine permission columns. +A Vaultwarden version from before that change cannot start against the new schema, because its +`schema.rs` still expects `access_all` to exist. + +Vaultwarden only ever applies *pending* migrations — it never reverts one on its own — so putting +the old image back is not enough. Run the script for your backend once and the old version starts +again. + +## What is lost + +The old schema has nowhere to store the nine permissions, so they are dropped: + +| Before the rollback | After | +|---|---| +| Owner / Admin | Owner / Admin with `access_all = TRUE` | +| Custom with **all three** collection permissions | Manager with `access_all = TRUE` | +| Custom with only some collection permissions | Manager with `access_all = FALSE` | +| Custom with `manageUsers` / `manageGroups` / `managePolicies` | Manager — those permissions are gone | +| Custom with `accessEventLogs` / `accessImportExport` / `accessReports` | Manager — those permissions are gone | +| plain User | plain User with `access_all = FALSE` | + +Per-collection assignments (`users_collections`, `collections_groups`) and `groups.access_all` are +untouched. Only `users_organizations` changes. + +Two of those rows do not come back byte-identical to what the database held before the *upgrade*, +because the information no longer exists to reconstruct them: + +- **Owner/Admin always come back with `access_all = TRUE`**, even if the flag was `FALSE` for them + before. The upgrade dropped the column precisely because Owners and Admins reach every collection + through their role, so the original value is unknown afterwards. It grants them nothing they did + not already have as Owner/Admin; the visible difference is that unassigned collections show up in + their personal vault view again. +- **A plain User that carried `access_all` comes back with `access_all = FALSE`.** The upgrade wrote + that member's reach out as explicit per-collection assignments before dropping the bit, and those + rows are left untouched here — so the member keeps access to the collections that existed at + upgrade time, just not automatically to ones created afterwards. + +Edit-any-collection deliberately does **not** become `access_all` on its own: in the old schema that +flag also carried the legacy "manage all collections" authority including deletion, so a member who +only held Edit must not come back with delete rights. + +## How to run it + +Stop every Vaultwarden instance and take a backup first. Then: + +```bash +# SQLite +sqlite3 /path/to/data/db.sqlite3 < tools/custom_role_rollback/sqlite.sql + +# MySQL / MariaDB +mysql -u -p < tools/custom_role_rollback/mysql.sql + +# PostgreSQL +psql -U -d -v ON_ERROR_STOP=1 -f tools/custom_role_rollback/postgresql.sql +``` + +Run it exactly once — a second run fails because the permission columns are already gone. On +MySQL/MariaDB the statements cannot be wrapped in a transaction (DDL commits implicitly there); if +the script is interrupted, restore the backup and start over. SQLite and PostgreSQL apply the whole +script atomically. + +Afterwards start the older Vaultwarden version. Upgrading again later re-applies the seven +migrations from a clean state. + +## Reverting with the Diesel CLI instead + +For development checkouts the down migrations do the same thing step by step. The newest one refuses +by default so an accidental revert cannot silently destroy the permission data; acknowledge it +explicitly first: + +```sql +CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +``` + +Then `diesel migration revert` works as usual. The rollback scripts above drop that table again. diff --git a/tools/custom_role_rollback/mysql.sql b/tools/custom_role_rollback/mysql.sql new file mode 100644 index 00000000..c72bd8ec --- /dev/null +++ b/tools/custom_role_rollback/mysql.sql @@ -0,0 +1,50 @@ +-- Roll a MySQL/MariaDB 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. +-- +-- NOTE: MySQL/MariaDB commit every DDL statement implicitly, so this script cannot be wrapped in a +-- transaction. Take a backup before running it; if it is interrupted, restore and start over. + +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; + +-- The legacy flag is recomputed with 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. +UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); +UPDATE users_organizations +SET access_all = TRUE +WHERE atype = 4 + AND create_new_collections = TRUE + AND edit_any_collection = TRUE + AND delete_any_collection = TRUE; + +-- The old server cannot load type 4; Custom members were stored as Manager back then. +UPDATE users_organizations SET atype = 3 WHERE atype = 4; + +ALTER TABLE users_organizations DROP COLUMN manage_users; +ALTER TABLE users_organizations DROP COLUMN manage_groups; +ALTER TABLE users_organizations DROP COLUMN manage_policies; +ALTER TABLE users_organizations DROP COLUMN create_new_collections; +ALTER TABLE users_organizations DROP COLUMN edit_any_collection; +ALTER TABLE users_organizations DROP COLUMN delete_any_collection; +ALTER TABLE users_organizations DROP COLUMN access_event_logs; +ALTER TABLE users_organizations DROP COLUMN access_import_export; +ALTER TABLE users_organizations DROP COLUMN access_reports; + +-- Bookkeeping tables this feature may have left behind. +DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; + +-- Finally forget the seven migrations, so the older binary does not see a ledger from the future +-- and a later upgrade applies them again from a clean state. +DELETE FROM __diesel_schema_migrations +WHERE version IN ( + '20260630120000', + '20260715120000', + '20260716120000', + '20260723120000', + '20260724120000', + '20260724130000', + '20260724140000' +); diff --git a/tools/custom_role_rollback/postgresql.sql b/tools/custom_role_rollback/postgresql.sql new file mode 100644 index 00000000..1484c862 --- /dev/null +++ b/tools/custom_role_rollback/postgresql.sql @@ -0,0 +1,54 @@ +-- 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. + +BEGIN; + +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; + +-- The legacy flag is recomputed with 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. +UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); +UPDATE users_organizations +SET access_all = TRUE +WHERE atype = 4 + AND create_new_collections = TRUE + AND edit_any_collection = TRUE + AND delete_any_collection = TRUE; + +-- The old server cannot load type 4; Custom members were stored as Manager back then. +UPDATE users_organizations SET atype = 3 WHERE atype = 4; + +ALTER TABLE 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; + +-- Bookkeeping tables this feature may have left behind. +DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; + +-- Finally forget the seven migrations, so the older binary does not see a ledger from the future +-- and a later upgrade applies them again from a clean state. +DELETE FROM __diesel_schema_migrations +WHERE version IN ( + '20260630120000', + '20260715120000', + '20260716120000', + '20260723120000', + '20260724120000', + '20260724130000', + '20260724140000' +); + +COMMIT; diff --git a/tools/custom_role_rollback/sqlite.sql b/tools/custom_role_rollback/sqlite.sql new file mode 100644 index 00000000..e95a1cee --- /dev/null +++ b/tools/custom_role_rollback/sqlite.sql @@ -0,0 +1,73 @@ +-- Roll a SQLite 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. +-- +-- `ALTER TABLE ... DROP COLUMN` is avoided on purpose: it only exists since SQLite 3.35, and this +-- script has to work on the same older system SQLite the forward migrations support. Rebuilding the +-- table also recreates `access_all` and drops all nine permission columns in one step. + +PRAGMA foreign_keys = OFF; + +BEGIN; + +CREATE TABLE users_organizations_rollback ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + org_uuid TEXT NOT NULL REFERENCES organizations (uuid), + access_all BOOLEAN NOT NULL DEFAULT 0, + akey TEXT NOT NULL, + status INTEGER NOT NULL, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + + UNIQUE (user_uuid, org_uuid) +); + +-- The legacy flag is recomputed with 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. +INSERT INTO users_organizations_rollback ( + uuid, user_uuid, org_uuid, access_all, akey, status, atype, + reset_password_key, external_id, invited_by_email +) +SELECT + uuid, user_uuid, org_uuid, + CASE + WHEN atype IN (0, 1) THEN 1 + WHEN atype = 4 + AND create_new_collections = 1 + AND edit_any_collection = 1 + AND delete_any_collection = 1 THEN 1 + ELSE 0 + END, + akey, status, + -- The old server cannot load type 4; Custom members were stored as Manager back then. + CASE WHEN atype = 4 THEN 3 ELSE atype END, + reset_password_key, external_id, invited_by_email +FROM users_organizations; + +DROP TABLE users_organizations; + +ALTER TABLE users_organizations_rollback RENAME TO users_organizations; + +-- Bookkeeping tables this feature may have left behind. +DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; + +-- Finally forget the seven migrations, so the older binary does not see a ledger from the future +-- and a later upgrade applies them again from a clean state. +DELETE FROM __diesel_schema_migrations +WHERE version IN ( + '20260630120000', + '20260715120000', + '20260716120000', + '20260723120000', + '20260724120000', + '20260724130000', + '20260724140000' +); + +COMMIT;