committed by
GitHub
75 changed files with 10085 additions and 633 deletions
@ -0,0 +1,75 @@ |
|||
-- Lossy revert: this removes the three Custom management permissions and the Custom role itself, |
|||
-- which the legacy role/access_all schema cannot represent. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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 TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- Convert Custom members back to a role the older server can load -- it cannot represent type 4 and |
|||
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its |
|||
-- authority *now*, and it is not symmetric with the upgrade. |
|||
-- |
|||
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager |
|||
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers |
|||
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom |
|||
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not |
|||
-- authorization. Use a list written for this downgrade instead. |
|||
-- |
|||
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe |
|||
-- direction: 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. To keep the |
|||
-- historical mapping, copy it over deliberately before reverting: |
|||
-- |
|||
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); |
|||
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) |
|||
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager; |
|||
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist ( |
|||
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY |
|||
); |
|||
|
|||
UPDATE users_organizations SET atype = 3 |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); |
|||
|
|||
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with |
|||
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection |
|||
-- permissions, on the assumption they are about to become a Manager; left behind on a User it |
|||
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would |
|||
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are |
|||
-- untouched, so these members keep every per-collection grant and lose only the organization-wide |
|||
-- powers the old schema cannot express. |
|||
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4; |
|||
|
|||
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements |
|||
-- mean two intermediate states that survive a failure while Diesel still considers the migration |
|||
-- unapplied; one statement is the closest this backend gets to all-or-nothing. |
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN manage_users, |
|||
DROP COLUMN manage_groups, |
|||
DROP COLUMN manage_policies; |
|||
|
|||
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the |
|||
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The |
|||
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade |
|||
-- rebuilds all of it from the restored `atype = 3` rows. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; |
|||
DROP TABLE IF EXISTS __vw_custom_role_history_verified; |
|||
@ -0,0 +1,37 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; |
|||
-- Record which memberships were legacy Managers *before* anything converts them. |
|||
-- |
|||
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom |
|||
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy |
|||
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason |
|||
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table |
|||
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. |
|||
-- |
|||
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is |
|||
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table |
|||
-- rebuild does not have to care about it. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( |
|||
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by |
|||
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup |
|||
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. |
|||
-- |
|||
-- Deliberately not the record table above: that one holds data an operator has to be able to write |
|||
-- during recovery, so its existence cannot also stand for "the history behind this data was |
|||
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( |
|||
verified INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
|
|||
-- Previously the server stored members created with the Custom role as Manager (3) and |
|||
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted |
|||
-- type, convert those members so clients (which no longer know the Manager role) keep |
|||
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, |
|||
-- which matches the capabilities these members had. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
@ -0,0 +1,13 @@ |
|||
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a |
|||
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( |
|||
marker INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT IGNORE INTO __vw_custom_role_same_run_0716 (marker) |
|||
SELECT 1 |
|||
FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 |
|||
FROM __diesel_schema_migrations |
|||
WHERE version = '20260716120000' |
|||
); |
|||
@ -0,0 +1,34 @@ |
|||
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy |
|||
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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 TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- The previous schema exposes access_all as the three collection permissions together. Avoid |
|||
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. |
|||
UPDATE users_organizations |
|||
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection |
|||
WHERE atype = 4; |
|||
|
|||
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements |
|||
-- mean two intermediate states that survive a failure while Diesel still considers the migration |
|||
-- unapplied; one statement is the closest this backend gets to all-or-nothing. |
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN create_new_collections, |
|||
DROP COLUMN edit_any_collection, |
|||
DROP COLUMN delete_any_collection; |
|||
@ -0,0 +1,68 @@ |
|||
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, |
|||
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE so a |
|||
-- refusal leaves no half-added column group behind -- every ALTER commits implicitly here, and a |
|||
-- partial group is what the startup preflight then has to recover from. |
|||
-- |
|||
-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this whole check is |
|||
-- free of durable side effects. |
|||
-- |
|||
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly |
|||
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. |
|||
-- This guard exists for a bare migration runner that never consulted the startup preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Before these permissions were persisted independently, access_all represented the legacy |
|||
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members. |
|||
-- |
|||
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a |
|||
-- member carrying access_all held exactly this capability, whenever the row was created. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all, |
|||
delete_any_collection = access_all |
|||
WHERE atype = 4; |
|||
|
|||
-- A legacy Manager also managed every collection when one of their groups had access_all, even if |
|||
-- the membership itself did not. Preserve that existing edit/delete capability without granting |
|||
-- collection creation, which historically still required membership access_all. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and |
|||
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: |
|||
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was |
|||
-- simply put into an ordinary access_all group, and granting on that shape hands them |
|||
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies |
|||
-- has_full_access() and therefore reaches every cipher in the organization. |
|||
-- |
|||
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every |
|||
-- `atype = 3` row, which at this point is every Custom member there is. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users |
|||
-- `groups` is a reserved word in MySQL 8 and must be quoted, matching the existing |
|||
-- `2022-07-27-110000_add_group_support` migration. (PostgreSQL/SQLite do not reserve it.) |
|||
INNER JOIN `groups` ON `groups`.uuid = groups_users.groups_uuid |
|||
WHERE groups_users.users_organizations_uuid = users_organizations.uuid |
|||
AND `groups`.organizations_uuid = users_organizations.org_uuid |
|||
AND `groups`.access_all = TRUE |
|||
); |
|||
@ -0,0 +1,4 @@ |
|||
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions |
|||
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe |
|||
-- conversion. |
|||
SELECT 1; |
|||
@ -0,0 +1,101 @@ |
|||
-- Repair the legacy role/permission state while membership `access_all` still exists. |
|||
-- |
|||
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not |
|||
-- converted: that state grants dynamic reach over every collection *without* management authority, |
|||
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: |
|||
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints |
|||
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run |
|||
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner |
|||
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few |
|||
-- statements later. Repeating the check before this file's first mutation is what makes the silent |
|||
-- loss impossible rather than unlikely. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted when such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup |
|||
-- preflight refuses a database whose ledger carries that version without it. Creating it here would |
|||
-- manufacture an empty, apparently valid history for precisely the databases that need an operator |
|||
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never |
|||
-- consulted the preflight. Refusing also keeps this file free of DDL, which on MySQL/MariaDB would |
|||
-- commit implicitly and break this migration out of its transaction. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten |
|||
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are |
|||
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this |
|||
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the |
|||
-- normal path. |
|||
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Step 1: a legacy Manager who managed every collection through an organization-local group with |
|||
-- `access_all` keeps that authority, materialized into the permission columns it now lives in. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership |
|||
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who |
|||
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and |
|||
-- would hand them organization-wide collection edit and delete. |
|||
-- |
|||
-- Earlier revisions derived this authority live from the group at request time instead, which was |
|||
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's |
|||
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the |
|||
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: creating collections historically required |
|||
-- membership-level `access_all`, and it is an independent permission now. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
|
|||
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. |
|||
-- Set only TRUE values so this repair never removes independently configured permissions, and again |
|||
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a |
|||
-- modern Custom member carrying the old column as well. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = TRUE, |
|||
edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND access_all = TRUE; |
|||
|
|||
-- Convert only after the legacy bit has been copied. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
|
|||
-- Clear only the marker row as transactional DML. Keeping the empty bookkeeping table avoids |
|||
-- MySQL DDL implicit commits, so the permission repair, marker clear, and Diesel ledger insert |
|||
-- either commit together or are all retried. |
|||
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; |
|||
@ -0,0 +1,13 @@ |
|||
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring |
|||
-- the invariant the immediately preceding schema relies on: access_all == access to every collection. |
|||
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. |
|||
-- |
|||
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, |
|||
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, |
|||
-- because in that older schema access_all also meant the legacy Manager "Manage all collections" |
|||
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* |
|||
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; |
|||
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. |
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; |
|||
@ -0,0 +1,5 @@ |
|||
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can |
|||
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it |
|||
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. |
|||
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. |
|||
ALTER TABLE users_organizations DROP COLUMN access_all; |
|||
@ -0,0 +1,28 @@ |
|||
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot |
|||
-- represent at all. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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 TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements |
|||
-- mean two intermediate states that survive a failure while Diesel still considers the migration |
|||
-- unapplied; one statement is the closest this backend gets to all-or-nothing. |
|||
ALTER TABLE users_organizations |
|||
DROP COLUMN access_event_logs, |
|||
DROP COLUMN access_import_export, |
|||
DROP COLUMN access_reports; |
|||
@ -0,0 +1,5 @@ |
|||
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members |
|||
-- (gated on the role in code); Owners/Admins hold every permission implicitly. |
|||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
@ -0,0 +1,60 @@ |
|||
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy |
|||
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create |
|||
-- the marker table below while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest |
|||
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade |
|||
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. |
|||
-- |
|||
-- 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); |
|||
-- 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 TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits |
|||
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; |
|||
|
|||
-- Second, MySQL/MariaDB-only guard: this revert chain cannot be resumed here. |
|||
-- |
|||
-- Every `ALTER TABLE` in it commits on its own, while Diesel deletes the ledger row in a separate |
|||
-- statement afterwards. A crash in between leaves the columns gone and the migration still recorded |
|||
-- as applied, and re-running it fails forever with `Unknown column` (1091) -- the startup preflight |
|||
-- then refuses the database, correctly, and the only way out is the backup. Making it resumable |
|||
-- needs conditional DDL, i.e. a stored procedure built before the checks have run; the standalone |
|||
-- script in tools/custom_role_rollback/mysql.sql does the whole downgrade in one audited pass |
|||
-- instead, and is what operators should use. |
|||
-- |
|||
-- So this is supported for development checkouts only, and it says so. Acknowledge separately from |
|||
-- the data-loss marker above -- that one is about what a downgrade discards, this one is about what |
|||
-- an interrupted downgrade cannot repair: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. |
|||
CREATE TEMPORARY TABLE __vw_mysql_resume_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_mysql_resume_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_mysql_resume_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_unresumable_mysql_downgrade' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_mysql_resume_guard; |
|||
|
|||
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here |
|||
-- when the next revert removes the first permission column, which is what this guard exists to |
|||
-- announce -- checking and dropping it in the same step would leave every following lossy revert |
|||
-- unguarded. |
|||
SELECT 1; |
|||
@ -0,0 +1,13 @@ |
|||
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade |
|||
-- before any granular permission column is removed. |
|||
-- |
|||
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now |
|||
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL |
|||
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
|
|||
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is |
|||
-- never inherited across an upgrade. Both of them: this backend's revert chain needs a second one, |
|||
-- acknowledging that it cannot be resumed after a crash between a committed ALTER and the ledger. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade; |
|||
@ -0,0 +1,4 @@ |
|||
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and |
|||
-- the original values are not recoverable. The permission columns themselves are removed further down |
|||
-- the chain by 2026-07-16-120000/down.sql, which is guarded. |
|||
SELECT 1; |
|||
@ -0,0 +1,111 @@ |
|||
-- Follow-up repair for databases that already recorded 2026-07-23-120000. |
|||
-- |
|||
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager |
|||
-- whose authority came from an organization-local `access_all` group, because the runtime derived the |
|||
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three |
|||
-- collection permissions, member of such a group" is also the shape of every newly created flagless |
|||
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the |
|||
-- authority into the permission columns. |
|||
-- |
|||
-- Rewriting that file is not enough on its own: a database whose ledger already carries |
|||
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the |
|||
-- materialization here, in its own version, so both paths converge on the same state. |
|||
-- |
|||
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written |
|||
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, |
|||
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom |
|||
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless |
|||
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. |
|||
-- |
|||
-- What this materialization *means* -- a group-bound capability becoming a permanent membership |
|||
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. |
|||
-- |
|||
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already |
|||
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two |
|||
-- permission columns, so it is also safe after `access_all` has been dropped. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: collection creation historically required |
|||
-- membership-level `access_all`. |
|||
-- |
|||
-- Every statement here is DML or TEMPORARY-table bookkeeping, so nothing commits implicitly and the |
|||
-- repair either lands with the ledger insert or not at all. |
|||
|
|||
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather |
|||
-- than creating it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- Fail closed on a database whose legacy provenance was never recorded. |
|||
-- |
|||
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a |
|||
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the |
|||
-- membership really is a converted legacy Manager whose record was never written (a ledger from an |
|||
-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not |
|||
-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real |
|||
-- capability. |
|||
-- |
|||
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator |
|||
-- creates it after auditing an older history, so its presence means the unrecorded memberships below |
|||
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup |
|||
-- preflight refuses that state before any migration runs; this guard is the backstop for a bare |
|||
-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection |
|||
-- FROM users_organizations uo |
|||
-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid |
|||
-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid |
|||
-- WHERE uo.atype = 4 AND g.access_all = 1 |
|||
-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager); |
|||
-- |
|||
-- The marker never grants anything by itself: the update below is always driven by the record table, |
|||
-- so an unrecorded membership keeps exactly the permissions it has. |
|||
CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_custom_role_history_verified' |
|||
) |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_legacy_group_authority_guard; |
|||
|
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
@ -0,0 +1,4 @@ |
|||
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The |
|||
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next |
|||
-- upgrade has to ask again. |
|||
SELECT 1; |
|||
@ -0,0 +1,121 @@ |
|||
-- Make the one semantic change this feature cannot express an owner's decision instead of a default. |
|||
-- |
|||
-- Before the Custom role, a Manager who reached every collection through an organization-local group |
|||
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the |
|||
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert |
|||
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a |
|||
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. |
|||
-- |
|||
-- So the earlier migrations in this chain write the authority onto the membership, and the result is |
|||
-- deliberately not identical to what it replaces: |
|||
-- |
|||
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
-- * it applies even with the groups feature switched off; |
|||
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
-- collection of the organization directly rather than through the group. |
|||
-- |
|||
-- Materializing it silently would be a migration that grants durable organization-wide collection |
|||
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is |
|||
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and |
|||
-- revokes nothing itself. |
|||
-- |
|||
-- On a database with no Custom membership that both has edit/delete authority and belongs to an |
|||
-- organization-local `access_all` group, there is nothing to decide and this is a no-op. |
|||
-- |
|||
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the |
|||
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy |
|||
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the |
|||
-- driver error, so on this path the question would arrive as `Duplicate entry '1' for key 'PRIMARY'` |
|||
-- and nothing else. Keep the two predicates identical. |
|||
-- |
|||
-- Review the affected memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, |
|||
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) |
|||
-- AS was_legacy_manager |
|||
-- FROM users_organizations uo |
|||
-- WHERE uo.atype = 4 |
|||
-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1) |
|||
-- AND EXISTS ( |
|||
-- SELECT 1 FROM groups_users gu |
|||
-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid |
|||
-- WHERE gu.users_organizations_uuid = uo.uuid |
|||
-- AND g.organizations_uuid = uo.org_uuid |
|||
-- AND g.access_all = 1); |
|||
-- |
|||
-- Reading the result: |
|||
-- |
|||
-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when |
|||
-- `create_new_collections = 1`: that independent permission can be changed after an earlier |
|||
-- revision materialized group-derived edit/delete, so its current value cannot prove where those |
|||
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may |
|||
-- therefore be listed conservatively even though its authority was already permanent. |
|||
-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c |
|||
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can |
|||
-- distinguish from a deliberate grant any more -- check them against what you intended. |
|||
-- |
|||
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- |
|||
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked |
|||
-- value outright -- but the permission is what it would come back with if it is ever restored, and |
|||
-- by then the group it came from may be gone. Status is therefore not part of the predicate. |
|||
-- |
|||
-- Clear whatever you do not want to keep, for example: |
|||
-- |
|||
-- UPDATE users_organizations |
|||
-- SET edit_any_collection = 0, delete_any_collection = 0 |
|||
-- WHERE uuid = '<MEMBERSHIP_UUID>'; |
|||
-- |
|||
-- Then record the decision once, with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. |
|||
-- |
|||
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the |
|||
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why |
|||
-- this never creates it. |
|||
-- |
|||
-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this check is free |
|||
-- of durable side effects. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 FROM DUAL |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. |
|||
CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_ack_permanent_collection_authority' |
|||
) |
|||
LIMIT 1; |
|||
DROP TEMPORARY TABLE __vw_permanent_authority_guard; |
|||
|
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
@ -0,0 +1,65 @@ |
|||
-- Lossy revert: this removes the three Custom management permissions and the Custom role itself, |
|||
-- which the legacy role/access_all schema cannot represent. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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; |
|||
|
|||
-- Convert Custom members back to a role the older server can load -- it cannot represent type 4 and |
|||
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its |
|||
-- authority *now*, and it is not symmetric with the upgrade. |
|||
-- |
|||
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager |
|||
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers |
|||
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom |
|||
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not |
|||
-- authorization. Use a list written for this downgrade instead. |
|||
-- |
|||
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe |
|||
-- direction: 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. To keep the |
|||
-- historical mapping, copy it over deliberately before reverting: |
|||
-- |
|||
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); |
|||
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) |
|||
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager; |
|||
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist ( |
|||
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY |
|||
); |
|||
|
|||
UPDATE users_organizations SET atype = 3 |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); |
|||
|
|||
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with |
|||
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection |
|||
-- permissions, on the assumption they are about to become a Manager; left behind on a User it |
|||
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would |
|||
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are |
|||
-- untouched, so these members keep every per-collection grant and lose only the organization-wide |
|||
-- powers the old schema cannot express. |
|||
UPDATE users_organizations SET atype = 2, access_all = FALSE 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; |
|||
|
|||
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the |
|||
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The |
|||
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade |
|||
-- rebuilds all of it from the restored `atype = 3` rows. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; |
|||
DROP TABLE IF EXISTS __vw_custom_role_history_verified; |
|||
@ -0,0 +1,38 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; |
|||
-- Record which memberships were legacy Managers *before* anything converts them. |
|||
-- |
|||
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom |
|||
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy |
|||
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason |
|||
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table |
|||
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. |
|||
-- |
|||
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is |
|||
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table |
|||
-- rebuild does not have to care about it. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( |
|||
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3 |
|||
ON CONFLICT DO NOTHING; |
|||
|
|||
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by |
|||
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup |
|||
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. |
|||
-- |
|||
-- Deliberately not the record table above: that one holds data an operator has to be able to write |
|||
-- during recovery, so its existence cannot also stand for "the history behind this data was |
|||
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( |
|||
verified INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
|
|||
-- Previously the server stored members created with the Custom role as Manager (3) and |
|||
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted |
|||
-- type, convert those members so clients (which no longer know the Manager role) keep |
|||
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, |
|||
-- which matches the capabilities these members had. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
@ -0,0 +1,13 @@ |
|||
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a |
|||
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( |
|||
marker INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_custom_role_same_run_0716 (marker) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 |
|||
FROM __diesel_schema_migrations |
|||
WHERE version = '20260716120000' |
|||
) |
|||
ON CONFLICT (marker) DO NOTHING; |
|||
@ -0,0 +1,25 @@ |
|||
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy |
|||
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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; |
|||
|
|||
-- The previous schema exposes access_all as the three collection permissions together. Avoid |
|||
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. |
|||
UPDATE users_organizations |
|||
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection |
|||
WHERE atype = 4; |
|||
|
|||
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; |
|||
@ -0,0 +1,60 @@ |
|||
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, |
|||
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so |
|||
-- the refusal is symmetrical with the other backends -- PostgreSQL DDL is transactional, so nothing |
|||
-- would be left behind either way. |
|||
-- |
|||
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly |
|||
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. |
|||
-- This guard exists for a bare migration runner that never consulted the startup preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Before these permissions were persisted independently, access_all represented the legacy |
|||
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members. |
|||
-- |
|||
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a |
|||
-- member carrying access_all held exactly this capability, whenever the row was created. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all, |
|||
delete_any_collection = access_all |
|||
WHERE atype = 4; |
|||
|
|||
-- A legacy Manager also managed every collection when one of their groups had access_all, even if |
|||
-- the membership itself did not. Preserve that existing edit/delete capability without granting |
|||
-- collection creation, which historically still required membership access_all. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and |
|||
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: |
|||
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was |
|||
-- simply put into an ordinary access_all group, and granting on that shape hands them |
|||
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies |
|||
-- has_full_access() and therefore reaches every cipher in the organization. |
|||
-- |
|||
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every |
|||
-- `atype = 3` row, which at this point is every Custom member there is. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users |
|||
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid |
|||
WHERE groups_users.users_organizations_uuid = users_organizations.uuid |
|||
AND groups.organizations_uuid = users_organizations.org_uuid |
|||
AND groups.access_all = TRUE |
|||
); |
|||
@ -0,0 +1,4 @@ |
|||
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions |
|||
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe |
|||
-- conversion. |
|||
SELECT 1; |
|||
@ -0,0 +1,96 @@ |
|||
-- Repair the legacy role/permission state while membership `access_all` still exists. |
|||
-- |
|||
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not |
|||
-- converted: that state grants dynamic reach over every collection *without* management authority, |
|||
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: |
|||
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints |
|||
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run |
|||
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner |
|||
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few |
|||
-- statements later. Repeating the check before this file's first mutation is what makes the silent |
|||
-- loss impossible rather than unlikely. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted when such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup |
|||
-- preflight refuses a database whose ledger carries that version without it. Creating it here would |
|||
-- manufacture an empty, apparently valid history for precisely the databases that need an operator |
|||
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never |
|||
-- consulted the preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten |
|||
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are |
|||
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this |
|||
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the |
|||
-- normal path. |
|||
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3 |
|||
ON CONFLICT DO NOTHING; |
|||
|
|||
-- Step 1: a legacy Manager who managed every collection through an organization-local group with |
|||
-- `access_all` keeps that authority, materialized into the permission columns it now lives in. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership |
|||
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who |
|||
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and |
|||
-- would hand them organization-wide collection edit and delete. |
|||
-- |
|||
-- Earlier revisions derived this authority live from the group at request time instead, which was |
|||
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's |
|||
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the |
|||
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: creating collections historically required |
|||
-- membership-level `access_all`, and it is an independent permission now. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
|
|||
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. |
|||
-- Set only TRUE values so this repair never removes independently configured permissions, and again |
|||
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a |
|||
-- modern Custom member carrying the old column as well. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = TRUE, |
|||
edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND access_all = TRUE; |
|||
|
|||
-- Convert only after the legacy bit has been copied. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
|
|||
-- Clear the same-run marker only after every permission update succeeds. |
|||
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; |
|||
@ -0,0 +1,13 @@ |
|||
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring |
|||
-- the invariant the immediately preceding schema relies on: access_all == access to every collection. |
|||
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. |
|||
-- |
|||
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, |
|||
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, |
|||
-- because in that older schema access_all also meant the legacy Manager "Manage all collections" |
|||
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* |
|||
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; |
|||
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. |
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; |
|||
@ -0,0 +1,5 @@ |
|||
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can |
|||
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it |
|||
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. |
|||
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. |
|||
ALTER TABLE users_organizations DROP COLUMN access_all; |
|||
@ -0,0 +1,19 @@ |
|||
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot |
|||
-- represent at all. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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; |
|||
|
|||
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; |
|||
@ -0,0 +1,5 @@ |
|||
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members |
|||
-- (gated on the role in code); Owners/Admins hold every permission implicitly. |
|||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
@ -0,0 +1,27 @@ |
|||
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy |
|||
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create |
|||
-- the marker table below while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest |
|||
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade |
|||
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. |
|||
-- |
|||
-- 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); |
|||
-- 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; |
|||
|
|||
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here |
|||
-- when the next revert removes the first permission column, which is what this guard exists to |
|||
-- announce -- checking and dropping it in the same step would leave every following lossy revert |
|||
-- unguarded. |
|||
SELECT 1; |
|||
@ -0,0 +1,11 @@ |
|||
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade |
|||
-- before any granular permission column is removed. |
|||
-- |
|||
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now |
|||
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL |
|||
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
|
|||
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is |
|||
-- never inherited across an upgrade. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
@ -0,0 +1,4 @@ |
|||
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and |
|||
-- the original values are not recoverable. The permission columns themselves are removed further down |
|||
-- the chain by 2026-07-16-120000/down.sql, which is guarded. |
|||
SELECT 1; |
|||
@ -0,0 +1,103 @@ |
|||
-- Follow-up repair for databases that already recorded 2026-07-23-120000. |
|||
-- |
|||
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager |
|||
-- whose authority came from an organization-local `access_all` group, because the runtime derived the |
|||
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three |
|||
-- collection permissions, member of such a group" is also the shape of every newly created flagless |
|||
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the |
|||
-- authority into the permission columns. |
|||
-- |
|||
-- Rewriting that file is not enough on its own: a database whose ledger already carries |
|||
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the |
|||
-- materialization here, in its own version, so both paths converge on the same state. |
|||
-- |
|||
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written |
|||
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, |
|||
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom |
|||
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless |
|||
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. |
|||
-- |
|||
-- What this materialization *means* -- a group-bound capability becoming a permanent membership |
|||
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. |
|||
-- |
|||
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already |
|||
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two |
|||
-- permission columns, so it is also safe after `access_all` has been dropped. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: collection creation historically required |
|||
-- membership-level `access_all`. |
|||
DO $$ |
|||
DECLARE |
|||
undecidable int := 0; |
|||
BEGIN |
|||
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses |
|||
-- rather than creating it. |
|||
IF to_regclass('__vw_custom_role_legacy_manager') IS NULL THEN |
|||
RAISE EXCEPTION |
|||
'Upgrade refused, nothing was changed: __vw_custom_role_legacy_manager does not exist, ' |
|||
'so which memberships were legacy Managers before the upgrade is unknown. Start ' |
|||
'Vaultwarden once to get the full recovery instructions, or see ' |
|||
'tools/custom_role_rollback/README.md.'; |
|||
END IF; |
|||
|
|||
-- Fail closed on a database whose legacy provenance was never recorded. |
|||
-- |
|||
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a |
|||
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the |
|||
-- membership really is a converted legacy Manager whose record was never written (a ledger from |
|||
-- an earlier revision of this feature branch), or it is an ordinary modern Custom member who must |
|||
-- not gain anything. Granting is a silent privilege escalation; skipping silently drops a real |
|||
-- capability. |
|||
-- |
|||
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator |
|||
-- creates it after auditing an older history, so its presence means the unrecorded memberships |
|||
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup |
|||
-- preflight refuses that state before any migration runs; this is the backstop for a bare |
|||
-- migration runner. |
|||
-- |
|||
-- The marker never grants anything by itself: the update below is always driven by the record |
|||
-- table, so an unrecorded membership keeps exactly the permissions it has. |
|||
IF to_regclass('__vw_custom_role_history_verified') IS NULL THEN |
|||
SELECT count(*) INTO undecidable |
|||
FROM users_organizations uo |
|||
WHERE uo.atype = 4 |
|||
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users gu |
|||
INNER JOIN "groups" g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
END IF; |
|||
|
|||
IF undecidable <> 0 THEN |
|||
RAISE EXCEPTION |
|||
'Upgrade refused, nothing was changed: % Custom membership(s) belong to an access_all ' |
|||
'group but are not on record as legacy Managers, and this database''s Custom-role ' |
|||
'history has never been audited, so a converted legacy Manager cannot be told from an ' |
|||
'ordinary Custom member. Review them with: SELECT uo.uuid, uo.org_uuid, uo.status, ' |
|||
'uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection FROM ' |
|||
'users_organizations uo JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid ' |
|||
'JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid ' |
|||
'WHERE uo.atype = 4 AND g.access_all AND uo.uuid NOT IN (SELECT ' |
|||
'users_organizations_uuid FROM __vw_custom_role_legacy_manager); Start Vaultwarden once ' |
|||
'for the full recovery instructions.', |
|||
undecidable; |
|||
END IF; |
|||
END $$; |
|||
|
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
@ -0,0 +1,4 @@ |
|||
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The |
|||
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next |
|||
-- upgrade has to ask again. |
|||
SELECT 1; |
|||
@ -0,0 +1,111 @@ |
|||
-- Make the one semantic change this feature cannot express an owner's decision instead of a default. |
|||
-- |
|||
-- Before the Custom role, a Manager who reached every collection through an organization-local group |
|||
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the |
|||
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert |
|||
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a |
|||
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. |
|||
-- |
|||
-- So the earlier migrations in this chain write the authority onto the membership, and the result is |
|||
-- deliberately not identical to what it replaces: |
|||
-- |
|||
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
-- * it applies even with the groups feature switched off; |
|||
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
-- collection of the organization directly rather than through the group. |
|||
-- |
|||
-- Materializing it silently would be a migration that grants durable organization-wide collection |
|||
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is |
|||
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and |
|||
-- revokes nothing itself. |
|||
-- |
|||
-- On a database with no Custom membership that both has edit/delete authority and belongs to an |
|||
-- organization-local `access_all` group, there is nothing to decide and this is a no-op. |
|||
-- |
|||
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the |
|||
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy |
|||
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the |
|||
-- driver error, so on this path the question would arrive as a bare duplicate-key violation on |
|||
-- `__vw_permanent_authority_guard` and nothing else. Keep the two predicates identical. |
|||
-- |
|||
-- Review the affected memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, |
|||
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) |
|||
-- AS was_legacy_manager |
|||
-- FROM users_organizations uo |
|||
-- WHERE uo.atype = 4 |
|||
-- AND (uo.edit_any_collection OR uo.delete_any_collection) |
|||
-- AND EXISTS ( |
|||
-- SELECT 1 FROM groups_users gu |
|||
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid |
|||
-- WHERE gu.users_organizations_uuid = uo.uuid |
|||
-- AND g.organizations_uuid = uo.org_uuid |
|||
-- AND g.access_all); |
|||
-- |
|||
-- Reading the result: |
|||
-- |
|||
-- * `was_legacy_manager = t` -- a converted Manager. Review it even when |
|||
-- `create_new_collections = t`: that independent permission can be changed after an earlier |
|||
-- revision materialized group-derived edit/delete, so its current value cannot prove where those |
|||
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may |
|||
-- therefore be listed conservatively even though its authority was already permanent. |
|||
-- * `was_legacy_manager = f` -- never a Manager. On a database first upgraded by revision bf54088c |
|||
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can |
|||
-- distinguish from a deliberate grant any more -- check them against what you intended. |
|||
-- |
|||
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- |
|||
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked |
|||
-- value outright -- but the permission is what it would come back with if it is ever restored, and |
|||
-- by then the group it came from may be gone. Status is therefore not part of the predicate. |
|||
-- |
|||
-- Clear whatever you do not want to keep, for example: |
|||
-- |
|||
-- UPDATE users_organizations |
|||
-- SET edit_any_collection = FALSE, delete_any_collection = FALSE |
|||
-- WHERE uuid = '<MEMBERSHIP_UUID>'; |
|||
-- |
|||
-- Then record the decision once, with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. |
|||
-- |
|||
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the |
|||
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why |
|||
-- this never creates it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. |
|||
CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND to_regclass('__vw_ack_permanent_collection_authority') IS NULL |
|||
LIMIT 1; |
|||
DROP TABLE __vw_permanent_authority_guard; |
|||
|
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
@ -0,0 +1,68 @@ |
|||
-- Lossy revert: this removes the three Custom management permissions and the Custom role itself, |
|||
-- which the legacy role/access_all schema cannot represent. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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; |
|||
|
|||
-- Convert Custom members back to a role the older server can load -- it cannot represent type 4 and |
|||
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its |
|||
-- authority *now*, and it is not symmetric with the upgrade. |
|||
-- |
|||
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager |
|||
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers |
|||
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom |
|||
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not |
|||
-- authorization. Use a list written for this downgrade instead. |
|||
-- |
|||
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe |
|||
-- direction: 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. To keep the |
|||
-- historical mapping, copy it over deliberately before reverting: |
|||
-- |
|||
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); |
|||
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) |
|||
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager; |
|||
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist ( |
|||
users_organizations_uuid TEXT NOT NULL PRIMARY KEY |
|||
); |
|||
|
|||
UPDATE users_organizations SET atype = 3 |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); |
|||
|
|||
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with |
|||
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection |
|||
-- permissions, on the assumption they are about to become a Manager; left behind on a User it |
|||
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would |
|||
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are |
|||
-- untouched, so these members keep every per-collection grant and lose only the organization-wide |
|||
-- powers the old schema cannot express. |
|||
UPDATE users_organizations SET atype = 2, access_all = FALSE 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; |
|||
|
|||
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the |
|||
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The |
|||
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade |
|||
-- rebuilds all of it from the restored `atype = 3` rows. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; |
|||
DROP TABLE IF EXISTS __vw_custom_role_history_verified; |
|||
@ -0,0 +1,37 @@ |
|||
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; |
|||
-- Record which memberships were legacy Managers *before* anything converts them. |
|||
-- |
|||
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom |
|||
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy |
|||
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason |
|||
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table |
|||
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. |
|||
-- |
|||
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is |
|||
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table |
|||
-- rebuild does not have to care about it. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( |
|||
users_organizations_uuid TEXT NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by |
|||
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup |
|||
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. |
|||
-- |
|||
-- Deliberately not the record table above: that one holds data an operator has to be able to write |
|||
-- during recovery, so its existence cannot also stand for "the history behind this data was |
|||
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( |
|||
verified INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
|
|||
-- Previously the server stored members created with the Custom role as Manager (3) and |
|||
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted |
|||
-- type, convert those members so clients (which no longer know the Manager role) keep |
|||
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, |
|||
-- which matches the capabilities these members had. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
@ -0,0 +1 @@ |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
@ -0,0 +1,12 @@ |
|||
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a |
|||
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. |
|||
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( |
|||
marker INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT OR IGNORE INTO __vw_custom_role_same_run_0716 (marker) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 |
|||
FROM __diesel_schema_migrations |
|||
WHERE version = '20260716120000' |
|||
); |
|||
@ -0,0 +1,28 @@ |
|||
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy |
|||
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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; |
|||
|
|||
-- The previous schema exposes access_all as the three collection permissions together. Avoid |
|||
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. |
|||
UPDATE users_organizations |
|||
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection |
|||
WHERE atype = 4; |
|||
|
|||
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; |
|||
@ -0,0 +1,63 @@ |
|||
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, |
|||
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so |
|||
-- a refusal leaves no half-added column group behind -- on MySQL/MariaDB every ALTER commits on its |
|||
-- own, and a partial group is what the startup preflight then has to recover from. |
|||
-- |
|||
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly |
|||
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. |
|||
-- This guard exists for a bare migration runner that never consulted the startup preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- Before these permissions were persisted independently, access_all represented the legacy |
|||
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members. |
|||
-- |
|||
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a |
|||
-- member carrying access_all held exactly this capability, whenever the row was created. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = access_all, |
|||
edit_any_collection = access_all, |
|||
delete_any_collection = access_all |
|||
WHERE atype = 4; |
|||
|
|||
-- A legacy Manager also managed every collection when one of their groups had access_all, even if |
|||
-- the membership itself did not. Preserve that existing edit/delete capability without granting |
|||
-- collection creation, which historically still required membership access_all. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and |
|||
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: |
|||
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was |
|||
-- simply put into an ordinary access_all group, and granting on that shape hands them |
|||
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies |
|||
-- has_full_access() and therefore reaches every cipher in the organization. |
|||
-- |
|||
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every |
|||
-- `atype = 3` row, which at this point is every Custom member there is. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users |
|||
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid |
|||
WHERE groups_users.users_organizations_uuid = users_organizations.uuid |
|||
AND groups.organizations_uuid = users_organizations.org_uuid |
|||
AND groups.access_all = TRUE |
|||
); |
|||
@ -0,0 +1,4 @@ |
|||
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions |
|||
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe |
|||
-- conversion. |
|||
SELECT 1; |
|||
@ -0,0 +1,98 @@ |
|||
-- Repair the legacy role/permission state while membership `access_all` still exists. |
|||
-- |
|||
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not |
|||
-- converted: that state grants dynamic reach over every collection *without* management authority, |
|||
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: |
|||
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints |
|||
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run |
|||
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner |
|||
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few |
|||
-- statements later. Repeating the check before this file's first mutation is what makes the silent |
|||
-- loss impossible rather than unlikely. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted when such a membership exists. |
|||
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_user_access_all_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations |
|||
WHERE atype = 2 |
|||
AND access_all = TRUE |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_user_access_all_guard; |
|||
|
|||
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup |
|||
-- preflight refuses a database whose ledger carries that version without it. Creating it here would |
|||
-- manufacture an empty, apparently valid history for precisely the databases that need an operator |
|||
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never |
|||
-- consulted the preflight. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten |
|||
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are |
|||
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this |
|||
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the |
|||
-- normal path where 2026-06-30-120000 already recorded them. |
|||
INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) |
|||
SELECT uuid FROM users_organizations WHERE atype = 3; |
|||
|
|||
-- Step 1: a legacy Manager who managed every collection through an organization-local group with |
|||
-- `access_all` keeps that authority, materialized into the permission columns it now lives in. |
|||
-- |
|||
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership |
|||
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who |
|||
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and |
|||
-- would hand them organization-wide collection edit and delete. |
|||
-- |
|||
-- Earlier revisions derived this authority live from the group at request time instead, which was |
|||
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's |
|||
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the |
|||
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: creating collections historically required |
|||
-- membership-level `access_all`, and it is an independent permission now. |
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
|
|||
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. |
|||
-- Set only TRUE values so this repair never removes independently configured permissions, and again |
|||
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a |
|||
-- modern Custom member carrying the old column as well. |
|||
UPDATE users_organizations |
|||
SET create_new_collections = TRUE, |
|||
edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype IN (3, 4) |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND access_all = TRUE; |
|||
|
|||
-- Convert only after the legacy bit has been copied. |
|||
UPDATE users_organizations SET atype = 4 WHERE atype = 3; |
|||
|
|||
-- Clear the same-run marker only after every permission update succeeds. |
|||
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; |
|||
@ -0,0 +1,13 @@ |
|||
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring |
|||
-- the invariant the immediately preceding schema relies on: access_all == access to every collection. |
|||
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. |
|||
-- |
|||
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, |
|||
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, |
|||
-- because in that older schema access_all also meant the legacy Manager "Manage all collections" |
|||
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* |
|||
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; |
|||
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. |
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; |
|||
@ -0,0 +1,46 @@ |
|||
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can |
|||
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it |
|||
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. |
|||
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. |
|||
-- |
|||
-- `ALTER TABLE ... DROP COLUMN` is deliberately NOT used here: it only exists since SQLite 3.35.0, |
|||
-- while a `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1 |
|||
-- (which is what Debian 11 ships). Forward migrations have to run on every supported build, so use |
|||
-- the portable table rebuild instead -- the same pattern as |
|||
-- 2022-03-02-210038_update_devices_primary_key. Vaultwarden runs SQLite migrations with |
|||
-- `PRAGMA foreign_keys = OFF`, so dropping the old table does not cascade into groups_users. |
|||
CREATE TABLE users_organizations_new ( |
|||
uuid TEXT NOT NULL PRIMARY KEY, |
|||
user_uuid TEXT NOT NULL REFERENCES users (uuid), |
|||
org_uuid TEXT NOT NULL REFERENCES organizations (uuid), |
|||
|
|||
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, |
|||
manage_users BOOLEAN NOT NULL DEFAULT FALSE, |
|||
manage_groups BOOLEAN NOT NULL DEFAULT FALSE, |
|||
manage_policies BOOLEAN NOT NULL DEFAULT FALSE, |
|||
create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, |
|||
edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, |
|||
|
|||
UNIQUE (user_uuid, org_uuid) |
|||
); |
|||
|
|||
INSERT INTO users_organizations_new ( |
|||
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, |
|||
invited_by_email, manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection |
|||
) |
|||
SELECT |
|||
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, |
|||
invited_by_email, manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection |
|||
FROM users_organizations; |
|||
|
|||
DROP TABLE users_organizations; |
|||
|
|||
ALTER TABLE users_organizations_new RENAME TO users_organizations; |
|||
@ -0,0 +1,22 @@ |
|||
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot |
|||
-- represent at all. The revert therefore |
|||
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, |
|||
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
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; |
|||
|
|||
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; |
|||
@ -0,0 +1,5 @@ |
|||
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members |
|||
-- (gated on the role in code); Owners/Admins hold every permission implicitly. |
|||
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; |
|||
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; |
|||
@ -0,0 +1,29 @@ |
|||
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy |
|||
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create |
|||
-- the marker table below while every Vaultwarden instance is stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest |
|||
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade |
|||
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. |
|||
-- |
|||
-- 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); |
|||
-- 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; |
|||
|
|||
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here |
|||
-- when the next revert removes the first permission column, which is what this guard exists to |
|||
-- announce -- checking and dropping it in the same step would leave every following lossy revert |
|||
-- unguarded. |
|||
SELECT 1; |
|||
@ -0,0 +1,11 @@ |
|||
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade |
|||
-- before any granular permission column is removed. |
|||
-- |
|||
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now |
|||
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL |
|||
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
|
|||
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is |
|||
-- never inherited across an upgrade. |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
@ -0,0 +1,4 @@ |
|||
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and |
|||
-- the original values are not recoverable. The permission columns themselves are removed further down |
|||
-- the chain by 2026-07-16-120000/down.sql, which is guarded. |
|||
SELECT 1; |
|||
@ -0,0 +1,107 @@ |
|||
-- Follow-up repair for databases that already recorded 2026-07-23-120000. |
|||
-- |
|||
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager |
|||
-- whose authority came from an organization-local `access_all` group, because the runtime derived the |
|||
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three |
|||
-- collection permissions, member of such a group" is also the shape of every newly created flagless |
|||
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the |
|||
-- authority into the permission columns. |
|||
-- |
|||
-- Rewriting that file is not enough on its own: a database whose ledger already carries |
|||
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the |
|||
-- materialization here, in its own version, so both paths converge on the same state. |
|||
-- |
|||
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written |
|||
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, |
|||
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom |
|||
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless |
|||
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. |
|||
-- |
|||
-- What this materialization *means* -- a group-bound capability becoming a permanent membership |
|||
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. |
|||
-- |
|||
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already |
|||
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two |
|||
-- permission columns, so it is also safe after `access_all` has been dropped. |
|||
-- |
|||
-- Deliberately not `create_new_collections`: collection creation historically required |
|||
-- membership-level `access_all`. |
|||
|
|||
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather |
|||
-- than creating it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- Fail closed on a database whose legacy provenance was never recorded. |
|||
-- |
|||
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a |
|||
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the |
|||
-- membership really is a converted legacy Manager whose record was never written (a ledger from an |
|||
-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not |
|||
-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real |
|||
-- capability. |
|||
-- |
|||
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator |
|||
-- creates it after auditing an older history, so its presence means the unrecorded memberships below |
|||
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup |
|||
-- preflight refuses that state before any migration runs; this guard is the backstop for a bare |
|||
-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection |
|||
-- FROM users_organizations uo |
|||
-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid |
|||
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid |
|||
-- WHERE uo.atype = 4 AND g.access_all = 1 |
|||
-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager); |
|||
-- |
|||
-- The marker never grants anything by itself: the update below is always driven by the record table, |
|||
-- so an unrecorded membership keeps exactly the permissions it has. |
|||
CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_group_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_history_verified' |
|||
) |
|||
LIMIT 1; |
|||
DROP TABLE __vw_legacy_group_authority_guard; |
|||
|
|||
UPDATE users_organizations |
|||
SET edit_any_collection = TRUE, |
|||
delete_any_collection = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = users_organizations.uuid |
|||
AND g.organizations_uuid = users_organizations.org_uuid |
|||
AND g.access_all = TRUE |
|||
); |
|||
@ -0,0 +1,4 @@ |
|||
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The |
|||
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next |
|||
-- upgrade has to ask again. |
|||
SELECT 1; |
|||
@ -0,0 +1,117 @@ |
|||
-- Make the one semantic change this feature cannot express an owner's decision instead of a default. |
|||
-- |
|||
-- Before the Custom role, a Manager who reached every collection through an organization-local group |
|||
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the |
|||
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert |
|||
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a |
|||
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. |
|||
-- |
|||
-- So the earlier migrations in this chain write the authority onto the membership, and the result is |
|||
-- deliberately not identical to what it replaces: |
|||
-- |
|||
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
-- * it applies even with the groups feature switched off; |
|||
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
-- collection of the organization directly rather than through the group. |
|||
-- |
|||
-- Materializing it silently would be a migration that grants durable organization-wide collection |
|||
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is |
|||
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and |
|||
-- revokes nothing itself. |
|||
-- |
|||
-- On a database with no Custom membership that both has edit/delete authority and belongs to an |
|||
-- organization-local `access_all` group, there is nothing to decide and this is a no-op. |
|||
-- |
|||
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the |
|||
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy |
|||
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the |
|||
-- driver error, so on this path the question would arrive as `UNIQUE constraint failed: |
|||
-- __vw_permanent_authority_guard.blocked` and nothing else. Keep the two predicates identical. |
|||
-- |
|||
-- Review the affected memberships: |
|||
-- |
|||
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, |
|||
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, |
|||
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) |
|||
-- AS was_legacy_manager |
|||
-- FROM users_organizations uo |
|||
-- WHERE uo.atype = 4 |
|||
-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1) |
|||
-- AND EXISTS ( |
|||
-- SELECT 1 FROM groups_users gu |
|||
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid |
|||
-- WHERE gu.users_organizations_uuid = uo.uuid |
|||
-- AND g.organizations_uuid = uo.org_uuid |
|||
-- AND g.access_all = 1); |
|||
-- |
|||
-- Reading the result: |
|||
-- |
|||
-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when |
|||
-- `create_new_collections = 1`: that independent permission can be changed after an earlier |
|||
-- revision materialized group-derived edit/delete, so its current value cannot prove where those |
|||
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may |
|||
-- therefore be listed conservatively even though its authority was already permanent. |
|||
-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c |
|||
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can |
|||
-- distinguish from a deliberate grant any more -- check them against what you intended. |
|||
-- |
|||
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- |
|||
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked |
|||
-- value outright -- but the permission is what it would come back with if it is ever restored, and |
|||
-- by then the group it came from may be gone. Status is therefore not part of the predicate. |
|||
-- |
|||
-- Clear whatever you do not want to keep, for example: |
|||
-- |
|||
-- UPDATE users_organizations |
|||
-- SET edit_any_collection = 0, delete_any_collection = 0 |
|||
-- WHERE uuid = '<MEMBERSHIP_UUID>'; |
|||
-- |
|||
-- Then record the decision once, with every Vaultwarden instance stopped: |
|||
-- |
|||
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
-- |
|||
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. |
|||
-- |
|||
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the |
|||
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why |
|||
-- this never creates it. |
|||
-- |
|||
-- The duplicate key aborts the migration. It is only inserted while the record table is absent. |
|||
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_legacy_manager_record_guard (blocked) |
|||
SELECT 1 |
|||
WHERE NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' |
|||
); |
|||
DROP TABLE __vw_legacy_manager_record_guard; |
|||
|
|||
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. |
|||
CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( |
|||
blocked INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); |
|||
INSERT INTO __vw_permanent_authority_guard (blocked) |
|||
SELECT 1 |
|||
FROM users_organizations AS uo |
|||
WHERE uo.atype = 4 |
|||
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) |
|||
AND EXISTS ( |
|||
SELECT 1 |
|||
FROM groups_users AS gu |
|||
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid |
|||
WHERE gu.users_organizations_uuid = uo.uuid |
|||
AND g.organizations_uuid = uo.org_uuid |
|||
AND g.access_all = TRUE |
|||
) |
|||
AND NOT EXISTS ( |
|||
SELECT 1 FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_ack_permanent_collection_authority' |
|||
) |
|||
LIMIT 1; |
|||
DROP TABLE __vw_permanent_authority_guard; |
|||
|
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,281 @@ |
|||
# 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. |
|||
|
|||
## Choosing which members come back as Manager |
|||
|
|||
The old and new role models are not ordered, so this is a decision, not a conversion. 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 it reads member and collection ACL details through `ManagerHeadersLoose`. |
|||
None of that needs a permission flag in the old schema. Mapping every Custom member to Manager would |
|||
therefore *grant* authority during a downgrade: a member with `deleteAnyCollection = false` but a |
|||
direct or group-based manage grant would come back able to delete those collections, and a member |
|||
with no permissions at all would come back able to read the organization's member list. |
|||
|
|||
So the scripts map to Manager only what you list, and everything else to plain User. Create the list |
|||
with every Vaultwarden instance stopped, right before running the rollback: |
|||
|
|||
```sql |
|||
CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); |
|||
``` |
|||
|
|||
Use `CHAR(36)` instead of `TEXT` on MySQL/MariaDB and PostgreSQL. An empty list is a valid answer and |
|||
maps every Custom member to plain User. To add members, list the candidates and pick from them: |
|||
|
|||
```sql |
|||
SELECT uuid, user_uuid, org_uuid, status, |
|||
manage_users, manage_groups, manage_policies, |
|||
create_new_collections, edit_any_collection, delete_any_collection, |
|||
access_event_logs, access_import_export, access_reports |
|||
FROM users_organizations WHERE atype = 4; |
|||
|
|||
INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>'); |
|||
``` |
|||
|
|||
The upgrade records which memberships held the Manager role beforehand, in |
|||
`__vw_custom_role_legacy_manager`. That is useful evidence, and copying it over is a reasonable |
|||
starting point: |
|||
|
|||
```sql |
|||
INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) |
|||
SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager; |
|||
``` |
|||
|
|||
But it is deliberately **not** used automatically. It records who was a Manager before the *first* |
|||
upgrade and is never updated afterwards, so a member whose Manager powers an owner has since reduced |
|||
— or who was demoted to User and later re-created as a limited Custom member — would be handed the |
|||
whole legacy role back. Historical provenance is evidence, not authorization. |
|||
|
|||
## 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 **on the allowlist**, with all three collection permissions | Manager with `access_all = TRUE` | |
|||
| Custom **on the allowlist**, with only some collection permissions | Manager with `access_all = FALSE` | |
|||
| Custom not on the allowlist | plain User with `access_all = FALSE` | |
|||
| 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, so a member mapped to plain User keeps every grant |
|||
those tables carry and loses only the organization-wide powers the old schema cannot express. |
|||
|
|||
One row does not come back byte-identical to what the database held before the *upgrade*, because |
|||
the information no longer exists to reconstruct it: |
|||
|
|||
- **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 carrying `access_all` cannot reach this point at all: the upgrade refuses to start on |
|||
such a database and asks an owner to resolve it first, precisely so that no rollback has to guess |
|||
what the bit meant. For the same reason a Custom member mapped to plain User never keeps `access_all` |
|||
— that combination is the one legacy state the upgrade refuses, and leaving it behind would make the |
|||
database unable to move forward again. |
|||
|
|||
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. |
|||
|
|||
## The upgrade asks one question of its own |
|||
|
|||
Migration `2026-08-10-120000` stops the *upgrade* — not the rollback — when a Custom member holds |
|||
`editAnyCollection` or `deleteAnyCollection` and belongs to an organization-local group with |
|||
`accessAll`. It grants nothing and revokes nothing; it exists because that combination is the one |
|||
place where the new model cannot reproduce the old semantics. |
|||
|
|||
Before the Custom role, a Manager who reached every collection through such a group held that |
|||
authority *while* the group relationship lasted: it ended when the group was deleted, when its |
|||
`accessAll` was cleared, when the member left it, and it was inert whenever `ORG_GROUPS_ENABLED` was |
|||
false. Nothing in the new model expresses a permission bound to a group like that — the permissions |
|||
live on the membership. The earlier migrations in the chain therefore write the authority onto the |
|||
membership, and the result is deliberately not identical to what it replaces: |
|||
|
|||
- it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; |
|||
- it applies even with the groups feature switched off; |
|||
- `editAnyCollection` additionally satisfies `has_full_access()`, so the member reaches every |
|||
collection directly rather than through the group. |
|||
|
|||
Doing that silently would be a migration granting durable organization-wide collection edit and |
|||
delete on its own authority; dropping it silently would take a capability away. Neither is the |
|||
migration's call, so it hands the decision to an owner. On a database with no Custom membership that |
|||
both has edit/delete authority and belongs to an organization-local `accessAll` group, there is |
|||
nothing to decide and it is a no-op. |
|||
|
|||
**Start Vaultwarden once to get the question.** The startup preflight looks ahead for the same |
|||
condition, from the legacy schema as well as the migrated one, and refuses with the review query, the |
|||
three differences above and the acknowledgement statement |
|||
(`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`). The migration keeps its own |
|||
guard as the backstop for a bare `diesel migration run`, but Diesel reports only the driver error |
|||
there, so on that path the question arrives as nothing but a duplicate-key violation on |
|||
`__vw_permanent_authority_guard`. |
|||
|
|||
Every matching membership is asked about, including a recorded legacy Manager with |
|||
`createNewCollections` set. That flag is an independent permission an owner can change after an |
|||
earlier revision materialized group-derived edit/delete, so its current value is not reliable |
|||
historical provenance. This deliberately prefers a conservative extra question over silently making |
|||
group-derived authority permanent. A membership whose own legacy `access_all` supplied all three |
|||
permissions may therefore be listed even though nothing changes meaning for it. An invited or revoked |
|||
membership is asked about too: it holds no authority today, but the permission is what it would come |
|||
back with if it is ever restored. |
|||
|
|||
Answering the question is a different statement depending on when you are asked, because the |
|||
preflight looks ahead from both schemas. Before the upgrade has run there is nothing to clear — the |
|||
permission columns do not exist yet — so declining means ending the group relationship the authority |
|||
comes from, either for one membership (`DELETE FROM groups_users …`) or for the whole group |
|||
(`UPDATE groups SET access_all = FALSE …`). Once the columns exist, clear them directly. Doing that |
|||
after the upgrade is equally safe: Vaultwarden does not start until the acknowledgement is recorded, |
|||
so nothing is ever live in between. The refusal prints both statements. |
|||
|
|||
## How to run it |
|||
|
|||
Stop every Vaultwarden instance and take a backup first. Create the allowlist as described above. |
|||
Then: |
|||
|
|||
```bash |
|||
# SQLite |
|||
sqlite3 -bail /path/to/data/db.sqlite3 < tools/custom_role_rollback/sqlite.sql |
|||
|
|||
# MySQL / MariaDB |
|||
mysql -u <user> -p <database> < tools/custom_role_rollback/mysql.sql |
|||
|
|||
# PostgreSQL |
|||
psql -U <user> -d <database> -v ON_ERROR_STOP=1 -f tools/custom_role_rollback/postgresql.sql |
|||
``` |
|||
|
|||
Every script begins with a **read-only precondition** that inspects the schema and the migration |
|||
ledger before it touches anything, and refuses unless all of these hold: |
|||
|
|||
- membership `access_all` is gone (so the upgrade did run, and this script has not), |
|||
- all nine permission columns exist, |
|||
- all nine Custom-role migrations are recorded in `__diesel_schema_migrations`, |
|||
- **no migration newer than `20260810120000` is recorded** — this script does not know what a later |
|||
migration changed, and removing only the Custom-role versions would leave the ledger claiming a |
|||
migration whose schema objects may have been undone, |
|||
- **`__vw_custom_role_history_verified` exists**, i.e. this database's Custom-role history was |
|||
produced by the migrations that ship today (see the next section), |
|||
- **`__vw_rollback_manager_allowlist` exists**, and on MySQL/MariaDB has exactly one non-nullable, |
|||
uniquely indexed `users_organizations_uuid` column — a table of the right name but the wrong shape |
|||
would otherwise pass every check and then fail on the first read, *after* the first `ALTER TABLE` |
|||
has already committed implicitly, |
|||
- SQLite only: **`users_organizations` has exactly the eighteen expected columns, two indexes and no |
|||
triggers.** The SQLite script rebuilds the table from a fixed column list, so anything it does not |
|||
know about would be dropped along with its data. The column check uses `pragma_table_xinfo`, which |
|||
unlike `table_info` also reports generated columns, and the index check counts `pragma_index_list` |
|||
rather than `sqlite_master`, because the index behind a `UNIQUE` constraint has no SQL text and |
|||
would otherwise be invisible. |
|||
|
|||
A second run, or a half-finished upgrade, is therefore refused with a message that names the reason |
|||
and leaves the database exactly as it was. This matters most on MySQL/MariaDB, where nothing can be |
|||
rolled back: without the check, a database whose `access_all` was already dropped but whose |
|||
access-permission columns were never added would get through the first `ADD COLUMN`, the value |
|||
rewrites, the type change and six `DROP COLUMN`s before failing on the seventh — ending up less |
|||
consistent than before. |
|||
|
|||
The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations`, |
|||
`__vw_rollback_manager_allowlist` and `__vw_custom_role_history_verified` once each, requires all of |
|||
them to live in the **same** schema, and addresses that schema explicitly from then on. An |
|||
unqualified name is otherwise resolved per statement through `search_path`, so a session with |
|||
`search_path = decoy, real` could have the table rewrite land in one schema and the ledger delete in |
|||
another. |
|||
|
|||
The MySQL/MariaDB script ends with an explicit `COMMIT`. Everything before it is DDL and commits |
|||
implicitly, but the final ledger `DELETE` is plain DML: under `autocommit = 0` it would be rolled |
|||
back on disconnect, leaving the schema old while all nine migrations still count as applied — and a |
|||
later upgrade would then skip them and start new code against the old schema. |
|||
|
|||
**Do not drop the `-bail` / `ON_ERROR_STOP=1` flags, do not pass `--force` to `mysql`, and do not run |
|||
these through a client that keeps going after a failed statement.** The sqlite3 shell continues after |
|||
errors by default; the script sets `.bail on` itself, but that is a shell command a different runner |
|||
will ignore. A runner that carries on past a failing statement would reach the `DROP TABLE` and commit |
|||
an empty `users_organizations`. |
|||
|
|||
SQLite and PostgreSQL apply the script in a single transaction, so an aborted run leaves the |
|||
database untouched. On MySQL/MariaDB the statements cannot be wrapped in a transaction (DDL commits |
|||
implicitly there); the precondition is what keeps a mismatch from being mutated at all, but if the |
|||
script is interrupted *after* it passed, restore the backup and start over. |
|||
|
|||
The SQLite script rebuilds `users_organizations` instead of using `ALTER TABLE ... DROP COLUMN`, which |
|||
only exists since SQLite 3.35 — the same reason the forward migration rebuilds the table. It therefore |
|||
also works against the older system SQLite that `sqlite_system` builds link. |
|||
|
|||
Afterwards start the older Vaultwarden version. Upgrading again later re-applies the nine |
|||
migrations from a clean state, and rebuilds `__vw_custom_role_legacy_manager` from the very |
|||
`atype = 3` rows the rollback restored — so the round trip converges. |
|||
|
|||
## Databases upgraded before the history marker existed |
|||
|
|||
`__vw_custom_role_history_verified` is created by `2026-06-30-120000`, and nothing else creates it. |
|||
A database upgraded by an earlier revision of this feature branch carries that migration's version in |
|||
its ledger without the table, and Diesel never re-runs a recorded version — so Vaultwarden refuses to |
|||
start and the rollback scripts refuse to run, rather than acting on migrations whose effects were |
|||
different. |
|||
|
|||
Start Vaultwarden once: it prints the full recovery, which depends on how far the earlier revision |
|||
got and covers up to three things — recording which memberships were legacy Managers, reviewing |
|||
permissions an earlier `20260809120000` granted in bulk to Custom members of `accessAll` groups, and |
|||
reviewing the direct collection assignments an earlier `20260723120000` wrote for a plain User that |
|||
carried membership `access_all`. If you still have the backup from before the first upgrade, |
|||
restoring it and upgrading again is simpler and needs no decision at all. |
|||
|
|||
The marker is created as a separate statement from the legacy-Manager record on purpose. That record |
|||
is data an operator has to be able to write during recovery, so its existence must not double as |
|||
evidence that the history behind it was reviewed — otherwise creating it empty to make the error |
|||
message go away would silently pass as the audit it is asking for. |
|||
|
|||
## Reverting with the Diesel CLI instead |
|||
|
|||
For development checkouts the down migrations do the same thing step by step. **Every one of them that |
|||
loses permission data refuses by default** — `2026-07-24-130000`, `2026-07-16-120000` and |
|||
`2026-06-30-120000` — and so does `2026-07-24-140000`, which loses nothing itself and exists to stop |
|||
the chain before the first destructive step. `2026-08-10-120000` and `2026-08-09-120000` are reverted |
|||
first and are no-ops. Acknowledge the downgrade once: |
|||
|
|||
```sql |
|||
CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
``` |
|||
|
|||
Then `diesel migration revert` works as usual for the whole chain. The acknowledgement is deliberately |
|||
*not* consumed by the first guard it satisfies: it is dropped by the oldest lossy migration |
|||
(`2026-06-30-120000`), so one decision covers one downgrade and a revert that stops halfway is still |
|||
guarded when it resumes. Re-upgrading clears a leftover acknowledgement |
|||
(`2026-07-24-140000/up.sql`), so consent never carries over into a later, unrelated revert. The |
|||
rollback scripts above drop the table as well. |
|||
|
|||
The down migrations use the same allowlist as the scripts above. Unlike the scripts they do not |
|||
refuse when `__vw_rollback_manager_allowlist` is missing — they create it empty, which means "nobody" |
|||
and maps every Custom member to plain User. Populate it first if that is not what you want. |
|||
|
|||
On SQLite the down migrations do use `ALTER TABLE ... DROP COLUMN` and therefore need SQLite 3.35 or |
|||
newer. That is fine for a development checkout with a bundled SQLite; operators on an older system |
|||
SQLite should use `sqlite.sql` above, which rebuilds the table instead. |
|||
|
|||
### MySQL/MariaDB: supported for development checkouts only |
|||
|
|||
On MySQL/MariaDB the Diesel revert chain **cannot be resumed**, and `2026-07-24-140000/down.sql` |
|||
requires a second, separate acknowledgement that says so: |
|||
|
|||
```sql |
|||
CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); |
|||
``` |
|||
|
|||
Every `ALTER TABLE` there commits on its own, while Diesel deletes the ledger row in a separate |
|||
statement afterwards. A crash in between leaves the columns gone and the migration still recorded as |
|||
applied; re-running it then fails forever with `Unknown column` (1091), the startup preflight refuses |
|||
the database — correctly — and the only way out is the backup. Making it resumable would need |
|||
conditional DDL, i.e. a stored procedure created before the checks have run. Each down migration |
|||
removes its three permission columns in a single `ALTER TABLE` rather than three, which is the |
|||
closest this backend gets to all-or-nothing, and temporary guard tables are removed with |
|||
`DROP TEMPORARY TABLE`, which is one implicit commit fewer and cannot hit a permanent table of the |
|||
same name by accident. Use `mysql.sql` above for anything you care about. |
|||
@ -0,0 +1,317 @@ |
|||
-- 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. That is exactly why everything below the precondition has to be reached in a known |
|||
-- state: an ALTER that fails halfway leaves every earlier statement committed. Take a backup before |
|||
-- running it; if it is interrupted, restore and start over. |
|||
|
|||
-- --------------------------------------------------------------------------------------------- |
|||
-- Precondition. Read-only and session-local: it reads `information_schema` and the migration ledger, |
|||
-- prints the reason when the database does not fit, and aborts on a duplicate key in a TEMPORARY |
|||
-- table. No permanent object is created, altered or dropped, so a database this script does not fit |
|||
-- keeps its exact state -- which matters here precisely because DDL cannot be rolled back. |
|||
-- |
|||
-- Without it, a partially upgraded database -- for example one where `access_all` was already dropped |
|||
-- but the access-permission columns were never added, which DDL autocommit makes reachable -- would |
|||
-- get through the first ADD COLUMN, the value rewrites, the type change and six DROP COLUMN statements before |
|||
-- failing on the seventh with error 1091, ending up *less* consistent than before. |
|||
-- |
|||
-- Deliberately not a stored procedure with SIGNAL: MySQL caps `MESSAGE_TEXT` at 128 characters and |
|||
-- answers a longer one with "ERROR 1648 Data too long for condition item 'MESSAGE_TEXT'" instead of |
|||
-- the diagnosis (MariaDB accepts it, so the difference is easy to miss), and CREATE PROCEDURE is a |
|||
-- permanent object that would have to be written *before* the checks have run -- replacing any |
|||
-- same-named routine, surviving a refusal, and requiring routine privileges this script otherwise |
|||
-- does not need. |
|||
-- --------------------------------------------------------------------------------------------- |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition ( |
|||
ok INTEGER NOT NULL PRIMARY KEY |
|||
); |
|||
INSERT INTO __vw_rollback_precondition (ok) VALUES (1); |
|||
|
|||
-- 1) Membership `access_all` has to be gone already, i.e. the upgrade ran and this script did not. |
|||
SELECT CONCAT( |
|||
'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.' |
|||
) AS rollback_precondition_failure |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name = 'access_all'; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name = 'access_all'; |
|||
|
|||
-- 2) All nine permission columns have to be present. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: expected all nine Custom-role permission columns on ', |
|||
'users_organizations, found ', c.n, '. The upgrade is incomplete, so restore the backup taken ', |
|||
'before it and start over.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 9; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users_organizations' |
|||
AND column_name IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 9; |
|||
|
|||
-- 3) All nine Custom-role migrations have to be recorded. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: expected all nine Custom-role migrations in ', |
|||
'__diesel_schema_migrations, found ', c.n, '. Schema and ledger disagree, so restore the backup ', |
|||
'taken before the upgrade and start over.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000', |
|||
'20260715120000', |
|||
'20260716120000', |
|||
'20260723120000', |
|||
'20260724120000', |
|||
'20260724130000', |
|||
'20260724140000', |
|||
'20260809120000', |
|||
'20260810120000' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 9; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000', |
|||
'20260715120000', |
|||
'20260716120000', |
|||
'20260723120000', |
|||
'20260724120000', |
|||
'20260724130000', |
|||
'20260724140000', |
|||
'20260809120000', |
|||
'20260810120000' |
|||
) |
|||
) AS c |
|||
WHERE c.n <> 9; |
|||
|
|||
-- 4) No migration newer than the Custom-role change may be recorded: this script does not know what |
|||
-- such a migration changed, and removing only the nine versions below would leave the ledger |
|||
-- claiming a migration whose schema objects this script may have undone. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: ', c.n, ' migration(s) newer than the Custom-role change are ', |
|||
'recorded. Use the rollback script shipped with that newer version.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version > '20260810120000' |
|||
) AS c |
|||
WHERE c.n <> 0; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM __diesel_schema_migrations |
|||
WHERE version > '20260810120000' |
|||
) AS c |
|||
WHERE c.n <> 0; |
|||
|
|||
-- 5) The upgrade records that this database's Custom-role history is accounted for. Without that |
|||
-- marker the database was migrated by an earlier revision of the change, whose migrations had |
|||
-- different effects. |
|||
SELECT CONCAT( |
|||
'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. Start Vaultwarden once ', |
|||
'and follow the recovery it prints before rolling back.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_custom_role_history_verified' |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_custom_role_history_verified' |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
|
|||
-- 6) 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. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist does not exist. See README.md, ', |
|||
'section "Choosing which members come back as Manager".' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_rollback_manager_allowlist' |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT COUNT(*) AS n |
|||
FROM information_schema.tables |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = '__vw_rollback_manager_allowlist' |
|||
) AS c |
|||
WHERE c.n <> 1; |
|||
|
|||
-- 7) ...and it has to have the shape the role mapping reads. Existence alone is not enough: a |
|||
-- hand-written or colliding table without a usable `users_organizations_uuid` column would pass |
|||
-- every check above and then fail on the first SELECT against it -- which happens *after* the |
|||
-- `ADD COLUMN` below has already committed implicitly, leaving a half-converted database. |
|||
-- Require exactly one non-nullable, uniquely indexed column of that name. |
|||
SELECT CONCAT( |
|||
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist must have exactly one column ', |
|||
'named users_organizations_uuid, NOT NULL and uniquely indexed. Create it as documented in ', |
|||
'README.md.' |
|||
) AS rollback_precondition_failure |
|||
FROM ( |
|||
SELECT |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist') AS cols, |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' AND is_nullable = 'NO') AS usable, |
|||
(SELECT COUNT(*) FROM information_schema.statistics |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' AND non_unique = 0) AS uniq |
|||
) AS c |
|||
WHERE c.cols <> 1 OR c.usable <> 1 OR c.uniq < 1; |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT 1 |
|||
FROM ( |
|||
SELECT |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist') AS cols, |
|||
(SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' AND is_nullable = 'NO') AS usable, |
|||
(SELECT COUNT(*) FROM information_schema.statistics |
|||
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist' |
|||
AND column_name = 'users_organizations_uuid' AND non_unique = 0) AS uniq |
|||
) AS c |
|||
WHERE c.cols <> 1 OR c.usable <> 1 OR c.uniq < 1; |
|||
|
|||
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits implicitly, |
|||
-- and it would happily drop a permanent table of the same name. |
|||
DROP TEMPORARY TABLE __vw_rollback_precondition; |
|||
|
|||
-- --------------------------------------------------------------------------------------------- |
|||
-- From here on the database is known to be in the state this script converts *from*. |
|||
-- --------------------------------------------------------------------------------------------- |
|||
|
|||
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; |
|||
|
|||
-- 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. |
|||
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); |
|||
UPDATE users_organizations |
|||
SET access_all = TRUE |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND create_new_collections = TRUE |
|||
AND edit_any_collection = TRUE |
|||
AND delete_any_collection = TRUE; |
|||
|
|||
-- The old server cannot load type 4. |
|||
UPDATE users_organizations SET atype = 3 |
|||
WHERE atype = 4 |
|||
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); |
|||
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4; |
|||
|
|||
-- One ALTER, not nine. Every `ALTER TABLE` commits implicitly here, so nine statements mean eight |
|||
-- intermediate states an interruption could leave behind; one statement is the closest this backend |
|||
-- gets to all-or-nothing. |
|||
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. The legacy-Manager record goes too: a later |
|||
-- re-upgrade rebuilds it from the very `atype = 3` rows this script just restored, so the round trip |
|||
-- converges. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade; |
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; |
|||
DROP TABLE IF EXISTS __vw_custom_role_history_verified; |
|||
|
|||
-- 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. |
|||
DELETE FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000', |
|||
'20260715120000', |
|||
'20260716120000', |
|||
'20260723120000', |
|||
'20260724120000', |
|||
'20260724130000', |
|||
'20260724140000', |
|||
'20260809120000', |
|||
'20260810120000' |
|||
); |
|||
|
|||
-- Every statement above except this DELETE is DDL and was therefore committed implicitly the moment |
|||
-- it ran. The DELETE is plain DML: under `autocommit = 0` -- which `mysql --init-command`, a my.cnf |
|||
-- default, or a connection pool can all set -- it would be rolled back on disconnect, leaving the |
|||
-- schema rolled back but all nine migrations still marked as applied. A later upgrade would then |
|||
-- skip them and start new code against the old schema. Commit it explicitly; harmless when |
|||
-- autocommit is already on. |
|||
COMMIT; |
|||
@ -0,0 +1,243 @@ |
|||
-- 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; |
|||
@ -0,0 +1,252 @@ |
|||
-- 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. |
|||
|
|||
-- Stop at the first error. Without this the sqlite3 shell keeps going after a failed statement, |
|||
-- and a second run -- where the SELECT below can no longer see the permission columns -- would |
|||
-- still reach DROP TABLE and commit an empty users_organizations. `.bail on` is a shell command; |
|||
-- a runner that is not the sqlite3 CLI has to abort on the first error and roll back by itself. |
|||
.bail on |
|||
|
|||
PRAGMA foreign_keys = OFF; |
|||
|
|||
BEGIN; |
|||
|
|||
-- Refuse to start at all unless the database is in the exact state this script converts *from*. A |
|||
-- repeat run, or a half-finished upgrade, would otherwise only fail somewhere in the middle. Each |
|||
-- check is read-only, and the name of the failing CHECK constraint *is* the error message. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_membership_access_all_still_exists_so_this_database_was_not_upgraded_or_was_already_rolled_back |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition (ok) |
|||
SELECT CASE |
|||
WHEN NOT EXISTS (SELECT 1 FROM pragma_table_xinfo('users_organizations') WHERE name = 'access_all') |
|||
THEN 1 |
|||
ELSE 0 |
|||
END; |
|||
DROP TABLE __vw_rollback_precondition; |
|||
|
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_columns ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_all_nine_custom_role_permission_columns_must_exist_restore_the_pre_upgrade_backup |
|||
CHECK (ok = 9) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_columns (ok) |
|||
SELECT COUNT(*) |
|||
FROM pragma_table_xinfo('users_organizations') |
|||
WHERE name IN ( |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_columns; |
|||
|
|||
-- The rebuild below copies a fixed column list, so anything this script does not know about would be |
|||
-- silently dropped together with its data. Require the table to hold *exactly* the eighteen columns |
|||
-- the Custom-role upgrade leaves behind -- not merely to contain them. A newer migration that added a |
|||
-- column, or a local modification, therefore refuses here instead of being destroyed at COMMIT. |
|||
-- |
|||
-- `table_xinfo`, not `table_info`: the latter omits generated columns entirely, so a STORED or |
|||
-- VIRTUAL column would pass the count unseen and then be lost in the rebuild. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_exact_columns ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_users_organizations_has_unexpected_columns_this_script_is_older_than_the_database |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_exact_columns (ok) |
|||
SELECT CASE WHEN total = 18 AND known = 18 THEN 1 ELSE 0 END |
|||
FROM ( |
|||
SELECT |
|||
COUNT(*) AS total, |
|||
SUM(CASE WHEN name IN ( |
|||
'uuid', 'user_uuid', 'org_uuid', 'akey', 'status', 'atype', |
|||
'reset_password_key', 'external_id', 'invited_by_email', |
|||
'manage_users', 'manage_groups', 'manage_policies', |
|||
'create_new_collections', 'edit_any_collection', 'delete_any_collection', |
|||
'access_event_logs', 'access_import_export', 'access_reports' |
|||
) THEN 1 ELSE 0 END) AS known |
|||
FROM pragma_table_xinfo('users_organizations') |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_exact_columns; |
|||
|
|||
-- Same reasoning for everything else attached to the table: `DROP TABLE` takes its indexes and |
|||
-- triggers with it, and the rebuild recreates only the PRIMARY KEY and the UNIQUE pair. |
|||
-- |
|||
-- Counting `index_list` rather than `sqlite_master` on purpose. An index that SQLite created for a |
|||
-- UNIQUE constraint has no SQL text, so `sqlite_master.sql IS NOT NULL` cannot see it -- an extra |
|||
-- `UNIQUE(external_id)` would pass unnoticed and be gone afterwards. `index_list` reports every |
|||
-- index, so the upgraded table's own two are the exact expected count. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_objects ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_users_organizations_has_extra_indexes_constraints_or_triggers_the_rebuild_would_destroy |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_objects (ok) |
|||
SELECT CASE WHEN indexes = 2 AND triggers = 0 THEN 1 ELSE 0 END |
|||
FROM ( |
|||
SELECT |
|||
(SELECT COUNT(*) FROM pragma_index_list('users_organizations')) AS indexes, |
|||
(SELECT COUNT(*) FROM sqlite_master |
|||
WHERE tbl_name = 'users_organizations' AND type = 'trigger') AS triggers |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_objects; |
|||
|
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_ledger ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_all_nine_custom_role_migrations_must_be_recorded_schema_and_ledger_disagree |
|||
CHECK (ok = 9) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_ledger (ok) |
|||
SELECT COUNT(*) |
|||
FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000', |
|||
'20260715120000', |
|||
'20260716120000', |
|||
'20260723120000', |
|||
'20260724120000', |
|||
'20260724130000', |
|||
'20260724140000', |
|||
'20260809120000', |
|||
'20260810120000' |
|||
); |
|||
DROP TABLE __vw_rollback_precondition_ledger; |
|||
|
|||
-- A migration newer than the last Custom-role one has run, so this script cannot know what it changed |
|||
-- or whether the rebuild below would undo it. Removing only the nine versions would also leave the |
|||
-- ledger claiming a migration whose schema objects are gone. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_migrations_newer_than_the_custom_role_change_are_recorded_use_a_newer_rollback_script |
|||
CHECK (ok = 0) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_future_ledger (ok) |
|||
SELECT COUNT(*) FROM __diesel_schema_migrations WHERE version > '20260810120000'; |
|||
DROP TABLE __vw_rollback_precondition_future_ledger; |
|||
|
|||
-- The upgrade records that this database's Custom-role history is accounted for. Without it the |
|||
-- database was migrated by an earlier revision of the change, whose migrations had different |
|||
-- effects -- start Vaultwarden once and follow the recovery it prints before rolling anything back. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_history ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_custom_role_history_not_verified_start_vaultwarden_once_and_follow_its_recovery |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_history (ok) |
|||
SELECT COUNT(*) |
|||
FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_custom_role_history_verified'; |
|||
DROP TABLE __vw_rollback_precondition_history; |
|||
|
|||
-- Which memberships come back as Manager has to be decided *for this rollback*. See README.md; an |
|||
-- empty list is a valid answer and maps every Custom member to plain User. |
|||
CREATE TEMPORARY TABLE __vw_rollback_precondition_allowlist ( |
|||
ok INTEGER NOT NULL CONSTRAINT |
|||
refused_create_vw_rollback_manager_allowlist_first_see_readme_role_mapping |
|||
CHECK (ok = 1) |
|||
); |
|||
INSERT INTO __vw_rollback_precondition_allowlist (ok) |
|||
SELECT COUNT(*) |
|||
FROM sqlite_master |
|||
WHERE type = 'table' AND name = '__vw_rollback_manager_allowlist'; |
|||
DROP TABLE __vw_rollback_precondition_allowlist; |
|||
|
|||
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) |
|||
); |
|||
|
|||
-- Roles and the legacy flag are recomputed together, because in the old schema they are not |
|||
-- independent. |
|||
-- |
|||
-- 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. Per-collection assignments are untouched, so those members |
|||
-- keep every grant `users_collections` and `collections_groups` carry. |
|||
-- |
|||
-- `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, so leaving it set would make this |
|||
-- database unable to move forward again. |
|||
INSERT INTO users_organizations_rollback ( |
|||
uuid, user_uuid, org_uuid, access_all, akey, status, atype, |
|||
reset_password_key, external_id, invited_by_email |
|||
) |
|||
SELECT |
|||
uo.uuid, uo.user_uuid, uo.org_uuid, |
|||
CASE |
|||
WHEN uo.atype IN (0, 1) THEN 1 |
|||
WHEN uo.atype = 4 |
|||
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
AND uo.create_new_collections = 1 |
|||
AND uo.edit_any_collection = 1 |
|||
AND uo.delete_any_collection = 1 THEN 1 |
|||
ELSE 0 |
|||
END, |
|||
uo.akey, uo.status, |
|||
-- The old server cannot load type 4. |
|||
CASE |
|||
WHEN uo.atype = 4 |
|||
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) |
|||
THEN 3 |
|||
WHEN uo.atype = 4 THEN 2 |
|||
ELSE uo.atype |
|||
END, |
|||
uo.reset_password_key, uo.external_id, uo.invited_by_email |
|||
FROM users_organizations AS uo; |
|||
|
|||
DROP TABLE users_organizations; |
|||
|
|||
ALTER TABLE users_organizations_rollback RENAME TO users_organizations; |
|||
|
|||
-- 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. |
|||
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; |
|||
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; |
|||
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; |
|||
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; |
|||
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; |
|||
DROP TABLE IF EXISTS __vw_custom_role_history_verified; |
|||
|
|||
-- 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. |
|||
DELETE FROM __diesel_schema_migrations |
|||
WHERE version IN ( |
|||
'20260630120000', |
|||
'20260715120000', |
|||
'20260716120000', |
|||
'20260723120000', |
|||
'20260724120000', |
|||
'20260724130000', |
|||
'20260724140000', |
|||
'20260809120000', |
|||
'20260810120000' |
|||
); |
|||
|
|||
COMMIT; |
|||
Loading…
Reference in new issue