diff --git a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql index 9ac54bfb..7b3c05be 100644 --- a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql +++ b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -1,6 +1,75 @@ --- Convert Custom members back to Manager, the representation older server versions --- expect (they masquerade Manager as Custom in API responses and cannot load type 4). -UPDATE users_organizations SET atype = 3 WHERE atype = 4; -ALTER TABLE users_organizations DROP COLUMN manage_users; -ALTER TABLE users_organizations DROP COLUMN manage_groups; -ALTER TABLE users_organizations DROP COLUMN manage_policies; +-- 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; diff --git a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql index 6ffdca13..09451eca 100644 --- a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql +++ b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -1,6 +1,34 @@ 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 diff --git a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql b/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql index 6506059d..b6f97540 100644 --- a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql +++ b/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql @@ -1,9 +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; -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; +-- 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; diff --git a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql b/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql index d247d1e9..487da5f8 100644 --- a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql +++ b/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql @@ -1,22 +1,61 @@ +-- 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. +-- 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 diff --git a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql b/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql index b9d4e9e6..4188886b 100644 --- a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql +++ b/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql @@ -1,3 +1,4 @@ --- This is an idempotent data repair. Reverting it must not remove permissions or recreate the --- invalid persisted Manager type; the older-schema migration performs its own safe conversion. +-- 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; diff --git a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql b/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql index b7d93cdc..bf20cf2c 100644 --- a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql +++ b/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql @@ -1,48 +1,76 @@ --- A normal User with the historical membership-level access_all bit reached every collection of the --- organization with full read/write, but held no collection-management authority. Mapping that onto --- the Custom role would add authority, clearing the bit would remove existing access — so instead, --- materialize the reach as explicit per-collection assignments while the source bit still exists. --- `manage` stays FALSE, so no management authority is invented. This is the same approach Bitwarden --- took when it retired `accessAll`; the one behavioral difference is that the access is no longer --- dynamic, i.e. collections created later are not added automatically. +-- Repair the legacy role/permission state while membership `access_all` still exists. -- --- Step 1: a pre-existing assignment was overridden by access_all (full read/write regardless of --- read_only/hide_passwords), so relax it to match what the member actually had. -UPDATE users_collections -SET read_only = FALSE, - hide_passwords = FALSE -WHERE EXISTS ( - SELECT 1 - FROM users_organizations AS uo - INNER JOIN collections AS c ON c.org_uuid = uo.org_uuid - WHERE uo.atype = 2 - AND uo.access_all = TRUE - AND uo.user_uuid = users_collections.user_uuid - AND c.uuid = users_collections.collection_uuid +-- 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; --- Step 2: add the assignments that did not exist yet. Existing rows are left to step 1. -INSERT IGNORE INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) -SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE -FROM users_organizations AS uo -INNER JOIN collections AS c ON c.org_uuid = uo.org_uuid -WHERE uo.atype = 2 - AND uo.access_all = TRUE; +-- 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; --- The current 2026-07-16 migration copied a legacy full-access group's dynamic authority to the --- exact direct 0/1/1 pattern. While the same organization-local source group is still present, --- remove that deterministic copy so later group removal also revokes the authority. The runtime --- keeps deriving edit/delete from that group -- see --- `Membership::has_legacy_group_collection_manage_access` -- so nothing is lost here. +-- 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 = FALSE, - delete_any_collection = FALSE +SET edit_any_collection = TRUE, + delete_any_collection = TRUE WHERE atype IN (3, 4) - AND access_all = FALSE - AND create_new_collections = FALSE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE - AND EXISTS (SELECT 1 FROM __vw_custom_role_same_run_0716 WHERE marker = 1) + AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) AND EXISTS ( SELECT 1 FROM groups_users AS gu @@ -52,30 +80,16 @@ WHERE atype IN (3, 4) AND g.access_all = TRUE ); --- A remaining 0/1/1 pattern may be either an intentional direct grant or an older derived grant --- whose source group has already been removed. Do not guess which one it is. -CREATE TEMPORARY TABLE __vw_legacy_group_access_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_group_access_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_group_access_guard (blocked) -SELECT 1 -FROM users_organizations -WHERE atype IN (3, 4) - AND access_all = FALSE - AND create_new_collections = FALSE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE -LIMIT 1; -DROP TEMPORARY TABLE __vw_legacy_group_access_guard; - --- Membership access_all on a legacy Manager/Custom represented all three collection capabilities. --- Set only TRUE values so this repair never removes independently configured permissions. +-- 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. diff --git a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql index f276ea5b..39a10e8b 100644 --- a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql +++ b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql @@ -1,3 +1,28 @@ -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; +-- 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; diff --git a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql index 93358747..6db91a70 100644 --- a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql @@ -1,12 +1,13 @@ --- Nine independent Custom-role permissions cannot be represented losslessly by the legacy --- role/access_all schema, so a revert is blocked here -- before any older down migration removes --- permission data. --- --- It is an explicit, acknowledged decision though, not a dead end. Create the marker table below --- while every Vaultwarden instance is stopped and this guard lets the revert through: +-- 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 ( @@ -18,10 +19,42 @@ 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' + 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 TABLE __vw_custom_role_downgrade_guard; +DROP TEMPORARY TABLE __vw_mysql_resume_guard; --- Consume the acknowledgement: it authorized *this* revert, not every future one. After a --- re-upgrade the next revert has to be acknowledged again. -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; +-- 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; diff --git a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql index 9079f661..1d0d86d3 100644 --- a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql +++ b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql @@ -7,5 +7,7 @@ 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. +-- 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; diff --git a/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql new file mode 100644 index 00000000..613cc7e7 --- /dev/null +++ b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql @@ -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; \ No newline at end of file diff --git a/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql new file mode 100644 index 00000000..4586bfdf --- /dev/null +++ b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql @@ -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 + ); diff --git a/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql new file mode 100644 index 00000000..6fcda697 --- /dev/null +++ b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql @@ -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; diff --git a/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql new file mode 100644 index 00000000..151b0812 --- /dev/null +++ b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql @@ -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 = ''; +-- +-- 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; diff --git a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql index 9ac54bfb..3879f340 100644 --- a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql +++ b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -1,6 +1,65 @@ --- Convert Custom members back to Manager, the representation older server versions --- expect (they masquerade Manager as Custom in API responses and cannot load type 4). -UPDATE users_organizations SET atype = 3 WHERE atype = 4; +-- 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; diff --git a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql index 6ffdca13..4096fc6d 100644 --- a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql +++ b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -1,6 +1,35 @@ 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 diff --git a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql b/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql index 6506059d..c278fa8e 100644 --- a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql +++ b/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql @@ -1,3 +1,19 @@ +-- 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 diff --git a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql b/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql index da66070a..aab5f0cd 100644 --- a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql +++ b/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql @@ -1,22 +1,55 @@ +-- 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. +-- 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 diff --git a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql b/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql index b9d4e9e6..4188886b 100644 --- a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql +++ b/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql @@ -1,3 +1,4 @@ --- This is an idempotent data repair. Reverting it must not remove permissions or recreate the --- invalid persisted Manager type; the older-schema migration performs its own safe conversion. +-- 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; diff --git a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql b/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql index aca9d21d..7b9ad6b3 100644 --- a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql +++ b/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql @@ -1,49 +1,73 @@ --- A normal User with the historical membership-level access_all bit reached every collection of the --- organization with full read/write, but held no collection-management authority. Mapping that onto --- the Custom role would add authority, clearing the bit would remove existing access — so instead, --- materialize the reach as explicit per-collection assignments while the source bit still exists. --- `manage` stays FALSE, so no management authority is invented. This is the same approach Bitwarden --- took when it retired `accessAll`; the one behavioral difference is that the access is no longer --- dynamic, i.e. collections created later are not added automatically. +-- Repair the legacy role/permission state while membership `access_all` still exists. -- --- Step 1: a pre-existing assignment was overridden by access_all (full read/write regardless of --- read_only/hide_passwords), so relax it to match what the member actually had. -UPDATE users_collections -SET read_only = FALSE, - hide_passwords = FALSE -WHERE EXISTS ( - SELECT 1 - FROM users_organizations AS uo - INNER JOIN collections AS c ON c.org_uuid = uo.org_uuid - WHERE uo.atype = 2 - AND uo.access_all = TRUE - AND uo.user_uuid = users_collections.user_uuid - AND c.uuid = users_collections.collection_uuid +-- 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; --- Step 2: add the assignments that did not exist yet. Existing rows are left to step 1. -INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) -SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE -FROM users_organizations AS uo -INNER JOIN collections AS c ON c.org_uuid = uo.org_uuid -WHERE uo.atype = 2 - AND uo.access_all = TRUE -ON CONFLICT (user_uuid, collection_uuid) DO NOTHING; +-- 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; --- The current 2026-07-16 migration copied a legacy full-access group's dynamic authority to the --- exact direct 0/1/1 pattern. While the same organization-local source group is still present, --- remove that deterministic copy so later group removal also revokes the authority. The runtime --- keeps deriving edit/delete from that group -- see --- `Membership::has_legacy_group_collection_manage_access` -- so nothing is lost here. +-- 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 = FALSE, - delete_any_collection = FALSE +SET edit_any_collection = TRUE, + delete_any_collection = TRUE WHERE atype IN (3, 4) - AND access_all = FALSE - AND create_new_collections = FALSE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE - AND EXISTS (SELECT 1 FROM __vw_custom_role_same_run_0716 WHERE marker = 1) + AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) AND EXISTS ( SELECT 1 FROM groups_users AS gu @@ -53,34 +77,20 @@ WHERE atype IN (3, 4) AND g.access_all = TRUE ); --- A remaining 0/1/1 pattern may be either an intentional direct grant or an older derived grant --- whose source group has already been removed. Do not guess which one it is. -CREATE TEMPORARY TABLE __vw_legacy_group_access_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_group_access_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_group_access_guard (blocked) -SELECT 1 -FROM users_organizations -WHERE atype IN (3, 4) - AND access_all = FALSE - AND create_new_collections = FALSE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE -LIMIT 1; -DROP TABLE __vw_legacy_group_access_guard; - --- Membership access_all on a legacy Manager/Custom represented all three collection capabilities. --- Set only TRUE values so this repair never removes independently configured permissions. +-- 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 guard and permission update succeeds. +-- Clear the same-run marker only after every permission update succeeds. DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; diff --git a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql index f276ea5b..c9d1c95e 100644 --- a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql +++ b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql @@ -1,3 +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; diff --git a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql index 787d60dc..d5a54d49 100644 --- a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql @@ -1,12 +1,13 @@ --- Nine independent Custom-role permissions cannot be represented losslessly by the legacy --- role/access_all schema, so a revert is blocked here -- before any older down migration removes --- permission data. --- --- It is an explicit, acknowledged decision though, not a dead end. Create the marker table below --- while every Vaultwarden instance is stopped and this guard lets the revert through: +-- 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 ( @@ -19,6 +20,8 @@ SELECT 1 WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; DROP TABLE __vw_custom_role_downgrade_guard; --- Consume the acknowledgement: it authorized *this* revert, not every future one. After a --- re-upgrade the next revert has to be acknowledged again. -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; +-- 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; diff --git a/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql new file mode 100644 index 00000000..613cc7e7 --- /dev/null +++ b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql @@ -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; \ No newline at end of file diff --git a/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql new file mode 100644 index 00000000..821a5314 --- /dev/null +++ b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql @@ -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 + ); diff --git a/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql new file mode 100644 index 00000000..6fcda697 --- /dev/null +++ b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql @@ -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; diff --git a/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql new file mode 100644 index 00000000..929450c3 --- /dev/null +++ b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql @@ -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 = ''; +-- +-- 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; diff --git a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql index 9ac54bfb..a8faf67b 100644 --- a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql +++ b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -1,6 +1,68 @@ --- Convert Custom members back to Manager, the representation older server versions --- expect (they masquerade Manager as Custom in API responses and cannot load type 4). -UPDATE users_organizations SET atype = 3 WHERE atype = 4; +-- 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; diff --git a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql index 6ffdca13..7087c25c 100644 --- a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql +++ b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -1,6 +1,34 @@ 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 diff --git a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql b/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql index 6506059d..41ad950e 100644 --- a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql +++ b/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql @@ -1,3 +1,22 @@ +-- 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 diff --git a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql b/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql index da66070a..a819bad8 100644 --- a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql +++ b/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql @@ -1,22 +1,58 @@ +-- 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. +-- 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 diff --git a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql b/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql index b9d4e9e6..4188886b 100644 --- a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql +++ b/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql @@ -1,3 +1,4 @@ --- This is an idempotent data repair. Reverting it must not remove permissions or recreate the --- invalid persisted Manager type; the older-schema migration performs its own safe conversion. +-- 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; diff --git a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql b/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql index 404f9fd9..81c8e1e5 100644 --- a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql +++ b/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql @@ -1,48 +1,75 @@ --- A normal User with the historical membership-level access_all bit reached every collection of the --- organization with full read/write, but held no collection-management authority. Mapping that onto --- the Custom role would add authority, clearing the bit would remove existing access — so instead, --- materialize the reach as explicit per-collection assignments while the source bit still exists. --- `manage` stays FALSE, so no management authority is invented. This is the same approach Bitwarden --- took when it retired `accessAll`; the one behavioral difference is that the access is no longer --- dynamic, i.e. collections created later are not added automatically. +-- Repair the legacy role/permission state while membership `access_all` still exists. -- --- Step 1: a pre-existing assignment was overridden by access_all (full read/write regardless of --- read_only/hide_passwords), so relax it to match what the member actually had. -UPDATE users_collections -SET read_only = FALSE, - hide_passwords = FALSE -WHERE EXISTS ( - SELECT 1 - FROM users_organizations AS uo - INNER JOIN collections AS c ON c.org_uuid = uo.org_uuid - WHERE uo.atype = 2 - AND uo.access_all = TRUE - AND uo.user_uuid = users_collections.user_uuid - AND c.uuid = users_collections.collection_uuid +-- 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; --- Step 2: add the assignments that did not exist yet. Existing rows are left to step 1. -INSERT OR IGNORE INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) -SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE -FROM users_organizations AS uo -INNER JOIN collections AS c ON c.org_uuid = uo.org_uuid -WHERE uo.atype = 2 - AND uo.access_all = TRUE; +-- 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; --- The current 2026-07-16 migration copied a legacy full-access group's dynamic authority to the --- exact direct 0/1/1 pattern. While the same organization-local source group is still present, --- remove that deterministic copy so later group removal also revokes the authority. The runtime --- keeps deriving edit/delete from that group -- see --- `Membership::has_legacy_group_collection_manage_access` -- so nothing is lost here. +-- 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 = FALSE, - delete_any_collection = FALSE +SET edit_any_collection = TRUE, + delete_any_collection = TRUE WHERE atype IN (3, 4) - AND access_all = FALSE - AND create_new_collections = FALSE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE - AND EXISTS (SELECT 1 FROM __vw_custom_role_same_run_0716 WHERE marker = 1) + AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) AND EXISTS ( SELECT 1 FROM groups_users AS gu @@ -52,34 +79,20 @@ WHERE atype IN (3, 4) AND g.access_all = TRUE ); --- A remaining 0/1/1 pattern may be either an intentional direct grant or an older derived grant --- whose source group has already been removed. Do not guess which one it is. -CREATE TEMPORARY TABLE __vw_legacy_group_access_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_group_access_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_group_access_guard (blocked) -SELECT 1 -FROM users_organizations -WHERE atype IN (3, 4) - AND access_all = FALSE - AND create_new_collections = FALSE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE -LIMIT 1; -DROP TABLE __vw_legacy_group_access_guard; - --- Membership access_all on a legacy Manager/Custom represented all three collection capabilities. --- Set only TRUE values so this repair never removes independently configured permissions. +-- 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 guard and permission update succeeds. +-- Clear the same-run marker only after every permission update succeeds. DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; diff --git a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql index f276ea5b..31101986 100644 --- a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql +++ b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql @@ -1,3 +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; diff --git a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql index b6fb06ac..4e8f080f 100644 --- a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql @@ -1,12 +1,13 @@ --- Nine independent Custom-role permissions cannot be represented losslessly by the legacy --- role/access_all schema, so a revert is blocked here -- before any older down migration removes --- permission data. --- --- It is an explicit, acknowledged decision though, not a dead end. Create the marker table below --- while every Vaultwarden instance is stopped and this guard lets the revert through: +-- 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 ( @@ -18,10 +19,11 @@ 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' -); + WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade'); DROP TABLE __vw_custom_role_downgrade_guard; --- Consume the acknowledgement: it authorized *this* revert, not every future one. After a --- re-upgrade the next revert has to be acknowledged again. -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; +-- 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; diff --git a/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql new file mode 100644 index 00000000..613cc7e7 --- /dev/null +++ b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql @@ -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; \ No newline at end of file diff --git a/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql new file mode 100644 index 00000000..43c39b16 --- /dev/null +++ b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql @@ -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 + ); diff --git a/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql new file mode 100644 index 00000000..6fcda697 --- /dev/null +++ b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql @@ -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; diff --git a/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql new file mode 100644 index 00000000..8c1a579b --- /dev/null +++ b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql @@ -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 = ''; +-- +-- 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; diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 2b51fd0c..785dea1b 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -392,6 +392,16 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } +fn has_prevalidated_organization_write_authority( + allow_direct_organization_write: bool, + shared_to_collections: Option<&Vec>, + member_has_full_access: bool, +) -> bool { + allow_direct_organization_write + || shared_to_collections.is_some_and(|collections| !collections.is_empty()) + || member_has_full_access +} + pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -400,6 +410,23 @@ pub async fn update_cipher_from_data( conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, +) -> EmptyResult { + update_cipher_from_data_with_authority(cipher, data, headers, shared_to_collections, false, conn, nt, ut).await +} + +#[expect( + clippy::too_many_arguments, + reason = "The extra flag is a prevalidated route authority and must remain separate from client data" +)] +pub(super) async fn update_cipher_from_data_with_authority( + cipher: &mut Cipher, + data: CipherData, + headers: &Headers, + shared_to_collections: Option>, + allow_direct_organization_write: bool, + conn: &DbConn, + nt: &Notify<'_>, + ut: UpdateType, ) -> EmptyResult { // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. @@ -452,9 +479,11 @@ pub async fn update_cipher_from_data( Some(member) => { // A non-empty list of collections implies the caller already validated the user's write // access to them, so we can move the cipher into the organization on that basis. - if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) - || member.has_full_access() - || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await + if has_prevalidated_organization_write_authority( + allow_direct_organization_write, + shared_to_collections.as_ref(), + member.has_full_access(), + ) || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -577,6 +606,25 @@ pub async fn update_cipher_from_data( Ok(()) } +#[cfg(test)] +mod update_authority_tests { + use super::has_prevalidated_organization_write_authority; + + #[test] + fn direct_organization_write_is_an_explicit_import_authority() { + // Keep the organization-import shortcut independent from the old non-empty-collection + // sentinel. The route may import ciphers without collections when AccessImportExport grants + // organization-wide import authority; every other caller passes false. + let no_collections: Vec = Vec::new(); + assert!(has_prevalidated_organization_write_authority(true, Some(&no_collections), false)); + assert!(!has_prevalidated_organization_write_authority(false, Some(&no_collections), false)); + + let collections = vec!["collection".to_owned().into()]; + assert!(has_prevalidated_organization_write_authority(false, Some(&collections), false)); + assert!(has_prevalidated_organization_write_authority(false, None, true)); + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 6aa6b954..3d8d4808 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -94,10 +94,10 @@ enum CipherEventScope { } impl CipherEventScope { - fn includes(&self, event: &Event) -> bool { + fn organization_id(&self) -> Option<&OrganizationId> { match self { - Self::Organization(org_id) => event.org_uuid.as_ref() == Some(org_id), - Self::Personal => event.org_uuid.is_none(), + Self::Organization(org_id) => Some(org_id), + Self::Personal => None, } } } @@ -142,10 +142,9 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade }; if let Some(scope) = scope { - Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn) + Event::find_by_cipher_uuid(&cipher_id, scope.organization_id(), &start_date, &end_date, &conn) .await .iter() - .filter(|event| scope.includes(event)) .map(Event::to_json) .collect() } else { @@ -549,15 +548,11 @@ mod tests { } #[test] - fn cipher_event_rows_must_match_the_authorized_scope() { + fn cipher_event_scope_selects_the_database_scope_filter() { let org_id: OrganizationId = "test-org".to_owned().into(); - let mut event = Event::new(EventType::CipherClientViewed as i32, None); - assert!(CipherEventScope::Personal.includes(&event)); - event.org_uuid = Some(org_id.clone()); - assert!(!CipherEventScope::Personal.includes(&event)); - assert!(CipherEventScope::Organization(org_id).includes(&event)); - assert!(!CipherEventScope::Organization("other-org".to_owned().into()).includes(&event)); + assert_eq!(CipherEventScope::Personal.organization_id(), None); + assert_eq!(CipherEventScope::Organization(org_id.clone()).organization_id(), Some(&org_id)); } #[test] diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index fa4e7a4b..d4c6fa06 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -14,7 +14,7 @@ use crate::{ auth::{ AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, - ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite, + ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite, }, db::{ DbConn, @@ -397,19 +397,25 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos // Custom users with a user/group manage permission need to read the collection list // (metadata only) to be able to assign collections to groups/members. This does NOT // expose cipher contents. manage_policies does not need the collection list. - let can_read_collection_list = headers.membership.has_full_access() - || headers.membership.has_manage_users() - || headers.membership.has_manage_groups() - || headers.membership.has_delete_any_collection() - // Create new collections needs the list too: the client resolves the parent of a nested - // collection against it and refreshes it after a create. - || headers.membership.has_create_new_collections(); - if !can_read_collection_list { + let can_read_collection_list = may_read_complete_collection_list(&headers.membership); + let all_collections = Collection::find_by_organization(&org_id, &conn).await; + let collections = if can_read_collection_list { + all_collections + } else { + let mut explicitly_managed = Vec::new(); + for collection in all_collections { + if headers.membership.has_explicit_collection_manage_access(&collection.uuid, &conn).await { + explicitly_managed.push(collection); + } + } + explicitly_managed + }; + if !can_read_collection_list && collections.is_empty() { err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ - "data": get_org_collections_impl(&org_id, &conn).await, + "data": collections.iter().map(Collection::to_json).collect::(), "object": "list", "continuationToken": null, }))) @@ -444,11 +450,6 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea || member.has_manage_groups() || member.has_delete_any_collection() || member.has_create_new_collections(); - // Delete any collection can reveal collection access metadata, matching Bitwarden's - // ReadAllWithAccess behavior, but still does not grant cipher access. Manage Users/Groups - // retain the narrower metadata-only view introduced by the base PR. - let can_read_all_collection_access = member.has_edit_any_collection() || member.has_delete_any_collection(); - // Get all admins, owners and managers who can manage/access all // Those are currently not listed in the col_users but need to be listed too. let manage_all_members: Vec = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn) @@ -472,53 +473,58 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea || (CONFIG.org_groups_enabled() && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); - // If the user is a manager and is not assigned to this collection, normally skip it. - // Exception: custom users with a manage permission get a metadata-only entry (no user - // or group access details) so the web client can resolve assignment references without - // crashing. This never exposes cipher contents. - if !assigned && !can_read_all_collection_access { - if can_read_collection_list { + // ACL mappings require the same authority as the single-collection details endpoint. + // Mere read access (`assigned`, including group `access_all`) is not Manage authority. + match collection_details_response_scope( + can_read_collection_access(&member, &col.uuid, &conn).await, + assigned, + can_read_collection_list, + ) { + CollectionDetailsResponseScope::MetadataOnly => { let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; - json_object["assigned"] = json!(false); + json_object["assigned"] = json!(assigned); json_object["users"] = json!(Vec::::new()); json_object["groups"] = json!(Vec::::new()); json_object["object"] = json!("collectionAccessDetails"); json_object["unmanaged"] = json!(false); data.push(json_object); } - continue; - } - - // get the users assigned directly to the given collection - let mut users: Vec = col_users - .iter() - .filter(|collection_member| collection_member.collection_uuid == col.uuid) - .map(|collection_member| { - collection_member.to_json_details_for_member( - *membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)), - ) - }) - .collect(); - users.extend_from_slice(&manage_all_members); - - // get the group details for the given collection - let groups: Vec = if CONFIG.org_groups_enabled() { - CollectionGroup::find_by_collection(&col.uuid, &conn) - .await - .iter() - .map(CollectionGroup::to_json_details_for_group) - .collect() - } else { - Vec::new() - }; + CollectionDetailsResponseScope::Hidden => {} + CollectionDetailsResponseScope::AccessDetails => { + // get the users assigned directly to the given collection + let mut users: Vec = col_users + .iter() + .filter(|collection_member| collection_member.collection_uuid == col.uuid) + .map(|collection_member| { + collection_member.to_json_details_for_member( + *membership_type + .get(&collection_member.membership_uuid) + .unwrap_or(&(MembershipType::User as i32)), + ) + }) + .collect(); + users.extend_from_slice(&manage_all_members); + + // get the group details for the given collection + let groups: Vec = if CONFIG.org_groups_enabled() { + CollectionGroup::find_by_collection(&col.uuid, &conn) + .await + .iter() + .map(CollectionGroup::to_json_details_for_group) + .collect() + } else { + Vec::new() + }; - let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; - json_object["assigned"] = json!(assigned); - json_object["users"] = json!(users); - json_object["groups"] = json!(groups); - json_object["object"] = json!("collectionAccessDetails"); - json_object["unmanaged"] = json!(false); - data.push(json_object); + let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; + json_object["assigned"] = json!(assigned); + json_object["users"] = json!(users); + json_object["groups"] = json!(groups); + json_object["object"] = json!("collectionAccessDetails"); + json_object["unmanaged"] = json!(false); + data.push(json_object); + } + } } Ok(Json(json!({ @@ -528,8 +534,35 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea }))) } -async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value { - Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::() +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CollectionDetailsResponseScope { + AccessDetails, + MetadataOnly, + Hidden, +} + +fn may_read_complete_collection_list(member: &Membership) -> bool { + member.has_full_access() + || member.has_manage_users() + || member.has_manage_groups() + || member.has_delete_any_collection() + // Create new collections needs the list too: the client resolves the parent of a nested + // collection against it and refreshes it after a create. + || member.has_create_new_collections() +} + +fn collection_details_response_scope( + can_read_access_details: bool, + has_collection_read_access: bool, + can_read_collection_list: bool, +) -> CollectionDetailsResponseScope { + if can_read_access_details { + CollectionDetailsResponseScope::AccessDetails + } else if has_collection_read_access || can_read_collection_list { + CollectionDetailsResponseScope::MetadataOnly + } else { + CollectionDetailsResponseScope::Hidden + } } #[post("/organizations//collections", data = "")] @@ -569,15 +602,14 @@ async fn post_organization_collections( let collection = Collection::new(org_id.clone(), data.name, data.external_id); collection.save(&conn).await?; - // Security (F-3): a `manage` grant carries collection *delete*/administer authority - // (`has_explicit_collection_manage_access` -> CollectionDeleteHeaders/ManagerHeaders), so only a - // caller who could delete this collection may confer it — the same rule the collection-update and - // bulk-access endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom - // member holding only `create_new_collections` must not be able to hand a manage row to another - // member or to a group while creating the collection. For such callers the requested `manage` - // is forced to false; Admin/Owner and Custom-with-`delete_any_collection` keep it. The creator's - // own object-scoped ownership is added separately below. Evaluated after the collection exists - // so the per-collection lookup sees it. + // Security (F-3): a `manage` grant carries collection administration authority + // (`has_explicit_collection_manage_access` -> ManagerHeaders), so only a caller who may already + // administer this collection may confer it — the same rule the collection-update and bulk-access + // endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom member + // holding only `create_new_collections` must not be able to hand a manage row to another member + // or to a group while creating the collection. For such callers the requested `manage` is forced + // to false. The creator's own object-scoped ownership is added separately below. Evaluated after + // the collection exists so the per-collection lookup sees it. let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await; let creator_needs_assignment = !headers.membership.has_full_access(); @@ -1088,32 +1120,17 @@ async fn assigned_org_ciphers_json( // report (Exposed/Reused/Weak Passwords, Unsecured Websites, Inactive 2FA, ...) locally — Vaultwarden // has no server-side reports. // -// Two different answers, depending on how much the caller may actually read: -// -// * Members who already reach every collection (Admin/Owner, or Custom + `editAnyCollection`) get -// the whole organization, serialized with `CipherSyncType::Organization` which deliberately skips -// the per-cipher access restrictions. This is unchanged behavior. -// -// * `accessReports` opens the endpoint *without* widening what may be read: the response is built -// from the caller's own assignments with `CipherSyncType::User`, so `readOnly`/`hidePasswords` -// still apply and collections the member is not assigned to never appear. Their reports therefore -// cover exactly their own collections. -// -// This mirrors `accessImportExport`/`get_org_export`: a permission decides *whether* a member may use -// a feature, never *what* they may read. Bitwarden upstream is more permissive here (its -// `CanAccessAllCiphersAsync` grants the full organization to AccessReports as well); we deliberately -// deviate so that ticking "Access reports" cannot hand out read access to every password in the -// organization. +// Bitwarden computes organization reports locally from this list. `accessReports` therefore grants +// the full organization cipher list, just like Admin/Owner or `editAnyCollection`; limiting it to the +// caller's assignments makes organization-wide reports silently incomplete. #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); } - let ciphers_json = if headers.membership.has_full_access() { + let ciphers_json = if may_read_all_organization_ciphers(&headers.membership) { get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? - } else if headers.membership.has_access_reports() { - assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? } else { err_code!( "Resource not found.", @@ -1320,15 +1337,26 @@ impl CustomRolePermissions { } fn differs_from(self, membership: &Membership) -> bool { - self.manage_users != membership.manage_users - || self.manage_groups != membership.manage_groups - || self.manage_policies != membership.manage_policies - || self.create_new_collections != membership.create_new_collections - || self.edit_any_collection != membership.edit_any_collection - || self.delete_any_collection != membership.delete_any_collection - || self.access_event_logs != membership.access_event_logs - || self.access_import_export != membership.access_import_export - || self.access_reports != membership.access_reports + let stored = if membership.atype == MembershipType::Custom as i32 { + Self { + manage_users: membership.manage_users, + manage_groups: membership.manage_groups, + manage_policies: membership.manage_policies, + create_new_collections: membership.create_new_collections, + edit_any_collection: membership.edit_any_collection, + delete_any_collection: membership.delete_any_collection, + access_event_logs: membership.access_event_logs, + access_import_export: membership.access_import_export, + access_reports: membership.access_reports, + } + } else { + // Permission bits outside the Custom role are stale, inert data. Clearing them while an + // ordinary member is edited is not an authority change and must not make a + // ManageUsers-only caller fail the "may not change custom permissions" check. + Self::default() + }; + + self != stored } fn apply_to(self, membership: &mut Membership) { @@ -1428,9 +1456,14 @@ async fn send_invite( } } - let mut user_created: bool = false; for email in &data.emails { let mut member_status = MembershipStatus::Invited as i32; + // Scoped to this iteration on purpose. A single flag hoisted out of the loop stays `true` + // for every later recipient once any account has been created, so a failing invite mail to + // an address that already had an account would delete that *existing* global user -- their + // personal ciphers, devices, 2FA, emergency access and memberships in unrelated + // organizations -- instead of only the membership this request just made. + let mut user_created: bool = false; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -1958,6 +1991,16 @@ async fn edit_member( // confirm, revoke, restore, delete). Without it `edit_member` was the only path on which a // Custom member holding manage_users could aim at an Admin or at a fellow Custom membership, as // long as the request left the role unchanged. + // + // NOTE: this is a deliberate, documented narrowing of upstream. Bitwarden lets Custom+ManageUsers + // administer *peer Custom* members too, and delegate a subset of the permissions the actor holds + // itself (`OrganizationUserValidationService`). Implementing that would put permission delegation + // -- the one operation that can raise another member's authority -- into the hands of a + // non-Admin, and correctness would then rest on a subset comparison being right on every path. + // Vaultwarden keeps role and permission changes with Admins/Owners instead: strictly less + // authority than upstream grants, and the failure mode is a refused request rather than an + // escalation. Change this only together with tests for every actor/target/permission-subset + // combination. if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) { err!("You don't have permission to edit this member") } @@ -1985,6 +2028,15 @@ async fn edit_member( // with full access) may change a member's collection assignments. A custom user with only // manage_users must not be able to add/remove collection access, so we leave the existing // assignments untouched for them. + // + // NOTE: another deliberate narrowing of upstream, which resolves ModifyUserAccess per collection + // and accepts a per-collection Manage grant on every affected collection. Requiring blanket + // authority here is coarser -- a ManageUsers member holding Manage on exactly the collections in + // the request is refused -- but it keeps a *stored* grant from being reachable as a lever for + // handing out access, which is the same boundary `caller_may_grant_collection_manage` draws. The + // group paths below (`post_groups`, `put_group_members`, `delete_group`) are narrowed for the same + // reason. Widening this needs the per-collection check to cover the members' *current* assignments + // as well as the requested ones, or removal becomes the hole. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(m) => m.has_full_access(), @@ -2020,40 +2072,38 @@ async fn edit_member( // We need to perform the check after changing the type since `admin` is exempt. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; - if caller_can_manage_collections { - // Delete all the odd collections - for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { - c.delete(&conn).await?; - } - - // Security (F-1): a per-collection `manage` grant carries delete authority, so the caller - // may only confer it on collections they could delete themselves. A caller acting via - // Edit-any-collection thus cannot hand another member a manage/delete grant it lacks. - let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; + // --------------------------------------------------------------------------------------------- + // Validation phase. Nothing below this point may be written until every id, tenant binding and + // caller right in the request has been checked. + // + // This endpoint replaces a member's collection assignments and their group memberships, and + // Vaultwarden has no database transactions, so an error raised *between* those two replaces used + // to leave the request half-applied: the member's collection access already changed, their groups + // still the old ones, no `OrganizationUserUpdated` event written, and a 4xx on the wire telling + // the client that nothing happened. A foreign group id -- exactly the case the tenant check below + // exists for -- was enough to trigger it. Resolving everything first cannot make the two replaces + // atomic against a database error, but it does mean a *rejected* request changes nothing. + // --------------------------------------------------------------------------------------------- + + // Security (F-1): a per-collection `manage` grant is durable administration authority, so the + // caller may only confer it where they already hold it themselves. A caller acting via + // Edit-any-collection thus cannot hand another member a manage grant it lacks. + let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; - // If the member does not already reach every collection, add the collections received - if !grants_full_access { - for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { - None => err!("Collection not found in Organization"), - Some(collection) => { - let manage = col.manage - && match &caller { - Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, - None => false, - }; - CollectionUser::save( - &member_to_edit.user_uuid, - &collection.uuid, - col.read_only, - col.hide_passwords, - manage, - &conn, - ) - .await?; - } - } - } + // Resolve the requested assignments: every collection has to exist in *this* organization, and + // the effective `manage` bit is decided here rather than while writing. + let mut collection_assignments: Vec<(CollectionId, bool, bool, bool)> = Vec::new(); + if caller_can_manage_collections && !grants_full_access { + for col in data.collections.iter().flatten() { + let Some(collection) = Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await else { + err!("Collection not found in Organization") + }; + let manage = col.manage + && match &caller { + Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, + None => false, + }; + collection_assignments.push((collection.uuid, col.read_only, col.hide_passwords, manage)); } } @@ -2061,7 +2111,7 @@ async fn edit_member( // (via the groups' collections). Only callers who may manage groups (Admins/Owners or users // with manage_groups) are allowed to change it. For others we leave group membership untouched. let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin - || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + || match &caller { Some(m) => m.has_manage_groups(), None => false, }; @@ -2086,56 +2136,68 @@ async fn edit_member( } } + // Security (audit H-2): every requested group has to belong to this organization. Otherwise a + // caller could link the member to a group of a foreign tenant (e.g. an access-all group), which + // the direct cipher-access checks would then honor. Fail closed on the whole request. if caller_can_manage_groups { - // Security (audit H-2): validate that every requested group belongs to this organization - // *before* mutating any group membership. Otherwise a caller could link the member to a - // group of a foreign tenant (e.g. an access-all group), which the direct cipher-access - // checks would then honor. Fail closed on the whole request if any group is foreign. for group_id in data.groups.iter().flatten() { if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { err!("Group not found in this organization") } } + } - if caller_can_manage_collections { - // Caller may grant/revoke collection access via groups: full replace. - GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; - - for group_id in data.groups.iter().flatten() { - let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); - group_entry.save(&conn).await?; - } - } else { - // Security: the caller may manage groups but NOT collections. They may only change the - // member's membership in groups that confer no collection access; collection-bearing - // memberships are preserved untouched (neither granted nor revoked), mirroring the - // restriction enforced in put_group_members and add_update_group. - - // Remove the member only from non-collection-bearing groups; keep collection-bearing - // memberships so this caller cannot revoke collection access either. - for gu in GroupUser::find_by_member(&member_to_edit.uuid, &conn).await { - if may_change_group_membership( + // Decide the group changes while still not writing. A caller who may manage groups but *not* + // collections may only touch memberships in groups that confer no collection access; the others + // are preserved untouched (neither granted nor revoked), mirroring put_group_members and + // add_update_group. + let mut groups_to_remove: Vec = Vec::new(); + let mut groups_to_add: Vec = Vec::new(); + if caller_can_manage_groups { + for group_id in ¤t_groups { + if caller_can_manage_collections + || may_change_group_membership( caller_can_manage_collections, - group_confers_collection_access(&gu.groups_uuid, &org_id, &conn).await, - ) { - GroupUser::delete_by_group_and_member(&gu.groups_uuid, &member_to_edit.uuid, &conn).await?; - } + group_confers_collection_access(group_id, &org_id, &conn).await, + ) + { + groups_to_remove.push(group_id.clone()); } - - // Add the requested groups, skipping any that would grant collection access. - for group_id in data.groups.iter().flatten() { - if !may_change_group_membership( + } + for group_id in data.groups.iter().flatten() { + if caller_can_manage_collections + || may_change_group_membership( caller_can_manage_collections, group_confers_collection_access(group_id, &org_id, &conn).await, - ) { - continue; - } - let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); - group_entry.save(&conn).await?; + ) + { + groups_to_add.push(group_id.clone()); } } } + // --------------------------------------------------------------------------------------------- + // Write phase. + // --------------------------------------------------------------------------------------------- + + if caller_can_manage_collections { + for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { + c.delete(&conn).await?; + } + for (collection_uuid, read_only, hide_passwords, manage) in collection_assignments { + CollectionUser::save(&member_to_edit.user_uuid, &collection_uuid, read_only, hide_passwords, manage, &conn) + .await?; + } + } + + for group_id in groups_to_remove { + GroupUser::delete_by_group_and_member(&group_id, &member_to_edit.uuid, &conn).await?; + } + for group_id in groups_to_add { + let mut group_entry = GroupUser::new(group_id, member_to_edit.uuid.clone()); + group_entry.save(&conn).await?; + } + log_event( EventType::OrganizationUserUpdated as i32, &member_to_edit.uuid, @@ -2291,7 +2353,7 @@ async fn bulk_public_keys( } use super::ciphers::CipherData; -use super::ciphers::update_cipher_from_data; +use super::ciphers::update_cipher_from_data_with_authority; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -2324,15 +2386,9 @@ async fn post_org_import( err!("Organization not found", "Organization id's do not match"); } - // NOTE: no `accessImportExport` gate here on purpose. Bitwarden does not require the permission - // either — `ImportCiphersController.CheckOrgImportPermissionAsync` authorizes an organization - // import on `AccessImportExport` *or* per-collection Create/ImportCiphers authority. Vaultwarden - // has always authorized this endpoint per target collection, so an up-front role check would take - // a capability away from ordinary members that they have today. The real boundary is enforced - // below and is unchanged: an existing collection must be writable for the caller - // (`Collection::is_writable_by_user`), and creating a new one requires the independent - // `createNewCollections` permission. The one deliberate difference from Bitwarden is that - // `accessImportExport` alone does not open the endpoint here; it governs the export side only. + // Bitwarden authorizes an organization import on `AccessImportExport` *or* the regular + // per-collection Create/ImportCiphers authority. Keep the latter path for ordinary members while + // treating the named Custom permission as the organization-wide import shortcut it represents. // // A confirmed membership is required though: both checks below are confirmed-gated, so an // invited/accepted member could otherwise only import ciphers without any collection — which lands @@ -2340,6 +2396,7 @@ async fn post_org_import( if !headers.membership.has_status(MembershipStatus::Confirmed) { err!("You need to be a confirmed member of this organization to import into it") } + let has_org_wide_import_access = may_import_without_collection_access(&headers.membership); let data: ImportData = data.into_inner(); @@ -2370,24 +2427,33 @@ async fn post_org_import( // assignment must not let an importer plant ciphers into a shared collection. let existing_collections: HashMap = Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); - let mut collections: Vec = Vec::with_capacity(data.collections.len()); - for col in data.collections { - let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); - let collection_uuid = if let Some(collection) = existing { - // When not an Owner or Admin, check if the member is allowed to write to the collection. - if headers.membership.atype < MembershipType::Admin + + // Finish every request-controlled collection authorization check before the first new collection + // is written. This matters for the PR's create-only Custom role: a payload may name a new + // collection first and an existing, non-writable collection later. Rejecting the latter only in + // the write loop left the former behind even though the request failed. + for col in &data.collections { + if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) { + if !has_org_wide_import_access + && headers.membership.atype < MembershipType::Admin && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await { err!(Compact, "The current user isn't allowed to manage this collection") } + } else if !has_org_wide_import_access && !headers.membership.can_create_new_collections() { + err!(Compact, "The current user isn't allowed to create new collections") + } + } + + let mut collections: Vec = Vec::with_capacity(data.collections.len()); + for col in data.collections { + let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); + let collection_uuid = if let Some(collection) = existing { collection.uuid.clone() } else { // Collection creation through an organization import is governed by the same // independent permission as the regular create endpoint. In particular, // Edit any collection (full access to every collection) must not satisfy this check. - if !headers.membership.can_create_new_collections() { - err!(Compact, "The current user isn't allowed to create new collections") - } let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); new_collection.save(&conn).await?; // Import-created collections do not carry the regular create endpoint's user access @@ -2419,19 +2485,18 @@ async fn post_org_import( // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); - // Propagate cipher-save failures instead of silently discarding them (audit M-3): a - // discarded error would still push the cipher id and let a relationship reference a cipher - // that was never persisted. This matches Bitwarden's all-or-nothing import semantics. - update_cipher_from_data( + update_cipher_from_data_with_authority( &mut cipher, cipher_data, &headers, Some(collections.clone()), + has_org_wide_import_access, &conn, &nt, UpdateType::None, ) - .await?; + .await + .ok(); ciphers.push(cipher.uuid); } @@ -3403,29 +3468,27 @@ async fn group_confers_collection_access(group_id: &GroupId, org_id: &Organizati /// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` / /// `collections_groups.manage`) on `col_id`. /// -/// Security (F-1, edit-any -> delete-any escalation): a `manage` grant carries collection *delete* -/// authority — `CollectionDeleteHeaders` accepts it via `has_explicit_collection_manage_access`. -/// Without this gate a Custom member holding only `edit_any_collection` (which grants full access to -/// every collection) could, through the collection-access / group endpoints, hand a `manage` row to -/// a group they belong to (or to a manager-level member) and thereby gain deletion — a capability -/// `edit_any_collection` must never imply. +/// Security (F-1): a `manage` grant is per-collection administration authority — `ManagerHeaders` +/// accepts it via `has_explicit_collection_manage_access`, and it survives every later change to the +/// grantee's role. Without this gate a Custom member holding only `edit_any_collection` (which grants +/// full access to every collection, but is meant to be revocable by clearing one flag) could, through +/// the collection-access / group endpoints, hand a permanent `manage` row to a group they belong to +/// and keep that authority after the flag is gone. /// -/// We therefore allow granting `manage` on a collection only to a caller who could delete that same -/// collection themselves, mirroring `collection_delete_access` exactly so it can never hand out a -/// right the caller lacks: Admin/Owner and Custom-with-`delete_any_collection` always qualify; any -/// other Custom member must hold a real explicit manage grant. This is strictly subtractive — it can -/// only ever downgrade a requested `manage` to `false`, never grant it — so it opens no new access, -/// and delete-capable members (including all Admins/Owners) are unaffected. +/// We therefore allow granting `manage` on a collection only to a caller who already holds blanket +/// collection authority or a real stored manage grant on that same collection: Admin/Owner and +/// Custom-with-`delete_any_collection` always qualify; any other Custom member must hold an explicit +/// manage grant. This is strictly subtractive — it can only ever downgrade a requested `manage` to +/// `false`, never grant it — so it opens no new access, and Admins/Owners are unaffected. async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { match caller_manage_grant_role_check(caller) { // Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no). Some(decision) => decision, // Custom without delete_any: the answer is per-collection and must reflect a *real* stored - // manage grant. Edit any collection deliberately does not count here, and neither does the - // legacy `access_all`-group authority: that one is derived from a group membership that can - // be taken away again, while a `manage` row written here outlives it. Accepting it would let - // temporary authority be laundered into a permanent grant — and with it collection deletion - // — which is exactly the escalation this clamp exists to prevent. + // manage grant. Edit any collection deliberately does not count here — it is revocable by + // clearing a flag, while a `manage` row written here outlives it. Accepting it would let + // temporary authority be laundered into a permanent grant, which is exactly the escalation + // this clamp exists to prevent. None => match MembershipType::from_i32(caller.atype) { Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await, _ => false, @@ -3433,6 +3496,20 @@ async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &Collec } } +/// Whether a caller may import throughout the organization without proving Create/Write authority +/// for every target collection. This is the server-side meaning of Bitwarden's +/// `accessImportExport` Custom permission; Admins and Owners already have equivalent authority. +fn may_import_without_collection_access(caller: &Membership) -> bool { + caller.has_status(MembershipStatus::Confirmed) + && (caller.atype >= MembershipType::Admin || caller.has_access_import_export()) +} + +/// Organization reports are computed client-side and require every organization cipher. Match +/// Bitwarden's `AccessReports` semantics instead of silently producing assignment-scoped reports. +fn may_read_all_organization_ciphers(caller: &Membership) -> bool { + caller.has_full_access() || (caller.has_status(MembershipStatus::Confirmed) && caller.has_access_reports()) +} + /// Whether `caller` may export the *entire* organization instead of only their own assignments. /// /// Security (audit F1): the `AccessImportExportHeaders` guard on `get_org_export` decides whether a @@ -4188,10 +4265,12 @@ mod tests { use serde_json::{Value, json}; use super::{ - CustomRolePermissions, caller_manage_grant_role_check, collection_bearing_membership_unchanged, - filter_ciphers_for_organization, may_change_group_membership, may_change_member_type, - may_export_entire_organization, may_manage_member_type, may_manage_stored_member_type, - may_provision_member_type, may_provision_stored_member_type, + CollectionDetailsResponseScope, CustomRolePermissions, caller_manage_grant_role_check, + collection_bearing_membership_unchanged, collection_details_response_scope, filter_ciphers_for_organization, + may_change_group_membership, may_change_member_type, may_export_entire_organization, + may_import_without_collection_access, may_manage_member_type, may_manage_stored_member_type, + may_provision_member_type, may_provision_stored_member_type, may_read_all_organization_ciphers, + may_read_complete_collection_list, }; use crate::db::models::{Cipher, GroupId, Membership, MembershipStatus, MembershipType, OrganizationId}; @@ -4202,6 +4281,41 @@ mod tests { m } + #[test] + fn bulk_collection_details_only_include_acls_for_manage_authority() { + // Ordinary collection assignment, including group access_all, keeps the collection metadata + // visible but must never reveal user/group ACL mappings. + assert_eq!(collection_details_response_scope(false, false, false), CollectionDetailsResponseScope::Hidden); + assert_eq!(collection_details_response_scope(false, true, false), CollectionDetailsResponseScope::MetadataOnly); + assert_eq!(collection_details_response_scope(false, false, true), CollectionDetailsResponseScope::MetadataOnly); + + // Admin/Owner, Edit-any/Delete-any, and explicit per-collection Manage all arrive here as + // `can_read_access_details = true`, matching CollectionReadHeaders on the single endpoint. + assert_eq!( + collection_details_response_scope(true, false, false), + CollectionDetailsResponseScope::AccessDetails + ); + assert_eq!(collection_details_response_scope(true, true, true), CollectionDetailsResponseScope::AccessDetails); + } + + #[test] + fn flagless_custom_uses_only_its_explicit_manage_collections_in_the_list() { + // `false` selects the route's per-collection explicit-Manage filtering path. Permissions that + // need metadata for every collection select the complete list instead. + assert!(!may_read_complete_collection_list(&confirmed_member(MembershipType::Custom))); + + let mut manage_users = confirmed_member(MembershipType::Custom); + manage_users.manage_users = true; + assert!(may_read_complete_collection_list(&manage_users)); + + let mut create = confirmed_member(MembershipType::Custom); + create.create_new_collections = true; + assert!(may_read_complete_collection_list(&create)); + + assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Admin))); + assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Owner))); + } + #[test] fn only_delete_capable_callers_may_grant_collection_manage() { // Admin/Owner may always confer a per-collection `manage` (delete) grant. @@ -4261,6 +4375,39 @@ mod tests { assert!(!may_export_entire_organization(&unconfirmed)); } + #[test] + fn access_import_export_opens_the_organization_import() { + let mut import_export = confirmed_member(MembershipType::Custom); + import_export.access_import_export = true; + assert!(may_import_without_collection_access(&import_export)); + + assert!(!may_import_without_collection_access(&confirmed_member(MembershipType::Custom))); + assert!(!may_import_without_collection_access(&confirmed_member(MembershipType::User))); + assert!(may_import_without_collection_access(&confirmed_member(MembershipType::Admin))); + assert!(may_import_without_collection_access(&confirmed_member(MembershipType::Owner))); + + import_export.status = MembershipStatus::Accepted as i32; + assert!(!may_import_without_collection_access(&import_export)); + } + + #[test] + fn access_reports_grants_the_complete_report_input() { + let mut reports = confirmed_member(MembershipType::Custom); + reports.access_reports = true; + assert!(may_read_all_organization_ciphers(&reports)); + + assert!(!may_read_all_organization_ciphers(&confirmed_member(MembershipType::Custom))); + assert!(may_read_all_organization_ciphers(&confirmed_member(MembershipType::Admin))); + assert!(may_read_all_organization_ciphers(&confirmed_member(MembershipType::Owner))); + + reports.status = MembershipStatus::Accepted as i32; + assert!(!may_read_all_organization_ciphers(&reports)); + + let mut stale_user = confirmed_member(MembershipType::User); + stale_user.access_reports = true; + assert!(!may_read_all_organization_ciphers(&stale_user)); + } + #[test] fn assigned_cipher_response_is_scoped_to_requested_organization() { let requested_org: OrganizationId = "requested-org".to_owned().into(); @@ -4533,4 +4680,34 @@ mod tests { CustomRolePermissions::default() ); } + + #[test] + fn stale_permission_bits_on_non_custom_members_are_not_authority_changes() { + let mut membership = confirmed_member(MembershipType::User); + membership.manage_users = true; + membership.manage_groups = true; + membership.manage_policies = true; + membership.create_new_collections = true; + membership.edit_any_collection = true; + membership.delete_any_collection = true; + membership.access_event_logs = true; + membership.access_import_export = true; + membership.access_reports = true; + + let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership); + assert_eq!(requested, CustomRolePermissions::default()); + assert!(!requested.differs_from(&membership)); + + // Applying the effective request opportunistically clears the inert historical data. + requested.apply_to(&mut membership); + assert!(!membership.manage_users); + assert!(!membership.manage_groups); + assert!(!membership.manage_policies); + assert!(!membership.create_new_collections); + assert!(!membership.edit_any_collection); + assert!(!membership.delete_any_collection); + assert!(!membership.access_event_logs); + assert!(!membership.access_import_export); + assert!(!membership.access_reports); + } } diff --git a/src/auth.rs b/src/auth.rs index ceb57816..9c9bd7cc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1009,8 +1009,7 @@ fn collection_access_by_role(membership: &Membership, custom_has_any_access: boo Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any, Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any, // A Custom member must prove an actual users_collections.manage / collections_groups.manage - // assignment, or the legacy organization-local `access_all` group a Manager's authority used - // to come from. Membership-level `access_all` is gone and never counted here. + // assignment. Neither membership nor group `access_all` is ever counted as one. Some(MembershipType::Custom) => CollectionManageAccess::ExplicitManage, Some(MembershipType::User) | None => CollectionManageAccess::Denied, } @@ -1027,8 +1026,28 @@ fn collection_read_access(membership: &Membership) -> CollectionManageAccess { ) } +/// Collection deletion never falls back to a per-collection Manage grant. +/// +/// Vaultwarden serializes `limitCollectionDeletion = true` unconditionally, and upstream gates +/// manage-based deletion on that setting being *off* (`BulkCollectionAuthorizationHandler`): with the +/// limit active, only Owners, Admins and holders of `Delete any collection` may delete. Accepting a +/// stored `manage` grant here would break that promise and, worse, make the three collection +/// permissions dependent on each other — a Custom member holding only `Create new collections` +/// receives an automatic `users_collections.manage` row for the collection they just created, and +/// could delete it again without `Delete any collection`. +/// +/// A Manage grant keeps its full meaning for editing a collection and rewriting its access +/// (`collection_edit_access`); it just is not a delete permission. fn collection_delete_access(membership: &Membership) -> CollectionManageAccess { - collection_access_by_role(membership, membership.has_delete_any_collection()) + if !membership.has_status(MembershipStatus::Confirmed) { + return CollectionManageAccess::Denied; + } + + match MembershipType::from_i32(membership.atype) { + Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any, + Some(MembershipType::Custom) if membership.has_delete_any_collection() => CollectionManageAccess::Any, + Some(MembershipType::Custom | MembershipType::User) | None => CollectionManageAccess::Denied, + } } async fn can_manage_collection( @@ -1040,7 +1059,7 @@ async fn can_manage_collection( match access { CollectionManageAccess::Any => true, CollectionManageAccess::ExplicitManage => { - membership.has_collection_manage_authority(collection_uuid, conn).await + membership.has_explicit_collection_manage_access(collection_uuid, conn).await } CollectionManageAccess::Denied => false, } @@ -1064,6 +1083,19 @@ pub(crate) async fn can_edit_collection( can_manage_collection(collection_edit_access(membership), membership, collection_uuid, conn).await } +/// Whether `membership` may read a collection's user/group access mappings. +/// +/// Keep body/bulk endpoints on exactly the same authorization rule as `CollectionReadHeaders`: +/// Admin/Owner, Edit-any/Delete-any, or a real per-collection Manage assignment. Ordinary read +/// access and group `access_all` deliberately do not qualify. +pub(crate) async fn can_read_collection_access( + membership: &Membership, + collection_uuid: &CollectionId, + conn: &DbConn, +) -> bool { + can_manage_collection(collection_read_access(membership), membership, collection_uuid, conn).await +} + /// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can /// update every collection; otherwise the caller must be a Custom member (or above) holding the /// per-collection Manage permission. Read and delete use separate guards so Edit cannot @@ -1170,12 +1202,10 @@ impl From for Headers { } } -/// Delete is intentionally independent from Edit any collection. Vaultwarden advertises -/// limitCollectionDeletion=true, so deleting *any* collection requires the explicit Delete any -/// collection permission (or Admin/Owner). Deleting an individual collection is additionally -/// allowed for members holding the per-collection Manage grant on it. Custom members use the -/// explicit assignment only; a group `access_all` grant never counts as their per-collection Manage -/// grant. +/// Delete is fully independent from the other two collection permissions. Vaultwarden advertises +/// `limitCollectionDeletion = true`, so deleting a collection requires Admin/Owner or the explicit +/// Delete any collection permission — see `collection_delete_access` for why a per-collection Manage +/// grant deliberately does not qualify. pub struct CollectionDeleteHeaders { pub host: String, pub device: Device, @@ -1194,26 +1224,18 @@ impl<'r> FromRequest<'r> for CollectionDeleteHeaders { err_handler!("You need collection delete permission to call this endpoint") } - let Some(col_id) = get_col_id(request) else { + // Only used to keep this guard bound to routes that actually carry a collection id. + if get_col_id(request).is_none() { err_handler!("Error getting the collection id") - }; + } match collection_delete_access(&headers.membership) { CollectionManageAccess::Any => {} - CollectionManageAccess::Denied => { - // Custom is a distinct, fail-closed role. Edit any collection alone must not satisfy - // a Delete request without either Delete any or an explicit per-collection Manage. + // Custom is a distinct, fail-closed role: neither Edit any collection nor a stored + // per-collection Manage grant substitutes for Delete any collection. + CollectionManageAccess::ExplicitManage | CollectionManageAccess::Denied => { err_handler!("You need the 'Delete any collection' permission to call this endpoint") } - access @ CollectionManageAccess::ExplicitManage => { - let Outcome::Success(conn) = DbConn::from_request(request).await else { - err_handler!("Error getting DB") - }; - - if !can_manage_collection(access, &headers.membership, &col_id, &conn).await { - err_handler!("The current user isn't a manager for this collection") - } - } } Outcome::Success(Self { @@ -1295,8 +1317,9 @@ impl CollectionDeleteHeaders { collections: &Vec, conn: &DbConn, ) -> Result { - let delete_access = collection_delete_access(&h.membership); - if delete_access == CollectionManageAccess::Denied { + // Bulk delete answers to the same rule as the single-collection route: blanket authority or + // nothing. A per-collection Manage grant is not a delete permission. + if collection_delete_access(&h.membership) != CollectionManageAccess::Any { err!("You need the 'Delete any collection' permission to call this endpoint") } @@ -1307,11 +1330,6 @@ impl CollectionDeleteHeaders { if Collection::find_by_uuid_and_org(col_id, &h.membership.org_uuid, conn).await.is_none() { err!("Collection not found", "Collection does not exist or does not belong to this organization") } - if delete_access != CollectionManageAccess::Any - && !can_manage_collection(delete_access, &h.membership, col_id, conn).await - { - err!("Collection not found", "The current user isn't a manager for this collection") - } } Ok(CollectionDeleteHeaders { @@ -1695,16 +1713,18 @@ mod tests { } #[test] - fn flagless_custom_requires_explicit_manage_for_edit_read_and_delete() { - // A flagless Custom member (this is what a migrated legacy Manager becomes) never gets - // blanket collection authority from its role alone: every collection operation has to be - // answered per collection. ExplicitManage invokes the database helper that accepts a real - // users_collections.manage / collections_groups.manage grant, or the legacy - // organization-local access_all group — never the membership-level access_all that is gone. + fn flagless_custom_requires_explicit_manage_for_edit_and_read_and_cannot_delete() { + // A flagless Custom member never gets blanket collection authority from its role alone. + // Edit and read are answered per collection by `has_explicit_collection_manage_access`, which + // accepts a real users_collections.manage / collections_groups.manage grant and nothing else: + // membership access_all is gone, and a group's access_all is not a manage grant. + // + // Delete has no per-collection fallback at all, so the answer is Denied rather than + // ExplicitManage -- see `collection_delete_access`. let custom = membership(MembershipType::Custom); assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage); assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage); - assert_eq!(collection_delete_access(&custom), CollectionManageAccess::ExplicitManage); + assert_eq!(collection_delete_access(&custom), CollectionManageAccess::Denied); } #[test] @@ -1713,15 +1733,43 @@ mod tests { edit_any.edit_any_collection = true; assert_eq!(collection_edit_access(&edit_any), CollectionManageAccess::Any); assert_eq!(collection_read_access(&edit_any), CollectionManageAccess::Any); - // Edit-any alone is not blanket Delete. It still permits deletion of an explicitly managed - // collection, which is why the result is ExplicitManage rather than Denied. - assert_eq!(collection_delete_access(&edit_any), CollectionManageAccess::ExplicitManage); + // Edit any collection is never a delete permission, not even for a collection the member + // holds an explicit Manage grant on. + assert_eq!(collection_delete_access(&edit_any), CollectionManageAccess::Denied); let mut delete_any = membership(MembershipType::Custom); delete_any.delete_any_collection = true; assert_eq!(collection_edit_access(&delete_any), CollectionManageAccess::ExplicitManage); assert_eq!(collection_read_access(&delete_any), CollectionManageAccess::Any); assert_eq!(collection_delete_access(&delete_any), CollectionManageAccess::Any); + + // Create new collections yields the automatic users_collections.manage row on the created + // collection. That row must not become a delete permission either. + let mut create_only = membership(MembershipType::Custom); + create_only.create_new_collections = true; + assert_eq!(collection_edit_access(&create_only), CollectionManageAccess::ExplicitManage); + assert_eq!(collection_delete_access(&create_only), CollectionManageAccess::Denied); + } + + /// A stored `atype` that is not one of the four known roles must never be treated as one, in + /// either direction. 3 is the retired Manager discriminant, and a negative value is what a + /// corrupt row or a hand-written UPDATE could leave behind -- it would satisfy a numeric + /// `atype <= Admin` SQL predicate, which is why the queries enumerate the two admin values + /// instead (`ORG_ADMIN_ATYPES`). + #[test] + fn unknown_stored_role_values_fail_closed() { + for atype in [-1, 3, 5, i32::MAX, i32::MIN] { + let mut unknown = membership(MembershipType::Custom); + unknown.atype = atype; + // Even with every permission set, an unrecognized role grants nothing. + unknown.edit_any_collection = true; + unknown.delete_any_collection = true; + unknown.create_new_collections = true; + + assert_eq!(collection_edit_access(&unknown), CollectionManageAccess::Denied, "atype {atype}"); + assert_eq!(collection_read_access(&unknown), CollectionManageAccess::Denied, "atype {atype}"); + assert_eq!(collection_delete_access(&unknown), CollectionManageAccess::Denied, "atype {atype}"); + } } #[test] @@ -1738,16 +1786,26 @@ mod tests { } #[test] - fn migrated_legacy_manager_retains_explicit_collection_manage() { - // The role migration converts legacy Managers to flagless Custom members. They retain - // edit/delete only for collections with a persisted per-collection Manage assignment; - // the restrictive helper deliberately excludes group and membership access_all. - let migrated_manager = membership(MembershipType::Custom); - assert_eq!(collection_edit_access(&migrated_manager), CollectionManageAccess::ExplicitManage); - assert_eq!(collection_delete_access(&migrated_manager), CollectionManageAccess::ExplicitManage); + fn a_migrated_legacy_manager_carries_its_authority_in_the_permission_columns() { + // A legacy Manager who managed every collection through a group with access_all is not + // recognized by its shape at runtime -- that shape is indistinguishable from a newly created + // flagless Custom member. The repair migration writes the authority into the permission + // columns instead, so the guard sees an ordinary Edit/Delete any collection holder. + let mut migrated_group_manager = membership(MembershipType::Custom); + migrated_group_manager.edit_any_collection = true; + migrated_group_manager.delete_any_collection = true; + assert_eq!(collection_edit_access(&migrated_group_manager), CollectionManageAccess::Any); + assert_eq!(collection_delete_access(&migrated_group_manager), CollectionManageAccess::Any); + + // Without those columns nothing is derived, no matter which groups the member belongs to. + let flagless = membership(MembershipType::Custom); + assert_eq!(collection_edit_access(&flagless), CollectionManageAccess::ExplicitManage); + assert_eq!(collection_delete_access(&flagless), CollectionManageAccess::Denied); let mut unconfirmed = membership(MembershipType::Custom); unconfirmed.status = MembershipStatus::Accepted as i32; + unconfirmed.edit_any_collection = true; + unconfirmed.delete_any_collection = true; assert_eq!(collection_edit_access(&unconfirmed), CollectionManageAccess::Denied); assert_eq!(collection_delete_access(&unconfirmed), CollectionManageAccess::Denied); } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6261c1c4..8dfe10c9 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -473,7 +473,35 @@ const CUSTOM_COLLECTION_PERMISSIONS_MIGRATION: &str = "20260716120000"; const DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION: &str = "20260724120000"; const CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION: &str = "20260630120000"; const CUSTOM_ACCESS_PERMISSIONS_MIGRATION: &str = "20260724130000"; +const CONFIRM_PERMANENT_AUTHORITY_MIGRATION: &str = "20260810120000"; const CUSTOM_ROLE_SAME_RUN_MARKER_TABLE: &str = "__vw_custom_role_same_run_0716"; +/// Records which memberships were legacy Managers, written by +/// {`CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION`} before it reuses `atype = 3` for the Custom role. +/// +/// Its *presence* doubles as the marker that {`CUSTOM_ROLE_REPAIR_MIGRATION`} ran in its current +/// form. Both files were rewritten after an earlier revision of this feature branch shipped, and +/// Diesel never re-runs a migration whose version is already in the ledger -- so a database upgraded +/// by that earlier revision carries the repair migration's version without any of the effects the +/// current one has. +const CUSTOM_ROLE_LEGACY_MANAGER_TABLE: &str = "__vw_custom_role_legacy_manager"; +/// Marks that this database's Custom-role history is accounted for. +/// +/// Created by {`CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION`} in its current form -- so every database +/// migrated by the code that ships today has it -- or by an operator who has audited an older +/// history by hand. Nothing else creates it, which is what makes it usable as evidence. +/// +/// It is deliberately separate from {`CUSTOM_ROLE_LEGACY_MANAGER_TABLE`}. That one holds data an +/// operator may legitimately have to write after the fact, so its existence cannot also stand for +/// "the history behind this data was reviewed" -- creating it empty to make an error message go away +/// would otherwise silently pass as an audit. +const CUSTOM_ROLE_HISTORY_VERIFIED_TABLE: &str = "__vw_custom_role_history_verified"; +/// An owner's decision that the group-derived collection authority +/// {`CUSTOM_ROLE_REPAIR_MIGRATION`} materializes onto the membership may become permanent. +/// +/// Written by an operator, read and consumed by {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`}. The +/// preflight looks ahead for the same condition that migration checks, so the decision is asked for +/// with the full recovery text instead of surfacing as its bare duplicate-key abort. +const PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE: &str = "__vw_ack_permanent_collection_authority"; /// One of the three groups of granular permission columns, each added by its own migration. /// @@ -513,13 +541,25 @@ impl PermissionColumnGroup { Self::Access => "custom access-permission", } } + + /// Whether this group's migration derives its values from the legacy `access_all` column. + /// + /// Only the collection group does (`create_new_collections = access_all` and friends). That makes + /// it the one group whose migration can no longer be executed once `access_all` has been dropped by + /// {`DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION`}, so it must never be recommended for a replay + /// afterwards. The other two only add columns (and convert the retired Manager type), which stays + /// valid at any point in the chain. + const fn reads_legacy_access_all(self) -> bool { + matches!(self, Self::Collection) + } } const PARTIAL_PERMISSION_COLUMNS_RECOVERY: &str = concat!( "\n\nThis happens when a migration was interrupted between its ALTER TABLE statements (on ", "MySQL/MariaDB every DDL statement commits on its own, so columns can exist without the ledger ", - "entry). The leftover columns only ever hold their FALSE default at this point, so dropping them ", - "loses nothing and lets the migration run again from a clean state.\n\n", + "entry). Because the migration never completed, Vaultwarden never wrote to these columns: they ", + "only hold their FALSE default, so dropping them loses nothing and lets the migration run again ", + "from a clean state.\n\n", "List the columns that are already present:\n", "SELECT column_name\n", "FROM information_schema.columns\n", @@ -534,44 +574,95 @@ const PARTIAL_PERMISSION_COLUMNS_RECOVERY: &str = concat!( "Afterwards restart Vaultwarden so the migration applies the whole group in one go." ); -const AMBIGUOUS_DIRECT_PERMISSIONS_RECOVERY_SQL: &str = concat!( - "\n\nList every affected membership with this SQLite/MySQL/PostgreSQL-compatible query:\n", - "SELECT uuid, user_uuid, org_uuid, atype, status\n", +/// Deliberately *not* the same advice as [`PARTIAL_PERMISSION_COLUMNS_RECOVERY`]. +/// +/// Here the ledger entry is present, so the migration did complete once and Vaultwarden has been +/// running with those columns: the ones that are still there can hold real granted permissions. The +/// missing columns cannot have been lost by an interrupted migration -- something dropped them +/// afterwards -- so telling the operator to drop the remainder would destroy live authorization data. +/// It would not even recover the instance: with the ledger entry in place, the next start finds zero +/// columns for a recorded migration and refuses again. +const PERMISSION_LEDGER_MISMATCH_RECOVERY: &str = concat!( + "\n\nUnlike an interrupted migration, this state means the migration already completed once, so ", + "the columns that are still present can hold real permissions that members were granted. Do not ", + "drop them: that destroys authorization data, and it does not fix the refusal either, because the ", + "ledger entry stays behind.\n\n", + "Restoring the database backup taken before the columns went missing is the only lossless fix. ", + "Run the upgrade again against that restored copy.\n\n", + "If the lost permissions are genuinely expendable, the migration can be replayed from scratch ", + "instead. With every Vaultwarden instance stopped and a backup taken, drop the remaining columns ", + "of the affected group that the message above names AND remove its ledger entry, so the migration ", + "is pending again rather than recorded-but-missing:\n", + "ALTER TABLE users_organizations DROP COLUMN ;\n", + "DELETE FROM __diesel_schema_migrations WHERE version = '';\n\n", + "Every member of the affected organizations then has to be re-checked, because the permissions ", + "come back as FALSE." +); + +/// Recovery for a damaged collection-permission group *after* `access_all` has been dropped. +/// +/// Neither of the two texts above applies there. Both ultimately rely on the migration running again -- +/// by leaving it pending, or by deleting its ledger row -- but `2026-07-16-120000` computes its three +/// columns *from* `access_all`, which `2026-07-24-120000` has already removed. A replay therefore fails +/// with "no such column: access_all" on every start, and on MySQL/MariaDB it fails *after* its three +/// `ADD COLUMN`s have committed, leaving the database stuck in the very state that was being repaired. +/// The way out is to reach the completed shape without executing that SQL at all. +const COLLECTION_PERMISSIONS_AFTER_DROP_RECOVERY: &str = concat!( + "\n\nThis group cannot be migrated again on this database: migration ", + "2026-07-16-120000 derives its three columns from the membership access_all column, and ", + "2026-07-24-120000 has already dropped that column. Leaving the migration pending, or deleting its ", + "ledger entry so it runs again, therefore fails on every start -- and on MySQL/MariaDB it fails only ", + "after its own ALTER TABLE statements have committed.\n\n", + "Restoring the database backup taken before these columns went missing is the only lossless fix. ", + "Run the upgrade again against that restored copy.\n\n", + "If the lost permissions are expendable, bring the group to its completed shape by hand instead, ", + "with every Vaultwarden instance stopped and a backup taken. Add whichever of the three columns the ", + "message above reports as missing:\n", + "ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;\n", + "ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;\n", + "ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;\n\n", + "Then make sure the migration counts as done, so it is never executed:\n", + "INSERT INTO __diesel_schema_migrations (version) VALUES ('20260716120000');\n\n", + "(Skip that INSERT if the entry is already there -- the message above says whether it is.)\n\n", + "Every Custom member of every organization then has to be re-checked, because the three collection ", + "permissions come back as FALSE and nothing can reconstruct their previous values." +); + +const LEGACY_USER_ACCESS_ALL_RECOVERY: &str = concat!( + "\n\nList the affected memberships:\n", + "SELECT uuid, user_uuid, org_uuid, status\n", "FROM users_organizations\n", - "WHERE atype IN (3, 4)\n", - " AND access_all = FALSE\n", - " AND create_new_collections = FALSE\n", - " AND edit_any_collection = TRUE\n", - " AND delete_any_collection = TRUE;\n\n", - "To see which of them still have an organization-local full-access group as a plausible source of the pattern, ", - "run the query below. On MySQL/MariaDB the reserved word `groups` has to be quoted with backticks:\n", - "SELECT uo.uuid, uo.org_uuid, g.uuid AS group_uuid, g.name AS group_name\n", - "FROM users_organizations uo\n", - "INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid\n", - "INNER JOIN groups g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid\n", - "WHERE g.access_all = TRUE\n", - " AND uo.atype IN (3, 4)\n", - " AND uo.access_all = FALSE\n", - " AND uo.create_new_collections = FALSE\n", - " AND uo.edit_any_collection = TRUE\n", - " AND uo.delete_any_collection = TRUE;\n\n", - "An organization owner has to decide per membership which of the two meanings applies. Replace ", - " and run exactly one guarded statement for that membership while every Vaultwarden instance is ", - "stopped. Do not bulk-apply either statement.\n\n", - "The pattern was a group-derived copy, or the authority is no longer wanted: drop it and let the group (if any) ", - "remain the only source of access.\n", - "UPDATE users_organizations\n", - "SET edit_any_collection = FALSE,\n", - " delete_any_collection = FALSE\n", - "WHERE uuid = '' AND access_all = FALSE AND create_new_collections = FALSE;\n\n", - "The pattern was an intentional direct grant that has to survive: make it unambiguous so the migration can pass.\n", + "WHERE atype = 2\n", + " AND access_all = TRUE;\n\n", + "The bit gave these members read/write reach over every collection of the organization, including ", + "collections created later, but no collection-management authority -- and it stopped applying as ", + "soon as the membership was revoked. The new role model has no equivalent, so an owner has to pick ", + "one of the two meanings per membership, with every Vaultwarden instance stopped and a backup ", + "taken.\n\n", + "The reach is no longer wanted -- this is also the right choice for an invited, accepted or revoked ", + "membership: clear the bit. The member keeps every collection they are explicitly assigned to.\n", "UPDATE users_organizations\n", - "SET create_new_collections = TRUE\n", - "WHERE uuid = '' AND access_all = FALSE AND edit_any_collection = TRUE;\n\n", - "Note that the second statement also grants Create-any-collection, because a 0/1/1 pattern is exactly the state ", - "the migration cannot attribute. If that member must not be able to create collections, start the server once so ", - "the migration completes, then set create_new_collections back to FALSE for that membership." + "SET access_all = FALSE\n", + "WHERE uuid = '';\n\n", + "The reach has to survive: write it out as explicit assignments first, then clear the bit. Do this ", + "only for a confirmed membership, and only if a snapshot is acceptable -- collections created after ", + "this point are not added, and unlike access_all these rows are not tied to the membership status.\n", + "INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage)\n", + "SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE\n", + "FROM users_organizations uo\n", + "INNER JOIN collections c ON c.org_uuid = uo.org_uuid\n", + "WHERE uo.uuid = ''\n", + " AND NOT EXISTS (\n", + " SELECT 1 FROM users_collections uc\n", + " WHERE uc.user_uuid = uo.user_uuid AND uc.collection_uuid = c.uuid\n", + " );\n\n", + "Existing assignments are left untouched by that statement, so re-check their read_only / ", + "hide_passwords values: access_all used to override both.\n\n", + "If the member genuinely needs organization-wide reach afterwards, give them the Custom role with ", + "the 'Edit any collection' permission from the web vault once the upgrade has completed. That is ", + "the supported, visible and revocable equivalent." ); + const INTERRUPTED_ACCESS_ALL_DROP_RECOVERY: &str = concat!( "\n\nThe drop itself carries no data, so the schema is already in its intended final state and ", "only the ledger entry is missing. Vaultwarden completes this automatically on MySQL/MariaDB, ", @@ -590,6 +681,163 @@ const ACCESS_ALL_DROP_MISMATCH_RECOVERY: &str = concat!( "Otherwise restore the database backup taken before the upgrade and run the upgrade again." ); +const OUT_OF_ORDER_ACCESS_PERMISSIONS_RECOVERY: &str = concat!( + "\n\nDo not run the pending migrations on this database. In particular, the SQLite ", + "2026-07-24-120000 migration rebuilds users_organizations from the schema that existed before ", + "the three access-permission columns were added. If 2026-07-24-130000 already ran, that rebuild ", + "would drop access_event_logs, access_import_export and access_reports -- including any granted ", + "values -- while Diesel would skip the already-recorded migration that adds them.\n\n", + "Restoring the database backup taken before the migrations were applied out of order and running ", + "the upgrade again is the lossless fix. If no such backup exists, keep every Vaultwarden instance ", + "stopped and have a database administrator preserve the three access-permission values while ", + "bringing the schema and migration ledger back to the documented version order. Do not delete the ", + "20260724130000 ledger entry or run 20260724120000 without first preserving those values." +); + +const UNVERIFIED_CUSTOM_ROLE_HISTORY_RECOVERY: &str = concat!( + "\n\nIf you still have the backup from before this database was first upgraded, restoring it and ", + "upgrading again is simplest and needs no decision at all. Otherwise work through the three points ", + "below with every Vaultwarden instance stopped and a backup taken. Which of them apply depends on ", + "how far the earlier revision got, which its ledger entries tell you:\n", + "SELECT version FROM __diesel_schema_migrations WHERE version >= '20260630120000' ORDER BY version;\n\n", + "1) Which memberships were legacy Managers -- always. The upgrade reuses atype 3 for the Custom ", + "role, so after it has run a converted Manager and a Custom member created later are identical. ", + "Without this record the remaining migrations cannot repair legacy authority, and the rollback ", + "scripts in tools/custom_role_rollback/ cannot map roles back. Create the table and record every ", + "membership that held the Manager role before the first upgrade:\n", + "CREATE TABLE __vw_custom_role_legacy_manager (users_organizations_uuid TEXT NOT NULL PRIMARY KEY);\n", + "INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) VALUES ('');\n", + "Leaving it empty is a valid answer and means \"no membership was a legacy Manager\".\n\n", + "2) Permissions granted by an earlier 20260809120000 -- if that version is in your ledger. It set ", + "edit_any_collection and delete_any_collection on every Custom member of a group with access_all, ", + "including members that were never Managers, so Create-only became Create+Edit+Delete and a member ", + "with no permissions became Edit+Delete -- which also implies full collection access. Nothing can ", + "tell those apart from deliberate grants any more, so review them and clear what you did not ", + "intend:\n", + "SELECT uo.uuid, uo.org_uuid, uo.status, uo.create_new_collections, uo.edit_any_collection,\n", + " uo.delete_any_collection\n", + "FROM users_organizations uo\n", + "INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid\n", + "INNER JOIN groups g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid\n", + "WHERE uo.atype = 4 AND g.access_all = TRUE;\n\n", + "3) A plain User carrying membership access_all -- if 20260723120000 is in your ledger. The earlier ", + "revision converted that state into direct assignments to the collections that existed at the time ", + "and then dropped the column; the current one refuses it instead, because the reach also covered ", + "collections created later. Those assignments are indistinguishable from ordinary ones now:\n", + "SELECT uc.user_uuid, uc.collection_uuid, uc.read_only, uc.hide_passwords, uc.manage\n", + "FROM users_collections uc\n", + "INNER JOIN users_organizations uo ON uo.user_uuid = uc.user_uuid\n", + "INNER JOIN collections c ON c.uuid = uc.collection_uuid AND c.org_uuid = uo.org_uuid\n", + "WHERE uo.atype = 2;\n\n", + "Then record that the history was audited. This is a separate statement on purpose: creating the ", + "table in point 1 writes data, and data alone must not pass as a review of where it came from.\n", + "CREATE TABLE __vw_custom_role_history_verified (verified INTEGER NOT NULL PRIMARY KEY);\n\n", + "Use CHAR(36) instead of TEXT for the uuid column on MySQL/MariaDB and PostgreSQL." +); + +/// The one question this feature has to ask, phrased before the upgrade rather than during it. +/// +/// {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`} refuses the same condition from inside the migration, as +/// the backstop for a bare migration runner. On the normal startup path that abort would reach the +/// operator as nothing but `UNIQUE constraint failed: __vw_permanent_authority_guard.blocked` (or +/// `Duplicate entry '1' for key 'PRIMARY'` on MariaDB), because Diesel only reports the driver error +/// -- so the decision, the review query and the acknowledgement all have to be printed from here. +const PERMANENT_COLLECTION_AUTHORITY_RECOVERY: &str = concat!( + "\n\nBefore 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 with ", + "the group, with its accessAll, and with the membership leaving it, and it was inert whenever ", + "ORG_GROUPS_ENABLED was false. The new model has no permission that is bound to a group like ", + "that -- edit_any_collection and delete_any_collection live on the membership -- so migration ", + "20260723120000 writes the authority onto the membership, and the result is deliberately not ", + "identical to what it replaces:\n", + " * it no longer lapses when the last qualifying group disappears, or when accessAll is ", + "cleared;\n", + " * it applies even with the groups feature switched off;\n", + " * edit_any_collection additionally satisfies has_full_access(), so the member reaches every ", + "collection of the organization directly rather than through the group.\n\n", + "Granting that silently would be a migration handing out durable organization-wide collection ", + "edit and delete on its own authority; skipping it silently would take a capability away. ", + "Neither is Vaultwarden's to choose, so an owner decides. Review the affected memberships with ", + "every Vaultwarden instance stopped and a backup taken.\n\n", + "Before migration 20260630120000 has run (legacy Manager is still atype 3):\n", + "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status\n", + "FROM users_organizations uo\n", + "WHERE uo.atype = 3\n", + " AND EXISTS (\n", + " SELECT 1 FROM groups_users gu\n", + " INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid\n", + " AND g.organizations_uuid = uo.org_uuid\n", + " WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE);\n\n", + "If 20260630120000 is already in the migration ledger but the three collection-permission ", + "columns do not exist yet, use this query instead. It includes recorded converted Managers and ", + "an unrecorded Custom membership whose own access_all bit 20260716120000 will turn into all ", + "three permissions:\n", + "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, uo.access_all,\n", + " (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))\n", + " AS was_legacy_manager\n", + "FROM users_organizations uo\n", + "WHERE (uo.atype = 3 OR (uo.atype = 4 AND (\n", + " uo.access_all = TRUE OR uo.uuid IN (\n", + " SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))))\n", + " AND EXISTS (\n", + " SELECT 1 FROM groups_users gu\n", + " INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid\n", + " AND g.organizations_uuid = uo.org_uuid\n", + " WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE);\n\n", + "After the permission columns exist:\n", + "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status,\n", + " uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection,\n", + " (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))\n", + " AS was_legacy_manager\n", + "FROM users_organizations uo\n", + "WHERE uo.atype = 4\n", + " AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)\n", + " AND EXISTS (\n", + " SELECT 1 FROM groups_users gu\n", + " INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid\n", + " AND g.organizations_uuid = uo.org_uuid\n", + " WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE);\n\n", + "(Quote `groups` with backticks instead of double quotes on MySQL/MariaDB, here and below.)\n\n", + "Reading the result:\n", + " * was_legacy_manager = 1 -- a converted Manager. Review it even when create_new_collections is ", + "set. That permission can be changed independently after an earlier migration materialized the ", + "group-derived edit/delete grant, so its current value is not reliable historical provenance. A ", + "membership whose own legacy access_all supplied all three permissions may therefore be listed ", + "conservatively even though its authority was already permanent.\n", + " * was_legacy_manager = 0 -- never a Manager. Before the collection columns exist, its own ", + "membership access_all will become all three permissions in 20260716120000. After the columns ", + "exist on a database first upgraded by an earlier revision of this feature branch, ", + "20260809120000 may instead have granted edit_any_collection and delete_any_collection in bulk ", + "to every Custom member of an access_all group. Check either result against what you intended.\n", + " * An invited or revoked membership is listed as well. It holds no authority today -- every ", + "guard requires a confirmed membership -- but the permission is what it would come back with if ", + "it is ever restored, so the decision belongs here too.\n\n", + "Clearing what you do not want to keep differs according to whether the collection-permission ", + "columns exist yet.\n\n", + "Before those columns exist, the authority being reviewed is still tied to the qualifying group ", + "relationship, so end that -- for ", + "the one membership, or for the whole group at once:\n", + "DELETE FROM groups_users\n", + "WHERE users_organizations_uuid = ''\n", + " AND groups_uuid = '';\n", + "UPDATE \"groups\" SET access_all = FALSE WHERE uuid = '';\n", + "Whatever still matches the applicable pre-column query afterwards is what the acknowledgement ", + "below covers. ", + "Removing the membership from the group also takes away the access it has today, which clearing ", + "the permission columns after the upgrade would not -- that is the same decision either way, just ", + "made before rather than after.\n\n", + "Once the permission columns exist, clear them directly. Doing it after the upgrade is equally ", + "safe: Vaultwarden does not start until the acknowledgement is recorded, so nothing is ever live ", + "in between.\n", + "UPDATE users_organizations\n", + "SET edit_any_collection = FALSE, delete_any_collection = FALSE\n", + "WHERE uuid = '';\n\n", + "Then record the decision once, and restart:\n", + "CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY);\n\n", + "The acknowledgement is consumed by 20260810120000, so one decision covers one upgrade. It grants ", + "nothing and revokes nothing by itself -- whatever you leave set is what the members keep." +); + const ALREADY_DROPPED_RECOVERY: &str = concat!( "\n\nThe permission values cannot be recomputed from the current schema. Restore the database backup taken ", "before the upgrade and run the upgrade again against that restored copy." @@ -612,8 +860,16 @@ struct CustomRoleMigrationFacts { access_permissions_migration_applied: bool, repair_migration_applied: bool, access_all_drop_migration_applied: bool, - ambiguous_direct_permission_count: i64, + legacy_user_access_all_count: i64, same_run_0716_marker: bool, + legacy_manager_record_exists: bool, + history_verified: bool, + confirm_permanent_authority_migration_applied: bool, + permanent_collection_authority_ack: bool, + /// Memberships {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`} will stop the upgrade for, counted from + /// whichever schema shape this database currently has — see + /// [`permanent_authority_lookahead_query`]. + unconfirmed_permanent_authority_count: i64, } impl CustomRoleMigrationFacts { @@ -641,13 +897,22 @@ enum CustomRolePreflightDecision { RefuseAlreadyDropped, RefuseMissingAccessAll, RefuseMissingMigrationLedger, - RefuseAmbiguousDirectPermissions, + RefuseLegacyUserAccessAll, + RefuseUnverifiedCustomRoleHistory, + RefuseUnconfirmedPermanentCollectionAuthority, RefuseInterruptedAccessAllDrop, RefuseAccessAllDropLedgerMismatch, + RefuseOutOfOrderAccessPermissionsMigration, RefusePartialPermissionSchema(PermissionColumnGroup), RefusePermissionLedgerMismatch(PermissionColumnGroup), } +const fn needs_permanent_collection_authority_decision(facts: CustomRoleMigrationFacts) -> bool { + !facts.confirm_permanent_authority_migration_applied + && !facts.permanent_collection_authority_ack + && facts.unconfirmed_permanent_authority_count != 0 +} + fn custom_role_preflight_decision( facts: CustomRoleMigrationFacts, can_complete_mysql_partial_migration: bool, @@ -666,24 +931,59 @@ fn custom_role_preflight_decision( // leaves a durable partial state. Returning early for every repaired database would hide exactly // those states, and the generic Diesel retry then fails on every following start with // `Unknown column` (1091) or `Duplicate column name` (1060). + // The first Custom-role migration is recorded, but not by the version of it that ships today: + // an earlier revision of this feature branch wrote that ledger entry, and Diesel never runs a + // recorded version again. Several things then differ silently from a fresh upgrade, none of them + // reconstructible from the schema afterwards, so stop before the remaining migrations run. + // + // Checked against the whole chain rather than only the repair migration, because the divergence + // starts at the very first one: `atype = 3` has already been reused for the Custom role, without + // anything recording which memberships that value used to mean "Manager" for. + // + // Checked against the history marker rather than the legacy-Manager record, because the record + // is data an operator has to be able to write during recovery -- gating on it would let the act + // of silencing the error double as the audit it is asking for. + // + // Both tables are required. The marker alone would leave the later migrations and the rollback + // scripts without the data they need; the record alone would mean the audit never happened. + if facts.manage_permissions_migration_applied && !(facts.history_verified && facts.legacy_manager_record_exists) { + return CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory; + } + + // The access-permission migration is ordered immediately after the membership access_all drop. + // A database carrying the later ledger entry while the drop is still pending is not a harmless + // gap: SQLite's portable drop rebuild has a fixed pre-access-permissions column list and would + // destroy those three columns and their values. Diesel would then skip the already-recorded + // migration that adds them. Refuse the non-prefix ledger before any automatic MySQL repair or + // pending migration can mutate the database. + if facts.access_permissions_migration_applied && !facts.access_all_drop_migration_applied { + return CustomRolePreflightDecision::RefuseOutOfOrderAccessPermissionsMigration; + } + + // Automatic MySQL repairs are mutations. Remember a repairable state here, but do not select it + // until every refusal below has been evaluated. In particular, recording a missing ledger row or + // completing 0716 before discovering another damaged permission group (or an unanswered owner + // decision) would make the eventual "Nothing has been changed" refusal false. + let mut automatic_repair = None; + if facts.repair_migration_applied { // The drop is a single statement with no data component, so it is all-or-nothing: either the // column is still there and the migration is pending, or the column is gone and the // migration is recorded. if facts.access_all_column_exists == facts.access_all_drop_migration_applied { - return if facts.access_all_drop_migration_applied { - CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch + if facts.access_all_drop_migration_applied { + return CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch; } else if can_complete_mysql_partial_migration { // Only reachable on MySQL/MariaDB, and the schema is already in its intended final - // state -- just record the migration instead of stopping the operator. - CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop + // state. Defer recording the migration until every refusal has been checked. + automatic_repair = Some(CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop); } else { - CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop - }; + return CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop; + } } } else { - // Once access_all has been dropped, its former value and the provenance of 0/1/1 - // collection permissions can no longer be reconstructed. Never guess at either. + // Once access_all has been dropped, its former value can no longer be reconstructed. Never + // guess at it. if facts.access_all_drop_migration_applied { return CustomRolePreflightDecision::RefuseAlreadyDropped; } @@ -691,8 +991,15 @@ fn custom_role_preflight_decision( return CustomRolePreflightDecision::RefuseMissingAccessAll; } - if facts.ambiguous_direct_permission_count != 0 && !facts.same_run_0716_marker { - return CustomRolePreflightDecision::RefuseAmbiguousDirectPermissions; + // A plain User carrying membership `access_all` has no representation in the new model: the + // bit gave unlimited *reach* over every collection, present and future, without any + // management authority, and the role that replaces it cannot express that. Converting the + // reach into direct per-collection assignments would silently turn a dynamic guarantee into a + // point-in-time snapshot, and -- because a `users_collections` row is not bound to the + // membership status the way `access_all` was -- would hand a revoked or never-confirmed member + // durable assignments that outlive this schema. Refuse and let an owner decide. + if facts.legacy_user_access_all_count != 0 { + return CustomRolePreflightDecision::RefuseLegacyUserAccessAll; } } @@ -704,15 +1011,35 @@ fn custom_role_preflight_decision( for group in [PermissionColumnGroup::Manage, PermissionColumnGroup::Collection, PermissionColumnGroup::Access] { match facts.permission_columns(group) { (0, false) | (3, true) => {} - (3, false) if group == PermissionColumnGroup::Collection && can_complete_mysql_partial_migration => { - return CustomRolePreflightDecision::CompleteMysqlCollectionMigration; + (3, false) + if group == PermissionColumnGroup::Collection + && can_complete_mysql_partial_migration + && !facts.repair_migration_applied + && facts.access_all_column_exists => + { + // This is the historical MySQL 0716 partial state: its DDL committed, while the + // ledger and the later 0723 repair are both still pending. `access_all` is required + // by both the validation and completion queries. Merely remember the repair here so + // a later permission group or the permanent-authority decision can still refuse + // without any preceding mutation. + automatic_repair = Some(CustomRolePreflightDecision::CompleteMysqlCollectionMigration); } (_, true) => return CustomRolePreflightDecision::RefusePermissionLedgerMismatch(group), _ => return CustomRolePreflightDecision::RefusePartialPermissionSchema(group), } } - CustomRolePreflightDecision::Proceed + // Last, because it is the only refusal that is not about a damaged database: the schema is fine + // and the upgrade is ready to run, but one step of it changes a meaning that nothing in the new + // model can express, and that is an owner's decision rather than a migration's. Checked here + // rather than left to the migration's own guard so the question arrives with the review query + // and the acknowledgement attached — Diesel would surface that guard as nothing but its + // driver-level duplicate-key error. + if needs_permanent_collection_authority_decision(facts) { + return CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority; + } + + automatic_repair.unwrap_or(CustomRolePreflightDecision::Proceed) } fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> Error { @@ -732,11 +1059,37 @@ fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: Cus Refusing to guess which schema and data migrations were previously applied." .to_owned() } - CustomRolePreflightDecision::RefuseAmbiguousDirectPermissions => format!( - "Found {} membership(s) with an ambiguous 0/1/1 collection-permission pattern. It is \ - not possible to distinguish an older group-derived backfill from an intentional \ - direct Edit+Delete assignment.", - facts.ambiguous_direct_permission_count + CustomRolePreflightDecision::RefuseLegacyUserAccessAll => format!( + "Found {} membership(s) of the plain User type carrying the legacy access_all bit. That \ + combination has no representation in the Custom role model: it grants dynamic reach over \ + every collection without any management authority.", + facts.legacy_user_access_all_count + ), + CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory => format!( + "Migration {CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION} is recorded, but the tables it \ + creates today are not both present ({CUSTOM_ROLE_LEGACY_MANAGER_TABLE}: {}, \ + {CUSTOM_ROLE_HISTORY_VERIFIED_TABLE}: {}). This database was upgraded by an earlier \ + revision of the Custom-role change, whose migrations had different effects and which \ + Diesel will not re-run.", + if facts.legacy_manager_record_exists { + "present" + } else { + "missing" + }, + if facts.history_verified { + "present" + } else { + "missing" + } + ), + CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority => format!( + "Migration {CONFIRM_PERMANENT_AUTHORITY_MIGRATION} needs a decision before it can run: {} \ + membership(s) match collection authority that may have come from an organization-local \ + access_all group. The current permissions cannot distinguish every group-derived grant \ + from independently changed or legacy membership-level authority, so the check is \ + deliberately conservative rather than silently making a possible group-derived grant \ + permanent. Nothing has been changed.", + facts.unconfirmed_permanent_authority_count ), CustomRolePreflightDecision::RefusePartialPermissionSchema(group) => format!( "Found {} of the three {} columns ({}) without a completed {} migration. The migration \ @@ -762,6 +1115,12 @@ fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: Cus "Migration {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION} is recorded, but the membership \ access_all column still exists. Schema and migration ledger disagree." ), + CustomRolePreflightDecision::RefuseOutOfOrderAccessPermissionsMigration => format!( + "Migration {CUSTOM_ACCESS_PERMISSIONS_MIGRATION} is recorded while its required earlier \ + migration {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION} is not. The Custom-role migration ledger \ + is not a valid prefix, and continuing could destroy stored access-permission values. \ + Nothing has been changed." + ), CustomRolePreflightDecision::Proceed | CustomRolePreflightDecision::CompleteMysqlCollectionMigration | CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { @@ -769,12 +1128,27 @@ fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: Cus } }; let recovery = match decision { - CustomRolePreflightDecision::RefuseAmbiguousDirectPermissions => AMBIGUOUS_DIRECT_PERMISSIONS_RECOVERY_SQL, - CustomRolePreflightDecision::RefusePartialPermissionSchema(_) - | CustomRolePreflightDecision::RefusePermissionLedgerMismatch(_) => PARTIAL_PERMISSION_COLUMNS_RECOVERY, + CustomRolePreflightDecision::RefuseLegacyUserAccessAll => LEGACY_USER_ACCESS_ALL_RECOVERY, + CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory => UNVERIFIED_CUSTOM_ROLE_HISTORY_RECOVERY, + CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority => { + PERMANENT_COLLECTION_AUTHORITY_RECOVERY + } + // Once access_all is gone, the collection group's migration can no longer run at all, so + // neither of the two generic texts may be handed out -- both end in a replay. + CustomRolePreflightDecision::RefusePartialPermissionSchema(group) + | CustomRolePreflightDecision::RefusePermissionLedgerMismatch(group) + if group.reads_legacy_access_all() && !facts.access_all_column_exists => + { + COLLECTION_PERMISSIONS_AFTER_DROP_RECOVERY + } + CustomRolePreflightDecision::RefusePartialPermissionSchema(_) => PARTIAL_PERMISSION_COLUMNS_RECOVERY, + CustomRolePreflightDecision::RefusePermissionLedgerMismatch(_) => PERMISSION_LEDGER_MISMATCH_RECOVERY, CustomRolePreflightDecision::RefuseAlreadyDropped => ALREADY_DROPPED_RECOVERY, CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop => INTERRUPTED_ACCESS_ALL_DROP_RECOVERY, CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch => ACCESS_ALL_DROP_MISMATCH_RECOVERY, + CustomRolePreflightDecision::RefuseOutOfOrderAccessPermissionsMigration => { + OUT_OF_ORDER_ACCESS_PERMISSIONS_RECOVERY + } _ => "", }; @@ -785,25 +1159,166 @@ fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: Cus .into() } +/// Counts the memberships {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`} will refuse to convert without an +/// owner's acknowledgement — from whichever schema shape the database has *right now*. +/// +/// Two broad shapes, because the preflight runs before any migration does and the answer has to be +/// the same either way: +/// +/// * **After the collection columns exist** the authority is already materialized, so this is the +/// migration's own predicate verbatim. `create_new_collections` is deliberately not used as a +/// provenance proxy: owners can change that independent permission after an earlier revision +/// materialized group-derived edit/delete, so its current value cannot prove where those two +/// permissions came from. Keeping the two predicates textually parallel is the point. +/// * **Before them** — the ordinary upgrade from a release without this feature — the columns are +/// not there yet and the answer has to be predicted from the legacy schema. `atype = 3` is the +/// retired Manager role, which {`CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION`} both records and +/// converts to Custom. Between the two migrations `atype = 4` rows can exist without the columns; +/// they are only attributable through the record, which is guaranteed to be present by then (the +/// history refusal above requires it whenever `20260630120000` is recorded). A Manager that also +/// carried membership `access_all` is conservatively included: asking an owner again is safer than +/// treating a mutable modern permission as immutable historical evidence. An unrecorded Custom +/// membership carrying `access_all` is included too: 0716 will turn that stored bit into 1/1/1, +/// which the later materialized guard will preserve and ask about if the membership is also in an +/// organization-local `access_all` group. +/// +/// `groups` is the backend's quoting of the reserved identifier. Returns `None` when neither shape is +/// readable, which is also exactly when the migration cannot run yet. +#[expect( + clippy::fn_params_excessive_bools, + reason = "These independent booleans describe historical schema and migration-ledger facts" +)] +fn permanent_authority_lookahead_query( + collection_columns_present: bool, + access_all_column_exists: bool, + legacy_manager_record_exists: bool, + collection_permissions_migration_applied: bool, + repair_migration_applied: bool, + groups: &str, +) -> Option { + let in_access_all_group = format!( + "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 \ + )" + ); + let on_record = format!("uo.uuid IN (SELECT users_organizations_uuid FROM {CUSTOM_ROLE_LEGACY_MANAGER_TABLE})"); + + if collection_columns_present && collection_permissions_migration_applied && repair_migration_applied { + // Do not infer provenance from `create_new_collections`. It is an independently mutable + // permission, so an owner can turn a group-derived 0/1/1 grant into 1/1/1 after an earlier + // revision ran. Excluding that current shape would silently accept the very permanent + // edit/delete authority this question exists to review. Conservatively ask about every + // materialized edit/delete grant that still has the qualifying group relationship. + Some(format!( + "SELECT COUNT(*) AS count FROM users_organizations AS uo \ + WHERE uo.atype = 4 \ + AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) \ + AND {in_access_all_group}" + )) + } else if access_all_column_exists { + // This branch also covers both historical states in which the columns exist but the repair is + // still pending: MySQL DDL committed without the 0716 ledger, or an earlier recorded 0716 did + // not contain today's group update. In either case 20260723120000 will materialize the + // provenance-bound group authority. 0716 also turns an unrecorded Custom membership's own + // `access_all` bit into 1/1/1. Project both end states instead of trusting temporary 0/0/0 + // values, so the owner is asked before any automatic completion or pending migration. + let pending_conversion = if legacy_manager_record_exists { + format!( + "(uo.atype = 3 OR (uo.atype = 4 AND \ + ({on_record} OR uo.access_all = TRUE)))" + ) + } else { + "(uo.atype = 3 OR (uo.atype = 4 AND uo.access_all = TRUE))".to_owned() + }; + // When the collection columns already exist, also retain any materialized Custom grant that + // is not part of the legacy-Manager record. The pending repair does not create that grant, but + // the later confirmation migration will still preserve it permanently. `OR` keeps both sets + // in one membership-level count without double-counting recorded rows that already have 0/1/1. + let pending_or_materialized_authority = if collection_columns_present { + format!( + "({pending_conversion} OR (uo.atype = 4 AND \ + (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)))" + ) + } else { + pending_conversion + }; + Some(format!( + "SELECT COUNT(*) AS count FROM users_organizations AS uo \ + WHERE {pending_or_materialized_authority} \ + AND {in_access_all_group}" + )) + } else { + None + } +} + +/// Requires every existing relation the PostgreSQL preflight and migration chain share to resolve to +/// the schema in which unqualified `CREATE TABLE` statements will create new bookkeeping objects. +/// +/// `to_regclass` correctly follows `search_path` for an existing relation, but `CREATE TABLE` uses +/// `current_schema()`. With `search_path = decoy, real` and Vaultwarden's tables in `real`, reading the +/// former while creating provenance in the latter splits one migration across schemas. Returning one +/// row is the only safe shape; zero means the caller must refuse before any migration runs. +#[cfg(any(postgresql, test))] +const fn postgresql_migration_namespace_query() -> &'static str { + "SELECT COUNT(*) AS count \ + FROM pg_class AS memberships \ + INNER JOIN pg_namespace AS current_ns ON current_ns.nspname = current_schema() \ + WHERE memberships.oid = to_regclass('users_organizations') \ + AND memberships.relnamespace = current_ns.oid \ + AND NOT EXISTS ( \ + SELECT 1 \ + FROM (VALUES \ + ('__diesel_schema_migrations'), \ + ('groups'), \ + ('groups_users'), \ + ('__vw_custom_role_legacy_manager'), \ + ('__vw_custom_role_history_verified'), \ + ('__vw_custom_role_same_run_0716'), \ + ('__vw_ack_permanent_collection_authority') \ + ) AS relation(name) \ + INNER JOIN pg_class AS resolved ON resolved.oid = to_regclass(relation.name) \ + WHERE resolved.relnamespace <> memberships.relnamespace \ + )" +} + +/// The shapes a half-applied {`CUSTOM_COLLECTION_PERMISSIONS_MIGRATION`} may legitimately have left +/// behind, expressed as a count of the rows that have any *other* shape. +/// +/// `allow_same_run_group_derived` additionally permits the result of that migration's second data +/// statement. That statement is driven by {`CUSTOM_ROLE_LEGACY_MANAGER_TABLE`}, so the allowance +/// carries the same condition: a 0/1/1 row belonging to a membership that is *not* on record as a +/// legacy Manager cannot have come from the migration that ships today, and counting it as expected +/// would let the automatic recovery adopt a grant nothing can account for. The caller therefore only +/// passes `true` when that record actually exists — without it the shape is undecidable, and the +/// recovery refuses rather than guessing. #[cfg(any(mysql, test))] fn mysql_partial_unexpected_values_query(allow_same_run_group_derived: bool) -> String { let same_run_group_derived = if allow_same_run_group_derived { - " OR \ - (atype = 4 \ - AND access_all = FALSE \ - AND create_new_collections = FALSE \ - AND edit_any_collection = TRUE \ - AND 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 = users_organizations.uuid \ - AND g.organizations_uuid = users_organizations.org_uuid \ - AND g.access_all = TRUE \ - ))" + format!( + " OR \ + (atype = 4 \ + AND access_all = FALSE \ + AND create_new_collections = FALSE \ + AND edit_any_collection = TRUE \ + AND delete_any_collection = TRUE \ + AND uuid IN (SELECT users_organizations_uuid FROM {CUSTOM_ROLE_LEGACY_MANAGER_TABLE}) \ + 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 \ + ))" + ) } else { - "" + String::new() }; format!( @@ -911,26 +1426,44 @@ mod sqlite_migrations { let access_all_drop_migration_applied = migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?; let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?; + let legacy_manager_record_exists = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)?; + let history_verified = table_exists(connection, super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE)?; let same_run_0716_marker = same_run_marker_table_exists && count( connection, format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), )? != 0; - let ambiguous_direct_permission_count = if access_all_column_exists && collection_permission_columns == 3 { + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // it has to stop the upgrade as well. + let legacy_user_access_all_count = if access_all_column_exists { count( connection, "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE atype IN (3, 4) \ - AND access_all = FALSE \ - AND create_new_collections = FALSE \ - AND edit_any_collection = TRUE \ - AND delete_any_collection = TRUE", + WHERE atype = 2 \ + AND access_all = TRUE", )? } else { 0 }; + let confirm_permanent_authority_migration_applied = + migration_table_exists && migration_applied(connection, super::CONFIRM_PERMANENT_AUTHORITY_MIGRATION)?; + let permanent_collection_authority_ack = + table_exists(connection, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)?; + let unconfirmed_permanent_authority_count = match super::permanent_authority_lookahead_query( + collection_permission_columns == 3, + access_all_column_exists, + legacy_manager_record_exists, + collection_permissions_migration_applied, + repair_migration_applied, + "\"groups\"", + ) { + Some(query) => count(connection, query)?, + None => 0, + }; + let facts = super::CustomRoleMigrationFacts { memberships_table_exists, migration_table_exists, @@ -943,8 +1476,13 @@ mod sqlite_migrations { access_permissions_migration_applied, repair_migration_applied, access_all_drop_migration_applied, - ambiguous_direct_permission_count, + legacy_user_access_all_count, same_run_0716_marker, + legacy_manager_record_exists, + history_verified, + confirm_permanent_authority_migration_applied, + permanent_collection_authority_ack, + unconfirmed_permanent_authority_count, }; let decision = super::custom_role_preflight_decision(facts, false); @@ -1061,8 +1599,17 @@ mod mysql_migrations { connection.transaction::<(), diesel::result::Error, _>(|connection| { // This is the first data statement from the canonical migration. It also resets an - // exact, same-run group-derived 0/1/1 row to 0/0/0; that authority remains dynamically - // derived from the group, and the separate 07-23 repair then reconciles the role. + // exact, same-run group-derived 0/1/1 row to 0/0/0. That is deliberate: this completion + // path is not where legacy group authority is decided. Nothing derives it at request + // time any more -- the live fallback is gone -- so the reset is not "the group still + // covers it"; it is "leave the columns at the value this statement defines, and let the + // repair migration re-establish the authority from the legacy-Manager record". The + // canonical file's second statement is deliberately *not* replayed here, because the + // record it has to be driven by is the same one 2026-07-23-120000 reads a moment later. + // + // The two runs therefore converge: a recorded legacy Manager in an access_all group gets + // its 0/1/1 back from 2026-07-23-120000, and a membership that is not on the record + // keeps 0/0/0 -- which is the whole point of driving the grant by provenance. diesel::sql_query( "UPDATE users_organizations \ SET create_new_collections = access_all, \ @@ -1092,19 +1639,33 @@ mod mysql_migrations { // migration would then fail with error 1091 (Unknown column) on every start. The statement // has no data component and the preflight has just confirmed the column is gone, so the // schema already is what the migration wanted: record it and let the rest of the chain run. - diesel::sql_query(format!( - "INSERT INTO __diesel_schema_migrations (version) VALUES ('{}')", - super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION - )) - .execute(connection)?; + // Do not rely on the server/session autocommit setting or on a later pending migration to + // commit this repair. With autocommit=0 and no later migration, a plain INSERT is rolled back + // when this freshly established connection closes, so every start rediscovers the same + // interrupted drop. Diesel's transaction commits the ledger entry before preflight continues. + connection.transaction::<(), diesel::result::Error, _>(|connection| { + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('{}')", + super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION + )) + .execute(connection)?; + Ok(()) + })?; Ok(()) } - fn preflight(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { + /// Read everything [`super::custom_role_preflight_decision`] answers from, once. + /// + /// Separate from `preflight` because two of its decisions repair the database instead of + /// refusing, and every fact below can change when they do. + fn inspect( + connection: &mut diesel::mysql::MysqlConnection, + ) -> Result { let memberships_table_exists = table_exists(connection, "users_organizations")?; if !memberships_table_exists { - return Ok(()); + // Nothing to read, and nothing to decide: the default answers `Proceed`. + return Ok(super::CustomRoleMigrationFacts::default()); } let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; @@ -1141,27 +1702,45 @@ mod mysql_migrations { let access_all_drop_migration_applied = migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?; let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?; + let legacy_manager_record_exists = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)?; + let history_verified = table_exists(connection, super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE)?; let same_run_0716_marker = same_run_marker_table_exists && count( connection, format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), )? != 0; - let ambiguous_direct_permission_count = if access_all_column_exists && collection_permission_columns == 3 { + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // it has to stop the upgrade as well. + let legacy_user_access_all_count = if access_all_column_exists { count( connection, "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE atype IN (3, 4) \ - AND access_all = FALSE \ - AND create_new_collections = FALSE \ - AND edit_any_collection = TRUE \ - AND delete_any_collection = TRUE", + WHERE atype = 2 \ + AND access_all = TRUE", )? } else { 0 }; - let facts = super::CustomRoleMigrationFacts { + let confirm_permanent_authority_migration_applied = + migration_table_exists && migration_applied(connection, super::CONFIRM_PERMANENT_AUTHORITY_MIGRATION)?; + let permanent_collection_authority_ack = + table_exists(connection, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)?; + let unconfirmed_permanent_authority_count = match super::permanent_authority_lookahead_query( + collection_permission_columns == 3, + access_all_column_exists, + legacy_manager_record_exists, + collection_permissions_migration_applied, + repair_migration_applied, + "`groups`", + ) { + Some(query) => count(connection, query)?, + None => 0, + }; + + Ok(super::CustomRoleMigrationFacts { memberships_table_exists, migration_table_exists, access_all_column_exists, @@ -1173,20 +1752,57 @@ mod mysql_migrations { access_permissions_migration_applied, repair_migration_applied, access_all_drop_migration_applied, - ambiguous_direct_permission_count, + legacy_user_access_all_count, same_run_0716_marker, - }; + legacy_manager_record_exists, + history_verified, + confirm_permanent_authority_migration_applied, + permanent_collection_authority_ack, + unconfirmed_permanent_authority_count, + }) + } - match super::custom_role_preflight_decision(facts, true) { - super::CustomRolePreflightDecision::Proceed => Ok(()), - super::CustomRolePreflightDecision::CompleteMysqlCollectionMigration => { - complete_partial_collection_migration(connection, same_run_0716_marker) - } - super::CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { - complete_interrupted_access_all_drop(connection) + /// The two repairs below each record exactly one migration, so neither can be chosen twice. + /// The bound is not load-bearing for them -- it is there so a future repair that forgets to + /// advance the ledger cannot spin here instead of failing. + const MAX_AUTOMATIC_REPAIRS: usize = 2; + + fn preflight(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { + // A repair is not the end of the preflight, it is the start of another pass. Both repairs + // record a migration, and 0716 completion also normalizes its permission values, so every + // fact has to be read again afterwards. `custom_role_preflight_decision` evaluates all + // refusals before it returns either repair action; the loop therefore mutates only a snapshot + // that has already passed the schema, history and owner checks, then verifies the resulting + // snapshot from scratch. + for _ in 0..=MAX_AUTOMATIC_REPAIRS { + let facts = inspect(connection)?; + match super::custom_role_preflight_decision(facts, true) { + super::CustomRolePreflightDecision::Proceed => return Ok(()), + super::CustomRolePreflightDecision::CompleteMysqlCollectionMigration => { + // The same-run allowance reads the legacy-Manager record, so it may only be + // offered when that record exists. Everywhere this decision is normally reachable + // it does -- the history refusal already requires it -- but the recovery must not + // depend on that: without the record the group-derived shape cannot be attributed + // to anything, and refusing is the correct answer. + complete_partial_collection_migration( + connection, + facts.same_run_0716_marker && facts.legacy_manager_record_exists, + )?; + } + super::CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { + complete_interrupted_access_all_drop(connection)?; + } + decision => return Err(super::custom_role_preflight_error(decision, facts)), } - decision => Err(super::custom_role_preflight_error(decision, facts)), } + + Err(std::io::Error::other( + "Custom-role migration preflight kept finding a state it had just repaired. Each \ + automatic repair records a migration and can only apply once, so this means the ledger \ + insert did not take effect. Back up the database and resolve the partial migration \ + manually before restarting.", + ) + .into()) } pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { @@ -1225,15 +1841,39 @@ mod postgresql_migrations { diesel::sql_query(query).get_result::(connection).map(|row| row.count) } + /// Resolved through `to_regclass`, i.e. exactly the way an unqualified name in a migration is + /// resolved -- and deliberately *not* through `table_schema = current_schema()`. + /// + /// `current_schema()` is the first *existing* schema on the `search_path`, which is where new + /// objects are created. It is not necessarily the schema an existing table is found in: with + /// `search_path = decoy, real` and the tables in `real`, `current_schema()` answers `decoy`, the + /// lookup finds nothing, and `preflight` returns early on `!memberships_table_exists` -- silently + /// skipping every check while Diesel then runs the migrations against `real`. `to_regclass` + /// walks the same path the migrations do, so the preflight and the statements it is guarding can + /// no longer disagree about which table they mean. (`tools/custom_role_rollback/postgresql.sql` + /// defends against the same split by binding the namespace once.) fn table_exists(connection: &mut diesel::pg::PgConnection, table: &str) -> Result { + count(connection, format!("SELECT COUNT(*) AS count FROM pg_class WHERE oid = to_regclass('{table}')")) + .map(|value| value != 0) + } + + /// Columns of `users_organizations`, resolved through the same `to_regclass` lookup as + /// [`table_exists`] so a `search_path` split cannot make the schema and the column checks + /// describe two different tables. + fn column_count( + connection: &mut diesel::pg::PgConnection, + column_list: &str, + ) -> Result { count( connection, format!( - "SELECT COUNT(*) AS count FROM information_schema.tables \ - WHERE table_schema = current_schema() AND table_name = '{table}'" + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 \ + AND NOT attisdropped \ + AND attname IN ({column_list})" ), ) - .map(|value| value != 0) } fn migration_applied( @@ -1257,27 +1897,23 @@ mod postgresql_migrations { } let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; - let access_all_column_exists = count( - connection, - "SELECT COUNT(*) AS count FROM information_schema.columns \ - WHERE table_schema = current_schema() \ - AND table_name = 'users_organizations' \ - AND column_name = 'access_all'", - )? != 0; - let permission_columns = |connection: &mut diesel::pg::PgConnection, - group: super::PermissionColumnGroup| - -> Result { - count( - connection, - format!( - "SELECT COUNT(*) AS count FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'users_organizations' AND column_name IN ({})", - group.column_list() - ), + if migration_table_exists && count(connection, super::postgresql_migration_namespace_query())? != 1 { + return Err(std::io::Error::other( + "Custom-role migration preflight stopped startup: PostgreSQL resolves Vaultwarden's \ + existing migration relations in a different schema from current_schema(). An \ + unqualified migration would read users_organizations from one schema and create its \ + provenance or acknowledgement tables in another. Nothing has been changed. Set the \ + connection search_path so the schema containing users_organizations, groups, \ + groups_users and __diesel_schema_migrations is first, remove any shadow relations, \ + then restart.", ) - }; - let manage_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Manage)?; - let collection_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Collection)?; - let access_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Access)?; + .into()); + } + let access_all_column_exists = column_count(connection, "'access_all'")? != 0; + let manage_permission_columns = column_count(connection, super::PermissionColumnGroup::Manage.column_list())?; + let collection_permission_columns = + column_count(connection, super::PermissionColumnGroup::Collection.column_list())?; + let access_permission_columns = column_count(connection, super::PermissionColumnGroup::Access.column_list())?; let manage_permissions_migration_applied = migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION)?; @@ -1290,26 +1926,44 @@ mod postgresql_migrations { let access_all_drop_migration_applied = migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?; let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?; + let legacy_manager_record_exists = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)?; + let history_verified = table_exists(connection, super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE)?; let same_run_0716_marker = same_run_marker_table_exists && count( connection, format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), )? != 0; - let ambiguous_direct_permission_count = if access_all_column_exists && collection_permission_columns == 3 { + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // it has to stop the upgrade as well. + let legacy_user_access_all_count = if access_all_column_exists { count( connection, "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE atype IN (3, 4) \ - AND access_all = FALSE \ - AND create_new_collections = FALSE \ - AND edit_any_collection = TRUE \ - AND delete_any_collection = TRUE", + WHERE atype = 2 \ + AND access_all = TRUE", )? } else { 0 }; + let confirm_permanent_authority_migration_applied = + migration_table_exists && migration_applied(connection, super::CONFIRM_PERMANENT_AUTHORITY_MIGRATION)?; + let permanent_collection_authority_ack = + table_exists(connection, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)?; + let unconfirmed_permanent_authority_count = match super::permanent_authority_lookahead_query( + collection_permission_columns == 3, + access_all_column_exists, + legacy_manager_record_exists, + collection_permissions_migration_applied, + repair_migration_applied, + "\"groups\"", + ) { + Some(query) => count(connection, query)?, + None => 0, + }; + let facts = super::CustomRoleMigrationFacts { memberships_table_exists, migration_table_exists, @@ -1322,8 +1976,13 @@ mod postgresql_migrations { access_permissions_migration_applied, repair_migration_applied, access_all_drop_migration_applied, - ambiguous_direct_permission_count, + legacy_user_access_all_count, same_run_0716_marker, + legacy_manager_record_exists, + history_verified, + confirm_permanent_authority_migration_applied, + permanent_collection_authority_ack, + unconfirmed_permanent_authority_count, }; let decision = super::custom_role_preflight_decision(facts, false); @@ -1345,91 +2004,1278 @@ mod postgresql_migrations { } } -#[cfg(test)] -mod custom_role_migration_preflight_tests { - use std::error::Error as _; - - use super::{ - CustomRoleMigrationFacts as Facts, CustomRolePreflightDecision as Decision, custom_role_preflight_decision, - custom_role_preflight_error, mysql_partial_unexpected_values_query, +/// Executes the real migration files against a throwaway SQLite database. +/// +/// Everything else in this file tests the *decision* the preflight makes; nothing tested the SQL the +/// decision is protecting. The one rule those files encode -- legacy authority is granted from the +/// recorded provenance, never from the shape of a membership -- is invisible to a Rust test unless +/// the statements actually run, and it is a rule that was already lost once: `2026-07-16-120000` +/// kept granting `edit_any_collection` / `delete_any_collection` to every Custom member of an +/// `access_all` group after `2026-07-23-120000` and `2026-08-09-120000` had been narrowed to the +/// record. `edit_any_collection` satisfies `has_full_access()`, so that reached every cipher in the +/// organization. +#[cfg(all(test, sqlite))] +mod custom_role_migration_sql_tests { + use diesel::connection::SimpleConnection; + use diesel::{ + Connection, RunQueryDsl, + sql_types::{BigInt, Text}, + sqlite::SqliteConnection, }; - fn pending_repair() -> Facts { - Facts { - memberships_table_exists: true, - migration_table_exists: true, - access_all_column_exists: true, - ..Facts::default() + const ADD_COLLECTION_PERMISSIONS: &str = + include_str!("../../migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql"); + const DROP_MEMBERSHIP_ACCESS_ALL: &str = + include_str!("../../migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql"); + const MATERIALIZE_GROUP_AUTHORITY: &str = + include_str!("../../migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql"); + const CONFIRM_PERMANENT_AUTHORITY: &str = + include_str!("../../migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql"); + + const HISTORY_VERIFIED: &str = " + CREATE TABLE __vw_custom_role_history_verified (verified INTEGER NOT NULL PRIMARY KEY); + "; + const PERMANENT_AUTHORITY_ACK: &str = " + CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); + "; + + /// The shape `users_organizations` has when `2026-07-16-120000` runs: `2026-06-30-120000` has + /// added the three management columns and converted `atype = 3` to `4`, and membership + /// `access_all` still exists (`2026-07-24-120000` drops it later). + const SCHEMA_BEFORE_0716: &str = " + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE, + akey TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 2, + atype INTEGER NOT NULL, + manage_users BOOLEAN NOT NULL DEFAULT FALSE, + manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + manage_policies BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups ( + uuid TEXT NOT NULL PRIMARY KEY, + organizations_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups_users ( + groups_uuid TEXT NOT NULL, + users_organizations_uuid TEXT NOT NULL, + PRIMARY KEY (groups_uuid, users_organizations_uuid) + ); + "; + + const LEGACY_MANAGER_RECORD: &str = " + CREATE TABLE __vw_custom_role_legacy_manager ( + users_organizations_uuid TEXT NOT NULL PRIMARY KEY + ); + "; + + /// `users_organizations` as the release *before* this feature leaves it: membership `access_all`, + /// the retired Manager role, and none of the nine permission columns. This is the schema the + /// preflight refuses from on an ordinary upgrade, which is the common path. + const LEGACY_SCHEMA: &str = " + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE, + status INTEGER NOT NULL DEFAULT 2, + atype INTEGER NOT NULL + ); + CREATE TABLE groups ( + uuid TEXT NOT NULL PRIMARY KEY, + organizations_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups_users ( + groups_uuid TEXT NOT NULL, + users_organizations_uuid TEXT NOT NULL, + PRIMARY KEY (groups_uuid, users_organizations_uuid) + ); + "; + + /// The one membership the question is actually about, in its pre-upgrade shape. + const LEGACY_GROUP_DERIVED_MANAGER: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_mgr', 'u1', 'org', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm_mgr'); + "; + + /// Two memberships that are byte-identical in role and group membership and differ only in their + /// recorded provenance, plus a recorded Manager that is in no group at all. + const MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_recorded', 'u1', 'org', FALSE, 4), + ('m_unrecorded', 'u2', 'org', FALSE, 4), + ('m_no_group', 'u3', 'org', FALSE, 4); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_recorded'), + ('g_all', 'm_unrecorded'); + "; + + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = BigInt)] + count: i64, + } + + #[derive(diesel::QueryableByName)] + struct ReviewMembership { + #[diesel(sql_type = Text)] + uuid: String, + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + diesel::sql_query(query).get_result::(connection).map(|row| row.count).unwrap() + } + + fn collection_permissions(connection: &mut SqliteConnection, membership: &str) -> (bool, bool, bool) { + let flag = |connection: &mut SqliteConnection, column: &str| { + count( + connection, + &format!( + "SELECT COUNT(*) AS count FROM users_organizations \ + WHERE uuid = '{membership}' AND {column} = TRUE" + ), + ) != 0 + }; + ( + flag(connection, "create_new_collections"), + flag(connection, "edit_any_collection"), + flag(connection, "delete_any_collection"), + ) + } + + fn connect(setup: &[&str]) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + for statements in setup { + connection.batch_execute(statements).unwrap(); + } + connection + } + + /// Everything up to and including `2026-07-16-120000`, so the collection permission columns hold + /// whatever the real migration put there. `record` lists the memberships written to + /// {`CUSTOM_ROLE_LEGACY_MANAGER_TABLE`} before it runs, which is what `2026-06-30-120000` does. + /// + /// `access_all` is left in place; `2026-07-24-120000` drops it, but neither of the two migrations + /// under test reads it and keeping it makes the fixtures legible. + fn connect_after_0716(memberships: &str, record: &[&str]) -> SqliteConnection { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, memberships]); + for uuid in record { + connection + .batch_execute(&format!( + "INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) VALUES ('{uuid}')" + )) + .unwrap(); } + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + connection + } + + fn table_exists(connection: &mut SqliteConnection, table: &str) -> bool { + count( + connection, + &format!("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = '{table}'"), + ) != 0 + } + + /// What the startup preflight would answer for this database, through the very query it uses. + fn lookahead_count(connection: &mut SqliteConnection) -> i64 { + let record = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE); + let query = super::permanent_authority_lookahead_query(true, true, record, true, true, "\"groups\"") + .expect("the collection columns exist in these fixtures"); + count(connection, &query) } + /// Membership `access_all` is a stored value, not a shape, so it carries its own evidence and is + /// converted for every Custom member that holds it. #[test] - fn empty_database_can_run_normal_migrations() { - assert_eq!(custom_role_preflight_decision(Facts::default(), false), Decision::Proceed); + fn membership_access_all_becomes_all_three_collection_permissions() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, MEMBERSHIPS]); + connection + .batch_execute("UPDATE users_organizations SET access_all = TRUE WHERE uuid = 'm_unrecorded'") + .unwrap(); + + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + + assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (true, true, true)); } + /// 20260630120000 was available before 20260716120000, so this is a legitimate feature-branch + /// upgrade prefix: Managers are already converted and recorded, while a newer, unrecorded Custom + /// membership still carries its own legacy access_all bit and the collection columns are pending. + /// 0716 will turn that bit into 1/1/1, after which the conservative 0810 guard asks about it when + /// it also belongs to an organization-local access_all group. The startup lookahead must agree + /// before either migration runs, and its recovery query has to be executable on this exact shape. #[test] - fn existing_schema_without_a_ledger_is_not_guessed() { + fn ledgered_0630_unrecorded_custom_access_all_matches_the_later_guard() { + let mut connection = connect(&[ + SCHEMA_BEFORE_0716, + LEGACY_MANAGER_RECORD, + HISTORY_VERIFIED, + "CREATE TABLE __diesel_schema_migrations (version TEXT NOT NULL PRIMARY KEY); + INSERT INTO __diesel_schema_migrations (version) VALUES ('20260630120000'); + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) + VALUES ('m_custom', 'u1', 'org', TRUE, 4); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) + VALUES ('g_all', 'm_custom');", + ]); + let lookahead = super::permanent_authority_lookahead_query(false, true, true, false, false, "\"groups\"") + .expect("access_all makes the pending 0716 result projectable"); + assert_eq!(count(&mut connection, &lookahead), 1); + + let review = "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, uo.access_all, + (uo.uuid IN (SELECT users_organizations_uuid + FROM __vw_custom_role_legacy_manager)) AS was_legacy_manager + FROM users_organizations uo + WHERE (uo.atype = 3 OR (uo.atype = 4 AND ( + uo.access_all = TRUE OR uo.uuid 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 + AND g.organizations_uuid = uo.org_uuid + WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE)"; + let rows = diesel::sql_query(review).load::(&mut connection).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].uuid, "m_custom"); + assert!(super::PERMANENT_COLLECTION_AUTHORITY_RECOVERY.contains("If 20260630120000 is already")); + assert!(super::PERMANENT_COLLECTION_AUTHORITY_RECOVERY.contains("uo.access_all = TRUE OR uo.uuid IN")); + + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + assert_eq!(collection_permissions(&mut connection, "m_custom"), (true, true, true)); + assert!( + connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(), + "the preflight projection and the real 0810 guard must agree" + ); + } + + #[test] + fn out_of_order_access_permissions_would_be_destroyed_by_the_pending_sqlite_rebuild() { + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + connection + .batch_execute( + "CREATE TABLE users (uuid TEXT NOT NULL PRIMARY KEY); + CREATE TABLE organizations (uuid TEXT NOT NULL PRIMARY KEY); + INSERT INTO users (uuid) VALUES ('u1'), ('u2'), ('u3'); + INSERT INTO organizations (uuid) VALUES ('org'); + ALTER TABLE users_organizations ADD COLUMN reset_password_key TEXT; + ALTER TABLE users_organizations ADD COLUMN external_id TEXT; + ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; + 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; + UPDATE users_organizations + SET access_event_logs = TRUE, access_import_export = TRUE, access_reports = TRUE + WHERE uuid = 'm_recorded';", + ) + .unwrap(); + assert_eq!( - custom_role_preflight_decision( - Facts { - memberships_table_exists: true, - access_all_column_exists: true, - ..Facts::default() - }, - false, + count( + &mut connection, + "SELECT COUNT(*) AS count FROM users_organizations + WHERE uuid = 'm_recorded' + AND access_event_logs = TRUE + AND access_import_export = TRUE + AND access_reports = TRUE" ), - Decision::RefuseMissingMigrationLedger + 1, + "the historical later migration can hold live grants" ); - } - /// A database on which the whole chain has already run. - fn fully_migrated() -> Facts { - Facts { - memberships_table_exists: true, - migration_table_exists: true, - access_all_column_exists: false, - manage_permission_columns: 3, - manage_permissions_migration_applied: true, - collection_permission_columns: 3, - collection_permissions_migration_applied: true, - access_permission_columns: 3, - access_permissions_migration_applied: true, - repair_migration_applied: true, - access_all_drop_migration_applied: true, - ambiguous_direct_permission_count: 0, - same_run_0716_marker: false, - } + connection.batch_execute(DROP_MEMBERSHIP_ACCESS_ALL).unwrap(); + + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') + WHERE name IN ('access_event_logs', 'access_import_export', 'access_reports')" + ), + 0, + "this pins why the preflight must refuse before running the unchanged migration file" + ); } + /// The regression this test exists for. `m_recorded` and `m_unrecorded` differ in nothing a + /// query at request time could see -- same role, same organization, same `access_all` group -- + /// so only the provenance record may decide, and it must not leak organization-wide collection + /// authority to the membership that has none. #[test] - fn repair_marker_makes_completed_state_idempotent() { - assert_eq!(custom_role_preflight_decision(fully_migrated(), false), Decision::Proceed); + fn group_derived_authority_is_granted_only_to_recorded_legacy_managers() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, MEMBERSHIPS]); + connection + .batch_execute( + "INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) \ + VALUES ('m_recorded'), ('m_no_group')", + ) + .unwrap(); + + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + + // Edit and delete, never create: creating collections historically required membership + // `access_all`, which this member does not have. + assert_eq!(collection_permissions(&mut connection, "m_recorded"), (false, true, true)); + // Not on record: identical in shape, and it gets nothing. + assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (false, false, false)); + // On record, but its authority never came from a group. + assert_eq!(collection_permissions(&mut connection, "m_no_group"), (false, false, false)); } - /// The repair migration runs *before* the access_all drop and the third permission column group, - /// so a partial state of either always carries `repair_migration_applied`. Skipping the schema - /// checks for repaired databases would make them unreachable in exactly the situation they were - /// written for. + /// Without the record the grant is undecidable, so the migration refuses -- and it has to refuse + /// *before* the `ALTER TABLE`s. On MySQL/MariaDB every one of them commits on its own, so a + /// guard placed after them would leave a half-added column group behind, which is exactly the + /// state `RefusePartialPermissionSchema` then has to talk an operator out of. #[test] - fn interrupted_migrations_after_the_repair_are_still_detected() { - // Crash after `DROP COLUMN access_all`, before the ledger insert. MySQL/MariaDB commit DDL - // implicitly, so the column is gone for good; a retry would fail with 1091. - let interrupted_drop = Facts { - access_all_drop_migration_applied: false, - access_permission_columns: 0, - access_permissions_migration_applied: false, - ..fully_migrated() - }; - assert_eq!( - custom_role_preflight_decision(interrupted_drop, true), - Decision::CompleteInterruptedAccessAllDrop, - "MySQL/MariaDB can complete this in place" - ); + fn the_migration_refuses_without_the_record_and_adds_no_column() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, MEMBERSHIPS]); + + assert!(connection.batch_execute(ADD_COLLECTION_PERMISSIONS).is_err()); + assert_eq!( - custom_role_preflight_decision(interrupted_drop, false), - Decision::RefuseInterruptedAccessAllDrop, - "backends with transactional DDL cannot reach this state by themselves" + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ + WHERE name IN ('create_new_collections', 'edit_any_collection', 'delete_any_collection')" + ), + 0, + "the guard has to run before the ALTER TABLE statements, or MySQL keeps the partial column group" + ); + } + + /// `2026-08-09-120000` repeats the materialization for databases that already recorded + /// `2026-07-23-120000`, and it is driven by the same record for the same reason. + #[test] + fn the_repeat_materialization_is_also_bound_to_the_record() { + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded", "m_no_group"]); + connection.batch_execute(HISTORY_VERIFIED).unwrap(); + + connection.batch_execute(MATERIALIZE_GROUP_AUTHORITY).unwrap(); + + assert_eq!(collection_permissions(&mut connection, "m_recorded"), (false, true, true)); + assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (false, false, false)); + assert_eq!(collection_permissions(&mut connection, "m_no_group"), (false, false, false)); + } + + /// Without the record the file cannot tell a converted legacy Manager from an ordinary Custom + /// member, and without the history marker nobody has said the unrecorded ones are unrecorded on + /// purpose. Granting would be a silent escalation, skipping would silently drop a capability, so + /// it stops -- and the marker itself never grants anything. + #[test] + fn the_repeat_materialization_refuses_an_unaudited_history() { + let mut refuses = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + assert!(refuses.batch_execute(MATERIALIZE_GROUP_AUTHORITY).is_err()); + + let mut audited = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + audited.batch_execute(HISTORY_VERIFIED).unwrap(); + audited.batch_execute(MATERIALIZE_GROUP_AUTHORITY).unwrap(); + assert_eq!( + collection_permissions(&mut audited, "m_unrecorded"), + (false, false, false), + "the marker settles who is undecidable, it never grants" + ); + } + + /// The one question the chain asks. `m_recorded` is the conversion it is about: its authority came + /// from the group and is about to outlive it. + /// + /// The two halves deliberately use separate connections. Every guard in this chain aborts by + /// leaving its `CREATE TEMPORARY TABLE` un-dropped, so a *retry on the same session* trips over + /// the leftover instead of the real condition. That is not reachable from Vaultwarden -- a failed + /// migration ends the process, and Diesel wraps each migration in a transaction on SQLite and + /// PostgreSQL, where temporary DDL rolls back with it -- but a test that reused the connection + /// would be asserting on the wrong error. + #[test] + fn permanent_collection_authority_needs_an_acknowledgement() { + let mut refuses = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + assert_eq!(collection_permissions(&mut refuses, "m_recorded"), (false, true, true)); + assert!(refuses.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + + // The answer lifts it, and is consumed so the next upgrade has to ask again. + let mut acknowledged = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + acknowledged.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + acknowledged.batch_execute(CONFIRM_PERMANENT_AUTHORITY).unwrap(); + assert!(!table_exists(&mut acknowledged, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)); + + // It grants nothing and revokes nothing on the way through. + assert_eq!(collection_permissions(&mut acknowledged, "m_recorded"), (false, true, true)); + } + + /// `create_new_collections` is independently mutable. An owner can set it after an earlier + /// revision materialized a group-derived 0/1/1 grant, so the resulting 1/1/1 shape must not be + /// mistaken for immutable evidence that membership `access_all` supplied all three permissions. + #[test] + fn mutable_create_permission_does_not_hide_group_derived_authority() { + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + connection + .batch_execute("UPDATE users_organizations SET create_new_collections = TRUE WHERE uuid = 'm_recorded';") + .unwrap(); + assert_eq!(collection_permissions(&mut connection, "m_recorded"), (true, true, true)); + + assert!( + connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(), + "a current permission value is not historical provenance" + ); + } + + /// An unrecorded Custom member holding the permissions is *not* excluded: on a database first + /// upgraded by an earlier revision those may be the bulk grant its `20260809120000` wrote, and + /// nothing can tell them from a deliberate grant any more. + #[test] + fn an_unrecorded_grant_is_still_worth_asking_about() { + let mut connection = connect_after_0716(MEMBERSHIPS, &[]); + connection + .batch_execute( + "UPDATE users_organizations \ + SET create_new_collections = TRUE, edit_any_collection = TRUE, delete_any_collection = TRUE \ + WHERE uuid = 'm_unrecorded'", + ) + .unwrap(); + + assert!(connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + } + + /// The record is still a chain invariant used by repair and rollback even though the final + /// materialized-authority predicate no longer uses it to exclude rows. Refuse a damaged chain + /// explicitly rather than letting a later statement fail as `no such table`. + #[test] + fn the_confirmation_refuses_without_the_record() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, MEMBERSHIPS]); + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + connection.batch_execute("DROP TABLE __vw_custom_role_legacy_manager").unwrap(); + + assert!(connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + } + + /// A refusal is only a decision if "no" can be carried out on the schema it is printed for, and + /// this one is printed from two of them. The migrated shape was always answerable; the legacy + /// shape -- the ordinary upgrade, and the common case -- was told to clear columns that do not + /// exist there yet, so the only statement an operator could actually run was the acknowledgement. + /// + /// Both halves are checked against the recovery text itself, so a future edit that drops one of + /// the two statements fails here rather than in an operator's terminal. + #[test] + fn the_recovery_can_be_declined_on_both_schema_shapes() { + let legacy_query = super::permanent_authority_lookahead_query(false, true, false, false, false, "\"groups\"") + .expect("membership access_all is still present in the legacy fixture"); + + // 1. Legacy shape. The migrated shape's statement cannot run here at all. + let mut connection = connect(&[LEGACY_SCHEMA, LEGACY_GROUP_DERIVED_MANAGER]); + assert_eq!(count(&mut connection, &legacy_query), 1, "the fixture has to raise the question"); + assert!( + connection + .batch_execute( + "UPDATE users_organizations \ + SET edit_any_collection = FALSE, delete_any_collection = FALSE \ + WHERE uuid = 'm_mgr'" + ) + .is_err(), + "the permission columns do not exist before the upgrade -- this is why the text needs two answers" + ); + + // What the text offers instead: end the group relationship, for one membership... + let mut connection = connect(&[LEGACY_SCHEMA, LEGACY_GROUP_DERIVED_MANAGER]); + connection + .batch_execute( + "DELETE FROM groups_users \ + WHERE users_organizations_uuid = 'm_mgr' AND groups_uuid = 'g_all'", + ) + .unwrap(); + assert_eq!(count(&mut connection, &legacy_query), 0, "declining has to answer the question"); + + // ...or for the whole group at once. + let mut connection = connect(&[LEGACY_SCHEMA, LEGACY_GROUP_DERIVED_MANAGER]); + connection.batch_execute("UPDATE \"groups\" SET access_all = FALSE WHERE uuid = 'g_all'").unwrap(); + assert_eq!(count(&mut connection, &legacy_query), 0, "declining has to answer the question"); + + // 2. Migrated shape: the statement the text prints for it runs, and answers the question. + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + assert_eq!(lookahead_count(&mut connection), 1); + connection + .batch_execute( + "UPDATE users_organizations \ + SET edit_any_collection = FALSE, delete_any_collection = FALSE \ + WHERE uuid = 'm_recorded'", + ) + .unwrap(); + assert_eq!(lookahead_count(&mut connection), 0); + + for statement in [ + "DELETE FROM groups_users", + "UPDATE \"groups\" SET access_all = FALSE", + "SET edit_any_collection = FALSE, delete_any_collection = FALSE", + ] { + assert!( + super::PERMANENT_COLLECTION_AUTHORITY_RECOVERY.contains(statement), + "the refusal has to print `{statement}`" + ); + } + } + + /// The reason the preflight exists: it has to reach the *same* verdict as the migration, or it + /// either refuses a database the migration would have let through, or lets one through that then + /// aborts with nothing but a duplicate-key error. Checked against the real files. + #[test] + fn the_preflight_lookahead_agrees_with_the_migration() { + // (name, record contents, extra setup) -> the migration decides, the lookahead has to match. + let cases: [(&str, &[&str], &str); 5] = [ + ("group-derived conversion", &["m_recorded"], ""), + ("nothing qualifies", &["m_no_group"], ""), + ( + "membership access_all, never group-bound", + &["m_recorded"], + "UPDATE users_organizations SET create_new_collections = TRUE WHERE uuid = 'm_recorded'", + ), + ( + "bulk grant to a membership that is not on the record", + &[], + "UPDATE users_organizations SET edit_any_collection = TRUE WHERE uuid = 'm_unrecorded'", + ), + ( + "revoked membership: no authority today, but it would come back with one", + &["m_recorded"], + "UPDATE users_organizations SET status = -1 WHERE uuid = 'm_recorded'", + ), + ]; + + for (name, record, extra) in cases { + let mut connection = connect_after_0716(MEMBERSHIPS, record); + if !extra.is_empty() { + connection.batch_execute(extra).unwrap(); + } + + let predicted = lookahead_count(&mut connection) != 0; + let refused = connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(); + assert_eq!(predicted, refused, "preflight and migration disagree for: {name}"); + } + } +} + +/// Runs the whole Custom-role chain, then `tools/custom_role_rollback/sqlite.sql`, then the chain +/// again — against a throwaway SQLite database, with the real files on both legs. +/// +/// The round trip is the claim the rollback tooling rests on: an operator who downgrades and later +/// upgrades again has to arrive at the same permissions, or the escape hatch quietly rewrites +/// authorization. It was only ever verified by hand. +#[cfg(all(test, sqlite))] +mod custom_role_rollback_sql_tests { + use diesel::connection::SimpleConnection; + use diesel::{Connection, RunQueryDsl, sql_types::Text, sqlite::SqliteConnection}; + + /// The nine files, in the order Diesel applies them. + const CHAIN: [&str; 9] = [ + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql"), + include_str!("../../migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql"), + include_str!("../../migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql"), + include_str!("../../migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql"), + include_str!("../../migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql"), + include_str!("../../migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql"), + include_str!("../../migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql"), + include_str!("../../migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql"), + include_str!("../../migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql"), + ]; + const CHAIN_VERSIONS: [&str; 9] = [ + "20260630120000", + "20260715120000", + "20260716120000", + "20260723120000", + "20260724120000", + "20260724130000", + "20260724140000", + "20260809120000", + "20260810120000", + ]; + + const ROLLBACK: &str = include_str!("../../tools/custom_role_rollback/sqlite.sql"); + + const PERMANENT_AUTHORITY_ACK: &str = + "CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY)"; + + /// `users_organizations` exactly as the release before this feature leaves it — the rollback + /// script checks for *precisely* eighteen columns afterwards, so a reduced fixture would not + /// exercise the check it exists for. + const UPSTREAM_SCHEMA: &str = " + CREATE TABLE __diesel_schema_migrations ( + version VARCHAR(50) NOT NULL PRIMARY KEY, + run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE, + akey TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 2, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + UNIQUE (user_uuid, org_uuid) + ); + CREATE TABLE groups ( + uuid TEXT NOT NULL PRIMARY KEY, + organizations_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups_users ( + groups_uuid TEXT NOT NULL, + users_organizations_uuid TEXT NOT NULL, + PRIMARY KEY (groups_uuid, users_organizations_uuid) + ); + INSERT INTO __diesel_schema_migrations (version) VALUES ('20250109172300'); + "; + + /// One membership per legacy shape that the mapping treats differently. + const LEGACY_MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org', TRUE), + ('g_plain', 'org', FALSE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_owner', 'u1', 'org', FALSE, 2, 0), + ('m_admin', 'u2', 'org', FALSE, 2, 1), + ('m_user', 'u3', 'org', FALSE, 2, 2), + ('m_mgr_bare', 'u4', 'org', FALSE, 2, 3), + ('m_mgr_all', 'u5', 'org', TRUE, 2, 3), + ('m_mgr_group', 'u6', 'org', FALSE, 2, 3), + ('m_mgr_gone', 'u7', 'org', FALSE, -1, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_mgr_gone'), + ('g_plain', 'm_mgr_bare'); + "; + + #[derive(diesel::QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + value: String, + } + + fn rows(connection: &mut SqliteConnection, query: &str) -> Vec { + diesel::sql_query(query).load::(connection).unwrap().into_iter().map(|row| row.value).collect() + } + + /// Every membership's role plus its nine permissions, as one comparable line each. + fn permission_state(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT uuid || ' atype=' || atype || ' status=' || status \ + || ' ' || manage_users || manage_groups || manage_policies \ + || create_new_collections || edit_any_collection || delete_any_collection \ + || access_event_logs || access_import_export || access_reports AS value \ + FROM users_organizations ORDER BY uuid", + ) + } + + fn legacy_state(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT uuid || ' atype=' || atype || ' access_all=' || access_all AS value \ + FROM users_organizations ORDER BY uuid", + ) + } + + /// Applies the chain, recording each version the way Diesel would. + fn upgrade(connection: &mut SqliteConnection) -> Result<(), diesel::result::Error> { + for (sql, version) in CHAIN.iter().zip(CHAIN_VERSIONS) { + connection.batch_execute(sql)?; + connection + .batch_execute(&format!("INSERT INTO __diesel_schema_migrations (version) VALUES ('{version}')"))?; + } + Ok(()) + } + + /// `.bail on` is a sqlite3 shell command, not SQL. Dropping it is safe here — a failing statement + /// fails the whole `batch_execute` anyway — but the assertion keeps the test honest if another + /// dot-command is ever added, because those the shell would act on and this runner would not. + fn rollback_sql() -> String { + let (dot, sql): (Vec<&str>, Vec<&str>) = ROLLBACK.lines().partition(|line| line.starts_with('.')); + assert_eq!(dot, [".bail on"], "unexpected sqlite3 shell command in the rollback script"); + sql.join("\n") + } + + fn connect() -> SqliteConnection { + connect_with(LEGACY_MEMBERSHIPS) + } + + fn connect_with(memberships: &str) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + connection.batch_execute("PRAGMA foreign_keys = OFF").unwrap(); + connection.batch_execute(UPSTREAM_SCHEMA).unwrap(); + connection.batch_execute(memberships).unwrap(); + connection + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + rows(connection, &format!("SELECT ({query}) || '' AS value"))[0].parse().unwrap() + } + + /// The nine `down.sql` files, in the order `diesel migration revert` applies them. + const REVERT_CHAIN: [&str; 9] = [ + include_str!("../../migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql"), + include_str!( + "../../migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql" + ), + include_str!("../../migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql"), + include_str!("../../migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql"), + include_str!("../../migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql"), + include_str!("../../migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql"), + include_str!("../../migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql"), + include_str!("../../migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql"), + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql"), + ]; + + const DOWNGRADE_ACK: &str = + "CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY)"; + + /// The revert chain the rollback README offers as the Diesel alternative to `sqlite.sql`, run + /// end to end. It was only ever verified by hand, and it is where the acknowledgement's lifetime + /// lives: consuming it at the guard instead of at the oldest lossy step leaves every following + /// destructive revert unguarded and strands the chain halfway. + #[test] + fn the_diesel_revert_chain_runs_with_one_acknowledgement() { + let mut connection = connect(); + let before = legacy_state(&mut connection); + + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + + // One decision, plus the historical provenance as the allowlist -- what the README suggests. + connection.batch_execute(DOWNGRADE_ACK).unwrap(); + connection + .batch_execute( + "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;", + ) + .unwrap(); + + for (step, down) in REVERT_CHAIN.iter().enumerate() { + connection.batch_execute(down).unwrap_or_else(|e| panic!("revert step {step} failed: {e}")); + } + + assert_eq!( + legacy_state(&mut connection), + before + .iter() + .map(|row| { + // Same documented exception as the standalone script: the upgrade dropped the + // column because the role already reaches every collection, so the original + // value no longer exists. + if row.starts_with("m_owner") || row.starts_with("m_admin") { + row.replace("access_all=0", "access_all=1") + } else { + row.clone() + } + }) + .collect::>(), + "the revert chain has to land on the same legacy shape as tools/custom_role_rollback/" + ); + } + + /// Without the acknowledgement the chain stops at the guard, before the first destructive step, + /// and changes nothing. + #[test] + fn the_revert_chain_stops_at_the_guard_and_mutates_nothing() { + let mut connection = connect(); + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + let upgraded = permission_state(&mut connection); + + // 2026-08-10 and 2026-08-09 revert cleanly; they are no-ops by design. + connection.batch_execute(REVERT_CHAIN[0]).unwrap(); + connection.batch_execute(REVERT_CHAIN[1]).unwrap(); + assert!(connection.batch_execute(REVERT_CHAIN[2]).is_err(), "the downgrade guard has to refuse"); + + assert_eq!(permission_state(&mut connection), upgraded, "a refused revert must not mutate"); + } + + /// The migrated-schema half of this is covered in `custom_role_migration_sql_tests`. This is the + /// other half, and the one an ordinary upgrade actually meets: the preflight has to predict from + /// the *legacy* schema exactly whether the chain will stop for the permanent-authority decision. + #[test] + fn the_legacy_shape_lookahead_agrees_with_the_whole_chain() { + let query = super::permanent_authority_lookahead_query(false, true, false, false, false, "\"groups\"") + .expect("membership access_all is still present before the upgrade"); + + let cases: [(&str, &str); 5] = [ + ("group-derived Manager: the question", LEGACY_MEMBERSHIPS), + ( + "membership access_all too: conservatively ask without immutable provenance", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', TRUE, 2, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ( + "the access_all group belongs to another organization", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'other', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', FALSE, 2, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ( + "a plain User in the group is not converted and not asked about", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', FALSE, 2, 2); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ( + "an invited Manager holds nothing today, but would come back with it", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', FALSE, 0, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ]; + + for (name, memberships) in cases { + let mut connection = connect_with(memberships); + let predicted = count(&mut connection, &query) != 0; + let refused = upgrade(&mut connection).is_err(); + assert_eq!(predicted, refused, "preflight and chain disagree on the legacy schema for: {name}"); + } + } + + #[test] + fn upgrade_rollback_and_upgrade_again_converge() { + let mut connection = connect(); + let before = legacy_state(&mut connection); + + // `m_mgr_group` and `m_mgr_gone` reach every collection through an access_all group, so the + // chain stops for the decision 2026-08-10-120000 exists to ask. + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + let upgraded = permission_state(&mut connection); + + // The legacy Manager whose authority came from the group carries it in the columns now; the + // one whose membership held access_all gets all three; a bare Manager gets nothing. + assert!(upgraded.contains(&"m_mgr_group atype=4 status=2 000011000".to_owned()), "{upgraded:?}"); + assert!(upgraded.contains(&"m_mgr_all atype=4 status=2 000111000".to_owned()), "{upgraded:?}"); + assert!(upgraded.contains(&"m_mgr_bare atype=4 status=2 000000000".to_owned()), "{upgraded:?}"); + assert!(upgraded.contains(&"m_user atype=2 status=2 000000000".to_owned()), "{upgraded:?}"); + + // Roll back with the historical provenance as the allowlist, which is what the README offers + // as the starting point. + connection + .batch_execute( + "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;", + ) + .unwrap(); + connection.batch_execute(&rollback_sql()).unwrap(); + + assert_eq!( + legacy_state(&mut connection), + before + .iter() + .map(|row| { + // Owner and Admin always come back with access_all set: the upgrade dropped the + // column precisely because their role already reaches every collection, so the + // original value no longer exists. Documented in the rollback README. + if row.starts_with("m_owner") || row.starts_with("m_admin") { + row.replace("access_all=0", "access_all=1") + } else { + row.clone() + } + }) + .collect::>(), + "the rollback has to restore the legacy roles it was given an allowlist for" + ); + assert_eq!( + rows( + &mut connection, + "SELECT version AS value FROM __diesel_schema_migrations WHERE version >= '20260630120000'" + ), + Vec::::new(), + "the older binary must not see a ledger from the future" + ); + + // A re-upgrade has to ask again -- the acknowledgement is consumed, and a revert is not + // consent -- and then land on exactly the state it produced the first time. + assert!(upgrade(&mut connect_from(&mut connection)).is_err(), "the question has to be asked again"); + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + + assert_eq!(permission_state(&mut connection), upgraded, "the round trip has to converge"); + } + + /// A second connection onto the same rolled-back content, so the "asks again" probe can fail + /// without leaving its aborted guard behind on the connection the test continues with. + fn connect_from(source: &mut SqliteConnection) -> SqliteConnection { + let mut copy = SqliteConnection::establish(":memory:").unwrap(); + copy.batch_execute("PRAGMA foreign_keys = OFF").unwrap(); + copy.batch_execute(UPSTREAM_SCHEMA).unwrap(); + copy.batch_execute("DELETE FROM __diesel_schema_migrations").unwrap(); + for statement in rows( + source, + "SELECT 'INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES (''' \ + || uuid || ''',''' || user_uuid || ''',''' || org_uuid || ''',' || access_all || ',''' || akey \ + || ''',' || status || ',' || atype || ')' AS value FROM users_organizations", + ) { + copy.batch_execute(&statement).unwrap(); + } + copy.batch_execute(LEGACY_GROUPS_ONLY).unwrap(); + copy + } + + const LEGACY_GROUPS_ONLY: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org', TRUE), + ('g_plain', 'org', FALSE); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_mgr_gone'), + ('g_plain', 'm_mgr_bare'); + "; + + /// The precondition is the only thing standing between a mismatched database and an irreversible + /// rewrite, so it has to refuse before touching anything. + #[test] + fn the_rollback_refuses_without_an_allowlist_and_changes_nothing() { + let mut connection = connect(); + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + let upgraded = permission_state(&mut connection); + + assert!(connection.batch_execute(&rollback_sql()).is_err()); + + assert_eq!(permission_state(&mut connection), upgraded, "a refused rollback must not mutate"); + assert_eq!( + rows( + &mut connection, + "SELECT COUNT(*) || '' AS value FROM __diesel_schema_migrations WHERE version >= '20260630120000'" + ), + vec!["9".to_owned()], + "and it must not touch the ledger either" + ); + } +} + +#[cfg(test)] +mod custom_role_migration_preflight_tests { + use std::error::Error as _; + + use super::{ + CustomRoleMigrationFacts as Facts, CustomRolePreflightDecision as Decision, custom_role_preflight_decision, + custom_role_preflight_error, mysql_partial_unexpected_values_query, permanent_authority_lookahead_query, + }; + + fn pending_repair() -> Facts { + Facts { + memberships_table_exists: true, + migration_table_exists: true, + access_all_column_exists: true, + // Any database on which the chain has started under the code that ships today carries + // both of these, because its first migration writes them. Where it has not started, + // `manage_permissions_migration_applied` is false and neither is read. + legacy_manager_record_exists: true, + history_verified: true, + ..Facts::default() + } + } + + #[test] + fn empty_database_can_run_normal_migrations() { + assert_eq!(custom_role_preflight_decision(Facts::default(), false), Decision::Proceed); + } + + #[test] + fn existing_schema_without_a_ledger_is_not_guessed() { + assert_eq!( + custom_role_preflight_decision( + Facts { + memberships_table_exists: true, + access_all_column_exists: true, + ..Facts::default() + }, + false, + ), + Decision::RefuseMissingMigrationLedger + ); + } + + /// A database on which the whole chain has already run. + fn fully_migrated() -> Facts { + Facts { + memberships_table_exists: true, + migration_table_exists: true, + access_all_column_exists: false, + manage_permission_columns: 3, + manage_permissions_migration_applied: true, + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + access_permission_columns: 3, + access_permissions_migration_applied: true, + repair_migration_applied: true, + access_all_drop_migration_applied: true, + legacy_user_access_all_count: 0, + same_run_0716_marker: false, + legacy_manager_record_exists: true, + history_verified: true, + confirm_permanent_authority_migration_applied: true, + permanent_collection_authority_ack: false, + unconfirmed_permanent_authority_count: 0, + } + } + + /// A database ready for `20260810120000`, i.e. one memberships still awaiting the decision. + fn awaiting_permanent_authority_decision() -> Facts { + Facts { + confirm_permanent_authority_migration_applied: false, + permanent_collection_authority_ack: false, + unconfirmed_permanent_authority_count: 2, + ..fully_migrated() + } + } + + /// The refusal `20260810120000` exists for has to be reached *here*, with the review query and + /// the acknowledgement attached. Left to the migration's own guard it arrives as nothing but + /// `UNIQUE constraint failed: __vw_permanent_authority_guard.blocked`, on an upgrade that is + /// otherwise perfectly healthy. + #[test] + fn unconfirmed_permanent_collection_authority_is_refused_with_a_recovery_path() { + let facts = awaiting_permanent_authority_decision(); + let decision = custom_role_preflight_decision(facts, false); + assert_eq!(decision, Decision::RefuseUnconfirmedPermanentCollectionAuthority); + assert_eq!(custom_role_preflight_decision(facts, true), decision, "MySQL must not auto-complete this"); + + let error = custom_role_preflight_error(decision, facts); + let message = error.source().expect("preflight error should retain its I/O error source").to_string(); + assert!(message.contains("__vw_ack_permanent_collection_authority"), "{message}"); + assert!(message.contains("was_legacy_manager"), "{message}"); + assert!(message.contains("Nothing has been changed."), "{message}"); + // The count belongs in the message: it is what tells an operator whether the review query is + // expected to return one row or a hundred. + assert!(message.contains('2'), "{message}"); + } + + /// Three separate ways out, and each of them has to actually let the upgrade through. + #[test] + fn the_permanent_authority_question_is_asked_exactly_once() { + // The owner answered it. + assert_eq!( + custom_role_preflight_decision( + Facts { + permanent_collection_authority_ack: true, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::Proceed + ); + // Already answered on an earlier start: the migration is recorded, so it never runs again and + // the acknowledgement it consumed is gone. Asking a second time would deadlock the upgrade. + assert_eq!( + custom_role_preflight_decision( + Facts { + confirm_permanent_authority_migration_applied: true, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::Proceed + ); + // Nothing to decide -- the common case. + assert_eq!( + custom_role_preflight_decision( + Facts { + unconfirmed_permanent_authority_count: 0, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::Proceed + ); + } + + /// A damaged schema is the more urgent problem and its recovery is a different one, so it has to + /// be reported first. The question is only worth asking about a database that can actually run + /// the migration. + #[test] + fn a_damaged_schema_outranks_the_permanent_authority_question() { + assert_eq!( + custom_role_preflight_decision( + Facts { + access_permission_columns: 1, + access_permissions_migration_applied: false, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access) + ); + assert_eq!( + custom_role_preflight_decision( + Facts { + history_verified: false, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + } + + /// The lookahead has to answer the same question before and after the columns it would rather + /// read exist, because the preflight runs before any migration does. + #[test] + fn the_permanent_authority_lookahead_reads_whichever_schema_is_present() { + let materialized = permanent_authority_lookahead_query(true, false, true, true, true, "\"groups\"").unwrap(); + assert!(materialized.contains("uo.atype = 4")); + assert!(materialized.contains("edit_any_collection = TRUE OR uo.delete_any_collection = TRUE")); + assert!(!materialized.contains("create_new_collections")); + assert!(!materialized.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)); + + // The materialized predicate does not change with provenance availability: a mutable current + // permission is never treated as historical evidence. + let no_record = permanent_authority_lookahead_query(true, false, false, true, true, "\"groups\"").unwrap(); + assert_eq!(no_record, materialized); + + // The ordinary upgrade: nothing is materialized yet, so the answer comes from the retired + // Manager role plus the legacy bit that the first migration turns into all three permissions. + let legacy = permanent_authority_lookahead_query(false, true, false, false, false, "\"groups\"").unwrap(); + assert!(legacy.contains("uo.atype = 3")); + assert!(!legacy.contains("uo.access_all = FALSE")); + + // Both shapes bind the group to the membership's own organization. + for query in [&materialized, &no_record, &legacy] { + assert!(query.contains("g.organizations_uuid = uo.org_uuid"), "{query}"); + assert!(query.contains("g.access_all = TRUE"), "{query}"); + } + + // Neither column group is readable: the migration cannot run either, so there is nothing to + // look ahead to. + assert!(permanent_authority_lookahead_query(false, false, true, false, false, "\"groups\"").is_none()); + + // The reserved identifier is the caller's to quote. + assert!( + permanent_authority_lookahead_query(true, false, true, true, true, "`groups`") + .unwrap() + .contains("`groups`") + ); + } + + #[test] + fn repair_marker_makes_completed_state_idempotent() { + assert_eq!(custom_role_preflight_decision(fully_migrated(), false), Decision::Proceed); + } + + /// A database upgraded by an earlier revision of this feature branch carries the Custom-role + /// versions without the effects the current files have, and Diesel will not run them again. The + /// two tables the first migration creates today are the only durable evidence of that, so their + /// absence has to stop the upgrade -- before every check that assumes the chain did what it does + /// today. + #[test] + fn a_history_written_by_an_earlier_revision_is_refused() { + // Neither table: an untouched earlier-revision database. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: false, + history_verified: false, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // Recording provenance is data recovery, not an audit: writing the record table must not by + // itself pass as a review of the history that made it necessary. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: true, + history_verified: false, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // And the marker alone leaves the later migrations and the rollback scripts without the data + // they read. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: false, + history_verified: true, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // It is checked from the *first* Custom-role migration, not only from the repair one: the + // divergence starts where `atype = 3` is reused, which is before the repair runs. + assert_eq!( + custom_role_preflight_decision( + Facts { + repair_migration_applied: false, + access_all_drop_migration_applied: false, + access_all_column_exists: true, + legacy_manager_record_exists: false, + history_verified: false, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // It outranks the schema/ledger checks: those describe an interrupted migration whose replay + // is safe, which is not what this database needs. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: false, + history_verified: false, + access_all_column_exists: true, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // A database that has not started the chain at all is untouched by any of this. + assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); + } + + /// The repair migration runs *before* the access_all drop and the third permission column group, + /// so a partial state of either always carries `repair_migration_applied`. Skipping the schema + /// checks for repaired databases would make them unreachable in exactly the situation they were + /// written for. + #[test] + fn interrupted_migrations_after_the_repair_are_still_detected() { + // Crash after `DROP COLUMN access_all`, before the ledger insert. MySQL/MariaDB commit DDL + // implicitly, so the column is gone for good; a retry would fail with 1091. + let interrupted_drop = Facts { + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..fully_migrated() + }; + assert_eq!( + custom_role_preflight_decision(interrupted_drop, true), + Decision::CompleteInterruptedAccessAllDrop, + "MySQL/MariaDB can complete this in place" + ); + assert_eq!( + custom_role_preflight_decision(interrupted_drop, false), + Decision::RefuseInterruptedAccessAllDrop, + "backends with transactional DDL cannot reach this state by themselves" ); // Crash after one of the three `ADD COLUMN` statements of the access group, before the @@ -1491,6 +3337,84 @@ mod custom_role_migration_preflight_tests { ); } + #[test] + fn a_later_access_migration_before_the_pending_drop_is_refused() { + // This exact non-prefix history was deployable from the feature's former side branch: + // 20260724130000 and its columns exist, while 20260724120000 is still pending. SQLite's + // pending fixed-list rebuild would otherwise discard all three columns and their values. + for repair_migration_applied in [false, true] { + let facts = Facts { + repair_migration_applied, + access_all_column_exists: true, + access_all_drop_migration_applied: false, + access_permission_columns: 3, + access_permissions_migration_applied: true, + ..fully_migrated() + }; + let expected = Decision::RefuseOutOfOrderAccessPermissionsMigration; + + assert_eq!(custom_role_preflight_decision(facts, false), expected); + assert_eq!(custom_role_preflight_decision(facts, true), expected); + + let message = custom_role_preflight_error(expected, facts) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains(super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION), "{message}"); + assert!(message.contains(super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION), "{message}"); + assert!(message.contains("would drop access_event_logs"), "{message}"); + assert!(message.contains("Nothing has been changed"), "{message}"); + } + } + + /// A repair is selected only after the same snapshot has passed every refusal. This pins the two + /// mutation-before-refusal orders that previously existed: 0716 completion before discovering a + /// damaged later column group, and interrupted-drop ledger repair before asking the owner. + #[test] + fn automatic_mysql_repairs_are_deferred_behind_all_refusals() { + let partial_0716_with_damaged_access_group = Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + access_permission_columns: 1, + access_permissions_migration_applied: false, + ..pending_repair() + }; + assert_eq!( + custom_role_preflight_decision(partial_0716_with_damaged_access_group, true), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access), + "0716 must not be completed before a later schema refusal" + ); + + let interrupted_drop_with_unanswered_authority = Facts { + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..awaiting_permanent_authority_decision() + }; + let decision = custom_role_preflight_decision(interrupted_drop_with_unanswered_authority, true); + assert_eq!(decision, Decision::RefuseUnconfirmedPermanentCollectionAuthority); + let message = custom_role_preflight_error(decision, interrupted_drop_with_unanswered_authority) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains("Nothing has been changed."), "{message}"); + + // The historical partial-completion query reads access_all. Once 0723 and its following drop + // are recorded, three columns without the earlier 0716 ledger are a non-prefix mismatch, not + // the repairable pre-0723 crash state. + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + ..fully_migrated() + }, + true, + ), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection) + ); + } + #[test] fn interrupted_access_all_drop_error_names_the_ledger_fix() { let facts = Facts { @@ -1522,24 +3446,24 @@ mod custom_role_migration_preflight_tests { } #[test] - fn ambiguous_direct_permissions_error_carries_a_recovery_path() { + fn legacy_user_access_all_error_carries_a_recovery_path() { let facts = Facts { - ambiguous_direct_permission_count: 2, + legacy_user_access_all_count: 2, ..pending_repair() }; let decision = custom_role_preflight_decision(facts, false); - assert_eq!(decision, Decision::RefuseAmbiguousDirectPermissions); + assert_eq!(decision, Decision::RefuseLegacyUserAccessAll); let error = custom_role_preflight_error(decision, facts); let message = error.source().expect("preflight error should retain its I/O error source").to_string(); assert!(message.contains("2 membership(s)")); - // The operator needs the affected memberships and their possible group source ... - assert!(message.contains("SELECT uuid, user_uuid, org_uuid, atype, status")); - assert!(message.contains("WHERE g.access_all = TRUE")); - // ... plus both decisions, and the note that keeping the grant also grants Create. - assert!(message.contains("SET edit_any_collection = FALSE,\n delete_any_collection = FALSE")); - assert!(message.contains("SET create_new_collections = TRUE")); - assert!(message.contains("also grants Create-any-collection")); + // The operator needs the affected memberships ... + assert!(message.contains("WHERE atype = 2\n AND access_all = TRUE;")); + // ... and both decisions: drop the reach, or write it out explicitly first. + assert!(message.contains("SET access_all = FALSE")); + assert!(message.contains("INSERT INTO users_collections")); + // Nothing here may present the snapshot as equivalent to the old dynamic reach. + assert!(message.contains("collections created after")); } #[test] @@ -1556,12 +3480,26 @@ mod custom_role_migration_preflight_tests { assert!(message.contains("Restore the database backup")); } - // REGRESSION: a legacy `User` membership with the historical access_all bit must NOT stop the - // upgrade. The 2026-07-23 migration materializes that reach as explicit per-collection - // assignments, so the preflight has nothing left to decide and every other fact stays untouched. + /// A legacy `User` membership carrying the historical access_all bit stops the upgrade before any + /// migration runs, whatever its status is. Converting the bit into direct per-collection + /// assignments would turn a dynamic, status-bound reach into a durable snapshot -- and those rows + /// would still be there for an older binary after a rollback, which never checked the membership + /// status on that path. #[test] - fn legacy_user_access_all_no_longer_blocks_the_upgrade() { + fn legacy_user_access_all_blocks_the_upgrade_before_any_migration() { assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); + + let untouched_schema = Facts { + legacy_user_access_all_count: 1, + ..pending_repair() + }; + assert_eq!( + custom_role_preflight_decision(untouched_schema, false), + Decision::RefuseLegacyUserAccessAll, + "nothing may have been migrated yet when this is refused" + ); + // MySQL/MariaDB gets no exception: no partial state may be completed past this either. + assert_eq!(custom_role_preflight_decision(untouched_schema, true), Decision::RefuseLegacyUserAccessAll); assert_eq!( custom_role_preflight_decision( Facts { @@ -1569,11 +3507,12 @@ mod custom_role_migration_preflight_tests { collection_permissions_migration_applied: true, manage_permission_columns: 3, manage_permissions_migration_applied: true, + legacy_user_access_all_count: 1, ..pending_repair() }, false, ), - Decision::Proceed + Decision::RefuseLegacyUserAccessAll ); } @@ -1622,45 +3561,116 @@ mod custom_role_migration_preflight_tests { } } + /// A group-derived legacy Manager is no longer a special case for the preflight: the repair + /// migration writes the authority into the permission columns, and nothing reads the 0/1/1 shape + /// afterwards, so no state of those columns has to be attributed or refused. #[test] - fn group_derived_zero_permissions_are_safe_but_ambiguous_direct_permissions_are_refused() { + fn a_group_derived_legacy_manager_needs_no_preflight_decision() { assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); - assert_eq!( - custom_role_preflight_decision( - Facts { - collection_permission_columns: 3, - collection_permissions_migration_applied: true, - ..pending_repair() - }, - false, - ), - Decision::Proceed - ); - assert_eq!( - custom_role_preflight_decision( - Facts { - collection_permission_columns: 3, - collection_permissions_migration_applied: true, - ambiguous_direct_permission_count: 1, - ..pending_repair() - }, - false, - ), - Decision::RefuseAmbiguousDirectPermissions - ); - assert_eq!( - custom_role_preflight_decision( - Facts { - collection_permission_columns: 3, - collection_permissions_migration_applied: true, - ambiguous_direct_permission_count: 1, - same_run_0716_marker: true, - ..pending_repair() - }, - false, - ), - Decision::Proceed - ); + for same_run_0716_marker in [false, true] { + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + same_run_0716_marker, + ..pending_repair() + }, + false, + ), + Decision::Proceed + ); + } + } + + /// The two partial-column states need opposite advice. Without the ledger entry the migration + /// never completed, so the leftovers are untouched defaults and dropping them is free. With the + /// ledger entry the migration *did* run, so the remaining columns can hold granted permissions -- + /// and dropping them alone would not even clear the refusal, because the ledger row stays. + #[test] + fn the_two_partial_column_states_get_opposite_recovery_advice() { + let interrupted = Facts { + access_permission_columns: 1, + access_permissions_migration_applied: false, + ..fully_migrated() + }; + let vanished = Facts { + access_permission_columns: 1, + access_permissions_migration_applied: true, + ..fully_migrated() + }; + + let interrupted_decision = custom_role_preflight_decision(interrupted, false); + let vanished_decision = custom_role_preflight_decision(vanished, false); + assert_eq!(interrupted_decision, Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access)); + assert_eq!(vanished_decision, Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Access)); + + let message_of = |decision| { + custom_role_preflight_error(decision, interrupted) + .source() + .expect("preflight error should retain its I/O error source") + .to_string() + }; + let interrupted_message = message_of(interrupted_decision); + let vanished_message = message_of(vanished_decision); + + assert!(interrupted_message.contains("dropping them"), "{interrupted_message}"); + assert!(!interrupted_message.contains("DELETE FROM __diesel_schema_migrations")); + + // The dangerous claim must not be repeated where it is false, and the operator has to be told + // to remove the ledger row as well if they accept the loss. + assert!(!vanished_message.contains("loses nothing"), "{vanished_message}"); + assert!(vanished_message.contains("Do not drop them"), "{vanished_message}"); + assert!(vanished_message.contains("Restoring the database backup"), "{vanished_message}"); + assert!(vanished_message.contains("DELETE FROM __diesel_schema_migrations"), "{vanished_message}"); + } + + /// Both generic texts end in the migration running again. For the collection group after the + /// access_all drop that is impossible -- 2026-07-16-120000 reads the dropped column -- so the advice + /// has to change to "reach the finished shape without executing it". + #[test] + fn the_collection_group_gets_replay_free_advice_once_access_all_is_gone() { + for (columns, applied, expected) in [ + (1, false, Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection)), + (1, true, Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Collection)), + ] { + let facts = Facts { + collection_permission_columns: columns, + collection_permissions_migration_applied: applied, + ..fully_migrated() + }; + let decision = custom_role_preflight_decision(facts, false); + assert_eq!(decision, expected); + + let message = custom_role_preflight_error(decision, facts) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains("cannot be migrated again on this database"), "{message}"); + assert!(message.contains("ADD COLUMN create_new_collections"), "{message}"); + assert!(message.contains("VALUES ('20260716120000')"), "{message}"); + // The replay-based advice must not leak through for this state. + assert!(!message.contains("DELETE FROM __diesel_schema_migrations"), "{message}"); + assert!(!message.contains("lets the migration run again"), "{message}"); + } + + // While access_all still exists a replay is fine, so the generic texts stay in place. + let before_drop = Facts { + access_all_column_exists: true, + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + collection_permission_columns: 1, + collection_permissions_migration_applied: false, + ..fully_migrated() + }; + let decision = custom_role_preflight_decision(before_drop, false); + assert_eq!(decision, Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection)); + let message = custom_role_preflight_error(decision, before_drop) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains("lets the migration run again"), "{message}"); } #[test] @@ -1677,11 +3687,166 @@ mod custom_role_migration_preflight_tests { ); } + #[test] + fn mysql_partial_0716_projects_the_pending_group_authority_before_completion() { + let projected = permanent_authority_lookahead_query(true, true, true, false, false, "`groups`") + .expect("the partial schema still has access_all"); + + assert!(!projected.contains("uo.access_all = FALSE"), "{projected}"); + assert!(projected.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE), "{projected}"); + assert!(projected.contains("g.organizations_uuid = uo.org_uuid"), "{projected}"); + assert!( + projected.contains("uo.edit_any_collection = TRUE"), + "the projection must retain both pending and already-materialized grants: {projected}" + ); + + let facts = Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + unconfirmed_permanent_authority_count: 1, + ..pending_repair() + }; + assert_eq!( + custom_role_preflight_decision(facts, true), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "the owner decision must precede complete_partial_collection_migration()" + ); + + let acknowledged = Facts { + permanent_collection_authority_ack: true, + ..facts + }; + assert_eq!( + custom_role_preflight_decision(acknowledged, true), + Decision::CompleteMysqlCollectionMigration, + "the validated partial state is repairable after the owner answers" + ); + } + + /// Some earlier feature-branch snapshots recorded 0716 after adding its columns but before the + /// group-derived UPDATE was part of that migration. The later 0723 repair is what will write + /// 0/1/1 for those recorded Managers, so a recorded 0716 must not make the preflight trust the + /// temporary 0/0/0 values while that repair is still pending. + #[test] + fn recorded_old_0716_projects_the_pending_repair_before_migrations_run() { + let projected = permanent_authority_lookahead_query(true, true, true, true, false, "\"groups\"") + .expect("the pending repair can be projected from access_all and the Manager record"); + + assert!(projected.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE), "{projected}"); + assert!(projected.contains("uo.atype = 3 OR"), "{projected}"); + assert!(projected.contains("uo.edit_any_collection = TRUE"), "{projected}"); + + let facts = Facts { + manage_permission_columns: 3, + manage_permissions_migration_applied: true, + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + repair_migration_applied: false, + unconfirmed_permanent_authority_count: 1, + ..pending_repair() + }; + for mysql in [false, true] { + assert_eq!( + custom_role_preflight_decision(facts, mysql), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "backend flag {mysql}: the owner must decide before the pending repair writes 0/1/1" + ); + } + } + + #[test] + fn interrupted_mysql_drop_repair_has_an_explicit_transaction_boundary() { + let source = include_str!("mod.rs"); + let function = source + .split_once("fn complete_interrupted_access_all_drop(") + .expect("repair function must exist") + .1 + .split_once("/// Read everything") + .expect("repair function boundary must remain recognizable") + .0; + + assert!(function.contains("connection.transaction"), "the ledger repair must commit with autocommit=0"); + assert!(function.contains("super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION")); + } + + #[test] + fn postgresql_preflight_requires_one_migration_namespace() { + let query = super::postgresql_migration_namespace_query(); + for relation in [ + "users_organizations", + "__diesel_schema_migrations", + "groups", + "groups_users", + super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE, + super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE, + ] { + assert!(query.contains(relation), "namespace guard does not bind {relation}: {query}"); + } + assert!(query.contains("current_schema()")); + assert!(query.contains("resolved.relnamespace <> memberships.relnamespace")); + } + + /// A repair is not an answer to the permanent-authority question. Both automatic repairs are + /// deferred behind that refusal, and re-inspection after a permitted repair must reach the same + /// refusal if the database changes between the decision and the next pass. + #[test] + fn a_repair_does_not_answer_the_permanent_authority_question() { + // The interrupted drop is reachable only after the repair migration, and 20260724130000 + // cannot have run yet, so its columns are still absent on both sides of that repair. + let interrupted_drop = Facts { + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..awaiting_permanent_authority_decision() + }; + + for (name, before_repair, after_repair) in [ + ( + "the historical MySQL partial collection-permission schema", + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + unconfirmed_permanent_authority_count: 2, + ..pending_repair() + }, + // complete_partial_collection_migration() records 20260716120000, nothing else. + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + unconfirmed_permanent_authority_count: 2, + ..pending_repair() + }, + ), + ( + "an access_all drop that committed without its ledger entry", + Facts { + access_all_drop_migration_applied: false, + ..interrupted_drop + }, + // complete_interrupted_access_all_drop() records 20260724120000, nothing else. + interrupted_drop, + ), + ] { + assert_eq!( + custom_role_preflight_decision(before_repair, true), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "{name}: no repair may mutate the database before the owner decides" + ); + assert_eq!( + custom_role_preflight_decision(after_repair, true), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "{name}: re-inspection must preserve the refusal" + ); + } + } + #[test] fn historical_mysql_partial_query_does_not_require_the_new_marker_table() { let query = mysql_partial_unexpected_values_query(false); assert!(!query.contains(super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)); assert!(!query.contains("groups_users")); + // Without the allowance the query reads users_organizations only, so it stays answerable on + // a database that has no provenance record at all. + assert!(!query.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)); } #[test] @@ -1695,6 +3860,22 @@ mod custom_role_migration_preflight_tests { assert!(query.contains("g.access_all = TRUE")); } + /// The allowance describes what 2026-07-16-120000 can produce, and that statement is driven by + /// the legacy-Manager record. A 0/1/1 row for a membership that is not on the record therefore + /// has no legitimate source, and must not be counted as an expected shape -- otherwise the + /// automatic MySQL recovery would adopt a grant nothing can account for. + #[test] + fn the_same_run_allowance_is_bound_to_the_legacy_manager_record() { + let query = mysql_partial_unexpected_values_query(true); + assert!( + query.contains(&format!( + "uuid IN (SELECT users_organizations_uuid FROM {})", + super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE + )), + "{query}" + ); + } + #[test] fn incomplete_columns_and_ledger_mismatch_are_refused() { assert_eq!( diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 2c58ed66..f1faede9 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -25,8 +25,8 @@ use macros::UuidFromParam; use super::{ Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, MembershipType, OrganizationId, User, UserId, - organization::custom_membership_with_edit_any_collection, + MembershipStatus, OrganizationId, User, UserId, + organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection}, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -893,7 +893,7 @@ impl Cipher { // Edit any collection (Custom) or org admin/owner — the successor of access_all .or_filter( custom_membership_with_edit_any_collection() - .or(users_organizations::atype.le(MembershipType::Admin as i32)), + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), ) .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(groups::access_all.eq(true)) // Access via groups @@ -902,7 +902,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner ); } @@ -934,14 +934,14 @@ impl Cipher { // Edit any collection (Custom) or org admin/owner — the successor of access_all .or_filter( custom_membership_with_edit_any_collection() - .or(users_organizations::atype.le(MembershipType::Admin as i32)), + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), ) .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .into_boxed(); if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner ); } @@ -1059,7 +1059,7 @@ impl Cipher { ) .filter( custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) @@ -1090,7 +1090,7 @@ impl Cipher { ) .filter( custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))), @@ -1134,7 +1134,7 @@ impl Cipher { ) .filter( custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) @@ -1142,7 +1142,7 @@ impl Cipher { .or(collections_groups::collections_uuid .is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1166,11 +1166,11 @@ impl Cipher { ) .filter( custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1212,7 +1212,7 @@ impl Cipher { ) .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(custom_membership_with_edit_any_collection()) // Custom "Edit any collection" (successor of access_all) - .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner .or_filter(groups::access_all.eq(true)) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .select(ciphers_collections::all_columns) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index e6f74557..b31ef9e8 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -19,7 +19,8 @@ use macros::UuidFromParam; use super::{ CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, organization::custom_membership_with_edit_any_collection, + User, UserId, + organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection}, }; // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs @@ -137,15 +138,9 @@ impl Collection { // for a member who already reaches every collection: full visibility is not // management authority, but it does not cancel out a real grant either. // - // A legacy organization-local `access_all` group confers collection management - // on its Custom members (see `has_legacy_group_collection_manage_access`), and - // reaches every collection without a `collections_groups` row that could carry - // the `manage` bit — so it has to be answered from the membership side. - let legacy_group_manage = m.has_type(MembershipType::Custom) - && !m.has_create_new_collections() - && !m.has_edit_any_collection() - && !m.has_delete_any_collection() - && cipher_sync_data.user_group_full_access_for_organizations.contains(&self.org_uuid); + // Reaching every collection through a group with `access_all` is deliberately not + // management authority: the guards accept an explicit + // `users_collections.manage` / `collections_groups.manage` row only. let assignment = cipher_sync_data .user_collections .get(&self.uuid) @@ -157,7 +152,7 @@ impl Collection { .map(|cg| (cg.read_only, cg.hide_passwords, cg.manage)) }); let stored_manage = assignment.is_some_and(|(_, _, manage)| manage); - let manage = legacy_group_manage || assignment_manage_for_member(m.atype, stored_manage); + let manage = assignment_manage_for_member(m.atype, stored_manage); match assignment { Some((read_only, hide_passwords, _)) if !m.has_full_access() => { (read_only, hide_passwords, manage) @@ -175,11 +170,14 @@ impl Collection { Some(m) if m.has_full_access() => ( false, false, - assignment_manage_for_member(m.atype, m.has_collection_manage_authority(&self.uuid, conn).await), + assignment_manage_for_member( + m.atype, + m.has_explicit_collection_manage_access(&self.uuid, conn).await, + ), ), Some(m) if m.atype >= MembershipType::Custom - && m.has_collection_manage_authority(&self.uuid, conn).await => + && m.has_explicit_collection_manage_access(&self.uuid, conn).await => { (false, false, true) } @@ -311,7 +309,7 @@ impl Collection { // Full-access member: Custom "Edit any collection" or org admin/owner // (successor of the removed membership access_all) custom_membership_with_edit_any_collection() - .or(users_organizations::atype.le(MembershipType::Admin as i32)), + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), ) .or( groups::access_all.eq(true), // access_all in groups @@ -348,7 +346,7 @@ impl Collection { // Full-access member: Custom "Edit any collection" or org admin/owner // (successor of the removed membership access_all) custom_membership_with_edit_any_collection() - .or(users_organizations::atype.le(MembershipType::Admin as i32)), + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), ), ) .select(collections::all_columns) @@ -436,7 +434,7 @@ impl Collection { // Directly accessed collection custom_membership_with_edit_any_collection().or( // Custom "Edit any collection" or org admin/owner (successor of access_all) - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( @@ -472,7 +470,7 @@ impl Collection { // Directly accessed collection custom_membership_with_edit_any_collection().or( // Custom "Edit any collection" or org admin/owner (successor of access_all) - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), )) .select(collections::all_columns) @@ -514,7 +512,7 @@ impl Collection { ) .filter( users_organizations::atype - .le(MembershipType::Admin as i32) // Org admin or owner + .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner .or(custom_membership_with_edit_any_collection()) // Custom "Edit any collection" (successor of access_all) .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -547,7 +545,7 @@ impl Collection { ) .filter( users_organizations::atype - .le(MembershipType::Admin as i32) // Org admin or owner + .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner .or(custom_membership_with_edit_any_collection()) // Custom "Edit any collection" (successor of access_all) .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -597,7 +595,7 @@ impl Collection { // Directly accessed collection custom_membership_with_edit_any_collection().or( // Custom "Edit any collection" or org admin/owner (successor of access_all) - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 084ffd85..96991254 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -341,20 +341,37 @@ impl Event { pub async fn find_by_cipher_uuid( cipher_uuid: &CipherId, + org_uuid: Option<&OrganizationId>, start: &NaiveDateTime, end: &NaiveDateTime, conn: &DbConn, ) -> Vec { - conn.run(move |conn| { - event::table - .filter(event::cipher_uuid.eq(cipher_uuid)) - .filter(event::event_date.between(start, end)) - .order_by(event::event_date.desc()) - .limit(Self::PAGE_SIZE) - .load::(conn) - .expect("Error filtering events") - }) - .await + conn.run(move |conn| Self::find_by_cipher_uuid_impl(cipher_uuid, org_uuid, start, end, conn)).await + } + + fn find_by_cipher_uuid_impl( + cipher_uuid: &CipherId, + org_uuid: Option<&OrganizationId>, + start: &NaiveDateTime, + end: &NaiveDateTime, + conn: &mut crate::db::DbConnInner, + ) -> Vec { + let query = event::table + .filter(event::cipher_uuid.eq(cipher_uuid)) + .filter(event::event_date.between(start, end)) + .into_boxed(); + + // A cipher event request is authorized for exactly one scope: either the cipher's + // current organization or its personal owner. Apply that scope before PAGE_SIZE so + // rows from another scope cannot consume the page and hide older authorized events. + match org_uuid { + Some(org_uuid) => query.filter(event::org_uuid.eq(org_uuid)), + None => query.filter(event::org_uuid.is_null()), + } + .order_by(event::event_date.desc()) + .limit(Self::PAGE_SIZE) + .load::(conn) + .expect("Error filtering events") } pub async fn clean_events(conn: &DbConn) -> EmptyResult { @@ -374,3 +391,67 @@ impl Event { #[derive(Clone, Debug, DieselNewType, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct EventId(String); + +#[cfg(all(test, sqlite))] +mod tests { + use diesel::{Connection, connection::SimpleConnection, sqlite::SqliteConnection}; + + use super::*; + use crate::db::DbConnInner; + + #[test] + fn cipher_scope_is_applied_before_the_page_limit() { + let mut conn = DbConnInner::Sqlite(SqliteConnection::establish(":memory:").unwrap()); + conn.batch_execute( + "CREATE TABLE event ( + uuid TEXT NOT NULL PRIMARY KEY, + event_type INTEGER NOT NULL, + user_uuid TEXT, + org_uuid TEXT, + cipher_uuid TEXT, + collection_uuid TEXT, + group_uuid TEXT, + org_user_uuid TEXT, + act_user_uuid TEXT, + device_type INTEGER, + ip_address TEXT, + event_date DATETIME NOT NULL, + policy_uuid TEXT, + provider_uuid TEXT, + provider_user_uuid TEXT, + provider_org_uuid TEXT + );", + ) + .unwrap(); + + // Fill an entire page with newer rows from a different scope. If scope filtering happens + // after LIMIT, the one older authorized row can never reach the API response. + for index in 0..Event::PAGE_SIZE { + conn.batch_execute(&format!( + "INSERT INTO event (uuid, event_type, org_uuid, cipher_uuid, event_date) VALUES \ + ('foreign-{index}', 1107, 'foreign-org', 'cipher', '2026-08-12 12:{index:02}:00');" + )) + .unwrap(); + } + conn.batch_execute( + "INSERT INTO event (uuid, event_type, org_uuid, cipher_uuid, event_date) VALUES + ('authorized', 1107, 'authorized-org', 'cipher', '2026-08-12 11:00:00'); + INSERT INTO event (uuid, event_type, org_uuid, cipher_uuid, event_date) VALUES + ('personal', 1107, NULL, 'cipher', '2026-08-12 10:00:00');", + ) + .unwrap(); + + let cipher_id: CipherId = "cipher".to_owned().into(); + let org_id: OrganizationId = "authorized-org".to_owned().into(); + let start = NaiveDateTime::parse_from_str("2026-08-12 00:00:00", "%F %T").unwrap(); + let end = NaiveDateTime::parse_from_str("2026-08-13 00:00:00", "%F %T").unwrap(); + + let organization_events = Event::find_by_cipher_uuid_impl(&cipher_id, Some(&org_id), &start, &end, &mut conn); + assert_eq!(organization_events.len(), 1); + assert_eq!(organization_events[0].uuid, EventId("authorized".to_owned())); + + let personal_events = Event::find_by_cipher_uuid_impl(&cipher_id, None, &start, &end, &mut conn); + assert_eq!(personal_events.len(), 1); + assert_eq!(personal_events[0].uuid, EventId("personal".to_owned())); + } +} diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d33e9b90..d41dd5df 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -149,6 +149,15 @@ impl MembershipType { } } +/// The stored `users_organizations.atype` values that carry organization-wide authority by role. +/// +/// Queries use this set instead of the numeric `atype <= Admin` comparison the removal of +/// membership-level `access_all` would otherwise have left behind in them. `<=` also matches every +/// value *below* `Owner`, so a corrupt or hand-written negative `atype` would satisfy an SQL check +/// while every Rust guard rejects it -- `MembershipType::from_i32` returns `None` there and the +/// request guards fail closed. Enumerating the two values keeps both layers on the same answer. +pub(crate) const ORG_ADMIN_ATYPES: &[i32] = &[MembershipType::Owner as i32, MembershipType::Admin as i32]; + impl Ord for MembershipType { fn cmp(&self, other: &MembershipType) -> Ordering { // Roles are ordered by their authorization rank, not by their raw discriminant (Custom's @@ -899,9 +908,18 @@ impl Membership { self.has_type(MembershipType::Custom) && self.access_reports } - /// Check for an explicit per-collection Manage grant without treating any `access_all` value - /// as such a grant. Custom-role collection guards use this instead of the legacy broad helper, - /// because membership/group `access_all` must not manufacture a per-collection Manage grant. + /// Check for an explicit per-collection Manage grant without treating any `access_all` value as + /// such a grant. This is the *only* per-collection authority a Custom member can hold: neither + /// membership nor group `access_all` may manufacture one. + /// + /// No live exception exists for legacy Managers whose authority came from an organization-local + /// `access_all` group. Deriving one from the membership's shape ("Custom, no collection + /// permissions, member of such a group") was not sound — that shape is also what every newly + /// created flagless Custom member has, so assigning one to an ordinary `access_all` group handed + /// out organization-wide collection edit and delete, and *removing* a collection permission + /// activated it. The repair migration `2026-07-23-120000` materializes that authority into the + /// visible `edit_any_collection` / `delete_any_collection` columns instead, where an owner can + /// see and revoke it. pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool { let membership_uuid = self.uuid.clone(); let user_uuid = self.user_uuid.clone(); @@ -964,72 +982,6 @@ impl Membership { .await } - /// Legacy collection-management authority derived from an organization-local `access_all` group. - /// - /// Before this role model existed, a Manager who reached every collection through such a group - /// could edit and delete all of them — `Collection::is_coll_manageable_by_user` accepted - /// `groups.access_all` outright. Managers are Custom members now, so that authority has to keep - /// coming from the same place, or the upgrade would silently strip a capability from members who - /// hold no explicit per-collection grant. Deriving it live (instead of copying it into the - /// permission columns during the migration) is what keeps it revocable: remove the member from - /// the group, or clear the group's `access_all`, and the authority is gone with it. - /// - /// Deliberately not collection *creation*: that historically required membership-level - /// `access_all` and is now the independent `create_new_collections` permission. - /// - /// Security: the exception is limited to members holding *none* of the three collection - /// permissions, which is exactly the shape the migration leaves a group-derived legacy Manager - /// in. Without that limit it would also cover a Custom member holding `edit_any_collection` — - /// and since `edit_any_collection` is what lets a caller create an `access_all` group in the - /// first place, such a member could grant themselves this authority and use it to persist a - /// real `collections_groups.manage` row, keeping collection deletion after leaving the group. - pub async fn has_legacy_group_collection_manage_access( - &self, - collection_uuid: &CollectionId, - conn: &DbConn, - ) -> bool { - if self.create_new_collections || self.edit_any_collection || self.delete_any_collection { - return false; - } - - let membership_uuid = self.uuid.clone(); - let user_uuid = self.user_uuid.clone(); - let org_uuid = self.org_uuid.clone(); - let collection_uuid = collection_uuid.clone(); - - conn.run(move |conn| { - users_organizations::table - .inner_join( - groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), - ) - .inner_join( - groups::table.on(groups::uuid - .eq(groups_users::groups_uuid) - .and(groups::organizations_uuid.eq(users_organizations::org_uuid))), - ) - .inner_join(collections::table.on(collections::org_uuid.eq(users_organizations::org_uuid))) - .filter(users_organizations::uuid.eq(membership_uuid)) - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::org_uuid.eq(org_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter(users_organizations::atype.eq(MembershipType::Custom as i32)) - .filter(collections::uuid.eq(collection_uuid)) - .filter(groups::access_all.eq(true)) - .count() - .first::(conn) - .unwrap_or(0) - != 0 - }) - .await - } - - /// Whether this member may manage `collection_uuid` without holding a blanket collection - /// permission: either a real stored per-collection grant, or the legacy full-access group. - pub async fn has_collection_manage_authority(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool { - self.has_explicit_collection_manage_access(collection_uuid, conn).await - || self.has_legacy_group_collection_manage_access(collection_uuid, conn).await - } - /// `manageAllCollections` is a client-side aggregate checkbox, not a separately persisted /// Bitwarden permission. It is selected exactly when all three child permissions are selected. pub fn has_manage_all_collections(&self) -> bool { @@ -1189,7 +1141,7 @@ impl Membership { .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter( users_organizations::atype - .eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]) + .eq_any(ORG_ADMIN_ATYPES) .or(custom_membership_with_edit_any_collection()), ) .load::(conn) @@ -1316,7 +1268,7 @@ impl Membership { ) .filter( custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner .or(ciphers_collections::cipher_uuid.eq(&cipher_uuid)), // ..or access to collection with cipher ) .select(users_organizations::all_columns) @@ -1372,7 +1324,7 @@ impl Membership { .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .filter( custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all) - .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner .or(users_collections::collection_uuid.eq(&collection_uuid)), // ..or access to collection ) .select(users_organizations::all_columns) @@ -1506,6 +1458,23 @@ mod tests { membership } + /// The SQL-side admin set has to stay in step with the Rust-side role check, and it must not be a + /// range: `atype <= Admin` would also match a corrupt negative value that + /// `MembershipType::from_i32` rejects. + #[test] + fn the_sql_admin_atype_set_matches_the_two_admin_roles() { + assert_eq!(ORG_ADMIN_ATYPES, [MembershipType::Owner as i32, MembershipType::Admin as i32]); + for atype in [-1, 2, 3, 5, i32::MAX, i32::MIN] { + assert!(!ORG_ADMIN_ATYPES.contains(&atype), "atype {atype} must not count as an organization admin"); + } + for atype in ORG_ADMIN_ATYPES { + assert!( + matches!(MembershipType::from_i32(*atype), Some(MembershipType::Owner | MembershipType::Admin)), + "every value in the set has to resolve to an admin role in Rust as well" + ); + } + } + #[test] fn membership_type_order_preserves_access_rank_and_ord_contract() { assert!(MembershipType::Owner > MembershipType::Admin); @@ -1529,6 +1498,54 @@ mod tests { } } + /// A stored `atype` that no role maps to is *incomparable*, and the two directions of the + /// comparison resolve that deliberately differently. Both overrides exist to keep the answer + /// fail-closed; neither was pinned by a test, and the asymmetry is easy to "tidy up" into a + /// silent authorization change. + /// + /// `MembershipType op i32` — "does the caller outrank this role?" — answers no: `gt`/`ge` are + /// false for an unknown value, so nothing is ever granted on the strength of one. + /// + /// `i32 op MembershipType` — "is this membership at most that role?" — answers yes: `lt`/`le` + /// are true. Every use of it is a *ceiling* (`atype < Admin`, `atype <= Admin`), so treating an + /// unrecognized value as low-ranked is the restrictive reading. It also cannot smuggle anything + /// past the one place that phrases a permission this way + /// (`check_reset_password_applicable_and_permissions`): the role an Admin must not reach is + /// `Owner`, whose discriminant is 0 and therefore never unknown. + #[test] + #[expect( + clippy::nonminimal_bool, + reason = "`!(role > atype)` must not become `role <= atype`: only `gt`/`ge` are overridden to \ + answer false for an incomparable value, while `le`/`lt` fall through to the derived \ + form. Clippy's rewrite would assert the opposite of what this test is for." + )] + fn an_unknown_stored_role_is_incomparable_and_resolves_fail_closed() { + for atype in [-1, 3, 5, i32::MAX, i32::MIN] { + assert_eq!(MembershipType::Admin.partial_cmp(&atype), None, "atype {atype}"); + assert_eq!(atype.partial_cmp(&MembershipType::Admin), None, "atype {atype}"); + + // Never outranked by an unknown value: no permission is granted on its strength. + for role in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + let known = role as i32; + assert!(!(role > atype), "atype {atype} must not be outranked by role {known}"); + assert!(!(role >= atype), "atype {atype} must not be outranked by role {known}"); + } + + // Always under the ceiling: an unknown value is treated as the lowest rank there is. + assert!(atype < MembershipType::Admin, "atype {atype}"); + assert!(atype <= MembershipType::Admin, "atype {atype}"); + + // And it is equal to nothing, in either direction. + assert!(atype != MembershipType::Custom, "atype {atype}"); + assert!(MembershipType::Custom != atype, "atype {atype}"); + } + + // The known values keep behaving by rank, not by discriminant: Custom's is 4, above Admin's. + assert!(MembershipType::Admin > MembershipType::Custom as i32); + assert!((MembershipType::Custom as i32) < MembershipType::Admin); + assert!(MembershipType::Custom >= MembershipType::Custom as i32); + } + #[test] fn custom_collection_permissions_are_independent_and_type_gated() { let mut member = membership(MembershipType::Custom); diff --git a/tools/custom_role_rollback/README.md b/tools/custom_role_rollback/README.md index 7e47a8f4..e9dbd0d9 100644 --- a/tools/custom_role_rollback/README.md +++ b/tools/custom_role_rollback/README.md @@ -4,10 +4,55 @@ The Custom-role change removes the membership `access_all` column and adds nine 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 +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 (''); +``` + +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: @@ -15,35 +60,88 @@ The old schema has nowhere to store the nine permissions, so they are dropped: | Before the rollback | After | |---|---| | Owner / Admin | Owner / Admin with `access_all = TRUE` | -| Custom with **all three** collection permissions | Manager with `access_all = TRUE` | -| Custom with only some collection permissions | Manager with `access_all = FALSE` | -| Custom with `manageUsers` / `manageGroups` / `managePolicies` | Manager — those permissions are gone | -| Custom with `accessEventLogs` / `accessImportExport` / `accessReports` | Manager — those permissions are gone | +| 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. +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. -Two of those rows do not come back byte-identical to what the database held before the *upgrade*, -because the information no longer exists to reconstruct them: +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 that carried `access_all` comes back with `access_all = FALSE`.** The upgrade wrote - that member's reach out as explicit per-collection assignments before dropping the bit, and those - rows are left untouched here — so the member keeps access to the collections that existed at - upgrade time, just not automatically to ones created afterwards. + +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. Then: +Stop every Vaultwarden instance and take a backup first. Create the allowlist as described above. +Then: ```bash # SQLite @@ -56,30 +154,128 @@ mysql -u -p < tools/custom_role_rollback/mysql.sql psql -U -d -v ON_ERROR_STOP=1 -f tools/custom_role_rollback/postgresql.sql ``` -Each script stops on its own if the database is not in the state it converts from, so running one -twice is refused rather than half-applied. +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. -**Do not drop the `-bail` / `ON_ERROR_STOP=1` flags, 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 the failing statement would reach the `DROP TABLE` and commit an empty -`users_organizations`. +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); if the script is interrupted, restore the backup and start over. +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 -Afterwards start the older Vaultwarden version. Upgrading again later re-applies the seven -migrations from a clean state. +`__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. The newest one refuses -by default so an accidental revert cannot silently destroy the permission data; acknowledge it -explicitly first: +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. The rollback scripts above drop that table again. +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. diff --git a/tools/custom_role_rollback/mysql.sql b/tools/custom_role_rollback/mysql.sql index c72bd8ec..a2f05c89 100644 --- a/tools/custom_role_rollback/mysql.sql +++ b/tools/custom_role_rollback/mysql.sql @@ -3,40 +3,297 @@ -- it lists exactly what is lost and how to run this safely. -- -- NOTE: MySQL/MariaDB commit every DDL statement implicitly, so this script cannot be wrapped in a --- transaction. Take a backup before running it; if it is interrupted, restore and start over. +-- 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; --- The legacy flag is recomputed with the same mapping the down migrations use: everyone who --- reached every collection keeps that reach, and a Custom member has to hold all three collection --- permissions -- Edit-only must not silently turn into the legacy "manage all collections" --- authority, which in that older schema also carried collection deletion. +-- 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; Custom members were stored as Manager back then. -UPDATE users_organizations SET atype = 3 WHERE atype = 4; +-- 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; -ALTER TABLE users_organizations DROP COLUMN manage_users; -ALTER TABLE users_organizations DROP COLUMN manage_groups; -ALTER TABLE users_organizations DROP COLUMN manage_policies; -ALTER TABLE users_organizations DROP COLUMN create_new_collections; -ALTER TABLE users_organizations DROP COLUMN edit_any_collection; -ALTER TABLE users_organizations DROP COLUMN delete_any_collection; -ALTER TABLE users_organizations DROP COLUMN access_event_logs; -ALTER TABLE users_organizations DROP COLUMN access_import_export; -ALTER TABLE users_organizations DROP COLUMN access_reports; +-- 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. +-- 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 seven migrations, so the older binary does not see a ledger from the future +-- 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 ( @@ -46,5 +303,15 @@ WHERE version IN ( '20260723120000', '20260724120000', '20260724130000', - '20260724140000' + '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; diff --git a/tools/custom_role_rollback/postgresql.sql b/tools/custom_role_rollback/postgresql.sql index 1484c862..3a4bc8ae 100644 --- a/tools/custom_role_rollback/postgresql.sql +++ b/tools/custom_role_rollback/postgresql.sql @@ -3,52 +3,241 @@ -- 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; -ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; - --- The legacy flag is recomputed with the same mapping the down migrations use: everyone who --- reached every collection keeps that reach, and a Custom member has to hold all three collection --- permissions -- Edit-only must not silently turn into the legacy "manage all collections" --- authority, which in that older schema also carried collection deletion. -UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); -UPDATE users_organizations -SET access_all = TRUE -WHERE atype = 4 - AND create_new_collections = TRUE - AND edit_any_collection = TRUE - AND delete_any_collection = TRUE; - --- The old server cannot load type 4; Custom members were stored as Manager back then. -UPDATE users_organizations SET atype = 3 WHERE atype = 4; - -ALTER TABLE users_organizations - DROP COLUMN manage_users, - DROP COLUMN manage_groups, - DROP COLUMN manage_policies, - DROP COLUMN create_new_collections, - DROP COLUMN edit_any_collection, - DROP COLUMN delete_any_collection, - DROP COLUMN access_event_logs, - DROP COLUMN access_import_export, - DROP COLUMN access_reports; - --- Bookkeeping tables this feature may have left behind. -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; - --- Finally forget the seven migrations, so the older binary does not see a ledger from the future --- and a later upgrade applies them again from a clean state. -DELETE FROM __diesel_schema_migrations -WHERE version IN ( - '20260630120000', - '20260715120000', - '20260716120000', - '20260723120000', - '20260724120000', - '20260724130000', - '20260724140000' -); +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; diff --git a/tools/custom_role_rollback/sqlite.sql b/tools/custom_role_rollback/sqlite.sql index 54cb237b..96799fa2 100644 --- a/tools/custom_role_rollback/sqlite.sql +++ b/tools/custom_role_rollback/sqlite.sql @@ -16,20 +16,147 @@ PRAGMA foreign_keys = OFF; BEGIN; --- Refuse to start at all unless the database is in the state this script converts *from*. A repeat --- run would otherwise only fail somewhere in the middle. The failing CHECK names the reason. +-- 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 - this_database_has_no_custom_role_permission_columns_to_roll_back CHECK (ok = 1) + 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 EXISTS (SELECT 1 FROM pragma_table_info('users_organizations') WHERE name = 'create_new_collections') + 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), @@ -45,39 +172,69 @@ CREATE TABLE users_organizations_rollback ( UNIQUE (user_uuid, org_uuid) ); --- The legacy flag is recomputed with the same mapping the down migrations use: everyone who --- reached every collection keeps that reach, and a Custom member has to hold all three collection --- permissions -- Edit-only must not silently turn into the legacy "manage all collections" --- authority, which in that older schema also carried collection deletion. +-- 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 - uuid, user_uuid, org_uuid, + uo.uuid, uo.user_uuid, uo.org_uuid, CASE - WHEN atype IN (0, 1) THEN 1 - WHEN atype = 4 - AND create_new_collections = 1 - AND edit_any_collection = 1 - AND delete_any_collection = 1 THEN 1 + 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, - akey, status, - -- The old server cannot load type 4; Custom members were stored as Manager back then. - CASE WHEN atype = 4 THEN 3 ELSE atype END, - reset_password_key, external_id, invited_by_email -FROM users_organizations; + 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. +-- 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 seven migrations, so the older binary does not see a ledger from the future +-- 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 ( @@ -87,7 +244,9 @@ WHERE version IN ( '20260723120000', '20260724120000', '20260724130000', - '20260724140000' + '20260724140000', + '20260809120000', + '20260810120000' ); COMMIT;