From e750b5ccaf92175c4e22ad881eea50b9ab1de037 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:15:34 +0200 Subject: [PATCH] Remove unnecessary migrations and some security fixes --- .../down.sql | 154 +- .../up.sql | 158 +- .../down.sql | 1 - .../up.sql | 13 - .../down.sql | 34 - .../up.sql | 68 - .../down.sql | 4 - .../up.sql | 101 - .../down.sql | 13 - .../up.sql | 5 - .../down.sql | 28 - .../up.sql | 5 - .../down.sql | 60 - .../up.sql | 13 - .../down.sql | 4 - .../up.sql | 111 - .../down.sql | 4 - .../up.sql | 121 - .../down.sql | 111 +- .../up.sql | 154 +- .../down.sql | 1 - .../up.sql | 13 - .../down.sql | 25 - .../up.sql | 60 - .../down.sql | 4 - .../up.sql | 96 - .../down.sql | 13 - .../up.sql | 5 - .../down.sql | 19 - .../up.sql | 5 - .../down.sql | 27 - .../up.sql | 11 - .../down.sql | 4 - .../up.sql | 103 - .../down.sql | 4 - .../up.sql | 111 - .../down.sql | 124 +- .../up.sql | 187 +- .../down.sql | 1 - .../up.sql | 12 - .../down.sql | 28 - .../up.sql | 63 - .../down.sql | 4 - .../up.sql | 98 - .../down.sql | 13 - .../up.sql | 46 - .../down.sql | 22 - .../up.sql | 5 - .../down.sql | 29 - .../up.sql | 11 - .../down.sql | 4 - .../up.sql | 107 - .../down.sql | 4 - .../up.sql | 117 - src/api/core/organizations.rs | 887 +++- src/api/core/two_factor/mod.rs | 8 +- src/auth.rs | 72 +- src/config.rs | 12 + src/db/mod.rs | 4477 +++++++---------- src/db/models/cipher.rs | 31 +- src/db/models/collection.rs | 27 +- src/db/models/group.rs | 23 +- src/db/models/organization.rs | 99 +- src/main.rs | 17 +- src/util.rs | 13 +- tools/custom_role_rollback/README.md | 211 +- tools/custom_role_rollback/mysql.sql | 146 +- tools/custom_role_rollback/postgresql.sql | 59 +- tools/custom_role_rollback/sqlite.sql | 89 +- 69 files changed, 3360 insertions(+), 5349 deletions(-) delete mode 100644 migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql delete mode 100644 migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql delete mode 100644 migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql delete mode 100644 migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql delete mode 100644 migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql delete mode 100644 migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql delete mode 100644 migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql delete mode 100644 migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql delete mode 100644 migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql delete mode 100644 migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql delete mode 100644 migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql delete mode 100644 migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql delete mode 100644 migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql delete mode 100644 migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql delete mode 100644 migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql delete mode 100644 migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql delete mode 100644 migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql delete mode 100644 migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql delete mode 100644 migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql delete mode 100644 migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql delete mode 100644 migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql delete mode 100644 migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql delete mode 100644 migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql delete mode 100644 migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql delete mode 100644 migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql delete mode 100644 migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql delete mode 100644 migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql delete mode 100644 migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql delete mode 100644 migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql delete mode 100644 migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql delete mode 100644 migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql delete mode 100644 migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql delete mode 100644 migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql delete mode 100644 migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql delete mode 100644 migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql delete mode 100644 migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql delete mode 100644 migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql delete mode 100644 migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql delete mode 100644 migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql delete mode 100644 migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql delete mode 100644 migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql delete mode 100644 migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql delete mode 100644 migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql delete mode 100644 migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql delete mode 100644 migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql delete mode 100644 migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql delete mode 100644 migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql delete mode 100644 migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql 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 2bd42441..aed55087 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,107 +1,95 @@ --- 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: +-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or +-- the Custom role. Two explicit operator decisions are required before anything is touched, and both +-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older +-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. -- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- Both guards use temporary tables on purpose: on MySQL/MariaDB temporary-table DDL is the only DDL +-- that does not commit implicitly, so a refusal here cannot leave a half-reverted schema behind. + +-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_allow_custom_role_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_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' + 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. +-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a +-- Custom member holds, so handing it out automatically would *grant* authority during a +-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every +-- Custom member to plain User. See README.md in tools/custom_role_rollback/. -- --- 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. +-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); +-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES (''); -- --- 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 -); - --- `IF NOT EXISTS` accepts an already existing table without checking its definition. Validate that --- definition before using it for the role mapping: MySQL/MariaDB compare a character UUID with a --- numeric allowlist as numbers, so an INT value such as 0 could match unrelated UUIDs. The duplicate --- key aborts the revert before any role or permission value is changed. -CREATE TEMPORARY TABLE __vw_rollback_manager_allowlist_guard ( +-- The duplicate key aborts the revert. It is only inserted while the list is absent. +CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( blocked INTEGER NOT NULL PRIMARY KEY ); -INSERT INTO __vw_rollback_manager_allowlist_guard (blocked) VALUES (1); -INSERT INTO __vw_rollback_manager_allowlist_guard (blocked) +INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); +INSERT INTO __vw_rollback_allowlist_guard (blocked) SELECT 1 FROM DUAL -WHERE (SELECT COUNT(*) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = '__vw_rollback_manager_allowlist') <> 1 - OR (SELECT COUNT(*) - FROM information_schema.columns - WHERE table_schema = DATABASE() - AND table_name = '__vw_rollback_manager_allowlist' - AND column_name = 'users_organizations_uuid' - AND data_type = 'char' - AND character_maximum_length = 36 - AND is_nullable = 'NO') <> 1 - OR NOT EXISTS ( - SELECT 1 - 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 - ); -DROP TEMPORARY TABLE __vw_rollback_manager_allowlist_guard; +WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = '__vw_rollback_manager_allowlist' +); +DROP TEMPORARY TABLE __vw_rollback_allowlist_guard; -UPDATE users_organizations SET atype = 3 -WHERE atype = 4 - AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; --- 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; +-- Roles and `access_all` are recomputed together, because in the old schema they are not +-- independent: +-- +-- * Owners and Admins always carried the bit and it grants them nothing extra; +-- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three +-- collection permissions -- in the old schema `access_all` also carried collection deletion, so +-- an Edit-only member must not silently gain it; +-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy +-- state the upgrade refuses, so leaving it set would make the database unable to move forward +-- again. +-- +-- Group-derived Manager authority is not restored here and does not need to be: `groups.access_all` +-- was never modified, so the older binary derives it again by itself for whoever comes back as +-- Manager. +UPDATE users_organizations +SET access_all = CASE + WHEN atype IN (0, 1) THEN TRUE + WHEN 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 THEN TRUE + ELSE FALSE + END, + atype = CASE + WHEN atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 + WHEN atype = 4 THEN 2 + ELSE atype + END; --- 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; + 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; --- 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. +-- Both decisions authorized *this* downgrade, not the next one. 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 09451eca..7a887df6 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,37 +1,127 @@ -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; +-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine +-- granular permissions. +-- +-- Two different columns are called `access_all`, and everything below depends on keeping them apart: +-- +-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped +-- at the end of this file. +-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read +-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group +-- members access to every collection afterwards exactly as before. +-- +-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection +-- through either. Only the membership bit is going away, but the capability an owner configured +-- through either route is preserved, so both are read below. While this file runs the membership +-- column still exists and `atype = 3` still unambiguously means "legacy Manager". +-- +-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates +-- the same condition at startup and prints the recovery text, because Diesel would surface the abort +-- below as nothing but a driver-level duplicate-key error. +-- +-- A temporary table on purpose: on MySQL/MariaDB it is the only DDL that does not commit implicitly, +-- so a refusal cannot leave a half-applied migration behind. --- 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 +-- A plain User carrying membership `access_all`, reachable only on databases written before the web +-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and +-- future, with no management authority, and the new model has no permission for that: +-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. +-- Refuse and let an owner choose. The duplicate key aborts the migration, and 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; + +-- One ALTER TABLE for all nine columns: MySQL/MariaDB commit every DDL statement implicitly, so nine +-- separate statements would leave nine points at which a crash produces a partially migrated schema. +-- A single ALTER is one such point, and on MySQL 8 it is atomic. +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; + +-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives +-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard +-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management +-- capability it is configured with right now: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * an organization-local `access_all` group covered editing and deleting every collection, but +-- never creation -- that always required the membership bit; +-- * a Manager with neither keeps all three at FALSE. +-- +-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the +-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on +-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL +-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the +-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its +-- own. The alternative is silently revoking access these members have today, or refusing an ordinary +-- upgrade; the permission is visible in the member's permission list and an owner can clear it. +-- +-- The management (manage_users / manage_groups / manage_policies) and access (event logs / +-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager +-- capability -- every member mutation, every policy write, the organization export and both +-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. +-- +-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` +-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list +-- (`GET /organizations//users`) for them and for nobody whose reach came from a group. +-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, +-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and +-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` +-- satisfies `has_full_access()`, which opens the organization collection list and +-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach +-- through the group, so only the route is new. +-- +-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means +-- Manager everywhere it is read. +-- +-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is +-- converted like a confirmed one, since none holds authority in that state and the permissions are +-- what it would come back with -- the same thing `access_all` would have done. +-- +-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at +-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. +UPDATE users_organizations +SET create_new_collections = access_all, + edit_any_collection = access_all + OR 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 + ), + delete_any_collection = access_all + OR 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 + ), + atype = 4 +WHERE atype = 3; + +-- The flag is now fully represented by the role model, so drop the redundant column. This concerns +-- `users_organizations` only; `groups.access_all` stays. +ALTER TABLE users_organizations DROP COLUMN access_all; --- Previously the server stored members created with the Custom role as Manager (3) and --- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted --- type, convert those members so clients (which no longer know the Manager role) keep --- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, --- which matches the capabilities these members had. -UPDATE users_organizations SET atype = 4 WHERE atype = 3; +-- Never inherit a downgrade acknowledgement left behind by an earlier revert. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; diff --git a/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql b/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql deleted file mode 100644 index 04346743..00000000 --- a/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; diff --git a/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql b/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql deleted file mode 100644 index 1ba47e9d..00000000 --- a/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a --- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. -CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( - marker INTEGER NOT NULL PRIMARY KEY -); -INSERT IGNORE INTO __vw_custom_role_same_run_0716 (marker) -SELECT 1 -FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 - FROM __diesel_schema_migrations - WHERE version = '20260716120000' -); 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 deleted file mode 100644 index b6f97540..00000000 --- a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Lossy revert: this removes the three independent Custom collection permissions, which the legacy --- role/access_all schema cannot represent -- it only knows all three together. The revert therefore --- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, --- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: --- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); -CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -INSERT INTO __vw_custom_role_downgrade_guard (blocked) -SELECT 1 FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade' -); --- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits --- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. -DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; - --- The previous schema exposes access_all as the three collection permissions together. Avoid --- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. -UPDATE users_organizations -SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection -WHERE atype = 4; - --- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements --- mean two intermediate states that survive a failure while Diesel still considers the migration --- unapplied; one statement is the closest this backend gets to all-or-nothing. -ALTER TABLE users_organizations - DROP COLUMN create_new_collections, - DROP COLUMN edit_any_collection, - DROP COLUMN delete_any_collection; 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 deleted file mode 100644 index 487da5f8..00000000 --- a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql +++ /dev/null @@ -1,68 +0,0 @@ --- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, --- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE so a --- refusal leaves no half-added column group behind -- every ALTER commits implicitly here, and a --- partial group is what the startup preflight then has to recover from. --- --- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this whole check is --- free of durable side effects. --- --- Creating the record here instead would manufacture an empty, apparently valid history for exactly --- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. --- This guard exists for a bare migration runner that never consulted the startup preflight. --- --- The duplicate key aborts the migration. It is only inserted while the record table is absent. -CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_manager_record_guard (blocked) -SELECT 1 FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' -); -DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; - -ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; - --- Before these permissions were persisted independently, access_all represented the legacy --- "Manage all collections" checkbox. Preserve that capability for existing Custom members. --- --- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a --- member carrying access_all held exactly this capability, whenever the row was created. -UPDATE users_organizations -SET create_new_collections = access_all, - edit_any_collection = access_all, - delete_any_collection = access_all -WHERE atype = 4; - --- A legacy Manager also managed every collection when one of their groups had access_all, even if --- the membership itself did not. Preserve that existing edit/delete capability without granting --- collection creation, which historically still required membership access_all. --- --- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and --- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: --- "Custom, member of an access_all group" is also the shape of every modern Custom member who was --- simply put into an ordinary access_all group, and granting on that shape hands them --- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies --- has_full_access() and therefore reaches every cipher in the organization. --- --- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every --- `atype = 3` row, which at this point is every Custom member there is. -UPDATE users_organizations -SET edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype = 4 - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND EXISTS ( - SELECT 1 - FROM groups_users - -- `groups` is a reserved word in MySQL 8 and must be quoted, matching the existing - -- `2022-07-27-110000_add_group_support` migration. (PostgreSQL/SQLite do not reserve it.) - INNER JOIN `groups` ON `groups`.uuid = groups_users.groups_uuid - WHERE groups_users.users_organizations_uuid = users_organizations.uuid - AND `groups`.organizations_uuid = users_organizations.org_uuid - AND `groups`.access_all = TRUE - ); 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 deleted file mode 100644 index 4188886b..00000000 --- a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index bf20cf2c..00000000 --- a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql +++ /dev/null @@ -1,101 +0,0 @@ --- Repair the legacy role/permission state while membership `access_all` still exists. --- --- A plain User carrying the historical membership-level `access_all` bit is deliberately not --- converted: that state grants dynamic reach over every collection *without* management authority, --- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: --- Vaultwarden's startup preflight already stops such a database before any migration runs and prints --- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run --- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner --- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few --- statements later. Repeating the check before this file's first mutation is what makes the silent --- loss impossible rather than unlikely. --- --- The duplicate key aborts the migration. It is only inserted when such a membership exists. -CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_user_access_all_guard (blocked) -SELECT 1 -FROM users_organizations -WHERE atype = 2 - AND access_all = TRUE -LIMIT 1; -DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard; - --- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup --- preflight refuses a database whose ledger carries that version without it. Creating it here would --- manufacture an empty, apparently valid history for precisely the databases that need an operator --- to look at them, so refuse instead -- this guard exists for a bare migration runner that never --- consulted the preflight. Refusing also keeps this file free of DDL, which on MySQL/MariaDB would --- commit implicitly and break this migration out of its transaction. --- --- The duplicate key aborts the migration. It is only inserted while the record table is absent. -CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_manager_record_guard (blocked) -SELECT 1 FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' -); -DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; - --- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten --- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are --- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this --- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the --- normal path. -INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) -SELECT uuid FROM users_organizations WHERE atype = 3; - --- Step 1: a legacy Manager who managed every collection through an organization-local group with --- `access_all` keeps that authority, materialized into the permission columns it now lives in. --- --- Restricted to memberships recorded as legacy Managers. Matching on role and group membership --- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who --- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and --- would hand them organization-wide collection edit and delete. --- --- Earlier revisions derived this authority live from the group at request time instead, which was --- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's --- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the --- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. --- --- Deliberately not `create_new_collections`: creating collections historically required --- membership-level `access_all`, and it is an independent permission now. -UPDATE users_organizations -SET edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype IN (3, 4) - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND EXISTS ( - SELECT 1 - FROM groups_users AS gu - INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid - WHERE gu.users_organizations_uuid = users_organizations.uuid - AND g.organizations_uuid = users_organizations.org_uuid - AND g.access_all = TRUE - ); - --- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. --- Set only TRUE values so this repair never removes independently configured permissions, and again --- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a --- modern Custom member carrying the old column as well. -UPDATE users_organizations -SET create_new_collections = TRUE, - edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype IN (3, 4) - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND access_all = TRUE; - --- Convert only after the legacy bit has been copied. -UPDATE users_organizations SET atype = 4 WHERE atype = 3; - --- Clear only the marker row as transactional DML. Keeping the empty bookkeeping table avoids --- MySQL DDL implicit commits, so the permission repair, marker clear, and Diesel ledger insert --- either commit together or are all retried. -DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; diff --git a/migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql b/migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql deleted file mode 100644 index a2035691..00000000 --- a/migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Recreate the column and repopulate it from the role/permission model that replaced it, restoring --- the invariant the immediately preceding schema relies on: access_all == access to every collection. --- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. --- --- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, --- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, --- because in that older schema access_all also meant the legacy Manager "Manage all collections" --- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* --- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; --- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. -ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; -UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); -UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; diff --git a/migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql b/migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql deleted file mode 100644 index e11fb611..00000000 --- a/migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can --- reach every collection". It is now fully represented by the role model: Owners/Admins hold it --- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. --- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. -ALTER TABLE users_organizations DROP COLUMN access_all; 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 deleted file mode 100644 index 39a10e8b..00000000 --- a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 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-130000_add_custom_access_permissions/up.sql b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql deleted file mode 100644 index 9d9c31ff..00000000 --- a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members --- (gated on the role in code); Owners/Admins hold every permission implicitly. -ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; 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 deleted file mode 100644 index 6db91a70..00000000 --- a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ /dev/null @@ -1,60 +0,0 @@ --- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy --- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create --- the marker table below while every Vaultwarden instance is stopped: --- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); --- --- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest --- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade --- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. --- --- Operators who only need the old server version to start again do not need Diesel at all -- --- tools/custom_role_rollback/ has a self-contained script per backend. -CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -INSERT INTO __vw_custom_role_downgrade_guard (blocked) -SELECT 1 FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade'); --- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits --- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name. -DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard; - --- Second, MySQL/MariaDB-only guard: this revert chain cannot be resumed here. --- --- Every `ALTER TABLE` in it commits on its own, while Diesel deletes the ledger row in a separate --- statement afterwards. A crash in between leaves the columns gone and the migration still recorded --- as applied, and re-running it fails forever with `Unknown column` (1091) -- the startup preflight --- then refuses the database, correctly, and the only way out is the backup. Making it resumable --- needs conditional DDL, i.e. a stored procedure built before the checks have run; the standalone --- script in tools/custom_role_rollback/mysql.sql does the whole downgrade in one audited pass --- instead, and is what operators should use. --- --- So this is supported for development checkouts only, and it says so. Acknowledge separately from --- the data-loss marker above -- that one is about what a downgrade discards, this one is about what --- an interrupted downgrade cannot repair: --- --- CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); --- --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -CREATE TEMPORARY TABLE __vw_mysql_resume_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_mysql_resume_guard (blocked) VALUES (1); -INSERT INTO __vw_mysql_resume_guard (blocked) -SELECT 1 FROM DUAL -WHERE NOT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_schema = DATABASE() AND table_name = '__vw_allow_unresumable_mysql_downgrade' -); -DROP TEMPORARY TABLE __vw_mysql_resume_guard; - --- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here --- when the next revert removes the first permission column, which is what this guard exists to --- announce -- checking and dropping it in the same step would leave every following lossy revert --- unguarded. -SELECT 1; 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 deleted file mode 100644 index 1d0d86d3..00000000 --- a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade --- before any granular permission column is removed. --- --- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now --- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL --- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; - --- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is --- never inherited across an upgrade. Both of them: this backend's revert chain needs a second one, --- acknowledging that it cannot be resumed after a crash between a committed ALTER and the ledger. -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; -DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade; 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 deleted file mode 100644 index 613cc7e7..00000000 --- a/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 4586bfdf..00000000 --- a/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql +++ /dev/null @@ -1,111 +0,0 @@ --- 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 deleted file mode 100644 index 6fcda697..00000000 --- a/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 151b0812..00000000 --- a/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql +++ /dev/null @@ -1,121 +0,0 @@ --- 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 3879f340..bc8e41d5 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,65 +1,84 @@ --- 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: +-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or +-- the Custom role. Two explicit operator decisions are required before anything is touched, and both +-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older +-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. + +-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); -- --- CREATE TABLE __vw_allow_custom_role_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_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. +-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a +-- Custom member holds, so handing it out automatically would *grant* authority during a +-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every +-- Custom member to plain User. See README.md in tools/custom_role_rollback/. -- --- 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. +-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); +-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES (''); -- --- 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 +-- The duplicate key aborts the revert. It is only inserted while the list is absent. +CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( + blocked INTEGER NOT NULL PRIMARY KEY ); +INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); +INSERT INTO __vw_rollback_allowlist_guard (blocked) +SELECT 1 +WHERE to_regclass('__vw_rollback_manager_allowlist') IS NULL; +DROP TABLE __vw_rollback_allowlist_guard; -UPDATE users_organizations SET atype = 3 -WHERE atype = 4 - AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; --- 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; +-- Roles and `access_all` are recomputed together, because in the old schema they are not +-- independent: +-- +-- * Owners and Admins always carried the bit and it grants them nothing extra; +-- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three +-- collection permissions -- in the old schema `access_all` also carried collection deletion, so +-- an Edit-only member must not silently gain it; +-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy +-- state the upgrade refuses, so leaving it set would make the database unable to move forward +-- again. +-- +-- Group-derived Manager authority is not restored here and does not need to be: `groups.access_all` +-- was never modified, so the older binary derives it again by itself for whoever comes back as +-- Manager. +UPDATE users_organizations +SET access_all = CASE + WHEN atype IN (0, 1) THEN TRUE + WHEN 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 THEN TRUE + ELSE FALSE + END, + atype = CASE + WHEN atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 + WHEN atype = 4 THEN 2 + ELSE atype + END; -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 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; --- 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. +-- Both decisions authorized *this* downgrade, not the next one. 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 4096fc6d..53ae67cc 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,38 +1,122 @@ -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; +-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine +-- granular permissions. +-- +-- Two different columns are called `access_all`, and everything below depends on keeping them apart: +-- +-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped +-- at the end of this file. +-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read +-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group +-- members access to every collection afterwards exactly as before. +-- +-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection +-- through either. Only the membership bit is going away, but the capability an owner configured +-- through either route is preserved, so both are read below. While this file runs the membership +-- column still exists and `atype = 3` still unambiguously means "legacy Manager". +-- +-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates +-- the same condition at startup and prints the recovery text, because Diesel would surface the abort +-- below as nothing but a driver-level duplicate-key error. --- 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 +-- A plain User carrying membership `access_all`, reachable only on databases written before the web +-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and +-- future, with no management authority, and the new model has no permission for that: +-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. +-- Refuse and let an owner choose. The duplicate key aborts the migration, and 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; + +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; + +-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives +-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard +-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management +-- capability it is configured with right now: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * an organization-local `access_all` group covered editing and deleting every collection, but +-- never creation -- that always required the membership bit; +-- * a Manager with neither keeps all three at FALSE. +-- +-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the +-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on +-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL +-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the +-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its +-- own. The alternative is silently revoking access these members have today, or refusing an ordinary +-- upgrade; the permission is visible in the member's permission list and an owner can clear it. +-- +-- The management (manage_users / manage_groups / manage_policies) and access (event logs / +-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager +-- capability -- every member mutation, every policy write, the organization export and both +-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. +-- +-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` +-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list +-- (`GET /organizations//users`) for them and for nobody whose reach came from a group. +-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, +-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and +-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` +-- satisfies `has_full_access()`, which opens the organization collection list and +-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach +-- through the group, so only the route is new. +-- +-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means +-- Manager everywhere it is read. +-- +-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is +-- converted like a confirmed one, since none holds authority in that state and the permissions are +-- what it would come back with -- the same thing `access_all` would have done. +-- +-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at +-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. +UPDATE users_organizations +SET create_new_collections = access_all, + edit_any_collection = access_all + OR 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 + ), + delete_any_collection = access_all + OR 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 + ), + atype = 4 +WHERE atype = 3; + +-- The flag is now fully represented by the role model: Owners/Admins hold it implicitly, a Custom +-- member holds it through `edit_any_collection`. Drop the redundant column. This only concerns +-- users_organizations; `groups.access_all` stays. +ALTER TABLE users_organizations DROP COLUMN access_all; --- Previously the server stored members created with the Custom role as Manager (3) and --- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted --- type, convert those members so clients (which no longer know the Manager role) keep --- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, --- which matches the capabilities these members had. -UPDATE users_organizations SET atype = 4 WHERE atype = 3; +-- Never inherit a downgrade acknowledgement left behind by an earlier revert. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; diff --git a/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql b/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql deleted file mode 100644 index 04346743..00000000 --- a/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; diff --git a/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql b/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql deleted file mode 100644 index f4f6862e..00000000 --- a/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a --- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. -CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( - marker INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_same_run_0716 (marker) -SELECT 1 -WHERE NOT EXISTS ( - SELECT 1 - FROM __diesel_schema_migrations - WHERE version = '20260716120000' -) -ON CONFLICT (marker) DO NOTHING; 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 deleted file mode 100644 index c278fa8e..00000000 --- a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Lossy revert: this removes the three independent Custom collection permissions, which the legacy --- role/access_all schema cannot represent -- it only knows all three together. The revert therefore --- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, --- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: --- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); -CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -INSERT INTO __vw_custom_role_downgrade_guard (blocked) -SELECT 1 -WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; -DROP TABLE __vw_custom_role_downgrade_guard; - --- The previous schema exposes access_all as the three collection permissions together. Avoid --- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. -UPDATE users_organizations -SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection -WHERE atype = 4; - -ALTER TABLE users_organizations DROP COLUMN create_new_collections; -ALTER TABLE users_organizations DROP COLUMN edit_any_collection; -ALTER TABLE users_organizations DROP COLUMN delete_any_collection; 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 deleted file mode 100644 index aab5f0cd..00000000 --- a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql +++ /dev/null @@ -1,60 +0,0 @@ --- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, --- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so --- the refusal is symmetrical with the other backends -- PostgreSQL DDL is transactional, so nothing --- would be left behind either way. --- --- Creating the record here instead would manufacture an empty, apparently valid history for exactly --- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. --- This guard exists for a bare migration runner that never consulted the startup preflight. --- --- The duplicate key aborts the migration. It is only inserted while the record table is absent. -CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_manager_record_guard (blocked) -SELECT 1 -WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; -DROP TABLE __vw_legacy_manager_record_guard; - -ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; - --- Before these permissions were persisted independently, access_all represented the legacy --- "Manage all collections" checkbox. Preserve that capability for existing Custom members. --- --- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a --- member carrying access_all held exactly this capability, whenever the row was created. -UPDATE users_organizations -SET create_new_collections = access_all, - edit_any_collection = access_all, - delete_any_collection = access_all -WHERE atype = 4; - --- A legacy Manager also managed every collection when one of their groups had access_all, even if --- the membership itself did not. Preserve that existing edit/delete capability without granting --- collection creation, which historically still required membership access_all. --- --- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and --- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: --- "Custom, member of an access_all group" is also the shape of every modern Custom member who was --- simply put into an ordinary access_all group, and granting on that shape hands them --- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies --- has_full_access() and therefore reaches every cipher in the organization. --- --- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every --- `atype = 3` row, which at this point is every Custom member there is. -UPDATE users_organizations -SET edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype = 4 - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND EXISTS ( - SELECT 1 - FROM groups_users - INNER JOIN groups ON groups.uuid = groups_users.groups_uuid - WHERE groups_users.users_organizations_uuid = users_organizations.uuid - AND groups.organizations_uuid = users_organizations.org_uuid - AND groups.access_all = TRUE - ); 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 deleted file mode 100644 index 4188886b..00000000 --- a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 7b9ad6b3..00000000 --- a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql +++ /dev/null @@ -1,96 +0,0 @@ --- Repair the legacy role/permission state while membership `access_all` still exists. --- --- A plain User carrying the historical membership-level `access_all` bit is deliberately not --- converted: that state grants dynamic reach over every collection *without* management authority, --- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: --- Vaultwarden's startup preflight already stops such a database before any migration runs and prints --- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run --- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner --- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few --- statements later. Repeating the check before this file's first mutation is what makes the silent --- loss impossible rather than unlikely. --- --- The duplicate key aborts the migration. It is only inserted when such a membership exists. -CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_user_access_all_guard (blocked) -SELECT 1 -FROM users_organizations -WHERE atype = 2 - AND access_all = TRUE -LIMIT 1; -DROP TABLE __vw_legacy_user_access_all_guard; - --- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup --- preflight refuses a database whose ledger carries that version without it. Creating it here would --- manufacture an empty, apparently valid history for precisely the databases that need an operator --- to look at them, so refuse instead -- this guard exists for a bare migration runner that never --- consulted the preflight. --- --- The duplicate key aborts the migration. It is only inserted while the record table is absent. -CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_manager_record_guard (blocked) -SELECT 1 -WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; -DROP TABLE __vw_legacy_manager_record_guard; - --- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten --- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are --- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this --- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the --- normal path. -INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) -SELECT uuid FROM users_organizations WHERE atype = 3 -ON CONFLICT DO NOTHING; - --- Step 1: a legacy Manager who managed every collection through an organization-local group with --- `access_all` keeps that authority, materialized into the permission columns it now lives in. --- --- Restricted to memberships recorded as legacy Managers. Matching on role and group membership --- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who --- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and --- would hand them organization-wide collection edit and delete. --- --- Earlier revisions derived this authority live from the group at request time instead, which was --- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's --- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the --- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. --- --- Deliberately not `create_new_collections`: creating collections historically required --- membership-level `access_all`, and it is an independent permission now. -UPDATE users_organizations -SET edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype IN (3, 4) - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND EXISTS ( - SELECT 1 - FROM groups_users AS gu - INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid - WHERE gu.users_organizations_uuid = users_organizations.uuid - AND g.organizations_uuid = users_organizations.org_uuid - AND g.access_all = TRUE - ); - --- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. --- Set only TRUE values so this repair never removes independently configured permissions, and again --- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a --- modern Custom member carrying the old column as well. -UPDATE users_organizations -SET create_new_collections = TRUE, - edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype IN (3, 4) - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND access_all = TRUE; - --- Convert only after the legacy bit has been copied. -UPDATE users_organizations SET atype = 4 WHERE atype = 3; - --- Clear the same-run marker only after every permission update succeeds. -DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; diff --git a/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql b/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql deleted file mode 100644 index a2035691..00000000 --- a/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Recreate the column and repopulate it from the role/permission model that replaced it, restoring --- the invariant the immediately preceding schema relies on: access_all == access to every collection. --- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. --- --- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, --- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, --- because in that older schema access_all also meant the legacy Manager "Manage all collections" --- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* --- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; --- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. -ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; -UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); -UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; diff --git a/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql b/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql deleted file mode 100644 index e11fb611..00000000 --- a/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can --- reach every collection". It is now fully represented by the role model: Owners/Admins hold it --- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. --- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. -ALTER TABLE users_organizations DROP COLUMN access_all; 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 deleted file mode 100644 index c9d1c95e..00000000 --- a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 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-130000_add_custom_access_permissions/up.sql b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql deleted file mode 100644 index 9d9c31ff..00000000 --- a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members --- (gated on the role in code); Owners/Admins hold every permission implicitly. -ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; 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 deleted file mode 100644 index d5a54d49..00000000 --- a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy --- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create --- the marker table below while every Vaultwarden instance is stopped: --- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); --- --- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest --- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade --- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. --- --- Operators who only need the old server version to start again do not need Diesel at all -- --- tools/custom_role_rollback/ has a self-contained script per backend. -CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -INSERT INTO __vw_custom_role_downgrade_guard (blocked) -SELECT 1 -WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; -DROP TABLE __vw_custom_role_downgrade_guard; - --- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here --- when the next revert removes the first permission column, which is what this guard exists to --- announce -- checking and dropping it in the same step would leave every following lossy revert --- unguarded. -SELECT 1; diff --git a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql deleted file mode 100644 index 9079f661..00000000 --- a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade --- before any granular permission column is removed. --- --- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now --- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL --- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; - --- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is --- never inherited across an upgrade. -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; 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 deleted file mode 100644 index 613cc7e7..00000000 --- a/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 821a5314..00000000 --- a/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql +++ /dev/null @@ -1,103 +0,0 @@ --- 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 deleted file mode 100644 index 6fcda697..00000000 --- a/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 929450c3..00000000 --- a/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql +++ /dev/null @@ -1,111 +0,0 @@ --- 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 a8faf67b..fd17319e 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,14 +1,17 @@ --- 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: +-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or +-- the Custom role. Two explicit operator decisions are required before anything is touched, and both +-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older +-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. + +-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); -- --- CREATE TABLE __vw_allow_custom_role_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_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 ( @@ -17,52 +20,83 @@ WHERE NOT EXISTS ( ); 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. +-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a +-- Custom member holds, so handing it out automatically would *grant* authority during a +-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every +-- Custom member to plain User. See README.md in tools/custom_role_rollback/. +-- +-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); +-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES (''); -- --- 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. +-- The duplicate key aborts the revert. It is only inserted while the list is absent. +CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); +INSERT INTO __vw_rollback_allowlist_guard (blocked) +SELECT 1 +WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_rollback_manager_allowlist' +); +DROP TABLE __vw_rollback_allowlist_guard; + +-- Roles and `access_all` are recomputed together, because in the old schema they are not independent: -- --- 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: +-- * Owners and Admins always carried the bit and it grants them nothing extra; +-- * an allowlisted Custom member becomes a Manager, keeping the bit only with all three collection +-- permissions -- `access_all` also carried collection deletion there, so an Edit-only member must +-- not silently gain it; +-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy +-- state the upgrade refuses, so leaving it set would strand the database. -- --- 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 +-- Group-derived Manager authority needs no restoring: `groups.access_all` was never modified, so the +-- older binary derives it again for whoever comes back as Manager. +CREATE TABLE users_organizations_old ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + org_uuid TEXT NOT NULL REFERENCES organizations (uuid), + + access_all BOOLEAN NOT NULL, + akey TEXT NOT NULL, + status INTEGER NOT NULL, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + + UNIQUE (user_uuid, org_uuid) ); -UPDATE users_organizations SET atype = 3 -WHERE atype = 4 - AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); +INSERT INTO users_organizations_old ( + uuid, user_uuid, org_uuid, access_all, akey, status, atype, + reset_password_key, external_id, invited_by_email +) +SELECT + uo.uuid, uo.user_uuid, uo.org_uuid, + CASE + WHEN uo.atype IN (0, 1) THEN TRUE + WHEN uo.atype = 4 + AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) + AND uo.create_new_collections = TRUE + AND uo.edit_any_collection = TRUE + AND uo.delete_any_collection = TRUE THEN TRUE + ELSE FALSE + END, + uo.akey, uo.status, + 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; --- 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; +DROP TABLE users_organizations; -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_old RENAME TO users_organizations; --- 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. +-- Both decisions authorized *this* downgrade, not the next one. 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 7087c25c..93e3169c 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,37 +1,156 @@ -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 +-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine +-- granular permissions. +-- +-- Two different columns are called `access_all`, and everything below depends on keeping them apart: +-- +-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped +-- at the end of this file. +-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read +-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group +-- members access to every collection afterwards exactly as before. +-- +-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection +-- through either. Only the membership bit is going away, but the capability an owner configured +-- through either route is preserved, so both are read below. While this file runs the membership +-- column still exists and `atype = 3` still unambiguously means "legacy Manager". +-- +-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates +-- the same condition at startup and prints the recovery text, because Diesel would surface the abort +-- below as nothing but a driver-level duplicate-key error. + +-- A plain User carrying membership `access_all`, reachable only on databases written before the web +-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and +-- future, with no management authority, and the new model has no permission for that: +-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. +-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when +-- such a membership exists. +CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( + blocked INTEGER 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 +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; + +-- Schema and data change in one table rebuild, which also keeps the conversion unambiguous: +-- `atype = 3` still means Manager while the permission values are computed from it. +-- +-- `ALTER TABLE ... DROP COLUMN` is deliberately not used -- it needs SQLite 3.35.0, while a +-- `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1. The +-- rebuild follows the existing 2022-03-02-210038_update_devices_primary_key pattern; Vaultwarden runs +-- SQLite migrations with `PRAGMA foreign_keys = OFF`, so the drop does not cascade into groups_users. +CREATE TABLE users_organizations_new ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + org_uuid TEXT NOT NULL REFERENCES organizations (uuid), + + akey TEXT NOT NULL, + status INTEGER NOT NULL, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + manage_users BOOLEAN NOT NULL DEFAULT FALSE, + manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + access_reports BOOLEAN NOT NULL DEFAULT FALSE, + + UNIQUE (user_uuid, org_uuid) ); --- Previously the server stored members created with the Custom role as Manager (3) and --- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted --- type, convert those members so clients (which no longer know the Manager role) keep --- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, --- which matches the capabilities these members had. -UPDATE users_organizations SET atype = 4 WHERE atype = 3; +-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives +-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard +-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management +-- capability it is configured with right now: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * an organization-local `access_all` group covered editing and deleting every collection, but +-- never creation -- that always required the membership bit; +-- * a Manager with neither keeps all three at FALSE. +-- +-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the +-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on +-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL +-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the +-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its +-- own. The alternative is silently revoking access these members have today, or refusing an ordinary +-- upgrade; the permission is visible in the member's permission list and an owner can clear it. +-- +-- The management (manage_users / manage_groups / manage_policies) and access (event logs / +-- import-export / reports) permissions start out FALSE for everyone. Nothing they unlock was a Manager +-- capability -- every member mutation, every policy write, the organization export and both +-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. +-- +-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` +-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list +-- (`GET /organizations//users`) for them and for nobody whose reach came from a group. +-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, +-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and +-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` +-- satisfies `has_full_access()`, which opens the organization collection list and +-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach +-- through the group, so only the route is new. +-- +-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is +-- converted like a confirmed one, since none holds authority in that state and the permissions are +-- what it would come back with -- the same thing `access_all` would have done. +-- +-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at +-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. +INSERT INTO users_organizations_new ( + uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, + invited_by_email, manage_users, manage_groups, manage_policies, + create_new_collections, edit_any_collection, delete_any_collection, + access_event_logs, access_import_export, access_reports +) +SELECT + uo.uuid, uo.user_uuid, uo.org_uuid, uo.akey, uo.status, + CASE WHEN uo.atype = 3 THEN 4 ELSE uo.atype END, + uo.reset_password_key, uo.external_id, uo.invited_by_email, + FALSE, FALSE, FALSE, + CASE WHEN uo.atype = 3 AND uo.access_all = TRUE THEN TRUE ELSE FALSE END, + CASE + WHEN uo.atype = 3 + AND (uo.access_all = TRUE + OR 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 + )) + THEN TRUE ELSE FALSE + END, + CASE + WHEN uo.atype = 3 + AND (uo.access_all = TRUE + OR 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 + )) + THEN TRUE ELSE FALSE + END, + FALSE, FALSE, FALSE +FROM users_organizations AS uo; + +DROP TABLE users_organizations; + +ALTER TABLE users_organizations_new RENAME TO users_organizations; + +-- Never inherit a downgrade acknowledgement left behind by an earlier revert. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; diff --git a/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql b/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql deleted file mode 100644 index 04346743..00000000 --- a/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; diff --git a/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql b/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql deleted file mode 100644 index 53fd7671..00000000 --- a/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql +++ /dev/null @@ -1,12 +0,0 @@ --- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a --- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data. -CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 ( - marker INTEGER NOT NULL PRIMARY KEY -); -INSERT OR IGNORE INTO __vw_custom_role_same_run_0716 (marker) -SELECT 1 -WHERE NOT EXISTS ( - SELECT 1 - FROM __diesel_schema_migrations - WHERE version = '20260716120000' -); 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 deleted file mode 100644 index 41ad950e..00000000 --- a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql +++ /dev/null @@ -1,28 +0,0 @@ --- Lossy revert: this removes the three independent Custom collection permissions, which the legacy --- role/access_all schema cannot represent -- it only knows all three together. The revert therefore --- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, --- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: --- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); -CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -INSERT INTO __vw_custom_role_downgrade_guard (blocked) -SELECT 1 -WHERE NOT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade' -); -DROP TABLE __vw_custom_role_downgrade_guard; - --- The previous schema exposes access_all as the three collection permissions together. Avoid --- turning Edit-only memberships into Create/Edit/Delete grants when rolling back. -UPDATE users_organizations -SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection -WHERE atype = 4; - -ALTER TABLE users_organizations DROP COLUMN create_new_collections; -ALTER TABLE users_organizations DROP COLUMN edit_any_collection; -ALTER TABLE users_organizations DROP COLUMN delete_any_collection; 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 deleted file mode 100644 index a819bad8..00000000 --- a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql +++ /dev/null @@ -1,63 +0,0 @@ --- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it, --- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so --- a refusal leaves no half-added column group behind -- on MySQL/MariaDB every ALTER commits on its --- own, and a partial group is what the startup preflight then has to recover from. --- --- Creating the record here instead would manufacture an empty, apparently valid history for exactly --- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning. --- This guard exists for a bare migration runner that never consulted the startup preflight. --- --- The duplicate key aborts the migration. It is only inserted while the record table is absent. -CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_manager_record_guard (blocked) -SELECT 1 -WHERE NOT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' -); -DROP TABLE __vw_legacy_manager_record_guard; - -ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE; - --- Before these permissions were persisted independently, access_all represented the legacy --- "Manage all collections" checkbox. Preserve that capability for existing Custom members. --- --- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a --- member carrying access_all held exactly this capability, whenever the row was created. -UPDATE users_organizations -SET create_new_collections = access_all, - edit_any_collection = access_all, - delete_any_collection = access_all -WHERE atype = 4; - --- A legacy Manager also managed every collection when one of their groups had access_all, even if --- the membership itself did not. Preserve that existing edit/delete capability without granting --- collection creation, which historically still required membership access_all. --- --- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and --- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority: --- "Custom, member of an access_all group" is also the shape of every modern Custom member who was --- simply put into an ordinary access_all group, and granting on that shape hands them --- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies --- has_full_access() and therefore reaches every cipher in the organization. --- --- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every --- `atype = 3` row, which at this point is every Custom member there is. -UPDATE users_organizations -SET edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype = 4 - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND EXISTS ( - SELECT 1 - FROM groups_users - INNER JOIN groups ON groups.uuid = groups_users.groups_uuid - WHERE groups_users.users_organizations_uuid = users_organizations.uuid - AND groups.organizations_uuid = users_organizations.org_uuid - AND groups.access_all = TRUE - ); 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 deleted file mode 100644 index 4188886b..00000000 --- a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 81c8e1e5..00000000 --- a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql +++ /dev/null @@ -1,98 +0,0 @@ --- Repair the legacy role/permission state while membership `access_all` still exists. --- --- A plain User carrying the historical membership-level `access_all` bit is deliberately not --- converted: that state grants dynamic reach over every collection *without* management authority, --- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust: --- Vaultwarden's startup preflight already stops such a database before any migration runs and prints --- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run --- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner --- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few --- statements later. Repeating the check before this file's first mutation is what makes the silent --- loss impossible rather than unlikely. --- --- The duplicate key aborts the migration. It is only inserted when such a membership exists. -CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_user_access_all_guard (blocked) -SELECT 1 -FROM users_organizations -WHERE atype = 2 - AND access_all = TRUE -LIMIT 1; -DROP TABLE __vw_legacy_user_access_all_guard; - --- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup --- preflight refuses a database whose ledger carries that version without it. Creating it here would --- manufacture an empty, apparently valid history for precisely the databases that need an operator --- to look at them, so refuse instead -- this guard exists for a bare migration runner that never --- consulted the preflight. --- --- The duplicate key aborts the migration. It is only inserted while the record table is absent. -CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); -INSERT INTO __vw_legacy_manager_record_guard (blocked) -SELECT 1 -WHERE NOT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' -); -DROP TABLE __vw_legacy_manager_record_guard; - --- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten --- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are --- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this --- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the --- normal path where 2026-06-30-120000 already recorded them. -INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) -SELECT uuid FROM users_organizations WHERE atype = 3; - --- Step 1: a legacy Manager who managed every collection through an organization-local group with --- `access_all` keeps that authority, materialized into the permission columns it now lives in. --- --- Restricted to memberships recorded as legacy Managers. Matching on role and group membership --- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who --- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and --- would hand them organization-wide collection edit and delete. --- --- Earlier revisions derived this authority live from the group at request time instead, which was --- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's --- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the --- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md. --- --- Deliberately not `create_new_collections`: creating collections historically required --- membership-level `access_all`, and it is an independent permission now. -UPDATE users_organizations -SET edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype IN (3, 4) - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND EXISTS ( - SELECT 1 - FROM groups_users AS gu - INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid - WHERE gu.users_organizations_uuid = users_organizations.uuid - AND g.organizations_uuid = users_organizations.org_uuid - AND g.access_all = TRUE - ); - --- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities. --- Set only TRUE values so this repair never removes independently configured permissions, and again --- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a --- modern Custom member carrying the old column as well. -UPDATE users_organizations -SET create_new_collections = TRUE, - edit_any_collection = TRUE, - delete_any_collection = TRUE -WHERE atype IN (3, 4) - AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) - AND access_all = TRUE; - --- Convert only after the legacy bit has been copied. -UPDATE users_organizations SET atype = 4 WHERE atype = 3; - --- Clear the same-run marker only after every permission update succeeds. -DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1; diff --git a/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql b/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql deleted file mode 100644 index a2035691..00000000 --- a/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Recreate the column and repopulate it from the role/permission model that replaced it, restoring --- the invariant the immediately preceding schema relies on: access_all == access to every collection. --- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`. --- --- NOTE: this only holds for reverting *this* migration. Reverting further down the chain, --- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members, --- because in that older schema access_all also meant the legacy Manager "Manage all collections" --- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without* --- access_all rather than silently gaining collection deletion. That is intentional and fail-closed; --- the full rollback is blocked by 2026-07-24-140000/down.sql anyway. -ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; -UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); -UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE; diff --git a/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql b/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql deleted file mode 100644 index 3638bc7a..00000000 --- a/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql +++ /dev/null @@ -1,46 +0,0 @@ --- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can --- reach every collection". It is now fully represented by the role model: Owners/Admins hold it --- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column. --- This only concerns users_organizations; groups.access_all is a separate, still-supported feature. --- --- `ALTER TABLE ... DROP COLUMN` is deliberately NOT used here: it only exists since SQLite 3.35.0, --- while a `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1 --- (which is what Debian 11 ships). Forward migrations have to run on every supported build, so use --- the portable table rebuild instead -- the same pattern as --- 2022-03-02-210038_update_devices_primary_key. Vaultwarden runs SQLite migrations with --- `PRAGMA foreign_keys = OFF`, so dropping the old table does not cascade into groups_users. -CREATE TABLE users_organizations_new ( - uuid TEXT NOT NULL PRIMARY KEY, - user_uuid TEXT NOT NULL REFERENCES users (uuid), - org_uuid TEXT NOT NULL REFERENCES organizations (uuid), - - akey TEXT NOT NULL, - status INTEGER NOT NULL, - atype INTEGER NOT NULL, - reset_password_key TEXT, - external_id TEXT, - invited_by_email TEXT DEFAULT NULL, - manage_users BOOLEAN NOT NULL DEFAULT FALSE, - manage_groups BOOLEAN NOT NULL DEFAULT FALSE, - manage_policies BOOLEAN NOT NULL DEFAULT FALSE, - create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, - edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, - - UNIQUE (user_uuid, org_uuid) -); - -INSERT INTO users_organizations_new ( - uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, - invited_by_email, manage_users, manage_groups, manage_policies, - create_new_collections, edit_any_collection, delete_any_collection -) -SELECT - uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, - invited_by_email, manage_users, manage_groups, manage_policies, - create_new_collections, edit_any_collection, delete_any_collection -FROM users_organizations; - -DROP TABLE users_organizations; - -ALTER TABLE users_organizations_new RENAME TO users_organizations; 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 deleted file mode 100644 index 31101986..00000000 --- a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 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-130000_add_custom_access_permissions/up.sql b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql deleted file mode 100644 index 9d9c31ff..00000000 --- a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members --- (gated on the role in code); Owners/Admins hold every permission implicitly. -ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; -ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; 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 deleted file mode 100644 index 4e8f080f..00000000 --- a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql +++ /dev/null @@ -1,29 +0,0 @@ --- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy --- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create --- the marker table below while every Vaultwarden instance is stopped: --- --- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); --- --- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest --- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade --- clears it again (2026-07-24-140000/up.sql), so consent is never inherited. --- --- Operators who only need the old server version to start again do not need Diesel at all -- --- tools/custom_role_rollback/ has a self-contained script per backend. -CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( - blocked INTEGER NOT NULL PRIMARY KEY -); -INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); --- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. -INSERT INTO __vw_custom_role_downgrade_guard (blocked) -SELECT 1 -WHERE NOT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade'); -DROP TABLE __vw_custom_role_downgrade_guard; - --- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here --- when the next revert removes the first permission column, which is what this guard exists to --- announce -- checking and dropping it in the same step would leave every following lossy revert --- unguarded. -SELECT 1; diff --git a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql deleted file mode 100644 index 9079f661..00000000 --- a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade --- before any granular permission column is removed. --- --- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now --- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL --- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op. -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; - --- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is --- never inherited across an upgrade. -DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; 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 deleted file mode 100644 index 613cc7e7..00000000 --- a/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 43c39b16..00000000 --- a/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql +++ /dev/null @@ -1,107 +0,0 @@ --- 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 deleted file mode 100644 index 6fcda697..00000000 --- a/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 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 deleted file mode 100644 index 8c1a579b..00000000 --- a/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql +++ /dev/null @@ -1,117 +0,0 @@ --- 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/organizations.rs b/src/api/core/organizations.rs index 84336c85..bbbb00c9 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -13,8 +13,8 @@ use crate::{ }, auth::{ AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, - ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, - ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite, + ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, + OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite, }, db::{ DbConn, @@ -602,14 +602,10 @@ 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 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. + // Security: a `manage` grant carries collection administration authority, so only a caller who may + // already administer this collection may confer it. Create is independent of Edit/Delete, so for + // `create_new_collections` alone the requested `manage` is forced to false; the creator's own grant + // is added separately below. Evaluated after the collection exists so the 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(); @@ -696,21 +692,15 @@ async fn post_bulk_access_collections( err!("Can't find organization details") } - // Security (F-1): authorization is enforced per collection below via `auth::can_edit_collection`, - // the exact same Custom-aware check the single-collection edit endpoint (`ManagerHeaders`) uses. - // Edit any collection (or Admin/Owner) may bulk-edit every collection; any other Custom member - // must hold a real per-collection Manage grant. In particular a Custom member's group - // `access_all` does NOT satisfy this here (it did under the previous `is_manageable_by_user` - // check, which diverged from the single-edit endpoint). A custom user with only manage_users / - // manage_groups / manage_policies holds no such grant and is rejected, while a member who manages - // some collections keeps the ability to bulk-edit exactly those. - - // Security (audit H-3) and atomicity (audit M-2): validate the whole request against this - // organization *before* mutating anything. Every collection must exist in the org and be - // manageable by the caller, and every referenced group and user must belong to the org. Only - // once the entire request is known-valid do we begin the destructive delete/replace of - // assignments, so a foreign-tenant group can never be linked and a later invalid element can no - // longer leave earlier collections with their assignments already wiped. + // Security: authorization is per collection below, via the same `auth::can_edit_collection` the + // single-collection edit endpoint uses — a body-param endpoint cannot use `ManagerHeaders`, and the + // two must not diverge. Group `access_all` deliberately does not satisfy it (the previous + // `is_manageable_by_user` check accepted it, and disagreed with the single-edit endpoint). + + // Security and atomicity: validate the whole request against this organization before mutating + // anything — every collection, group and user must belong to it and be manageable by the caller. + // Only then does the destructive delete/replace begin, so a foreign-tenant group can never be linked + // and a later invalid element cannot leave earlier collections already wiped. let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { @@ -737,9 +727,8 @@ async fn post_bulk_access_collections( for collection in collections { let col_id = &collection.uuid; - // Security (F-1): only a caller who could delete this collection may confer a `manage` grant - // on it. Otherwise the requested `manage` is forced to false, so a caller whose access comes - // from Edit-any-collection cannot escalate into deletion by self-assigning a manage row. + // Security: only a caller who could delete this collection may confer a `manage` grant on it; + // otherwise the requested `manage` is forced to false. let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, col_id, &conn).await; // update collection modification date @@ -1067,13 +1056,11 @@ fn filter_ciphers_for_organization(ciphers: Vec, org_id: &OrganizationId ciphers.into_iter().filter(|cipher| cipher.organization_uuid.as_ref() == Some(org_id)).collect() } -// The Admin Console uses this endpoint when the acting member is not allowed to read every -// cipher in the organization. In particular, a Custom member with only DeleteAnyCollection -// needs an empty successful response so the collection list can finish loading and expose its -// collection-only delete controls. +// The Admin Console calls this when the acting member may not read every cipher: DeleteAnyCollection +// alone needs an empty successful response so the collection list can finish loading. // -// Security: start from the regular user-visible cipher query and then constrain the result to -// the requested organization. DeleteAnyCollection itself must never make cipher contents visible. +// Security: start from the regular user-visible cipher query and constrain it to the requested +// organization. DeleteAnyCollection must never make cipher contents visible. #[get("/ciphers/organization-details/assigned?")] async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { @@ -1096,9 +1083,9 @@ async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbCon // the caller's own collections is returned and every cipher carries its real `edit`/`viewPassword` // flags. // -// NOTE: as everywhere else in Vaultwarden (and in Bitwarden), a `hidePasswords` assignment is -// reported to the client as `viewPassword: false` rather than redacted server-side. This therefore -// returns exactly what the same member already receives from `/api/sync` — never more. +// NOTE: as everywhere else in Vaultwarden (and Bitwarden), `hidePasswords` is reported as +// `viewPassword: false` rather than redacted server-side, so this returns exactly what the same +// member already receives from `/api/sync` — never more. async fn assigned_org_ciphers_json( org_id: &OrganizationId, host: &str, @@ -1285,22 +1272,52 @@ struct CustomRolePermissions { } impl CustomRolePermissions { - fn from_request(member_type: MembershipType, permissions: &HashMap) -> Self { - if member_type != MembershipType::Custom { - return Self::default(); + /// Read one known permission key. + /// + /// An absent key is `false`: the object is the complete set the caller wants. A key that *is* present + /// must be a JSON boolean — treating `"true"`, `1` or `null` as "not `Value::Bool(true)`" turned a + /// malformed request into a silent permission *removal* that still answered 200. + fn read_known(permissions: &HashMap, key: &str) -> Result { + match permissions.get(key) { + None => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(other) => { + let found = match other { + Value::Null => "null", + Value::String(_) => "a string", + Value::Number(_) => "a number", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + Value::Bool(_) => unreachable!("booleans are handled above"), + }; + err!(format!("Invalid permissions: '{key}' must be true or false, but is {found}")) + } } + } + + /// Parse a permissions object. + /// + /// Every known key is type-checked even when the role makes the flags inert, so a malformed request + /// is rejected identically whatever role it names, and always before anything is mutated. Unknown + /// keys are ignored: Bitwarden sends `manageSso`, `manageScim` and `manageResetPassword`, and + /// rejecting them would break clients over permissions Vaultwarden does not implement. + fn from_request(member_type: MembershipType, permissions: &HashMap) -> Result { + let parsed = Self { + manage_users: Self::read_known(permissions, "manageUsers")?, + manage_groups: Self::read_known(permissions, "manageGroups")?, + manage_policies: Self::read_known(permissions, "managePolicies")?, + create_new_collections: Self::read_known(permissions, "createNewCollections")?, + edit_any_collection: Self::read_known(permissions, "editAnyCollection")?, + delete_any_collection: Self::read_known(permissions, "deleteAnyCollection")?, + access_event_logs: Self::read_known(permissions, "accessEventLogs")?, + access_import_export: Self::read_known(permissions, "accessImportExport")?, + access_reports: Self::read_known(permissions, "accessReports")?, + }; - let enabled = |key: &str| matches!(permissions.get(key), Some(Value::Bool(true))); - Self { - manage_users: enabled("manageUsers"), - manage_groups: enabled("manageGroups"), - manage_policies: enabled("managePolicies"), - create_new_collections: enabled("createNewCollections"), - edit_any_collection: enabled("editAnyCollection"), - delete_any_collection: enabled("deleteAnyCollection"), - access_event_logs: enabled("accessEventLogs"), - access_import_export: enabled("accessImportExport"), - access_reports: enabled("accessReports"), + if member_type == MembershipType::Custom { + Ok(parsed) + } else { + Ok(Self::default()) } } @@ -1319,9 +1336,9 @@ impl CustomRolePermissions { member_type: MembershipType, permissions: Option<&HashMap>, membership: &Membership, - ) -> Self { - match permissions { - Some(permissions) => Self::from_request(member_type, permissions), + ) -> Result { + Ok(match permissions { + Some(permissions) => Self::from_request(member_type, permissions)?, None if member_type == MembershipType::Custom && membership.atype == MembershipType::Custom as i32 => { Self { manage_users: membership.manage_users, @@ -1336,7 +1353,7 @@ impl CustomRolePermissions { } } None => Self::default(), - } + }) } fn differs_from(self, membership: &Membership) -> bool { @@ -1426,10 +1443,11 @@ async fn send_invite( err!("You don't have permission to invite this role") } - // manageAllCollections is a client-only aggregate. Persist its three children independently. - // Whether the member reaches every collection (Admin/Owner, or Custom + Edit any collection) - // decides whether we skip creating individual per-collection assignments below. - let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions); + // manageAllCollections is a client-only aggregate; its three children are persisted independently. + // Parsed and type-checked before the loop below creates any user, invitation or membership, so a + // malformed value leaves nothing behind. Reaching every collection decides whether the individual + // per-collection assignments below are skipped. + let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions)?; let grants_full_access = custom_permissions.grants_full_collection_access(new_type); // Security: only callers who can manage collections (Admins/Owners, or users with full access) @@ -1459,6 +1477,18 @@ async fn send_invite( } } + // Security: the membership does not exist yet, so every group named here is an addition — putting + // the invitee into an `access_all` group is the same durable grant the other paths reserve for + // Admins and Owners. `caller_can_manage_collections` above does not cover it: `editAnyCollection` + // satisfies that, and could plant the grant on an account outliving the flag it was made under. + if !may_grant_access_all_group(headers.membership_type) { + for group_id in &data.groups { + if group_grants_access_to_all_collections(group_id, &org_id, &conn).await { + err!("Only Admins and Owners can invite a member into a group with access to all collections") + } + } + } + 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` @@ -1777,7 +1807,19 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let member_id = invite.id.unwrap(); + // The id is request-controlled and optional. Unwrapping it aborted the worker with a 500 and, because + // the panic unwound mid-loop, discarded the response for every entry already confirmed in the same + // batch. Report it as a per-entry error, like an id that is present but empty. + let Some(member_id) = invite.id else { + bulk_response.push(json!( + { + "object": "OrganizationBulkConfirmResponseModel", + "id": null, + "error": "Key or UserId is not set, unable to process request" + } + )); + continue; + }; let user_key = invite.key.unwrap_or_default(); let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), @@ -1964,8 +2006,11 @@ async fn edit_member( err!("The specified user isn't member of the organization") }; + // Parsed (and type-checked) here, long before the write phase further down, so a malformed + // permission value leaves the role, the permission flags, the collection assignments and the + // group memberships exactly as they were. let custom_permissions = - CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit); + CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit)?; let grants_full_access = custom_permissions.grants_full_collection_access(new_type); if new_type != member_to_edit.atype @@ -1975,13 +2020,9 @@ async fn edit_member( err!("Only Owners can grant and remove Admin or Owner privileges") } - // Security: only Admins and Owners may change a member's role type at all. A Custom member - // with manage_users must not change roles: raising a member to Custom can activate existing - // explicit collection-Manage assignments and other Custom-only authorization paths, while - // lowering it revokes them. Those authority changes are outside Manage Users even though - // granular permission changes are independently gated below. Requests that leave the role - // unchanged are allowed, so such members can still use the regular edit dialog. The - // Admin/Owner guard above still governs Admin/Owner transitions for Owners. + // Security: raising a member to Custom activates existing explicit collection-Manage assignments and + // other Custom-only paths, and lowering it revokes them — authority changes outside Manage Users. An + // unchanged role is still allowed, so such members can use the regular edit dialog. if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) { err!("Only Admins or Owners can change a member's role") } @@ -1990,20 +2031,14 @@ async fn edit_member( err!("Only Owners can edit Owner users") } - // Security: apply the same actor/target role matrix as every other member endpoint (reinvite, - // 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. + // Security: the same actor/target role matrix as every other member endpoint. Without it + // `edit_member` was the only path on which Custom+manage_users could aim at an Admin or a peer + // Custom membership, as long as the role stayed 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. + // Deliberate narrowing of upstream: Bitwarden lets Custom+ManageUsers administer peer Custom members + // and delegate a subset of its own permissions. That hands permission delegation to a non-Admin and + // makes correctness rest on a subset comparison being right on every path, so Vaultwarden keeps role + // and permission changes with Admins/Owners instead. if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) { err!("You don't have permission to edit this member") } @@ -2018,39 +2053,30 @@ async fn edit_member( } } - // Security: only Admins and Owners may change the granular custom-role permissions. A Custom - // member with manage_users must not be able to grant them to themselves or others (a - // privilege escalation), nor strip flags an Admin/Owner has granted to fellow Custom members. - // Requests that leave the flags unchanged are allowed, so such members can still use the - // regular edit dialog. + // Security: only Admins and Owners may change the granular permissions — manage_users must not grant + // them to itself or others, nor strip flags an Admin/Owner granted. Unchanged flags are allowed, so + // such members can still use the regular edit dialog. if headers.membership_type < MembershipType::Admin && custom_permissions.differs_from(&member_to_edit) { err!("Only Admins or Owners can change custom permissions") } - // Security: only callers who can actually manage collections (Admins/Owners, or users - // 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. + // Security: only callers who can manage collections (Admin/Owner, or full access) may change a + // member's collection assignments; manage_users alone leaves them untouched. // - // 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. + // Narrowing of upstream, which resolves ModifyUserAccess per collection. Requiring blanket authority + // is coarser, but it keeps a stored `manage` grant from becoming a lever for handing out access — the + // same boundary `caller_may_grant_collection_manage` draws, and the group paths below follow it. + // Widening this needs the per-collection check to cover *current* assignments too, 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(), None => false, }; - // API consistency: dropping these fields silently while still answering 200 let client and - // server drift apart after an apparently saved change. Reject the request instead — but only - // when it would actually add or remove an assignment, because the regular edit dialog echoes the - // current assignments back and has to keep working. Flag-only differences (readOnly, - // hidePasswords, manage) remain ignored for these callers. + // API consistency: dropping these fields while answering 200 let client and server drift apart after + // an apparently saved change. Reject instead — but only for an actual add or removal, since the edit + // dialog echoes the current assignments back. Flag-only differences stay ignored. if !caller_can_manage_collections && !grants_full_access { let requested: HashSet = data.collections.iter().flatten().map(|c| c.id.clone()).collect(); let current: HashSet = @@ -2076,21 +2102,15 @@ async fn edit_member( OrgPolicy::check_user_allowed(&member_to_edit, "modify", &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. + // Validation phase. Nothing may be written until every id, tenant binding and caller right has + // been checked: this endpoint replaces both collection assignments and group memberships, and with + // no database transactions an error between the two replaces used to leave the request + // half-applied while answering 4xx. This cannot make them atomic, but a *rejected* request now + // 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. + // Security: a per-collection `manage` grant is durable administration authority, so it may only be + // conferred where the caller already holds it. let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; // Resolve the requested assignments: every collection has to exist in *this* organization, and @@ -2139,6 +2159,17 @@ async fn edit_member( } } + // Security: adding this member to an `access_all` group grants durable organization-wide access, so + // a caller whose own reach comes from `editAnyCollection` must not hand it out. Removals are + // unrestricted, so only the groups this request adds are examined. + if caller_can_manage_groups && !may_grant_access_all_group(headers.membership_type) { + for group_id in requested_groups.difference(¤t_groups) { + if group_grants_access_to_all_collections(group_id, &org_id, &conn).await { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + } + } + // 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. @@ -2276,7 +2307,7 @@ async fn delete_member_impl( err!("User to delete isn't member of the organization") }; - if !may_provision_stored_member_type(headers.membership_type, member_to_delete.atype) { + if !may_delete_stored_member_type(headers.membership_type, member_to_delete.atype) { err!("You don't have permission to delete this user") } @@ -2405,11 +2436,9 @@ async fn post_org_import( // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; - // Robustness/DoS (audit M-3): validate every collection<->cipher relationship index against the - // import payload *before* creating any collection or cipher. `key` indexes into `ciphers` and - // `value` into `collections`; an out-of-range index would otherwise cause an out-of-bounds panic - // when the relations are applied below — a 500 (or a process abort under panic="abort") that - // happens after rows have already been written, leaving partial state behind. + // Robustness: validate every collection<->cipher relationship index against the payload *before* + // creating anything. `key` indexes into `ciphers` and `value` into `collections`, and an out-of-range + // index would otherwise panic when the relations are applied — after rows have already been written. let import_cipher_count = data.ciphers.len(); let import_collection_count = data.collections.len(); for relation in &data.collection_relationships { @@ -2753,10 +2782,11 @@ async fn put_policy( // When enabling the SingleOrg policy, remove this org's members that are members of other orgs if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { for mut member in Membership::find_by_org(&org_id, &conn).await { - // Policy only applies to non-Owner/non-Admin members who have accepted joining the org + // Policy only applies to non-Owner/non-Admin members who have accepted joining the org, + // and never to the member enabling it -- see `Membership::is_policy_enforcement_target`. // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. - if member.atype < MembershipType::Admin + if member.is_policy_enforcement_target(&headers.user.uuid) && member.status != MembershipStatus::Invited as i32 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await > 0 @@ -2951,7 +2981,7 @@ async fn revoke_member_impl( if member.user_uuid == headers.user.uuid { err!("You cannot revoke yourself") } - if !may_manage_stored_member_type(headers.membership_type, member.atype) { + if !may_revoke_stored_member_type(headers.membership_type, member.atype) { err!("You don't have permission to revoke this user") } if member.atype == MembershipType::Owner @@ -3078,23 +3108,28 @@ async fn restore_member_impl( Ok(()) } +/// Whether `membership` may read group→collection/user mappings. +/// +/// Two independent routes to the same data: the Manage Users / Manage Groups permissions, which is +/// what Bitwarden gates ReadAll on, and organization-wide collection reach, which is what released +/// Vaultwarden gated it on. Both are kept so a legacy Manager with "Manage all collections" still +/// reads these mappings after the migration converts them. The single-group view returns exactly this +/// data and so asks exactly this question. +async fn can_read_group_details(org_id: &OrganizationId, membership: &Membership, conn: &DbConn) -> bool { + membership.has_manage_users() + || membership.has_manage_groups() + || membership.has_full_access() + || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(org_id, &membership.uuid, conn).await) +} + async fn get_groups_data(details: bool, org_id: OrganizationId, membership: &Membership, conn: DbConn) -> JsonResult { - // The details view (group→collection/user mappings) needs full org access; the plain list only - // needs manage access to a collection, so a manager of a collection (directly or via a group) - // can load it to assign groups. - // Custom roles: the 'Manage Users'/'Manage Groups' permissions are the authority for reading the - // group mappings (they are what the route guards enforce for the details view), so they satisfy - // this check as well even when the member reaches no collection of their own. - let has_full_access = membership.has_full_access() - || (CONFIG.org_groups_enabled() - && GroupUser::has_full_access_by_member(&org_id, &membership.uuid, &conn).await); - let can_manage_users_or_groups = membership.has_manage_users() || membership.has_manage_groups(); + let can_read_details = can_read_group_details(&org_id, membership, &conn).await; + // The plain list (id, name, externalId) carries no access mappings, so it additionally opens to + // anyone who manages a single collection: they need the group names to assign groups to it. let allowed = if details { - has_full_access || can_manage_users_or_groups + can_read_details } else { - has_full_access - || can_manage_users_or_groups - || Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await + can_read_details || Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await }; if !allowed { err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); @@ -3138,12 +3173,12 @@ async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: get_groups_data(false, org_id, &headers.membership, conn).await } -// Security (audit M-1): group *details* expose accessAll, external IDs and collection mappings, so -// reading them requires the 'Manage Users' or 'Manage Groups' permission (or Admin/Owner), matching -// Bitwarden's ReadAll/ReadAllWithAccess authorization. +// Group *details* expose accessAll, external IDs and collection mappings. The condition is +// `can_read_group_details`, enforced in `get_groups_data`; keeping the guard loose and the condition +// in one place is what stops the list and single-group views from drifting apart, as they had. #[get("/organizations//groups/details", rank = 1)] -async fn get_groups_details(org_id: OrganizationId, headers: ManageUsersOrGroupsHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { +async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } get_groups_data(true, org_id, &headers.membership, conn).await @@ -3243,21 +3278,17 @@ async fn post_groups( None => false, }; - // Security: `access_all` grants the group access to every collection, so it is a - // collection-access grant just like assigning collections. A custom user without - // collection-management rights must not be able to create an access_all group. - // - // API consistency: reject instead of silently creating a group without the requested access, so a - // caller never believes it granted something the server dropped. A request that grants nothing - // (no access_all, no collections) is still accepted, which is what the plain "new group" dialog - // sends for such a caller. - if !caller_can_manage_collections { - if group_request.access_all { - err!("You don't have permission to create a group with access to all collections") - } - if !group_request.collections.is_empty() { - err!("You don't have permission to assign collections to a group") - } + // Security: creating an `access_all` group is reserved for Admins and Owners; `has_full_access()`, + // which `editAnyCollection` satisfies, is deliberately not enough. See `may_grant_access_all_group`. + if group_request.access_all && !may_grant_access_all_group(headers.membership_type) { + err!("Only Admins and Owners can create a group with access to all collections") + } + + // Assigning collections to a group is a collection-access grant. Rejected rather than silently + // created without it, so a caller never believes it granted something the server dropped; a request + // that grants nothing is still accepted, which is what the plain "new group" dialog sends. + if !caller_can_manage_collections && !group_request.collections.is_empty() { + err!("You don't have permission to assign collections to a group") } let group = group_request.to_group(&org_id); @@ -3322,11 +3353,15 @@ async fn put_group( None => false, }; - // API consistency: reject a collection-access change this caller may not make instead of - // answering 200 and silently keeping the old value — the same rule `edit_member` and - // `send_invite` follow. Only an actual difference is rejected (the regular group dialog echoes - // the current assignments back and has to keep working), and per-assignment flag differences - // (readOnly, hidePasswords, manage) stay ignored, exactly as for a member's assignments. + // Security: turning an ordinary group into an organization-wide one is the same durable grant as + // creating one. Clearing the flag is a reduction and keeps the rule below. + if group_request.access_all && !group.access_all && !may_grant_access_all_group(headers.membership_type) { + err!("Only Admins and Owners can give a group access to all collections") + } + + // API consistency: reject a collection-access change this caller may not make rather than answering + // 200 and keeping the old value, as `edit_member` and `send_invite` do. Only an actual difference is + // rejected, since the group dialog echoes the current assignments back; flag differences stay ignored. if !caller_can_manage_collections { if group_request.access_all != group.access_all { err!("You don't have permission to change a group's access to all collections") @@ -3345,55 +3380,118 @@ async fn put_group( let updated_group = group_request.update_group(group); + // Security (audit F-1): `add_update_group` asks this too, but it runs *after* the destructive + // `CollectionGroup::delete_all_by_group` below -- a refusal there answered 400 with the group's + // collection assignments already gone, revoking them for every member of the group. Ask it here, + // while nothing has been written. `updated_group.access_all` is the value this request leaves + // behind, which is what actually grants, and it is the same value the later check reads. + if !may_grant_access_all_group(headers.membership_type) + && adds_member_to_access_all_group(&updated_group, &group_request.users, &org_id, &conn).await + { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + if caller_can_manage_collections { CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?; } // NOTE: group membership is replaced (and access-gated) inside add_update_group. - log_event( - EventType::GroupUpdated as i32, - &updated_group.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; - // Only pass collection changes through if the caller is allowed to manage collections. let collections_to_apply = if caller_can_manage_collections { group_request.collections } else { Vec::new() }; - add_update_group( + let response = add_update_group( updated_group, collections_to_apply, group_request.users, - org_id, + org_id.clone(), &headers, &conn, caller_can_manage_collections, ) - .await + .await?; + + // Logged once the update has actually been applied. `add_update_group` still refuses a membership + // change the caller may not make, and an event written before it recorded an update that never + // happened. + log_event( + EventType::GroupUpdated as i32, + &group_id, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + + Ok(response) } /// Whether a caller may change (add OR remove) a member's membership in a group. /// -/// A caller who cannot manage collections must never touch a *collection-bearing* group's -/// membership: adding would indirectly grant collection access, removing would revoke it. -/// Callers who can manage collections may change any group's membership. This mirrors the -/// restriction already enforced inline in `put_group_members` and `add_update_group`. +/// A caller who cannot manage collections must never touch a collection-bearing group's membership: +/// adding grants those collections, removing revokes them. Organization-wide (`access_all`) groups +/// are a separate, stricter question — see [`may_grant_access_all_group`]. fn may_change_group_membership(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool { caller_can_manage_collections || !group_confers_collection_access } +/// Whether `caller_type` may hand out organization-wide group access: create a group carrying +/// `groups.access_all`, turn one into it, or add a member to one. +/// +/// Security: `access_all` reaches every collection and is not bound to the grantee's role, so it +/// survives their Custom permissions being cleared — a durable grant like a `users_collections.manage` +/// row, which `caller_may_grant_collection_manage` already reserves. Gating on `has_full_access()` +/// instead would let `manageGroups` + `editAnyCollection` mint one and outlive the flag it came from, +/// so no Custom permission satisfies this, `deleteAnyCollection` included. +/// +/// Only granting is restricted; removals, clearing the flag and deleting the group all reduce access. +/// Asked by `post_groups`, `put_group`, `add_update_group`, `put_group_members`, `edit_member` and +/// `send_invite` — the last needs it because a new membership has no current groups to diff against, +/// so every group in an invite is an addition. +/// +/// A missing call site is invisible to `cargo test`, so that list is the thing to check when a new +/// group path is added. So is *where* the call sits: it has to precede every write the request makes, +/// or a refusal leaves the endpoint half-applied (audit F-1). +fn may_grant_access_all_group(caller_type: MembershipType) -> bool { + caller_type >= MembershipType::Admin +} + +/// Whether `requested` would add at least one member that `current` does not already contain. +/// +/// Separates an addition to an access-all group (restricted) from a pure removal (always allowed) on +/// the endpoints that replace a whole member list. +fn adds_group_member(requested: &HashSet<&MembershipId>, current: &HashSet<&MembershipId>) -> bool { + !requested.is_subset(current) +} + +/// Whether replacing `group`'s member list with `members` adds someone to an organization-wide group. +/// +/// The question `may_grant_access_all_group` gates, resolved against the database. Shared by +/// `put_group` and `add_update_group` so the pre-write check and the one next to the write can never +/// answer differently. Reads `group.access_all` first, so an ordinary group costs no query. +async fn adds_member_to_access_all_group( + group: &Group, + members: &[MembershipId], + org_id: &OrganizationId, + conn: &DbConn, +) -> bool { + if !group.access_all { + return false; + } + + let current_members = GroupUser::find_by_group(&group.uuid, org_id, conn).await; + let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); + adds_group_member(&members.iter().collect(), ¤t) +} + /// Whether `requested` and `current` agree on every group that confers collection access. /// -/// A caller who may manage groups but not collections may only change memberships in groups that -/// confer no collection access. Anything else has to be rejected with an error rather than skipped -/// silently, so a save that appears to succeed never means something different on the server. +/// Such a change has to be rejected rather than skipped silently, so a save that appears to succeed +/// never means something different on the server. fn collection_bearing_membership_unchanged( requested: &HashSet, current: &HashSet, @@ -3406,12 +3504,10 @@ fn collection_bearing_membership_unchanged( /// Whether a caller of `edit_member` may change a member's role type. /// -/// Only Admins and Owners may change a member's role at all. A Custom member with `manage_users` -/// must not, because the role type changes organization-wide collection reach and which granular -/// permissions are effective. `manage_users` covers the user lifecycle, not the data plane, so -/// role changes are reserved for Admins/Owners. Leaving the role unchanged is always allowed so -/// `manage_users` members can still use the regular edit dialog. Admin/Owner transitions are -/// additionally governed by the dedicated Owner-only guard in `edit_member`. +/// The role type changes organization-wide collection reach and which granular permissions are +/// effective — the data plane, not the user lifecycle `manage_users` covers — so only Admins and +/// Owners may change it. An unchanged role is always allowed, so `manage_users` members can still use +/// the regular edit dialog; Admin/Owner transitions have their own guard in `edit_member`. fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool { caller_type >= MembershipType::Admin || new_type == current_atype } @@ -3436,13 +3532,10 @@ fn may_manage_stored_member_type(caller_type: MembershipType, target_atype: i32) /// Whether a caller may *provision* a membership of `target_type` — create it (invite), activate it /// (confirm) or remove it (delete). /// -/// This is deliberately stricter than [`may_manage_member_type`] and preserves the pre-existing -/// Vaultwarden rule that only Owners bring Admin (or Owner) memberships into or out of existence -/// ("Only Owners can invite Managers, Admins or Owners" / "Only Owners can delete Admins or Owners"). -/// `edit_member` keeps that boundary too, via its dedicated Owner-only guard on Admin/Owner role -/// transitions, so an Admin must not be able to route around it by inviting a fresh Admin instead. -/// State changes that leave the membership in place (reinvite, revoke, restore, edit) keep using -/// [`may_manage_member_type`], which is what Vaultwarden allowed for them before this feature. +/// Stricter than [`may_manage_member_type`], preserving the rule that only Owners bring Admin/Owner +/// memberships into or out of existence — `edit_member` guards the same boundary on role transitions, +/// so an Admin must not route around it by inviting a fresh Admin. State changes that leave the +/// membership in place keep using [`may_manage_member_type`]. fn may_provision_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { match caller_type { MembershipType::Owner => true, @@ -3457,6 +3550,38 @@ fn may_provision_stored_member_type(caller_type: MembershipType, target_atype: i .is_some_and(|target_type| may_provision_member_type(caller_type, target_type)) } +/// Whether a caller may act on a membership whose stored `atype` this build cannot interpret. +/// +/// Such a row (a future build, a partial rollback, a hand edit) holds no authority — `OrgHeaders` +/// refuses it and every permission flag on it is inert — but the helpers above fail closed on the +/// unknown value, which left nobody able to remove it either, unlike Vaultwarden. So: an Owner only, +/// and only for the two actions that reduce what the row can become. Editing, confirming, restoring +/// and reinviting keep refusing, because they preserve or reactivate a role the server cannot reason +/// about. +fn may_act_on_unknown_stored_member_type(caller_type: MembershipType) -> bool { + caller_type == MembershipType::Owner +} + +/// Whether a caller may delete `target_atype`. Provisioning rules for a role this build knows; +/// Owner-only for one it does not (see [`may_act_on_unknown_stored_member_type`]). +fn may_delete_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { + match MembershipType::from_i32(target_atype) { + Some(role) => may_provision_member_type(caller_type, role), + None => may_act_on_unknown_stored_member_type(caller_type), + } +} + +/// Whether a caller may revoke `target_atype`. Management rules for a role this build knows; +/// Owner-only for one it does not. Kept separate from [`may_delete_stored_member_type`] so revoking +/// keeps the looser actor/target matrix it has always had (an Admin may revoke a peer Admin, which +/// provisioning does not allow). +fn may_revoke_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { + match MembershipType::from_i32(target_atype) { + Some(role) => may_manage_member_type(caller_type, role), + None => may_act_on_unknown_stored_member_type(caller_type), + } +} + /// Returns true if being a member of `group_id` confers collection access — either because the /// group has `access_all` set, or because it has collections assigned. async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { @@ -3466,21 +3591,23 @@ async fn group_confers_collection_access(group_id: &GroupId, org_id: &Organizati } } +/// Returns true if `group_id` carries `groups.access_all`, i.e. membership of it reaches every +/// collection of the organization. A group that does not exist in this organization confers +/// nothing. See [`may_grant_access_all_group`] for why this is asked separately from +/// [`group_confers_collection_access`]. +async fn group_grants_access_to_all_collections(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { + Group::find_by_uuid_and_org(group_id, org_id, conn).await.is_some_and(|group| group.access_all) +} + /// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` / /// `collections_groups.manage`) on `col_id`. /// -/// 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 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. +/// Security: a `manage` grant is collection administration authority (`ManagerHeaders` accepts it via +/// `has_explicit_collection_manage_access`) and survives every later change to the grantee's role, so +/// a caller acting through `edit_any_collection` — revocable by clearing one flag — must not be able +/// to write one. Only blanket authority (Admin/Owner, or Custom with `delete_any_collection`) or a +/// real stored grant on that same collection qualifies. Strictly subtractive: it can only downgrade a +/// requested `manage` to false. 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). @@ -3542,11 +3669,9 @@ fn organization_report_scope(caller: &Membership) -> OrganizationReportScope { /// 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 -/// member may export at all; it must not decide *what* they get. Only members who already reach -/// every collection — Admins/Owners, and Custom members holding `edit_any_collection` — may receive -/// the full organization dump. For anyone else the export is built from their own assigned -/// collections/ciphers, so 'Access Import/Export' can never turn into a full vault read. +/// Security: the `AccessImportExportHeaders` guard decides whether a member may export at all, not +/// *what* they get. Only members who already reach every collection get the full dump; for anyone +/// else it is built from their own assignments, so the permission never becomes a full vault read. fn may_export_entire_organization(caller: &Membership) -> bool { caller.has_full_access() } @@ -3558,8 +3683,8 @@ fn may_export_entire_organization(caller: &Membership) -> bool { /// `Some(false)` -> the caller may never grant `manage` (unconfirmed, plain User, or unknown type). /// `None` -> depends on a real per-collection manage grant, resolved against the database. /// -/// Kept separate so the role gating — in particular that `edit_any_collection` alone yields `None` -/// (a DB check for a genuine grant) rather than `Some(true)` — is unit-testable without a DB. +/// Separate so the role gating — in particular `edit_any_collection` alone yielding `None` rather +/// than `Some(true)` — is unit-testable without a DB. fn caller_manage_grant_role_check(caller: &Membership) -> Option { if caller.can_delete_any_collection() { return Some(true); // Admin/Owner, or a Custom member holding delete_any_collection @@ -3582,15 +3707,21 @@ async fn add_update_group( conn: &DbConn, caller_can_manage_collections: bool, ) -> JsonResult { - // Security: assigning members to a group that grants collection access (via `access_all` or - // assigned collections) would indirectly grant those members access to the collections' contents, - // and removing them would revoke it. Only callers who can manage collections may change the - // membership of such a group. + // Security: membership of a collection-bearing group grants (or revokes) access to those + // collections' contents, so only a caller who can manage collections may change it. Rejected rather + // than silently ignored, and checked before the first write so a refused request leaves nothing + // behind. On create the group is brand new and this never triggers. // - // API consistency: reject a membership change this caller may not make instead of answering 200 - // and keeping the old membership — same rule as `edit_member`/`send_invite`. Checked before the - // first write so a rejected request leaves nothing behind. On create the group is brand new, so - // it grants no collection access yet and this never triggers. + // `group.access_all` is the value this request leaves behind, which is what actually grants: adding a + // member to it is Admin/Owner authority even for a caller who can manage collections. Removals stay + // under the collection rule below. `put_group` asks the same question before its own destructive + // delete (audit F-1); this call covers `post_groups`, where nothing has been written yet either. + if !may_grant_access_all_group(headers.membership_type) + && adds_member_to_access_all_group(&group, &members, &org_id, conn).await + { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + if !caller_can_manage_collections && (group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty()) { @@ -3604,10 +3735,9 @@ async fn add_update_group( group.save(conn).await?; - // Security (F-1): a `collections_groups.manage` grant carries collection delete authority, so a - // caller may only set it on a collection they could delete themselves. This stops a caller whose - // access derives from Edit-any-collection from creating a manage-bearing group and then joining - // it to reach Delete-any-collection. Fetched once; delete-capable callers keep `manage`. + // Security: a `collections_groups.manage` grant carries collection delete authority, so it may only + // be set on a collection the caller could delete themselves — otherwise Edit-any-collection reaches + // Delete-any-collection through a manage-bearing group the caller then joins. let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await; for col_selection in collections { let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); @@ -3658,24 +3788,26 @@ async fn add_update_group( }))) } -// Reads a single group's details (accessAll, externalId, collection mappings). This is the same -// data the `/groups/details` list endpoint returns, so it uses the same guard: Manage Users OR -// Manage Groups (or Admin/Owner). Requiring Manage Groups here while the list only requires Manage -// Users-or-Groups would let a manage_users member read every group's details in bulk but be denied -// the single-group view of the same data. +// Reads a single group's details (accessAll, externalId, collection mappings). This is the same data +// the `/groups/details` list endpoint returns, so it asks the same question — `can_read_group_details`. +// Any divergence would let a member read every group's details in bulk but be denied the single-group +// view of the same data, or the reverse. #[get("/organizations//groups//details")] async fn get_group_details( org_id: OrganizationId, group_id: GroupId, - headers: ManageUsersOrGroupsHeaders, + headers: ManagerHeadersLoose, conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } + if !can_read_group_details(&org_id, &headers.membership, &conn).await { + err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); + } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") @@ -3892,6 +4024,17 @@ async fn put_group_members( let assigned_members = data.into_inner(); + // Security: adding a member to an `access_all` group hands out durable organization-wide access, so + // it is Admin/Owner authority even for a caller who can manage collections. Removals stay allowed. + if group.access_all && !may_grant_access_all_group(headers.membership_type) { + let current_members = GroupUser::find_by_group(&group_id, &org_id, &conn).await; + let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); + let requested: HashSet<&MembershipId> = assigned_members.iter().collect(); + if adds_group_member(&requested, ¤t) { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + } + let org_memberships = Membership::find_by_org(&org_id, &conn).await; let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) { @@ -4329,13 +4472,16 @@ mod tests { use super::{ CollectionDetailsResponseScope, CustomRolePermissions, OrganizationImportTarget, OrganizationReportScope, - 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_delete_group, - may_export_entire_organization, may_import_to_collection, may_manage_member_type, - may_manage_stored_member_type, may_provision_member_type, may_provision_stored_member_type, - may_read_complete_collection_list, organization_report_scope, + adds_group_member, 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_delete_group, may_delete_stored_member_type, may_export_entire_organization, + may_grant_access_all_group, may_import_to_collection, may_manage_member_type, may_manage_stored_member_type, + may_provision_member_type, may_provision_stored_member_type, may_read_complete_collection_list, + may_revoke_stored_member_type, organization_report_scope, + }; + use crate::db::models::{ + Cipher, GroupId, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, }; - use crate::db::models::{Cipher, GroupId, Membership, MembershipStatus, MembershipType, OrganizationId}; fn confirmed_member(member_type: MembershipType) -> Membership { let mut m = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); @@ -4344,6 +4490,149 @@ mod tests { m } + /// Handing out `groups.access_all` is Admin/Owner authority: the grant keeps working after the + /// grantee's Custom permissions are cleared. + #[test] + fn only_admins_and_owners_may_hand_out_access_all_group_authority() { + assert!(may_grant_access_all_group(MembershipType::Owner)); + assert!(may_grant_access_all_group(MembershipType::Admin)); + assert!(!may_grant_access_all_group(MembershipType::Custom)); + assert!(!may_grant_access_all_group(MembershipType::User)); + } + + /// No Custom permission opens this, and in particular not the two that come closest: + /// `editAnyCollection` satisfies `has_full_access()` (which gates every other group operation) and + /// `deleteAnyCollection` is accepted for a per-collection `manage` row. + #[test] + fn no_custom_permission_grants_access_all_group_authority() { + let mut edit_any = confirmed_member(MembershipType::Custom); + edit_any.edit_any_collection = true; + assert!(edit_any.has_full_access(), "the escalation starts from a member who has full access"); + + let mut delete_any = confirmed_member(MembershipType::Custom); + delete_any.delete_any_collection = true; + assert_eq!( + caller_manage_grant_role_check(&delete_any), + Some(true), + "delete-any may still confer a per-collection manage grant" + ); + + // Neither, nor any combination, is organization-wide group authority. + assert!(!may_grant_access_all_group(MembershipType::Custom)); + } + + /// The invite path's group gate is `caller_can_manage_collections`, which `editAnyCollection` + /// satisfies, so this caller clears every precondition checked before the access-all rule. + #[test] + fn inviting_into_an_access_all_group_is_admin_only() { + let mut inviter = confirmed_member(MembershipType::Custom); + inviter.manage_users = true; + inviter.manage_groups = true; + inviter.edit_any_collection = true; + + // Everything `send_invite` checks before the access-all rule passes for this caller ... + assert!(inviter.has_manage_users(), "reaches send_invite at all"); + assert!(inviter.has_manage_groups(), "satisfies caller_can_manage_groups"); + assert!(inviter.has_full_access(), "satisfies caller_can_manage_collections"); + assert!( + may_provision_member_type(MembershipType::Custom, MembershipType::User), + "and may invite the one role a Custom member can provision" + ); + + // ... and the organization-wide group is still out of reach. + assert!(!may_grant_access_all_group(MembershipType::Custom)); + assert!(may_grant_access_all_group(MembershipType::Admin)); + assert!(may_grant_access_all_group(MembershipType::Owner)); + } + + /// Only *adding* to an access-all group is restricted; removals and an unchanged set are allowed. + #[test] + fn only_additions_to_an_access_all_group_are_restricted() { + let a: MembershipId = "member-a".to_owned().into(); + let b: MembershipId = "member-b".to_owned().into(); + let c: MembershipId = "member-c".to_owned().into(); + let current: HashSet<&MembershipId> = HashSet::from([&a, &b]); + + // unchanged, and pure removals + assert!(!adds_group_member(&HashSet::from([&a, &b]), ¤t)); + assert!(!adds_group_member(&HashSet::from([&a]), ¤t)); + assert!(!adds_group_member(&HashSet::new(), ¤t)); + + // any new member, including alongside a removal + assert!(adds_group_member(&HashSet::from([&a, &b, &c]), ¤t)); + assert!(adds_group_member(&HashSet::from([&c]), ¤t)); + assert!(adds_group_member(&HashSet::from([&a, &c]), ¤t)); + } + + /// The collection-bearing rule still admits any caller who can manage collections. + #[test] + fn the_collection_bearing_group_rule_is_unchanged() { + assert!(may_change_group_membership(true, true)); + assert!(!may_change_group_membership(false, true)); + assert!(may_change_group_membership(false, false)); + } + + /// An unparsable stored role holds no authority but still has to be removable: an Owner may delete + /// or revoke it, nobody else may touch it, and no reactivating action opens up for anyone. + #[test] + fn an_owner_may_remove_a_membership_with_an_unknown_stored_role() { + // 3 is the retired Manager wire value, which this build never persists; the rest are values + // no Vaultwarden release writes at all. + for unknown in [3, 5, -1, i32::MAX, i32::MIN] { + // 0, 1, 2 and 4 are the only values `MembershipType::from_i32` accepts. + assert!(![0, 1, 2, 4].contains(&unknown), "{unknown} must not be a known role"); + + assert!(may_delete_stored_member_type(MembershipType::Owner, unknown), "{unknown}"); + assert!(may_revoke_stored_member_type(MembershipType::Owner, unknown), "{unknown}"); + + for caller in [MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert!(!may_delete_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); + assert!(!may_revoke_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); + } + + // Editing, confirming, restoring and reinviting all still refuse, for every caller. + for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert!(!may_manage_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); + assert!( + !may_provision_stored_member_type(caller, unknown), + "caller={} target={unknown}", + caller as i32 + ); + } + } + } + + /// For a role this build knows, delete keeps the provisioning matrix and revoke the looser + /// management one. + #[test] + fn known_roles_keep_their_existing_delete_and_revoke_matrices() { + for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert_eq!( + may_delete_stored_member_type(caller, target as i32), + may_provision_member_type(caller, target), + "delete: caller={} target={}", + caller as i32, + target as i32 + ); + assert_eq!( + may_revoke_stored_member_type(caller, target as i32), + may_manage_member_type(caller, target), + "revoke: caller={} target={}", + caller as i32, + target as i32 + ); + } + } + + // The place the two matrices differ, kept intact: an Admin may revoke a peer Admin but may + // not delete one, while an Owner may do both. + assert!(may_revoke_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); + assert!(!may_delete_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); + assert!(may_revoke_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32)); + assert!(may_delete_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32)); + } + #[test] fn bulk_collection_details_only_include_acls_for_manage_authority() { // Ordinary collection assignment, including group access_all, keeps the collection metadata @@ -4695,7 +4984,7 @@ mod tests { ("deleteAnyCollection".to_owned(), json!(delete)), ]); - let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions); + let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions).unwrap(); assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}"); assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}"); assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}"); @@ -4704,22 +4993,25 @@ mod tests { } } + const KNOWN_PERMISSION_KEYS: [&str; 9] = [ + "manageUsers", + "manageGroups", + "managePolicies", + "createNewCollections", + "editAnyCollection", + "deleteAnyCollection", + "accessEventLogs", + "accessImportExport", + "accessReports", + ]; + #[test] - fn custom_permission_parser_is_strict_and_non_custom_roles_are_fail_closed() { - let permissions = HashMap::from([ - ("manageUsers".to_owned(), Value::String("true".to_owned())), - ("manageGroups".to_owned(), json!(true)), - ("managePolicies".to_owned(), json!(true)), - ("createNewCollections".to_owned(), json!(true)), - ("editAnyCollection".to_owned(), json!(true)), - ("deleteAnyCollection".to_owned(), json!(true)), - ("accessEventLogs".to_owned(), json!(true)), - ("accessImportExport".to_owned(), json!(true)), - ("accessReports".to_owned(), json!(true)), - ]); + fn custom_permission_parser_accepts_only_booleans_and_non_custom_roles_are_fail_closed() { + let all_true: HashMap = + KNOWN_PERMISSION_KEYS.iter().map(|key| ((*key).to_owned(), json!(true))).collect(); - let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions); - assert!(!custom.manage_users, "string values must not be accepted as booleans"); + let custom = CustomRolePermissions::from_request(MembershipType::Custom, &all_true).unwrap(); + assert!(custom.manage_users); assert!(custom.manage_groups); assert!(custom.manage_policies); assert!(custom.create_new_collections); @@ -4729,15 +5021,83 @@ mod tests { assert!(custom.access_import_export); assert!(custom.access_reports); - let user = CustomRolePermissions::from_request(MembershipType::User, &permissions); + let user = CustomRolePermissions::from_request(MembershipType::User, &all_true).unwrap(); assert_eq!(user, CustomRolePermissions::default()); assert!(!user.grants_full_collection_access(MembershipType::User)); - let admin = CustomRolePermissions::from_request(MembershipType::Admin, &permissions); + let admin = CustomRolePermissions::from_request(MembershipType::Admin, &all_true).unwrap(); assert_eq!(admin, CustomRolePermissions::default()); assert!(admin.grants_full_collection_access(MembershipType::Admin)); } + /// A known key carrying anything other than a JSON boolean is a malformed request. It used to be + /// read as `false`, which turned a client bug into a silent permission removal. + #[test] + fn a_known_permission_with_a_non_boolean_value_is_rejected() { + let bad_values = [ + json!("true"), + json!("false"), + json!(""), + json!(1), + json!(0), + json!(1.5), + Value::Null, + json!({}), + json!([]), + json!(["manageUsers"]), + ]; + + for key in KNOWN_PERMISSION_KEYS { + for value in &bad_values { + let permissions = HashMap::from([(key.to_owned(), value.clone())]); + for member_type in + [MembershipType::Custom, MembershipType::User, MembershipType::Admin, MembershipType::Owner] + { + assert!( + CustomRolePermissions::from_request(member_type, &permissions).is_err(), + "{key} = {value} must be rejected for {}", + member_type as i32 + ); + } + + let membership = confirmed_member(MembershipType::Custom); + assert!( + CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&permissions), &membership) + .is_err(), + "{key} = {value} must be rejected on the edit path" + ); + } + + // ... while both booleans stay valid for the same key. + for value in [true, false] { + let permissions = HashMap::from([(key.to_owned(), json!(value))]); + let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions) + .expect("a boolean is always valid"); + assert_eq!(parsed != CustomRolePermissions::default(), value, "{key} = {value}"); + } + } + } + + /// Bitwarden already sends permission keys Vaultwarden does not implement (`manageSso`, + /// `manageScim`, `manageResetPassword`) and may add more. Unknown keys stay ignored, whatever + /// they contain, so the strictness above cannot break a newer client. + #[test] + fn unknown_permission_keys_are_ignored_whatever_they_contain() { + let permissions = HashMap::from([ + ("manageUsers".to_owned(), json!(true)), + ("manageSso".to_owned(), Value::String("yes".to_owned())), + ("manageScim".to_owned(), Value::Null), + ("manageResetPassword".to_owned(), json!(0)), + ("someFuturePermission".to_owned(), json!({"nested": true})), + ]); + + let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions) + .expect("unknown keys must not make a request invalid"); + assert!(parsed.manage_users); + assert!(!parsed.manage_groups); + assert!(!parsed.edit_any_collection); + } + #[test] fn custom_permission_change_detection_covers_collection_flags() { let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); @@ -4778,7 +5138,7 @@ mod tests { membership.access_import_export = true; membership.access_reports = true; - let preserved = CustomRolePermissions::from_edit_request(MembershipType::Custom, None, &membership); + let preserved = CustomRolePermissions::from_edit_request(MembershipType::Custom, None, &membership).unwrap(); assert!(preserved.manage_users); assert!(preserved.manage_groups); assert!(preserved.manage_policies); @@ -4792,11 +5152,12 @@ mod tests { let explicit_reset = HashMap::new(); assert_eq!( - CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&explicit_reset), &membership), + CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&explicit_reset), &membership) + .unwrap(), CustomRolePermissions::default() ); assert_eq!( - CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership), + CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap(), CustomRolePermissions::default() ); } @@ -4814,7 +5175,7 @@ mod tests { membership.access_import_export = true; membership.access_reports = true; - let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership); + let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap(); assert_eq!(requested, CustomRolePermissions::default()); assert!(!requested.differs_from(&membership)); diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 8869d23d..20c28c1e 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -214,8 +214,12 @@ pub async fn enforce_2fa_policy_for_org( ) -> EmptyResult { let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); for member in Membership::find_confirmed_by_org(org_id, conn).await { - // Don't enforce the policy for Admins and Owners. - if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { + // Don't enforce the policy for Admins and Owners, nor for the member who just enabled it -- + // see `Membership::is_policy_enforcement_target`. Every other non-compliant member is + // revoked exactly as before. + if member.is_policy_enforcement_target(act_user_id) + && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() + { if CONFIG.mail_enabled() { let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap(); mail::send_2fa_removed_from_org(&user.email, &org.name).await?; diff --git a/src/auth.rs b/src/auth.rs index 9c9bd7cc..20c6d64a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -749,15 +749,6 @@ impl OrgHeaders { fn can_manage_policies(&self) -> bool { self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_policies()) } - // Reading the full member/group *details* (PII, 2FA status, permission flags, access mappings) - // requires the ability to manage users or groups, matching Bitwarden's `ReadAll`/`ReadAllWithAccess` - // authorization. Basic member mini-details and the plain group list remain member-readable. - fn can_manage_users_or_groups(&self) -> bool { - self.is_confirmed() - && (self.membership_type >= MembershipType::Admin - || self.membership.has_manage_users() - || self.membership.has_manage_groups()) - } fn can_access_event_logs(&self) -> bool { self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_access_event_logs()) @@ -766,11 +757,10 @@ impl OrgHeaders { self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export()) } - // NOTE: there is deliberately no `can_access_reports` guard helper. Vaultwarden has no - // server-side report endpoints — the clients compute every report locally from the - // organization cipher list — so `accessReports` is enforced inline where that list is served - // (`get_org_details`), not through a request guard. A guard here would be dead code that - // invites gating an endpoint on "may call reports" instead of "may read these ciphers". + // NOTE: no `can_access_reports` helper on purpose. Vaultwarden has no server-side report endpoints -- + // clients compute reports from the organization cipher list -- so `accessReports` is enforced where + // that list is served (`get_org_details`). A guard here would invite gating an endpoint on "may call + // reports" instead of "may read these ciphers". } // org_id is usually the second path param ("/organizations/"), @@ -956,11 +946,9 @@ generate_manage_headers!( can_manage_policies, "You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint" ); -generate_manage_headers!( - ManageUsersOrGroupsHeaders, - can_manage_users_or_groups, - "You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint" -); +// NOTE: no `ManageUsersOrGroupsHeaders`. Reading group *details* is not a single-permission question +// -- organization-wide collection reach grants it too -- so both routes take `ManagerHeadersLoose` +// and ask `can_read_group_details`. The full *member* list is, and keeps `ManageUsersHeaders`. generate_manage_headers!( AccessEventLogsHeaders, can_access_event_logs, @@ -1029,15 +1017,12 @@ 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. +/// manage-based deletion on that setting being *off*: 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 make the three collection permissions depend on each other — `Create new +/// collections` alone receives an automatic `manage` row for the collection it just created, and +/// could then delete it. A Manage grant keeps its full meaning for editing (`collection_edit_access`); +/// it just is not a delete permission. fn collection_delete_access(membership: &Membership) -> CollectionManageAccess { if !membership.has_status(MembershipStatus::Confirmed) { return CollectionManageAccess::Denied; @@ -1065,16 +1050,13 @@ async fn can_manage_collection( } } -/// Whether `membership` may edit (rewrite the access of) `collection_uuid`, using exactly the same -/// Custom-aware rules as the path-based `ManagerHeaders` guard (`collection_edit_access`): Edit any -/// collection (or Admin/Owner) may edit every collection, otherwise only collections on which the -/// member holds a real per-collection Manage grant. In particular, a Custom member's membership or -/// group `access_all` does NOT satisfy this — it must be an explicit `users_collections.manage` / -/// `collections_groups.manage` assignment, exactly as an in-path collection edit would require. +/// Whether `membership` may edit (rewrite the access of) `collection_uuid`, on exactly the same rules +/// as the path-based `ManagerHeaders` guard: Edit-any (or Admin/Owner) reaches every collection, +/// otherwise only those carrying a real per-collection Manage grant. Group `access_all` deliberately +/// does not qualify. /// -/// Body-param endpoints (e.g. bulk collection access) take collection ids in the request body and -/// therefore cannot use `ManagerHeaders`; they must run this per collection to stay consistent with -/// the single-collection edit endpoint. +/// Body-param endpoints take collection ids in the request body and so cannot use `ManagerHeaders`; +/// they run this per collection instead, so the two cannot diverge. pub(crate) async fn can_edit_collection( membership: &Membership, collection_uuid: &CollectionId, @@ -1085,9 +1067,8 @@ pub(crate) async fn can_edit_collection( /// 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. +/// The same rule as `CollectionReadHeaders`: Admin/Owner, Edit-any/Delete-any, or a real +/// per-collection Manage assignment. Ordinary read access and group `access_all` do not qualify. pub(crate) async fn can_read_collection_access( membership: &Membership, collection_uuid: &CollectionId, @@ -1714,13 +1695,10 @@ mod tests { #[test] 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`. + // A flagless Custom member gets no blanket collection authority from its role. Edit and read are + // answered per collection by `has_explicit_collection_manage_access`, which accepts a real manage + // grant and nothing else -- a group's `access_all` is not one. Delete has no per-collection fallback + // at all, hence 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); diff --git a/src/config.rs b/src/config.rs index d5b50146..85f1ecca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -750,6 +750,11 @@ make_config! { /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely db_connection_retries: u32, false, def, 15; + /// Legacy User access_all migration |> What the Custom-role migration does with a plain User membership that still carries the legacy access_all flag. + /// "refuse" stops startup and prints the recovery procedure, "drop" clears the flag, "materialize" writes the reach out as explicit collection + /// assignments (confirmed memberships only) and then clears it. Only read while that migration is pending. + legacy_user_access_all_migration: String, false, def, "refuse".to_owned(); + /// Timeout when acquiring database connection database_timeout: u64, false, def, 30; @@ -970,6 +975,13 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } + if crate::db::LegacyUserAccessAllPolicy::from_config(&cfg.legacy_user_access_all_migration).is_none() { + err!(format!( + "Invalid LEGACY_USER_ACCESS_ALL_MIGRATION value `{}`, expected `refuse`, `drop` or `materialize`", + cfg.legacy_user_access_all_migration + )); + } + if cfg.password_iterations < 100_000 { err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); } diff --git a/src/db/mod.rs b/src/db/mod.rs index e89cf204..bbb0a275 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -468,168 +468,218 @@ impl<'r> FromRequest<'r> for DbConn { } } -const CUSTOM_ROLE_REPAIR_MIGRATION: &str = "20260723120000"; -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. +/// The single migration this feature adds. /// -/// 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. +/// This section exists because some database states cannot be converted without a decision that +/// belongs to an owner. The migration file refuses them itself as a backstop, but Diesel surfaces +/// only the driver-level duplicate-key error that refusal produces; the preflight evaluates the same +/// predicates first, so the operator gets the question and the way out instead. +const CUSTOM_ROLE_PERMISSIONS_MIGRATION: &str = "20260630120000"; + +/// The nine permission columns the migration adds. +const CUSTOM_ROLE_PERMISSION_COLUMNS: [&str; 9] = [ + "manage_users", + "manage_groups", + "manage_policies", + "create_new_collections", + "edit_any_collection", + "delete_any_collection", + "access_event_logs", + "access_import_export", + "access_reports", +]; + +/// Every column `users_organizations` has once the migration has run, and nothing else. /// -/// 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. +/// A fingerprint, not a schema definition: a table carrying exactly these eighteen names is the one +/// this migration produces. One column more or fewer and nothing may be inferred about it. +const EXPECTED_MEMBERSHIP_COLUMNS: [&str; 18] = [ + "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", +]; + +/// The one-line reason the Custom-role preflight refused to start, once it has. /// -/// 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"; +/// The refusal is deterministic -- it reads schema and ledger state no retry can change -- so +/// `create_db_pool` stops immediately instead of retrying it as a connection problem and repeating +/// the whole recovery procedure each time. It also gives the startup path a plain sentence to print: +/// `Error`'s `Display` renders the JSON API body and its `Debug` escapes newlines. +static CUSTOM_ROLE_PREFLIGHT_REFUSAL: OnceLock = OnceLock::new(); + +/// Why startup was stopped by the Custom-role preflight, if it was. `None` means the database was +/// simply not reachable (yet), which is worth retrying. +pub fn custom_role_preflight_refusal() -> Option<&'static str> { + CUSTOM_ROLE_PREFLIGHT_REFUSAL.get().map(String::as_str) +} -/// One of the three groups of granular permission columns, each added by its own migration. +/// What to do with a legacy `User + access_all` membership, from `LEGACY_USER_ACCESS_ALL_MIGRATION`. /// -/// A partially present group means the migration was interrupted between its `ALTER TABLE` -/// statements. On MySQL/MariaDB that is reachable because DDL commits implicitly, so the ledger entry -/// can be missing while some columns already exist; re-running the migration then fails forever with -/// `Duplicate column name`. Detect it and hand the operator an unambiguous fix instead. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PermissionColumnGroup { - Manage, - Collection, - Access, +/// The bit is a state official Vaultwarden wrote: until upstream commit `0d16da44` both the invite +/// and the edit endpoint stored a client-supplied `access_all` regardless of the role requested. It +/// has no representation in the new model -- it granted dynamic read/write reach over every +/// collection, present and future, and nothing else -- so which meaning to keep is a decision about +/// that member's access, not something the upgrade can infer. +/// +/// Refusing stays the default; the other two let an owner take that decision once for the instance +/// instead of hand-writing SQL per membership. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyUserAccessAllPolicy { + /// Stop and print the recovery procedure. + #[default] + Refuse, + /// The reach is no longer wanted: clear the bit. Explicit assignments are kept. + Drop, + /// The reach has to survive: write it out as explicit assignments, then clear the bit. + Materialize, } -impl PermissionColumnGroup { - const fn migration(self) -> &'static str { - match self { - Self::Manage => CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION, - Self::Collection => CUSTOM_COLLECTION_PERMISSIONS_MIGRATION, - Self::Access => CUSTOM_ACCESS_PERMISSIONS_MIGRATION, - } - } - - /// SQL list literal of the group's column names, for the `IN (...)` lookups. - const fn column_list(self) -> &'static str { - match self { - Self::Manage => "'manage_users', 'manage_groups', 'manage_policies'", - Self::Collection => "'create_new_collections', 'edit_any_collection', 'delete_any_collection'", - Self::Access => "'access_event_logs', 'access_import_export', 'access_reports'", - } - } - - const fn description(self) -> &'static str { - match self { - Self::Manage => "custom management-permission", - Self::Collection => "custom collection-permission", - Self::Access => "custom access-permission", +impl LegacyUserAccessAllPolicy { + pub fn from_config(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "refuse" => Some(Self::Refuse), + "drop" => Some(Self::Drop), + "materialize" => Some(Self::Materialize), + _ => None, } } - /// 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) + /// An unparsable value cannot reach here -- `validate_config` rejects it at startup -- but + /// falling back to the refusal keeps the failure mode closed rather than silently permissive. + fn configured() -> Self { + Self::from_config(&CONFIG.legacy_user_access_all_migration()).unwrap_or_default() } } -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). 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", - "WHERE table_name = 'users_organizations'\n", - " AND column_name IN ('manage_users', 'manage_groups', 'manage_policies',\n", - " 'create_new_collections', 'edit_any_collection', 'delete_any_collection',\n", - " 'access_event_logs', 'access_import_export', 'access_reports');\n\n", - "(On SQLite: SELECT name FROM pragma_table_info('users_organizations');)\n\n", - "Then, with every Vaultwarden instance stopped and a backup taken, drop exactly the columns of ", - "the affected group that the message above names, e.g.:\n", - "ALTER TABLE users_organizations DROP COLUMN ;\n\n", - "Afterwards restart Vaultwarden so the migration applies the whole group in one go." -); +/// The migration's own last statement. +/// +/// `RecordCompletedMigration` has to run it too. That path records a migration whose schema changes +/// all committed but whose ledger entry did not, and Diesel then skips the file entirely -- so +/// nothing else would ever execute the statements after the last `ALTER TABLE`. An acknowledgement +/// surviving a repaired upgrade would let a later revert run without fresh consent, which is exactly +/// what the migration drops it to prevent. +const DROP_DOWNGRADE_ACK_SQL: &str = "DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade"; + +/// Whether this backend commits a migration's schema statements one at a time, so an interrupted +/// upgrade can leave the migration half-applied. +/// +/// MySQL and MariaDB do: every `ALTER TABLE` implicitly commits. SQLite and PostgreSQL run the whole +/// migration in one transaction, so a half-applied schema there was not produced by an interruption +/// and nothing may be resumed on the assumption this migration was the only writer. +type InterruptibleSchemaChanges = bool; -/// Deliberately *not* the same advice as [`PARTIAL_PERMISSION_COLUMNS_RECOVERY`]. +/// The migration's Manager -> Custom conversion, replayed when an interrupted upgrade is resumed. /// -/// 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." -); +/// Character for character the `UPDATE` in +/// `migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql`. Idempotent for the same +/// reason it is safe there -- it matches only `atype = 3` and leaves none behind -- which is what +/// lets one recovery path cover *both* interruption points without telling them apart. It reads +/// `access_all`, so it must run before that column is dropped. +#[cfg(mysql)] +const CUSTOM_ROLE_MANAGER_CONVERSION_SQL: &str = "\ +UPDATE users_organizations \ +SET create_new_collections = access_all, \ + edit_any_collection = access_all \ + OR 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 \ + ), \ + delete_any_collection = access_all \ + OR 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 \ + ), \ + atype = 4 \ +WHERE atype = 3"; + +/// The migration's final schema statement. +#[cfg(mysql)] +const DROP_ACCESS_ALL_SQL: &str = "ALTER TABLE users_organizations DROP COLUMN access_all"; + +/// What an interrupted upgrade still owes, in order -- exactly what the migration file does from its +/// `UPDATE` onwards. The caller records the ledger entry afterwards. Gated on MySQL, the only backend +/// a resume is reachable on. +#[cfg(mysql)] +const CUSTOM_ROLE_RESUME_STATEMENTS: [&str; 3] = + [CUSTOM_ROLE_MANAGER_CONVERSION_SQL, DROP_ACCESS_ALL_SQL, DROP_DOWNGRADE_ACK_SQL]; -/// Recovery for a damaged collection-permission group *after* `access_all` has been dropped. +/// Relax the direct assignments of an affected membership before the bit goes away. /// -/// 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." -); +/// `access_all` *overrode* `read_only` and `hide_passwords`: a member carrying it reached every +/// collection read/write with passwords visible even where an explicit row said otherwise. Inserting +/// only the missing rows would therefore quietly downgrade every collection the member was also +/// explicitly assigned to. `manage` is deliberately untouched -- `access_all` never conferred it, +/// and an existing grant is an independent decision. +const LEGACY_USER_ACCESS_ALL_RELAX_SQL: &str = "\ +UPDATE users_collections \ +SET read_only = FALSE, hide_passwords = FALSE \ +WHERE EXISTS ( \ + SELECT 1 \ + FROM users_organizations uo \ + INNER JOIN collections c ON c.org_uuid = uo.org_uuid \ + WHERE uo.user_uuid = users_collections.user_uuid \ + AND c.uuid = users_collections.collection_uuid \ + AND uo.atype = 2 \ + AND uo.access_all = TRUE \ + AND uo.status = 2 \ +)"; + +/// Write the reach out as explicit assignments. +/// +/// Confirmed memberships only: a `users_collections` row is not bound to the membership status the +/// way `access_all` was, so materialising an invited, accepted or revoked membership would hand it +/// durable assignments it does not have today. Those only lose the bit. +const LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL: &str = "\ +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 uo \ +INNER JOIN collections c ON c.org_uuid = uo.org_uuid \ +WHERE uo.atype = 2 \ + AND uo.access_all = TRUE \ + AND uo.status = 2 \ + AND NOT EXISTS ( \ + SELECT 1 FROM users_collections uc \ + WHERE uc.user_uuid = uo.user_uuid \ + AND uc.collection_uuid = c.uuid \ + )"; + +/// Clear the bit on every affected membership, whatever its status. Always the last statement: the +/// two above select on it. +const LEGACY_USER_ACCESS_ALL_CLEAR_SQL: &str = + "UPDATE users_organizations SET access_all = FALSE WHERE atype = 2 AND access_all = TRUE"; const LEGACY_USER_ACCESS_ALL_RECOVERY: &str = concat!( - "\n\nList the affected memberships:\n", + "\n\nThe same decision applies to every affected membership on this instance, so it can also be ", + "taken once, without any SQL, by setting LEGACY_USER_ACCESS_ALL_MIGRATION before the next start:\n", + " drop clear the bit. Each member keeps the collections they are explicitly assigned\n", + " to and loses the organization-wide reach.\n", + " materialize write the reach out as explicit assignments first, then clear the bit. Confirmed\n", + " memberships only; the others are treated as 'drop'.\n", + "Both are applied before the migration touches anything, and the setting is inert afterwards.\n\n", + "To decide per membership instead, list them:\n", "SELECT uuid, user_uuid, org_uuid, status\n", "FROM users_organizations\n", "WHERE atype = 2\n", @@ -647,6 +697,17 @@ const LEGACY_USER_ACCESS_ALL_RECOVERY: &str = concat!( "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", + "access_all overrode read_only and hide_passwords, so the collections the member is *already* ", + "assigned to have to be relaxed as well -- otherwise they come out of the upgrade with less access ", + "than they have now. Run both statements, in this order:\n", + "UPDATE users_collections\n", + "SET read_only = FALSE, hide_passwords = FALSE\n", + "WHERE user_uuid = (SELECT user_uuid FROM users_organizations WHERE uuid = '')\n", + " AND collection_uuid IN (\n", + " SELECT c.uuid FROM collections c\n", + " INNER JOIN users_organizations uo ON uo.org_uuid = c.org_uuid\n", + " WHERE uo.uuid = ''\n", + " );\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", @@ -656,685 +717,357 @@ const LEGACY_USER_ACCESS_ALL_RECOVERY: &str = concat!( " 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, ", - "where it is reachable because DDL commits implicitly. On this backend DDL is transactional, so ", - "the state points at a manual schema change. With every Vaultwarden instance stopped and a ", - "backup taken, record the migration:\n", - "INSERT INTO __diesel_schema_migrations (version) VALUES ('20260724120000');\n\n", - "Afterwards restart Vaultwarden so the remaining migrations run." -); - -const ACCESS_ALL_DROP_MISMATCH_RECOVERY: &str = concat!( - "\n\nThis state cannot arise from a normal upgrade -- the column is removed before the migration ", - "is recorded. Verify whether the column was re-added manually. If it was, and its values are no ", - "longer needed, drop it again with every Vaultwarden instance stopped and a backup taken:\n", - "ALTER TABLE users_organizations DROP COLUMN access_all;\n\n", - "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 AMBIGUOUS_PARTIAL_MIGRATION_RECOVERY: &str = concat!( + "\n\nSome of the columns this migration adds already exist, so a previous attempt changed the ", + "table -- but the result is not the schema an interrupted run leaves behind, so how far it got ", + "cannot be established and finishing it would run the conversion against a table this build does ", + "not recognise.\n\n", + "An interruption is resumed automatically, and only on MySQL and MariaDB, where each ALTER TABLE ", + "commits on its own. It requires all of:\n", + " * all nine Custom-role permission columns present and NOT NULL\n", + " * users_organizations carrying exactly the eighteen expected columns plus access_all\n", + " * a migration ledger that exists and records nothing newer than this migration\n", + " * no plain User membership still carrying access_all\n\n", + "On SQLite and PostgreSQL the whole migration runs inside one transaction, so it cannot stop ", + "half-way: this schema was produced by something else and is never resumed.\n\n", + "Restore the backup taken before the schema was changed and start the upgrade again. If the ", + "database was rolled back with tools/custom_role_rollback/, run that script to completion first -- ", + "it restores the column set and the ledger together." ); -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." +const MISSING_ACCESS_ALL_RECOVERY: &str = concat!( + "\n\nThe upgrade derives every Custom collection permission from that column, so it cannot run ", + "without it, and neither of the two questions above it can be answered.\n\n", + "One way to reach this state *is* recoverable and is repaired automatically: on MySQL and ", + "MariaDB every ALTER TABLE commits on its own, so a process that dies after the migration's ", + "final DROP COLUMN and before Diesel records the migration leaves a database that is already ", + "fully converted and only missing its ledger row. That is not this database -- the checks below ", + "did not all pass, so the schema is not the one the completed migration produces and nothing may ", + "be assumed about how far it got:\n", + " * all nine Custom-role permission columns present and NOT NULL\n", + " * users_organizations carrying exactly the eighteen expected columns\n", + " * no membership left on the legacy Manager role (atype = 3)\n", + " * a migration ledger that exists and records nothing newer than this migration\n\n", + "If the database was rolled back with tools/custom_role_rollback/, run that script to completion ", + "-- it restores the column and the ledger together. Otherwise restore the backup taken before the ", + "schema was changed and start again from there." ); +/// What the preflight reads. All of it comes from the schema and the migration ledger. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -#[expect( - clippy::struct_excessive_bools, - reason = "These are independent facts read from a historical database schema and migration ledger" -)] +// Each field is an independent observation about the database, not a mode: they are combined by +// `custom_role_preflight_decision` and `custom_role_migration_is_complete`, which is exactly what +// the lint would have them replaced by. +#[allow(clippy::struct_excessive_bools)] struct CustomRoleMigrationFacts { memberships_table_exists: bool, - migration_table_exists: bool, + /// {`CUSTOM_ROLE_PERMISSIONS_MIGRATION`} is recorded, i.e. this database is already upgraded. + migration_applied: bool, access_all_column_exists: bool, - manage_permission_columns: i64, - manage_permissions_migration_applied: bool, - collection_permission_columns: i64, - collection_permissions_migration_applied: bool, - access_permission_columns: i64, - access_permissions_migration_applied: bool, - repair_migration_applied: bool, - access_all_drop_migration_applied: bool, 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, + /// The migration ledger table exists, so a missing entry means "not recorded" rather than + /// "nowhere to look". + migration_ledger_exists: bool, + /// How many of [`CUSTOM_ROLE_PERMISSION_COLUMNS`] exist, and how many of those are NOT NULL. + permission_columns_present: i64, + permission_columns_not_null: i64, + /// Total number of columns on `users_organizations`, and how many of them are names from + /// [`EXPECTED_MEMBERSHIP_COLUMNS`]. Both have to equal the expected count: the first rules out a + /// column this build knows nothing about, the second rules out a missing one. + membership_column_count: i64, + expected_membership_columns_present: i64, + /// Memberships still carrying the legacy persisted Manager role. + legacy_manager_rows: i64, + /// A migration newer than the Custom-role one is recorded. Diesel applies migrations in order, + /// so this can only mean the ledger was edited or the binary is older than the database. + newer_migration_recorded: bool, } -impl CustomRoleMigrationFacts { - /// `(columns present, migration recorded)` for one permission column group. - const fn permission_columns(self, group: PermissionColumnGroup) -> (i64, bool) { - match group { - PermissionColumnGroup::Manage => { - (self.manage_permission_columns, self.manage_permissions_migration_applied) - } - PermissionColumnGroup::Collection => { - (self.collection_permission_columns, self.collection_permissions_migration_applied) - } - PermissionColumnGroup::Access => { - (self.access_permission_columns, self.access_permissions_migration_applied) - } - } - } +/// Whether the facts prove that the Custom-role migration ran to completion and only its ledger +/// entry is missing. +/// +/// A conjunction rather than "the legacy column is gone": dropping `access_all` is the migration's +/// *last* schema statement, so its absence alone is also what a hand-edited or half-rolled-back +/// database looks like, and recording the migration there would start the server against a schema +/// the code does not match. Each condition rules out one way of arriving here with the column gone: +/// +/// * a column dropped by hand fails the permission-column checks; +/// * a partially applied later schema change fails the exact-column-count check; +/// * a half-finished conversion still has `atype = 3` rows; +/// * a tampered ledger fails the newer-migration check. +fn custom_role_migration_is_complete(facts: CustomRoleMigrationFacts) -> bool { + let counted = |count: i64, expected: usize| usize::try_from(count).is_ok_and(|found| found == expected); + + facts.memberships_table_exists + && !facts.migration_applied + && !facts.access_all_column_exists + && facts.migration_ledger_exists + && !facts.newer_migration_recorded + && counted(facts.permission_columns_present, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.permission_columns_not_null, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.membership_column_count, EXPECTED_MEMBERSHIP_COLUMNS.len()) + && counted(facts.expected_membership_columns_present, EXPECTED_MEMBERSHIP_COLUMNS.len()) + && facts.legacy_manager_rows == 0 } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CustomRolePreflightDecision { Proceed, - CompleteMysqlCollectionMigration, - CompleteInterruptedAccessAllDrop, - RefuseAlreadyDropped, + /// The migration finished but its ledger entry never committed. Record it and continue. + RecordCompletedMigration, + /// The migration got as far as adding its columns -- and possibly as far as converting the + /// legacy Managers -- but not to the end. Finish it, then record it. + ResumeInterruptedMigration, + /// Clear the legacy `User + access_all` bit, then continue. + DropLegacyUserAccessAll, + /// Write the reach of a confirmed legacy `User + access_all` membership out as explicit + /// assignments, clear the bit, then continue. + MaterializeLegacyUserAccessAll, RefuseMissingAccessAll, - RefuseMissingMigrationLedger, RefuseLegacyUserAccessAll, - RefuseUnverifiedCustomRoleHistory, - RefuseUnconfirmedPermanentCollectionAuthority, - RefuseInterruptedAccessAllDrop, - RefuseAccessAllDropLedgerMismatch, - RefuseOutOfOrderAccessPermissionsMigration, - RefusePartialPermissionSchema(PermissionColumnGroup), - RefusePermissionLedgerMismatch(PermissionColumnGroup), + /// Some of the migration's columns exist while it is still unrecorded, but the schema is not the + /// one an interrupted run leaves behind. Nothing may be assumed about how far it got. + RefuseAmbiguousPartialMigration, +} + +/// Whether the facts prove the migration was interrupted after it added its columns, leaving a +/// schema that can be finished rather than restored from a backup. +/// +/// An exact fingerprint of the one state an interrupted run produces, not "some of the columns are +/// there": any other shape means something other than this migration changed the table, and resuming +/// would run the conversion against a schema this build does not know. +/// +/// `legacy_manager_rows` is deliberately *not* constrained — it is non-zero at the earlier +/// interruption point and zero at the later one, and the conversion is idempotent, so one resume +/// covers both. `legacy_user_access_all_count` must be zero, which the caller establishes first. +fn custom_role_migration_is_resumable(facts: CustomRoleMigrationFacts) -> bool { + let counted = |count: i64, expected: usize| usize::try_from(count).is_ok_and(|found| found == expected); + + facts.memberships_table_exists + && !facts.migration_applied + && facts.access_all_column_exists + && facts.legacy_user_access_all_count == 0 + && facts.migration_ledger_exists + && !facts.newer_migration_recorded + && counted(facts.permission_columns_present, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.permission_columns_not_null, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + // Exactly the finished table, plus the legacy column the migration has not dropped yet. + && counted(facts.membership_column_count, EXPECTED_MEMBERSHIP_COLUMNS.len() + 1) + && counted(facts.expected_membership_columns_present, EXPECTED_MEMBERSHIP_COLUMNS.len()) } -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 +/// The decision to act on once any legacy `User + access_all` rows have been resolved. +/// +/// Resolving them changes one fact, so the answer is recomputed: a database that is *both* +/// half-applied and carries such a row must still be resumed, not handed to Diesel. +fn custom_role_decision_after_legacy_resolution( + facts: CustomRoleMigrationFacts, + legacy_user_access_all: LegacyUserAccessAllPolicy, + interruptible_schema_changes: InterruptibleSchemaChanges, +) -> CustomRolePreflightDecision { + let mut resolved = facts; + resolved.legacy_user_access_all_count = 0; + custom_role_preflight_decision(resolved, legacy_user_access_all, interruptible_schema_changes) } fn custom_role_preflight_decision( facts: CustomRoleMigrationFacts, - can_complete_mysql_partial_migration: bool, + legacy_user_access_all: LegacyUserAccessAllPolicy, + interruptible_schema_changes: InterruptibleSchemaChanges, ) -> CustomRolePreflightDecision { + // A fresh installation: Diesel creates the schema from scratch and there is nothing to convert. if !facts.memberships_table_exists { return CustomRolePreflightDecision::Proceed; } - if !facts.migration_table_exists { - return CustomRolePreflightDecision::RefuseMissingMigrationLedger; - } - - // The legacy reconstruction below only makes sense while the repair migration is still ahead of - // us. Everything *after* it -- the access_all drop and the third permission column group -- still - // has to be checked on every start: both run after the repair, and on MySQL/MariaDB each DDL - // statement commits on its own, so a crash between the statement and Diesel's ledger insert - // 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 { - 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. Defer recording the migration until every refusal has been checked. - automatic_repair = Some(CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop); - } else { - return CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop; - } - } - } else { - // 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; - } - if !facts.access_all_column_exists { - return CustomRolePreflightDecision::RefuseMissingAccessAll; - } - // 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; - } + // Already upgraded. Every question below is about the legacy schema, which no longer exists, and + // Diesel never runs a recorded migration again. + if facts.migration_applied { + return CustomRolePreflightDecision::Proceed; } - // Every permission column group must be either completely absent (its migration is still pending) - // or completely present with its ledger entry. Anything else is an interrupted migration whose - // re-run would fail with `Duplicate column name`, so refuse with an actionable message. The single - // historical exception is the collection group on MySQL, where the known-good partial state is - // completed in place. - 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 - && !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), + // The migration is pending, so the legacy column has to be there -- both questions below read it, + // and the conversion derives all three collection permissions from it. + // + // Unless the migration already ran and only its ledger entry is missing. MySQL and MariaDB + // commit every ALTER TABLE on their own, so a process killed between the migration's final + // `DROP COLUMN access_all` and Diesel's ledger insert leaves exactly that: a fully converted + // database that looks pending. Record the entry instead of sending the operator to a backup. + if !facts.access_all_column_exists { + if custom_role_migration_is_complete(facts) { + return CustomRolePreflightDecision::RecordCompletedMigration; } + return CustomRolePreflightDecision::RefuseMissingAccessAll; + } + + // A plain User carrying membership `access_all` has no representation in the new model: unlimited + // reach over every collection, present and future, with no management authority. Materialising it as + // direct assignments turns a dynamic guarantee into a snapshot and -- since a `users_collections` row + // is not bound to the membership status -- would hand a revoked or never-confirmed member durable + // assignments. Refuse, unless the owner has already decided once (`LegacyUserAccessAllPolicy`). + if facts.legacy_user_access_all_count != 0 { + return match legacy_user_access_all { + LegacyUserAccessAllPolicy::Refuse => CustomRolePreflightDecision::RefuseLegacyUserAccessAll, + LegacyUserAccessAllPolicy::Drop => CustomRolePreflightDecision::DropLegacyUserAccessAll, + LegacyUserAccessAllPolicy::Materialize => CustomRolePreflightDecision::MaterializeLegacyUserAccessAll, + }; } - // 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; + // Nothing left to resolve and the legacy column still there. If the migration's own columns are + // *also* present, a previous run stopped part-way: on MySQL/MariaDB each `ALTER TABLE` commits on + // its own. Handing the file back to Diesel would re-run the `ADD COLUMN` and abort with a bare + // duplicate-column error, which is what this branch replaces. + if facts.permission_columns_present != 0 { + if interruptible_schema_changes && custom_role_migration_is_resumable(facts) { + return CustomRolePreflightDecision::ResumeInterruptedMigration; + } + return CustomRolePreflightDecision::RefuseAmbiguousPartialMigration; } - automatic_repair.unwrap_or(CustomRolePreflightDecision::Proceed) + // A legacy Manager whose organization-wide management comes from an org-local `access_all` group is + // deliberately not a question: an ordinary state that the migration maps onto `edit_any_collection` / + // `delete_any_collection`. Refusing it would block a normal upgrade; see the migration file. + CustomRolePreflightDecision::Proceed } -fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> Error { +/// The full operator-facing text for a refusal: what was found, and what to do about it. +/// +/// Kept separate from the `Error` so it can be logged with `Display` (the only formatting that +/// preserves the newlines the SQL below depends on) and asserted on in tests. +fn custom_role_preflight_report(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> String { let detail = match decision { - CustomRolePreflightDecision::RefuseAlreadyDropped => format!( - "The membership access_all column was already dropped by migration \ - {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION}, but the required repair migration \ - {CUSTOM_ROLE_REPAIR_MIGRATION} is not recorded. The former permission values cannot \ - be reconstructed safely." - ), CustomRolePreflightDecision::RefuseMissingAccessAll => format!( - "The membership access_all column is missing before repair migration \ - {CUSTOM_ROLE_REPAIR_MIGRATION}; refusing to infer deleted permissions." + "The membership access_all column is missing while migration \ + {CUSTOM_ROLE_PERMISSIONS_MIGRATION} is still pending." ), - CustomRolePreflightDecision::RefuseMissingMigrationLedger => { - "The users_organizations table exists, but the Diesel migration ledger does not. \ - Refusing to guess which schema and data migrations were previously applied." - .to_owned() - } 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 \ - was interrupted between its ALTER TABLE statements.", - facts.permission_columns(group).0, - group.description(), - group.column_list(), - group.migration() - ), - CustomRolePreflightDecision::RefusePermissionLedgerMismatch(group) => format!( - "Migration {} is recorded, but only {} of its three {} columns ({}) exist.", - group.migration(), - facts.permission_columns(group).0, - group.description(), - group.column_list() - ), - CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop => format!( - "The membership access_all column is already gone, but migration \ - {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION} is not recorded. The column was dropped without \ - its ledger entry, so re-running the migration would fail on every start." - ), - CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch => format!( - "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::RefuseAmbiguousPartialMigration => format!( + "Migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION} is still pending, but {} of its {} \ + permission columns already exist on users_organizations ({} of them NOT NULL) and the \ + table currently has {} columns.", + facts.permission_columns_present, + CUSTOM_ROLE_PERMISSION_COLUMNS.len(), + facts.permission_columns_not_null, + facts.membership_column_count ), - CustomRolePreflightDecision::Proceed - | CustomRolePreflightDecision::CompleteMysqlCollectionMigration - | CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { - unreachable!("successful preflight decisions do not produce errors") - } + _ => unreachable!("only a refusal is an error"), }; + let recovery = match decision { + CustomRolePreflightDecision::RefuseMissingAccessAll => MISSING_ACCESS_ALL_RECOVERY, CustomRolePreflightDecision::RefuseLegacyUserAccessAll => LEGACY_USER_ACCESS_ALL_RECOVERY, - CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory => UNVERIFIED_CUSTOM_ROLE_HISTORY_RECOVERY, - CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority => { - PERMANENT_COLLECTION_AUTHORITY_RECOVERY + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration => AMBIGUOUS_PARTIAL_MIGRATION_RECOVERY, + _ => "", + }; + + format!("Custom-role migration preflight stopped startup. Nothing has been changed.\n\n{detail}{recovery}") +} + +/// `'a', 'b', 'c'` — a literal list for an `IN (...)` predicate. The names are compile-time +/// constants from this file, never request data. +fn sql_name_list(names: &[&str]) -> String { + names.iter().map(|name| format!("'{name}'")).collect::>().join(", ") +} + +/// Report a refusal and produce the error that stops startup. +/// +/// Printed here through `Display`, and only here: the startup path logs a failed pool with `{e:?}`, +/// whose `Debug` formatting escapes the newlines the recovery SQL depends on, and pool creation is +/// retried. Log it once readably, flag the refusal so the retry loop stops, and let a one-line error +/// travel back. +fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> Error { + error!("{}", custom_role_preflight_report(decision, facts)); + + let detail = match decision { + CustomRolePreflightDecision::RefuseMissingAccessAll => { + "the membership access_all column is missing while the Custom-role migration is still pending" } - // 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::RefuseLegacyUserAccessAll => { + "a plain User membership still carries the legacy access_all bit" } - 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 + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration => { + "the Custom-role migration is partially applied and the schema is not one it can finish" } - _ => "", + _ => unreachable!("only a refusal is an error"), }; - std::io::Error::other(format!( - "Custom-role migration preflight stopped startup: {detail} Back up the database and resolve \ - the legacy membership state manually before restarting.{recovery}" - )) - .into() + let summary = format!( + "The Custom-role migration preflight refused to start: {detail}. \ + Nothing has been changed; the recovery procedure is printed above." + ); + // First refusal wins; a second would say the same thing about the same database. + drop(CUSTOM_ROLE_PREFLIGHT_REFUSAL.set(summary.clone())); + + std::io::Error::other(summary).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: +/// Record a migration that finished but whose ledger entry never committed. /// -/// * **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. +/// `sql` is the backend's idempotent insert, so a second startup that races or repeats this is a +/// no-op rather than a duplicate-key failure. +/// The statements that resolve the legacy flag, in the order they have to run. /// -/// `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 +/// The last one is always the clear, so its row count is the number of memberships resolved. +fn legacy_user_access_all_statements(decision: CustomRolePreflightDecision) -> &'static [&'static str] { + match decision { + CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => &[ + LEGACY_USER_ACCESS_ALL_RELAX_SQL, + LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL, + LEGACY_USER_ACCESS_ALL_CLEAR_SQL, + ], + CustomRolePreflightDecision::DropLegacyUserAccessAll => &[LEGACY_USER_ACCESS_ALL_CLEAR_SQL], + _ => &[], } } -/// 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 \ - )" +fn log_resolved_legacy_user_access_all(decision: CustomRolePreflightDecision, memberships: usize) { + let action = match decision { + CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + "their organization-wide reach was written out as explicit collection assignments \ + (confirmed memberships only) and the flag was cleared" + } + CustomRolePreflightDecision::DropLegacyUserAccessAll => { + "the flag was cleared; each member keeps the collections they are explicitly assigned to" + } + _ => unreachable!("no other decision resolves the legacy flag"), + }; + warn!( + "LEGACY_USER_ACCESS_ALL_MIGRATION resolved {memberships} plain User membership(s) carrying the \ + legacy access_all flag before migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION}: {action}. This ran \ + once, on the configured policy; the setting has no effect on an upgraded database." + ); } -/// 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 { - 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() - }; +fn log_recorded_completed_migration() { + warn!( + "Custom-role migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION}: the schema is fully converted but the \ + migration was not recorded. This is what an interrupted migration leaves behind on MySQL and \ + MariaDB, where every ALTER TABLE commits on its own. Every completed-schema check passed, so the \ + missing ledger entry has been recorded and startup continues; no data was changed." + ); +} - format!( - "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE NOT ( \ - (create_new_collections = FALSE \ - AND edit_any_collection = FALSE \ - AND delete_any_collection = FALSE) \ - OR \ - (atype = 4 \ - AND create_new_collections = access_all \ - AND edit_any_collection = access_all \ - AND delete_any_collection = access_all) \ - {same_run_group_derived} \ - )" - ) +#[cfg(mysql)] +fn log_resumed_interrupted_migration(converted: usize) { + warn!( + "Custom-role migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION}: its permission columns were already \ + present while the migration was still unrecorded, which is what an interrupted upgrade leaves \ + behind on MySQL and MariaDB, where every ALTER TABLE commits on its own. The schema matched the \ + expected fingerprint exactly, so the migration was finished: {converted} legacy Manager \ + membership(s) converted, the access_all column dropped and the migration recorded. The \ + conversion is the migration's own statement and matches only atype = 3, so a run that had \ + already converted them changed nothing here." + ); } // Embed the migrations from the migrations folder into the application @@ -1346,6 +1079,10 @@ mod sqlite_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite"); + /// Diesel runs each SQLite migration inside a transaction, so a failure rolls the whole file + /// back and no half-applied schema can be left behind. + const INTERRUPTIBLE_SCHEMA_CHANGES: super::InterruptibleSchemaChanges = false; + #[derive(diesel::QueryableByName)] struct Count { #[diesel(sql_type = diesel::sql_types::BigInt)] @@ -1373,66 +1110,70 @@ mod sqlite_migrations { .map(|value| value != 0) } - fn migration_applied( - connection: &mut diesel::sqlite::SqliteConnection, - version: &str, - ) -> Result { - count( - connection, - format!( - "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ - WHERE version = '{version}'" - ), - ) - .map(|value| value != 0) - } - + /// Read-only, with exactly one exception: the idempotent ledger insert that records a migration + /// which provably already ran (see `custom_role_migration_is_complete`). + /// + /// `pragma_table_xinfo` rather than `table_info`: the latter omits generated columns, so one would + /// pass the exact-column-count fingerprint unseen. fn preflight(connection: &mut diesel::sqlite::SqliteConnection) -> Result<(), super::Error> { let memberships_table_exists = table_exists(connection, "users_organizations")?; if !memberships_table_exists { return Ok(()); } - let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; - let access_all_column_exists = count( - connection, - "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ - WHERE name = 'access_all'", - )? != 0; - let permission_columns = |connection: &mut diesel::sqlite::SqliteConnection, - group: super::PermissionColumnGroup| - -> Result { - count( + let migration_ledger_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_applied = migration_ledger_exists + && count( connection, format!( - "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ - WHERE name IN ({})", - group.column_list() + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION ), - ) - }; - 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)?; - - let manage_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION)?; - let collection_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?; - let access_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION)?; - let repair_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?; - 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 + )? != 0; + let newer_migration_recorded = migration_ledger_exists && count( connection, - format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version > '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), )? != 0; + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name = 'access_all'", + )? != 0; + + let permission_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let permission_columns_not_null = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({}) AND \"notnull\" = 1", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let membership_column_count = + count(connection, "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations')")?; + let expected_membership_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({})", + super::sql_name_list(&super::EXPECTED_MEMBERSHIP_COLUMNS) + ), + )?; + let legacy_manager_rows = + count(connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 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 @@ -1441,55 +1182,72 @@ mod sqlite_migrations { count( connection, "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE atype = 2 \ - AND access_all = 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, + migration_applied, access_all_column_exists, - manage_permission_columns, - manage_permissions_migration_applied, - collection_permission_columns, - collection_permissions_migration_applied, - access_permission_columns, - access_permissions_migration_applied, - repair_migration_applied, - access_all_drop_migration_applied, 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, + migration_ledger_exists, + permission_columns_present, + permission_columns_not_null, + membership_column_count, + expected_membership_columns_present, + legacy_manager_rows, + newer_migration_recorded, }; - let decision = super::custom_role_preflight_decision(facts, false); - if decision == super::CustomRolePreflightDecision::Proceed { - Ok(()) - } else { - Err(super::custom_role_preflight_error(decision, facts)) + let policy = super::LegacyUserAccessAllPolicy::configured(); + let decision = super::custom_role_preflight_decision(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + match decision { + super::CustomRolePreflightDecision::Proceed => Ok(()), + super::CustomRolePreflightDecision::RecordCompletedMigration => { + // Diesel will not run the file, so the migration's own statements after its last + // schema change have to happen here. + diesel::sql_query(super::DROP_DOWNGRADE_ACK_SQL).execute(connection)?; + diesel::sql_query(format!( + "INSERT OR IGNORE INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{}', CURRENT_TIMESTAMP)", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + )) + .execute(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag changes one fact, so the answer has to be recomputed before the + // file goes back to Diesel. + // + // Data integrity (audit F-2): recomputed *before* the statements run. They commit + // immediately and cannot be undone -- `materialize` even relaxes `read_only` and + // `hide_passwords` on existing assignments -- so a database that would be refused + // afterwards anyway has to be refused now, while the refusal's "Nothing has been + // changed" is still true. + match super::custom_role_decision_after_legacy_resolution(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES) { + super::CustomRolePreflightDecision::Proceed => {} + followup => return Err(super::custom_role_preflight_error(followup, facts)), + } + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + super::log_resolved_legacy_user_access_all(decision, resolved); + Ok(()) + } + // SQLite runs the whole migration inside one transaction, so it cannot stop half-way and + // `custom_role_preflight_decision` never resumes for it. Fail closed rather than rely on + // that from a distance. + super::CustomRolePreflightDecision::ResumeInterruptedMigration => Err(super::custom_role_preflight_error( + super::CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + facts, + )), + decision => Err(super::custom_role_preflight_error(decision, facts)), } } @@ -1523,6 +1281,11 @@ mod mysql_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql"); + /// MySQL and MariaDB commit every `ALTER TABLE` on their own, so a process killed part-way + /// through a migration leaves it half-applied. This is the only backend an interrupted upgrade + /// can be resumed on. + const INTERRUPTIBLE_SCHEMA_CHANGES: super::InterruptibleSchemaChanges = true; + #[derive(diesel::QueryableByName)] struct Count { #[diesel(sql_type = diesel::sql_types::BigInt)] @@ -1550,125 +1313,34 @@ mod mysql_migrations { .map(|value| value != 0) } - fn migration_applied( - connection: &mut diesel::mysql::MysqlConnection, - version: &str, - ) -> Result { - count( - connection, - format!( - "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ - WHERE version = '{version}'" - ), - ) - .map(|value| value != 0) - } - - fn complete_partial_collection_migration( - connection: &mut diesel::mysql::MysqlConnection, - allow_same_run_group_derived: bool, - ) -> Result<(), super::Error> { - // MySQL implicitly committed the three historical ALTER TABLE statements before the - // unquoted `groups` identifier made the migration fail. Complete that exact, known state - // without dropping columns or inventing values. - let matching_column_definitions = count( - connection, - "SELECT COUNT(*) AS count FROM information_schema.columns \ - WHERE table_schema = DATABASE() \ - AND table_name = 'users_organizations' \ - AND column_name IN \ - ('create_new_collections', 'edit_any_collection', 'delete_any_collection') \ - AND data_type = 'tinyint' \ - AND is_nullable = 'NO' \ - AND LOWER(COALESCE(CAST(column_default AS CHAR), '')) IN ('0', 'false')", - )?; - let unexpected_values = - count(connection, super::mysql_partial_unexpected_values_query(allow_same_run_group_derived))?; - - if matching_column_definitions != 3 || unexpected_values != 0 { - return Err(std::io::Error::other(format!( - "Custom-role migration preflight found the historical MySQL partial \ - {version} schema, but its column definitions or data were modified \ - (matching columns: {matching_column_definitions}/3, unexpected rows: \ - {unexpected_values}). Refusing automatic recovery. Back up the database and \ - resolve the partial migration manually before restarting.", - version = super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION, - )) - .into()); - } - - 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 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, \ - edit_any_collection = access_all, \ - delete_any_collection = access_all \ - WHERE atype = 4", - ) - .execute(connection)?; - - diesel::sql_query(format!( - "INSERT INTO __diesel_schema_migrations (version) \ - VALUES ('{}')", - super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION - )) - .execute(connection)?; - Ok(()) - })?; - - Ok(()) - } - - fn complete_interrupted_access_all_drop( - connection: &mut diesel::mysql::MysqlConnection, - ) -> Result<(), super::Error> { - // MySQL/MariaDB commit DDL implicitly, so the single `ALTER TABLE ... DROP COLUMN access_all` - // can be durable while Diesel's ledger insert that follows it is not. Re-running the - // 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. - // 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(()) - } - - /// 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 { + /// Read-only apart from the idempotent ledger insert and the resume below. This is the backend that + /// produces both states: MySQL and MariaDB commit every ALTER TABLE on their own, so a process killed + /// part-way through leaves a database that looks pending but is not. + fn preflight(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { let memberships_table_exists = table_exists(connection, "users_organizations")?; if !memberships_table_exists { - // Nothing to read, and nothing to decide: the default answers `Proceed`. - return Ok(super::CustomRoleMigrationFacts::default()); + return Ok(()); } - let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_ledger_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_applied = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let newer_migration_recorded = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version > '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; let access_all_column_exists = count( connection, "SELECT COUNT(*) AS count FROM information_schema.columns \ @@ -1676,39 +1348,45 @@ mod mysql_migrations { AND table_name = 'users_organizations' \ AND column_name = 'access_all'", )? != 0; - let permission_columns = |connection: &mut diesel::mysql::MysqlConnection, - group: super::PermissionColumnGroup| - -> Result { - count( - connection, - format!( - "SELECT COUNT(*) AS count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'users_organizations' AND column_name IN ({})", - group.column_list() - ), - ) - }; - 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)?; - - let manage_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION)?; - let collection_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?; - let access_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION)?; - let repair_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?; - 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 permission_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let permission_columns_not_null = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({}) \ + AND is_nullable = 'NO'", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let membership_column_count = count( + connection, + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() AND table_name = 'users_organizations'", + )?; + let expected_membership_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({})", + super::sql_name_list(&super::EXPECTED_MEMBERSHIP_COLUMNS) + ), + )?; + let legacy_manager_rows = + count(connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 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 @@ -1717,92 +1395,103 @@ mod mysql_migrations { count( connection, "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE atype = 2 \ - AND access_all = 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, - }; - - Ok(super::CustomRoleMigrationFacts { + let facts = super::CustomRoleMigrationFacts { memberships_table_exists, - migration_table_exists, + migration_applied, access_all_column_exists, - manage_permission_columns, - manage_permissions_migration_applied, - collection_permission_columns, - collection_permissions_migration_applied, - access_permission_columns, - access_permissions_migration_applied, - repair_migration_applied, - access_all_drop_migration_applied, 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, - }) - } - - /// 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; + migration_ledger_exists, + permission_columns_present, + permission_columns_not_null, + membership_column_count, + expected_membership_columns_present, + legacy_manager_rows, + newer_migration_recorded, + }; - 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, - )?; + let policy = super::LegacyUserAccessAllPolicy::configured(); + let decision = super::custom_role_preflight_decision(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + match decision { + super::CustomRolePreflightDecision::Proceed => Ok(()), + super::CustomRolePreflightDecision::RecordCompletedMigration => { + // Diesel will not run the file, so the migration's own statements after its last + // schema change have to happen here. + diesel::sql_query(super::DROP_DOWNGRADE_ACK_SQL).execute(connection)?; + record_migration(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::ResumeInterruptedMigration => resume_migration(connection), + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag changes one fact, so the answer has to be recomputed: this + // database may *also* be a half-applied upgrade, which must never be handed back to + // Diesel -- it would re-run `ALTER TABLE ... ADD COLUMN` and abort. + // + // Data integrity (audit F-2): recomputed *before* the statements run. They commit + // immediately and cannot be undone -- `materialize` even relaxes `read_only` and + // `hide_passwords` on existing assignments -- so a database that would be refused + // afterwards anyway has to be refused now, while the refusal's "Nothing has been + // changed" is still true. Resuming is not a refusal: those statements do run, and the + // interrupted migration is finished afterwards. + let followup = + super::custom_role_decision_after_legacy_resolution(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + if !matches!( + followup, + super::CustomRolePreflightDecision::Proceed + | super::CustomRolePreflightDecision::ResumeInterruptedMigration + ) { + return Err(super::custom_role_preflight_error(followup, facts)); } - super::CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { - complete_interrupted_access_all_drop(connection)?; + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + super::log_resolved_legacy_user_access_all(decision, resolved); + match followup { + super::CustomRolePreflightDecision::ResumeInterruptedMigration => resume_migration(connection), + _ => Ok(()), } - 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()) + /// Idempotent ledger insert, so a repeated or racing startup is a no-op rather than a + /// duplicate-key failure. + fn record_migration(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), diesel::result::Error> { + diesel::sql_query(format!( + "INSERT IGNORE INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{}', CURRENT_TIMESTAMP)", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + )) + .execute(connection) + .map(|_| ()) + } + + /// Finish a migration that stopped between its first `ALTER TABLE` and its last. + /// + /// Only reached once `custom_role_migration_is_resumable` has confirmed the schema, so these are the + /// statements that run has not executed -- or, for the conversion, one it may already have executed, + /// which matches nothing the second time. Diesel then finds the migration recorded and never opens + /// the file, so the `ADD COLUMN` that would abort startup is never reached. + fn resume_migration(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { + let mut converted = 0; + for statement in super::CUSTOM_ROLE_RESUME_STATEMENTS { + let affected = diesel::sql_query(statement).execute(connection)?; + if statement == super::CUSTOM_ROLE_MANAGER_CONVERSION_SQL { + converted = affected; + } + } + record_migration(connection)?; + super::log_resumed_interrupted_migration(converted); + Ok(()) } pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { @@ -1828,6 +1517,10 @@ mod postgresql_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql"); + /// Diesel runs each PostgreSQL migration inside a transaction, and PostgreSQL DDL is + /// transactional, so a failure rolls the whole file back. + const INTERRUPTIBLE_SCHEMA_CHANGES: super::InterruptibleSchemaChanges = false; + #[derive(diesel::QueryableByName)] struct Count { #[diesel(sql_type = diesel::sql_types::BigInt)] @@ -1844,95 +1537,96 @@ mod postgresql_migrations { /// 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.) + /// `current_schema()` is where new objects are created, not necessarily where an existing table is + /// found: with `search_path = decoy, real` and the tables in `real` it answers `decoy`, the lookup + /// finds nothing, `preflight` returns early on `!memberships_table_exists`, and Diesel then runs the + /// migration against `real` with both checks silently skipped. `to_regclass` walks the same path the + /// migration does, so the two cannot disagree about which table they mean. (The migration and + /// `tools/custom_role_rollback/postgresql.sql` resolve it the same way.) 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 pg_attribute \ - WHERE attrelid = to_regclass('users_organizations') \ - AND attnum > 0 \ - AND NOT attisdropped \ - AND attname IN ({column_list})" - ), - ) - } - - fn migration_applied( - connection: &mut diesel::pg::PgConnection, - version: &str, - ) -> Result { - count( - connection, - format!( - "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ - WHERE version = '{version}'" - ), - ) - .map(|value| value != 0) - } - + /// Read-only, with exactly one exception: the idempotent ledger insert that records a migration + /// which provably already ran (see `custom_role_migration_is_complete`). PostgreSQL has + /// transactional DDL, so it never produces that state itself -- the repair is here so a database + /// restored or copied from a MySQL-side incident is handled identically on every backend. fn preflight(connection: &mut diesel::pg::PgConnection) -> Result<(), super::Error> { let memberships_table_exists = table_exists(connection, "users_organizations")?; if !memberships_table_exists { return Ok(()); } - let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; - 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.", - ) - .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)?; - let collection_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?; - let access_permissions_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION)?; - let repair_migration_applied = - migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?; - 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 + let migration_ledger_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_applied = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let newer_migration_recorded = migration_ledger_exists && count( connection, - format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version > '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), )? != 0; + // Columns are resolved through the same `to_regclass` lookup as [`table_exists`], so a + // `search_path` split cannot make the schema and the column check describe two different + // tables. + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 \ + AND NOT attisdropped \ + AND attname = 'access_all'", + )? != 0; + + let permission_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped \ + AND attname IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let permission_columns_not_null = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped AND attnotnull \ + AND attname IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let membership_column_count = count( + connection, + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped", + )?; + let expected_membership_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped \ + AND attname IN ({})", + super::sql_name_list(&super::EXPECTED_MEMBERSHIP_COLUMNS) + ), + )?; + let legacy_manager_rows = + count(connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 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 @@ -1941,55 +1635,72 @@ mod postgresql_migrations { count( connection, "SELECT COUNT(*) AS count FROM users_organizations \ - WHERE atype = 2 \ - AND access_all = 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, + migration_applied, access_all_column_exists, - manage_permission_columns, - manage_permissions_migration_applied, - collection_permission_columns, - collection_permissions_migration_applied, - access_permission_columns, - access_permissions_migration_applied, - repair_migration_applied, - access_all_drop_migration_applied, 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, + migration_ledger_exists, + permission_columns_present, + permission_columns_not_null, + membership_column_count, + expected_membership_columns_present, + legacy_manager_rows, + newer_migration_recorded, }; - let decision = super::custom_role_preflight_decision(facts, false); - if decision == super::CustomRolePreflightDecision::Proceed { - Ok(()) - } else { - Err(super::custom_role_preflight_error(decision, facts)) + let policy = super::LegacyUserAccessAllPolicy::configured(); + let decision = super::custom_role_preflight_decision(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + match decision { + super::CustomRolePreflightDecision::Proceed => Ok(()), + super::CustomRolePreflightDecision::RecordCompletedMigration => { + // Diesel will not run the file, so the migration's own statements after its last + // schema change have to happen here. + diesel::sql_query(super::DROP_DOWNGRADE_ACK_SQL).execute(connection)?; + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{}', CURRENT_TIMESTAMP) ON CONFLICT (version) DO NOTHING", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + )) + .execute(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag changes one fact, so the answer has to be recomputed before the + // file goes back to Diesel. + // + // Data integrity (audit F-2): recomputed *before* the statements run. They commit + // immediately and cannot be undone -- `materialize` even relaxes `read_only` and + // `hide_passwords` on existing assignments -- so a database that would be refused + // afterwards anyway has to be refused now, while the refusal's "Nothing has been + // changed" is still true. + match super::custom_role_decision_after_legacy_resolution(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES) { + super::CustomRolePreflightDecision::Proceed => {} + followup => return Err(super::custom_role_preflight_error(followup, facts)), + } + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + super::log_resolved_legacy_user_access_all(decision, resolved); + Ok(()) + } + // PostgreSQL runs the whole migration inside one transaction, so it cannot stop half-way + // and `custom_role_preflight_decision` never resumes for it. Fail closed rather than rely + // on that from a distance. + super::CustomRolePreflightDecision::ResumeInterruptedMigration => Err(super::custom_role_preflight_error( + super::CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + facts, + )), + decision => Err(super::custom_role_preflight_error(decision, facts)), } } @@ -2004,16 +1715,11 @@ mod postgresql_migrations { } } -/// Executes the real migration files against a throwaway SQLite database. +/// Executes the real migration file 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. +/// Everything else here tests the *decision* the preflight makes; this tests the SQL it protects. +/// The rules the migration encodes -- authority materialized from what a membership held then, and +/// only from its own organization -- are invisible to a Rust test unless the statements run. #[cfg(all(test, sqlite))] mod custom_role_migration_sql_tests { use diesel::connection::SimpleConnection; @@ -2023,37 +1729,24 @@ mod custom_role_migration_sql_tests { sqlite::SqliteConnection, }; - 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); - "; + const ADD_CUSTOM_ROLE_PERMISSIONS: &str = + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql"); - /// 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 = " + /// `users_organizations` exactly as current upstream main leaves it: membership `access_all`, the + /// retired Manager role, and none of the nine permission columns. + 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, - 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 + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL, + 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, @@ -2065,57 +1758,70 @@ mod custom_role_migration_sql_tests { 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 collections ( + uuid TEXT NOT NULL PRIMARY KEY, + org_uuid TEXT NOT NULL ); - CREATE TABLE groups_users ( - groups_uuid TEXT NOT NULL, - users_organizations_uuid TEXT NOT NULL, - PRIMARY KEY (groups_uuid, users_organizations_uuid) + CREATE TABLE users_collections ( + user_uuid TEXT NOT NULL, + collection_uuid TEXT NOT NULL, + read_only BOOLEAN NOT NULL DEFAULT FALSE, + hide_passwords BOOLEAN NOT NULL DEFAULT FALSE, + manage BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (user_uuid, collection_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'); + /// One membership per legacy shape the conversion treats differently, in two organizations. + /// + /// `m_mgr_foreign` is the tenancy probe: an org-1 Manager carrying a `groups_users` row pointing at + /// org 2's `accessAll` group. No HTTP path creates that row, which is why the migration's + /// organization predicate has to be tested rather than assumed. + const LEGACY_MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org1', TRUE), + ('g_plain', 'org1', FALSE), + ('g2_all', 'org2', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_owner', 'u1', 'org1', TRUE, 2, 0), + ('m_admin', 'u2', 'org1', TRUE, 2, 1), + ('m_user', 'u3', 'org1', FALSE, 2, 2), + ('m_mgr_all', 'u4', 'org1', TRUE, 2, 3), + ('m_mgr_bare', 'u5', 'org1', FALSE, 2, 3), + ('m_mgr_plain_g', 'u6', 'org1', FALSE, 2, 3), + ('m_mgr_group', 'u7', 'org1', FALSE, 2, 3), + ('m_user_group', 'u8', 'org1', FALSE, 2, 2), + ('m_mgr_invited', 'u9', 'org1', FALSE, 0, 3), + ('m_mgr_revoked', 'u10', 'org1', FALSE, -1, 3), + ('m_mgr_foreign', 'u11', 'org1', FALSE, 2, 3), + ('m2_mgr_group', 'u12', 'org2', FALSE, 2, 3), + ('m2_user', 'u7', 'org2', FALSE, 2, 2); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_user_group'), + ('g_all', 'm_mgr_revoked'), + ('g_plain', 'm_mgr_plain_g'), + ('g2_all', 'm2_mgr_group'), + ('g2_all', 'm_mgr_foreign'); "; - /// 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'); + /// The one state the upgrade refuses by default: a plain User still carrying `access_all`. + /// + /// `access_all` overrode `read_only` and `hide_passwords` but never conferred `manage`, so `u20`'s + /// two existing assignments must be relaxed and its `manage` grant left alone. The row on `c3` is a + /// different organization's and unbacked by any membership: nothing may touch it. `u21` and `u22` are + /// revoked and invited, so they must come out with no assignments at all. + const LEGACY_USER_ACCESS_ALL: &str = " + INSERT INTO collections (uuid, org_uuid) VALUES + ('c1', 'org1'), ('c2', 'org1'), ('c4', 'org1'), ('c3', 'org2'); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_uaa', 'u20', 'org1', TRUE, 2, 2), + ('m_uaa_rev', 'u21', 'org1', TRUE, -1, 2), + ('m_uaa_inv', 'u22', 'org1', TRUE, 0, 2); + INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) VALUES + ('u20', 'c1', TRUE, TRUE, FALSE), + ('u20', 'c2', TRUE, FALSE, TRUE), + ('u20', 'c3', TRUE, FALSE, TRUE); "; #[derive(diesel::QueryableByName)] @@ -2125,482 +1831,467 @@ mod custom_role_migration_sql_tests { } #[derive(diesel::QueryableByName)] - struct ReviewMembership { + struct Row { #[diesel(sql_type = Text)] - uuid: String, + value: 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 rows(connection: &mut SqliteConnection, query: &str) -> Vec { + diesel::sql_query(query).load::(connection).unwrap().into_iter().map(|row| row.value).collect() } - fn connect(setup: &[&str]) -> SqliteConnection { + fn connect(memberships: &str) -> SqliteConnection { let mut connection = SqliteConnection::establish(":memory:").unwrap(); - for statements in setup { - connection.batch_execute(statements).unwrap(); - } + connection.batch_execute("PRAGMA foreign_keys = OFF").unwrap(); + connection.batch_execute(LEGACY_SCHEMA).unwrap(); + connection.batch_execute(memberships).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 + /// Applies the migration the way Diesel's harness does: inside a transaction, so a refusal rolls + /// back the temporary guard tables as well and a retry starts from the same state a restart would. + fn migrate(connection: &mut SqliteConnection) -> Result<(), diesel::result::Error> { + connection.transaction(|connection| connection.batch_execute(ADD_CUSTOM_ROLE_PERMISSIONS)) } - 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 + /// Runs what the preflight runs for the configured policy, in the same order. + fn resolve(connection: &mut SqliteConnection, decision: super::CustomRolePreflightDecision) { + for statement in super::legacy_user_access_all_statements(decision) { + diesel::sql_query(*statement).execute(connection).unwrap(); + } } - /// 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) + /// Every collection assignment, as one line each. + fn assignments(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT user_uuid || ' ' || collection_uuid \ + || ' ro=' || read_only || ' hide=' || hide_passwords || ' manage=' || manage AS value \ + FROM users_collections ORDER BY user_uuid, collection_uuid", + ) } - /// 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. + /// Resuming replays the migration's own conversion, so it has to land on exactly what the migration + /// produces and stay there when replayed -- that idempotence is what covers both interruption points. + /// Run on SQLite because that is the backend with an in-process harness; the `DROP COLUMN` companion + /// is left out because SQLite before 3.35 cannot run it. + #[cfg(mysql)] #[test] - 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'") + fn the_resume_conversion_matches_the_migration_and_is_idempotent() { + // What the migration produces when it runs to completion. + let mut finished = connect(LEGACY_MEMBERSHIPS); + migrate(&mut finished).unwrap(); + let expected = state(&mut finished); + + // The same database, interrupted straight after `ALTER TABLE ... ADD COLUMN`: the nine + // columns exist at their defaults, nothing is converted, `access_all` is still there. + let mut interrupted = connect(LEGACY_MEMBERSHIPS); + for column in super::CUSTOM_ROLE_PERMISSION_COLUMNS { + diesel::sql_query(format!( + "ALTER TABLE users_organizations ADD COLUMN {column} BOOLEAN NOT NULL DEFAULT FALSE" + )) + .execute(&mut interrupted) .unwrap(); + } + assert_ne!(state(&mut interrupted), expected, "the interrupted database must not already match"); + + let converted = diesel::sql_query(super::CUSTOM_ROLE_MANAGER_CONVERSION_SQL).execute(&mut interrupted).unwrap(); + assert!(converted > 0, "the first replay has legacy Managers to convert"); + assert_eq!(state(&mut interrupted), expected, "resuming must land on the migration's own result"); + + // The later interruption point: the conversion already ran, so replaying it matches nothing. + let replayed = diesel::sql_query(super::CUSTOM_ROLE_MANAGER_CONVERSION_SQL).execute(&mut interrupted).unwrap(); + assert_eq!(replayed, 0, "the conversion must match nothing the second time"); + assert_eq!(state(&mut interrupted), expected, "replaying the conversion must change nothing"); + } + + /// Every membership's role plus the six permissions the conversion can set, as one line each. + fn state(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT uuid || ' atype=' || atype \ + || ' ' || create_new_collections || edit_any_collection || delete_any_collection \ + || ' ' || manage_users || manage_groups || manage_policies \ + || access_event_logs || access_import_export || access_reports AS value \ + FROM users_organizations ORDER BY uuid", + ) + } - connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + 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", + ) + } - assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (true, true, true)); + 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 } - /// 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. + /// The whole conversion, in one comparison. Written out per membership on purpose: every line is + /// a rule, and a regression in any of them is a silent authorization change. #[test] - 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" + fn the_conversion_maps_every_legacy_shape_exactly_once() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + + assert_eq!( + state(&mut connection), + [ + // The second organization is converted on its own terms... + "m2_mgr_group atype=4 011 000000", + // ...and the same *user* holding a plain User membership there gains nothing from + // being a Manager in the first organization. + "m2_user atype=2 000 000000", + // Admin keeps its role; the new model grants it everything implicitly, so no + // permission column is set. + "m_admin atype=1 000 000000", + // Membership access_all was the "Manage all collections" checkbox: all three. + "m_mgr_all atype=4 111 000000", + // Manager with nothing: Custom with nothing. + "m_mgr_bare atype=4 000 000000", + // A groups_users row pointing at another organization's accessAll group grants + // nothing -- the migration requires the group to belong to the membership's own org. + "m_mgr_foreign atype=4 000 000000", + // Group-derived authority: edit and delete, never create. + "m_mgr_group atype=4 011 000000", + // Invited is converted like any other membership. + "m_mgr_invited atype=4 000 000000", + // A group without accessAll conveys nothing. + "m_mgr_plain_g atype=4 000 000000", + // Revoked is converted like any other membership: status is not part of the rule. + "m_mgr_revoked atype=4 011 000000", + "m_owner atype=0 000 000000", + // A plain User is never converted, not even inside an accessAll group. + "m_user atype=2 000 000000", + "m_user_group atype=2 000 000000", + ] ); } + /// The nine columns exist, `access_all` does not, and nothing else about the table changed. #[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(); + fn the_rebuilt_table_has_the_final_shape() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + assert_eq!( + rows(&mut connection, "SELECT name AS value FROM pragma_table_xinfo('users_organizations')"), + [ + "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", + ] + ); + // The primary key and the UNIQUE pair, and nothing else: the rollback script checks for + // exactly these two and would refuse a database the rebuild had changed. + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM pragma_index_list('users_organizations')"), 2); assert_eq!( 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" + "SELECT COUNT(*) AS count FROM users_organizations WHERE uuid = 'm_owner' AND user_uuid = 'u1'" ), - 1, - "the historical later migration can hold live grants" + 1 ); + } + + /// The one shape the upgrade refuses outright, checked in the SQL rather than only in the Rust + /// preflight: `diesel migration run` and a bare `MigrationHarness` never consult the preflight, + /// and the column that carries the reach is gone a few statements later. + #[test] + fn a_plain_user_carrying_access_all_is_refused_and_nothing_changes() { + let memberships = " + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_user_all', 'u1', 'org1', TRUE, 2); + "; + let mut connection = connect(memberships); + let before = legacy_state(&mut connection); - connection.batch_execute(DROP_MEMBERSHIP_ACCESS_ALL).unwrap(); + assert!(migrate(&mut connection).is_err()); + assert_eq!(legacy_state(&mut connection), before); + assert!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') WHERE name = 'access_all'" + ) == 1, + "the refusal must leave the legacy column in place" + ); 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')" + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ + WHERE name IN ('manage_users', 'create_new_collections', 'access_reports')" ), 0, - "this pins why the preflight must refuse before running the unchanged migration file" + "the refusal must not leave a half-applied schema behind" ); } - /// 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 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)); - } - - /// 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. + /// The whole point of this revision: a legacy Manager whose organization-wide collection + /// management comes from an organization-local `access_all` group is an ordinary, valid + /// current-main state. It must migrate straight through -- no acknowledgement table, no failed + /// first startup -- and land on edit + delete without collection creation. #[test] - 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()); - + fn group_derived_authority_migrates_without_any_acknowledgement() { + let memberships = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_mgr_group', 'u1', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm_mgr_group'); + "; + let mut connection = connect(memberships); + + migrate(&mut connection).expect("a valid current-main database must migrate on the first try"); + + assert_eq!(state(&mut connection), ["m_mgr_group atype=4 011 000000"]); + // The group and its flag are what they were: only the membership row changed. assert_eq!( 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')" + "SELECT COUNT(*) AS count FROM \"groups\" WHERE uuid = 'g_all' AND access_all = TRUE" ), - 0, - "the guard has to run before the ALTER TABLE statements, or MySQL keeps the partial column group" + 1 ); + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM groups_users WHERE groups_uuid = 'g_all'"), 1); } - /// `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. + /// Nothing about the upgrade is conditional on operator state any more, so running it twice from + /// the same legacy database has to produce the same row both times. + /// LEGACY_USER_ACCESS_ALL_MIGRATION=materialize: the reach becomes explicit assignments that + /// reproduce it exactly, and the upgrade then runs. #[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(); + fn materializing_the_legacy_flag_reproduces_the_reach_and_unblocks_the_upgrade() { + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + assert!(migrate(&mut connection).is_err(), "the guard must refuse this database untouched"); + + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + resolve(&mut connection, super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll); - connection.batch_execute(MATERIALIZE_GROUP_AUTHORITY).unwrap(); + assert_eq!( + assignments(&mut connection), + [ + // relaxed: access_all overrode read_only and hide_passwords ... + "u20 c1 ro=0 hide=0 manage=0", + // ... but never conferred manage, so an explicit grant survives + "u20 c2 ro=0 hide=0 manage=1", + // a row whose collection belongs to another organization is not this membership\'s + "u20 c3 ro=1 hide=0 manage=1", + // the third collection of the organization, written out + "u20 c4 ro=0 hide=0 manage=0", + ], + "a revoked or invited membership must not receive any assignment" + ); + assert_eq!( + count(&mut connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE access_all = TRUE"), + 0 + ); - 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)); + migrate(&mut connection).expect("the upgrade runs once the flag is resolved"); + assert!(!table_exists(&mut connection, "users_organizations_new")); } - /// 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. + /// LEGACY_USER_ACCESS_ALL_MIGRATION=drop: only the flag goes; explicit assignments are kept + /// exactly as they are, including their restrictions. #[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()); + fn dropping_the_legacy_flag_keeps_every_explicit_assignment_untouched() { + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + let before = assignments(&mut connection); + resolve(&mut connection, super::CustomRolePreflightDecision::DropLegacyUserAccessAll); - let mut audited = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); - audited.batch_execute(HISTORY_VERIFIED).unwrap(); - audited.batch_execute(MATERIALIZE_GROUP_AUTHORITY).unwrap(); + assert_eq!(assignments(&mut connection), before, "drop must not write a single assignment"); assert_eq!( - collection_permissions(&mut audited, "m_unrecorded"), - (false, false, false), - "the marker settles who is undecidable, it never grants" + count(&mut connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE access_all = TRUE"), + 0 ); + + migrate(&mut connection).expect("the upgrade runs once the flag is resolved"); + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 2"), 3); } - /// 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)); + fn the_conversion_is_deterministic() { + let first = { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + state(&mut connection) + }; + let second = { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + state(&mut connection) + }; + assert_eq!(first, second); } - /// `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. + /// A Manager whose own `access_all` bit is set /// A Manager whose own `access_all` bit is set is not part of the question: that bit is already a + /// durable membership-level grant, so converting it changes no meaning and must not stop an + /// upgrade that has nothing else to decide. #[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)); + fn a_manager_with_its_own_access_all_bit_is_not_asked_about() { + let memberships = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_mgr_both', 'u1', 'org1', TRUE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm_mgr_both'); + "; + let mut connection = connect(memberships); - assert!( - connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(), - "a current permission value is not historical provenance" - ); + migrate(&mut connection).unwrap(); + + assert_eq!(state(&mut connection), ["m_mgr_both atype=4 111 000000"]); } - /// 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. + /// A leftover downgrade acknowledgement is cleared by the upgrade, so consent from an earlier + /// revert is never inherited by a later one. #[test] - fn an_unrecorded_grant_is_still_worth_asking_about() { - let mut connection = connect_after_0716(MEMBERSHIPS, &[]); + fn a_leftover_downgrade_acknowledgement_is_cleared() { + let mut connection = connect(LEGACY_MEMBERSHIPS); connection - .batch_execute( - "UPDATE users_organizations \ - SET create_new_collections = TRUE, edit_any_collection = TRUE, delete_any_collection = TRUE \ - WHERE uuid = 'm_unrecorded'", - ) + .batch_execute("CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY)") .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(); + migrate(&mut connection).unwrap(); - assert!(connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + assert!(!table_exists(&mut connection, "__vw_allow_custom_role_downgrade")); } - /// 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. + /// The upgrade must not depend on, or leave behind, any bookkeeping table of its own. #[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"); + fn the_upgrade_creates_no_bookkeeping_table() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); - // 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" + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name LIKE '__vw_%'" + ), + 0 ); - - // 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. + /// Every shape a valid current-main database can hold has to migrate on the first try. Only the + /// one legacy state that cannot be represented at all may abort -- and it is the last case here. #[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"], ""), + fn every_valid_current_main_shape_migrates_on_the_first_try() { + let cases: [(&str, &str, bool); 7] = [ ( - "membership access_all, never group-bound", - &["m_recorded"], - "UPDATE users_organizations SET create_new_collections = TRUE WHERE uuid = 'm_recorded'", + "Manager, nothing else", + "INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) \ + VALUES ('m', 'u', 'org1', FALSE, 3);", + true, ), ( - "bulk grant to a membership that is not on the record", - &[], - "UPDATE users_organizations SET edit_any_collection = TRUE WHERE uuid = 'm_unrecorded'", + "Manager in a group without accessAll", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', FALSE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, ), ( - "revoked membership: no authority today, but it would come back with one", - &["m_recorded"], - "UPDATE users_organizations SET status = -1 WHERE uuid = 'm_recorded'", + "Manager in an accessAll group", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, ), - ]; - - for (name, record, extra) in cases { - let mut connection = connect_after_0716(MEMBERSHIPS, record); - if !extra.is_empty() { - connection.batch_execute(extra).unwrap(); - } + ( + "Manager with membership access_all as well", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', TRUE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "plain User in an accessAll group", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 2); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "Manager in another organization's accessAll group", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org2', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "plain User carrying membership access_all", + "INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) \ + VALUES ('m', 'u', 'org1', TRUE, 2);", + false, + ), + ]; - 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}"); + for (name, memberships, should_migrate) in cases { + let mut connection = connect(memberships); + assert_eq!(migrate(&mut connection).is_ok(), should_migrate, "unexpected outcome 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. +/// Runs the real migration, then `tools/custom_role_rollback/sqlite.sql`} + +/// Runs the real migration, then `tools/custom_role_rollback/sqlite.sql`, then the migration 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. +/// The claim the rollback tooling rests on: downgrade then upgrade again has to arrive at the same +/// permissions, or the escape hatch quietly rewrites authorization. #[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 MIGRATION: &str = + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql"); + const REVERT: &str = include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql"); 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)"; + const VERSION: &str = "20260630120000"; - /// `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 DOWNGRADE_ACK: &str = + "CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY)"; + const ALLOWLIST: &str = + "CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY)"; + + /// `users_organizations` exactly as current upstream main leaves it -- the rollback script checks + /// for *precisely* eighteen columns and two indexes afterwards, so a reduced fixture would not + /// exercise the checks it exists for. const UPSTREAM_SCHEMA: &str = " CREATE TABLE __diesel_schema_migrations ( version VARCHAR(50) NOT NULL PRIMARY KEY, @@ -2638,8 +2329,8 @@ mod custom_role_rollback_sql_tests { ('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_owner', 'u1', 'org', TRUE, 2, 0), + ('m_admin', 'u2', 'org', TRUE, 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), @@ -2661,6 +2352,10 @@ mod custom_role_rollback_sql_tests { diesel::sql_query(query).load::(connection).unwrap().into_iter().map(|row| row.value).collect() } + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + rows(connection, &format!("SELECT ({query}) || '' AS value"))[0].parse().unwrap() + } + /// Every membership's role plus its nine permissions, as one comparable line each. fn permission_state(connection: &mut SqliteConnection) -> Vec { rows( @@ -2681,18 +2376,14 @@ mod custom_role_rollback_sql_tests { ) } - /// Applies the chain, recording each version the way Diesel would. + /// Applies the migration, recording its 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(()) + connection.batch_execute(MIGRATION)?; + connection.batch_execute(&format!("INSERT INTO __diesel_schema_migrations (version) VALUES ('{VERSION}')")) } - /// `.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 + /// `.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('.')); @@ -2712,1232 +2403,648 @@ mod custom_role_rollback_sql_tests { 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. + /// The whole point of the tooling: upgrade, roll back, upgrade again, and land on the same + /// permissions. The allowlist is what makes it converge -- it names exactly the memberships that + /// were Managers, which is what the second upgrade then reads. #[test] - fn the_diesel_revert_chain_runs_with_one_acknowledgement() { + fn upgrade_rollback_and_upgrade_again_converge() { let mut connection = connect(); - let before = legacy_state(&mut connection); - - connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); upgrade(&mut connection).unwrap(); + let after_first_upgrade = permission_state(&mut connection); - // One decision, plus the historical provenance as the allowlist -- what the README suggests. - connection.batch_execute(DOWNGRADE_ACK).unwrap(); + connection.batch_execute(ALLOWLIST).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;", + "INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES \ + ('m_mgr_bare'), ('m_mgr_all'), ('m_mgr_group'), ('m_mgr_gone')", ) .unwrap(); - - for (step, down) in REVERT_CHAIN.iter().enumerate() { - connection.batch_execute(down).unwrap_or_else(|e| panic!("revert step {step} failed: {e}")); - } + connection.batch_execute(&rollback_sql()).unwrap(); 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/" + [ + "m_admin atype=1 access_all=1", + "m_mgr_all atype=3 access_all=1", + "m_mgr_bare atype=3 access_all=0", + // Group-derived authority came back as 0/1/1, which is not all three, so the legacy + // "manage all collections" bit stays off -- the old binary derives the same authority + // from `groups.access_all` again anyway. + "m_mgr_gone atype=3 access_all=0", + "m_mgr_group atype=3 access_all=0", + "m_owner atype=0 access_all=1", + "m_user atype=2 access_all=0", + ] ); + assert_eq!(count(&mut connection, "SELECT COUNT(*) FROM __diesel_schema_migrations"), 1); + + upgrade(&mut connection).unwrap(); + assert_eq!(permission_state(&mut connection), after_first_upgrade, "the round trip must converge"); } - /// Without the acknowledgement the chain stops at the guard, before the first destructive step, - /// and changes nothing. + /// The role mapping is a decision, not a conversion, so the script refuses to make it up. #[test] - fn the_revert_chain_stops_at_the_guard_and_mutates_nothing() { + 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); + let before = 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!(connection.batch_execute(&rollback_sql()).is_err()); - assert_eq!(permission_state(&mut connection), upgraded, "a refused revert must not mutate"); + assert_eq!(permission_state(&mut connection), before); + assert_eq!(count(&mut connection, "SELECT COUNT(*) FROM __diesel_schema_migrations"), 2); } - /// 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. + /// A migration this script has never seen may have changed anything, including the table it + /// rebuilds from a fixed column list. #[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');", - ), - ]; + fn the_rollback_refuses_a_ledger_from_the_future() { + let mut connection = connect(); + upgrade(&mut connection).unwrap(); + connection.batch_execute(ALLOWLIST).unwrap(); + connection.batch_execute("INSERT INTO __diesel_schema_migrations (version) VALUES ('20270101000000')").unwrap(); + let before = permission_state(&mut connection); - 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}"); - } + assert!(connection.batch_execute(&rollback_sql()).is_err()); + assert_eq!(permission_state(&mut connection), before); } + /// The Diesel alternative the README documents, end to end. Both decisions are required, and both + /// are consumed by the revert they authorize. #[test] - fn upgrade_rollback_and_upgrade_again_converge() { + fn the_diesel_revert_runs_with_both_acknowledgements() { 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(DOWNGRADE_ACK).unwrap(); + connection.batch_execute(ALLOWLIST).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;", + "INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES \ + ('m_mgr_bare'), ('m_mgr_all'), ('m_mgr_group'), ('m_mgr_gone')", ) .unwrap(); - connection.batch_execute(&rollback_sql()).unwrap(); + connection.batch_execute(REVERT).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( + // The fixture's Owner and Admin already carry the bit, which is what current main writes for + // them, so this database round-trips byte-identically. (A database where an Owner somehow had + // it cleared would come back with it set: the upgrade dropped the column precisely because + // their role already implies it, so the original value is gone.) + assert_eq!(legacy_state(&mut connection), before); + assert!( + count( &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" + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' \ + AND name IN ('__vw_allow_custom_role_downgrade', '__vw_rollback_manager_allowlist')" + ) == 0, + "both decisions authorized exactly this downgrade" ); - - // 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. + /// Without the acknowledgement the revert stops before its first mutation. #[test] - fn the_rollback_refuses_without_an_allowlist_and_changes_nothing() { + fn the_revert_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); + let before = permission_state(&mut connection); - assert!(connection.batch_execute(&rollback_sql()).is_err()); + assert!(connection.batch_execute(REVERT).is_err()); + assert_eq!(permission_state(&mut connection), before); - 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" - ); - } -} - -/// MySQL/MariaDB use numeric comparison when one side of an equality is numeric. A malformed -/// rollback allowlist with an INT column can consequently select UUIDs that were never placed on -/// the list. These source-level contract tests complement the backend rollback tests: both entry -/// points must validate the documented CHAR(36) shape before their first role-mapping query. -#[cfg(test)] -mod mysql_custom_role_rollback_sql_tests { - const STANDALONE_ROLLBACK: &str = include_str!("../../tools/custom_role_rollback/mysql.sql"); - const DIESEL_DOWN_MIGRATION: &str = - include_str!("../../migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql"); - const ROLE_MAPPING: &str = "uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist)"; - const STANDALONE_FIRST_MUTATION: &str = "ALTER TABLE users_organizations ADD COLUMN access_all"; - const DIESEL_FIRST_AUTHORIZATION_MUTATION: &str = "UPDATE users_organizations SET atype = 3"; - - fn assert_char_36_guard_precedes_mutation(sql: &str, mutation: &str, expected_guard_copies: usize) { - let role_mapping = sql.find(ROLE_MAPPING).expect("rollback must contain the allowlist role mapping"); - let mutation = sql.find(mutation).expect("rollback must contain the guarded mutation"); - assert!(mutation <= role_mapping, "the selected boundary must precede the role mapping"); - let preconditions = &sql[..mutation]; - - assert_eq!( - preconditions.matches("data_type = 'char'").count(), - expected_guard_copies, - "every allowlist shape check must require a character column" - ); - assert_eq!( - preconditions.matches("character_maximum_length = 36").count(), - expected_guard_copies, - "every allowlist shape check must require the complete UUID length" - ); - } - - #[test] - fn non_char_36_allowlists_are_rejected_before_mysql_role_mapping() { - // The standalone script duplicates each predicate: once for its readable diagnostic and - // once for the duplicate-key guard that actually stops execution. - assert_char_36_guard_precedes_mutation(STANDALONE_ROLLBACK, STANDALONE_FIRST_MUTATION, 2); - assert_char_36_guard_precedes_mutation(DIESEL_DOWN_MIGRATION, DIESEL_FIRST_AUTHORIZATION_MUTATION, 1); + // The acknowledgement alone is not enough either: the role mapping is a separate decision. + connection.batch_execute(DOWNGRADE_ACK).unwrap(); + assert!(connection.batch_execute(REVERT).is_err()); + assert_eq!(permission_state(&mut connection), before); } } #[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, + CUSTOM_ROLE_PERMISSION_COLUMNS, CustomRoleMigrationFacts, CustomRolePreflightDecision, + EXPECTED_MEMBERSHIP_COLUMNS, LEGACY_USER_ACCESS_ALL_CLEAR_SQL, LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL, + LEGACY_USER_ACCESS_ALL_RELAX_SQL, LegacyUserAccessAllPolicy, custom_role_decision_after_legacy_resolution, + custom_role_preflight_decision, custom_role_preflight_report, legacy_user_access_all_statements, }; - 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() - } + /// The operator-facing refusal text. + fn message(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> String { + custom_role_preflight_report(decision, facts) } - #[test] - fn empty_database_can_run_normal_migrations() { - assert_eq!(custom_role_preflight_decision(Facts::default(), false), Decision::Proceed); + /// Refusing is the default policy; the tests that care about the other two pass them explicitly. + /// MySQL/MariaDB is the backend an interrupted upgrade can be resumed on, so it is the default + /// here too; `decide_atomic` covers SQLite and PostgreSQL. + fn decide(facts: CustomRoleMigrationFacts) -> CustomRolePreflightDecision { + custom_role_preflight_decision(facts, LegacyUserAccessAllPolicy::Refuse, true) } - #[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 - ); + /// The same question on a backend that runs the whole migration in one transaction. + fn decide_atomic(facts: CustomRoleMigrationFacts) -> CustomRolePreflightDecision { + custom_role_preflight_decision(facts, LegacyUserAccessAllPolicy::Refuse, false) } - /// A database on which the whole chain has already run. - fn fully_migrated() -> Facts { - Facts { + /// A database that has not been upgraded yet and has nothing to decide. + fn ready() -> CustomRoleMigrationFacts { + CustomRoleMigrationFacts { 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, + migration_applied: false, + access_all_column_exists: 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, + migration_ledger_exists: true, + // The legacy schema: no permission columns yet, `access_all` instead of the nine. + permission_columns_present: 0, + permission_columns_not_null: 0, + membership_column_count: 10, + expected_membership_columns_present: 9, + legacy_manager_rows: 0, + newer_migration_recorded: false, } } - /// 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() + /// A database on which the migration ran to completion but whose ledger entry never committed -- + /// what an interrupted migration leaves behind on MySQL and MariaDB. + fn completed_but_unrecorded() -> CustomRoleMigrationFacts { + CustomRoleMigrationFacts { + memberships_table_exists: true, + migration_applied: false, + access_all_column_exists: false, + legacy_user_access_all_count: 0, + migration_ledger_exists: true, + permission_columns_present: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + permission_columns_not_null: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + membership_column_count: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + expected_membership_columns_present: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + legacy_manager_rows: 0, + newer_migration_recorded: false, } } - /// 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}"); + /// What an interrupted MySQL/MariaDB upgrade leaves behind: the nine permission columns are + /// there, `access_all` has not been dropped yet, and the ledger entry never committed. + fn interrupted() -> CustomRoleMigrationFacts { + CustomRoleMigrationFacts { + permission_columns_present: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + permission_columns_not_null: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + // the finished table plus the legacy column that still has to go + membership_column_count: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len() + 1).unwrap(), + expected_membership_columns_present: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + ..ready() + } } - /// Three separate ways out, and each of them has to actually let the upgrade through. + /// Audit M-2: the state that used to reach Diesel and abort with a bare duplicate-column error. #[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 - ); + fn an_interrupted_migration_is_resumed_instead_of_reaching_diesel() { + assert_eq!(decide(interrupted()), CustomRolePreflightDecision::ResumeInterruptedMigration); } - /// 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. + /// SQLite and PostgreSQL run the whole migration in one transaction, so a half-applied schema + /// there was not produced by an interruption and must never be finished on that assumption. #[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 - ); + fn a_transactional_backend_never_resumes() { + assert_eq!(decide_atomic(interrupted()), CustomRolePreflightDecision::RefuseAmbiguousPartialMigration); } - /// 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. + /// Every single deviation from the expected schema falls back to the controlled refusal. #[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}"); + fn only_the_exact_interrupted_fingerprint_is_resumed() { + let mut partial_columns = interrupted(); + partial_columns.permission_columns_present = 4; + let mut nullable_column = interrupted(); + nullable_column.permission_columns_not_null -= 1; + let mut extra_column = interrupted(); + extra_column.membership_column_count += 1; + let mut renamed_column = interrupted(); + renamed_column.expected_membership_columns_present -= 1; + let mut tampered_ledger = interrupted(); + tampered_ledger.newer_migration_recorded = true; + let mut no_ledger = interrupted(); + no_ledger.migration_ledger_exists = false; + + for (what, facts) in [ + ("only some permission columns", partial_columns), + ("a nullable permission column", nullable_column), + ("an unknown extra column", extra_column), + ("a missing expected column", renamed_column), + ("a newer migration recorded", tampered_ledger), + ("no migration ledger", no_ledger), + ] { + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, "{what}"); } - - // 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`") - ); } + /// An untouched database still has none of the columns, so it is never mistaken for an + /// interrupted one on either kind of backend. #[test] - fn repair_marker_makes_completed_state_idempotent() { - assert_eq!(custom_role_preflight_decision(fully_migrated(), false), Decision::Proceed); + fn an_untouched_pending_database_is_not_mistaken_for_an_interrupted_one() { + assert_eq!(decide(ready()), CustomRolePreflightDecision::Proceed); + assert_eq!(decide_atomic(ready()), CustomRolePreflightDecision::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. + /// A completed-but-unrecorded database keeps its own answer: `access_all` is already gone there, + /// so it is recorded rather than resumed. #[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); + fn a_completed_migration_is_recorded_not_resumed() { + assert_eq!(decide(completed_but_unrecorded()), CustomRolePreflightDecision::RecordCompletedMigration); } - /// 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. + /// The legacy `User + access_all` question still comes first on an interrupted database — and + /// once it is resolved the resume must still happen, rather than the file going back to Diesel. #[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 - // ledger insert. A retry would fail with 1060. - for present in [1, 2] { + fn the_legacy_flag_is_answered_before_an_interrupted_migration_is_resumed() { + let mut facts = interrupted(); + facts.legacy_user_access_all_count = 2; + + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseLegacyUserAccessAll); + for (policy, expected) in [ + (LegacyUserAccessAllPolicy::Drop, CustomRolePreflightDecision::DropLegacyUserAccessAll), + (LegacyUserAccessAllPolicy::Materialize, CustomRolePreflightDecision::MaterializeLegacyUserAccessAll), + ] { + assert_eq!(custom_role_preflight_decision(facts, policy, true), expected, "{policy:?}"); assert_eq!( - custom_role_preflight_decision( - Facts { - access_permission_columns: present, - access_permissions_migration_applied: false, - ..fully_migrated() - }, - true, - ), - Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access) + custom_role_decision_after_legacy_resolution(facts, policy, true), + CustomRolePreflightDecision::ResumeInterruptedMigration, + "{policy:?} must still finish the interrupted migration" ); } - - // Ledger recorded, columns missing. - assert_eq!( - custom_role_preflight_decision( - Facts { - access_permission_columns: 2, - ..fully_migrated() - }, - true - ), - Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Access) - ); - - // Drop recorded, but the column is back: schema and ledger disagree. - assert_eq!( - custom_role_preflight_decision( - Facts { - access_all_column_exists: true, - ..fully_migrated() - }, - true - ), - Decision::RefuseAccessAllDropLedgerMismatch - ); } + /// Audit F-2. Resolving the legacy flag writes -- and `materialize` also relaxes `read_only` and + /// `hide_passwords` on assignments that already exist. Those statements commit immediately, so + /// the preflight asks what the *resolved* database would answer before running any of them: a + /// schema no backend can finish has to be refused while "Nothing has been changed" is still true. + /// + /// The two cases that must not be confused: on a backend whose schema changes are interruptible + /// the exact fingerprint still resumes (the statements do run), while every other partial schema, + /// and every partial schema at all on a transactional backend, refuses without writing. #[test] - fn a_pending_drop_after_the_repair_proceeds() { - // The repair ran, the drop is simply next in line: column present, migration not recorded. - assert_eq!( - custom_role_preflight_decision( - Facts { - access_all_column_exists: true, - access_all_drop_migration_applied: false, - access_permission_columns: 0, - access_permissions_migration_applied: false, - ..fully_migrated() - }, - false, - ), - Decision::Proceed - ); - } + fn a_partial_schema_is_refused_before_the_legacy_flag_is_resolved() { + let resolving = [LegacyUserAccessAllPolicy::Drop, LegacyUserAccessAllPolicy::Materialize]; + + let mut half_applied = interrupted(); + half_applied.legacy_user_access_all_count = 3; + half_applied.permission_columns_present = 4; + half_applied.permission_columns_not_null = 4; + for policy in resolving { + for interruptible in [true, false] { + assert_eq!( + custom_role_decision_after_legacy_resolution(half_applied, policy, interruptible), + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + "{policy:?} interruptible={interruptible}: a 4-of-9 schema must refuse, not write first" + ); + } + } - #[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}"); + let mut fingerprinted = interrupted(); + fingerprinted.legacy_user_access_all_count = 3; + for policy in resolving { + assert_eq!( + custom_role_decision_after_legacy_resolution(fingerprinted, policy, false), + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + "{policy:?}: a transactional backend never resumes, so it must refuse before writing" + ); + assert_eq!( + custom_role_decision_after_legacy_resolution(fingerprinted, policy, true), + CustomRolePreflightDecision::ResumeInterruptedMigration, + "{policy:?}: the exact fingerprint still resumes, and the statements do run" + ); + } + + // The control: with a schema that is not partial at all, resolving is all there is to do. + let mut ordinary = ready(); + ordinary.legacy_user_access_all_count = 3; + for policy in resolving { + for interruptible in [true, false] { + assert_eq!( + custom_role_decision_after_legacy_resolution(ordinary, policy, interruptible), + CustomRolePreflightDecision::Proceed, + "{policy:?} interruptible={interruptible}" + ); + } } } - /// 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. + /// The refusal has to say what was found and what to do about it, and must not claim the + /// database was changed. #[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}"); + fn the_ambiguous_refusal_reports_the_schema_and_a_way_out() { + let mut facts = interrupted(); + facts.permission_columns_present = 4; + let text = message(CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, facts); - // 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) - ); + assert!(text.contains("Nothing has been changed"), "{text}"); + assert!(text.contains("4 of its 9 permission columns"), "{text}"); + assert!(text.contains("Restore the backup"), "{text}"); + assert!(text.contains("SQLite and PostgreSQL"), "{text}"); } #[test] - fn interrupted_access_all_drop_error_names_the_ledger_fix() { - let facts = Facts { - access_all_drop_migration_applied: false, - access_permission_columns: 0, - access_permissions_migration_applied: false, - ..fully_migrated() - }; - let decision = custom_role_preflight_decision(facts, false); - 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(super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)); - assert!(message.contains("INSERT INTO __diesel_schema_migrations")); + fn an_empty_database_proceeds() { + assert_eq!(decide(CustomRoleMigrationFacts::default()), CustomRolePreflightDecision::Proceed); } #[test] - fn a_historical_drop_without_the_repair_is_refused() { - assert_eq!( - custom_role_preflight_decision( - Facts { - access_all_drop_migration_applied: true, - access_all_column_exists: false, - ..pending_repair() - }, - false, - ), - Decision::RefuseAlreadyDropped - ); + fn an_ordinary_upgrade_proceeds() { + assert_eq!(decide(ready()), CustomRolePreflightDecision::Proceed); } + /// The checks below all read the legacy schema, so an already-upgraded database must not be + /// asked about them again -- and a re-run of the question would have no data to answer it from. #[test] - fn legacy_user_access_all_error_carries_a_recovery_path() { - let facts = Facts { - legacy_user_access_all_count: 2, - ..pending_repair() + fn an_already_upgraded_database_is_not_asked_anything() { + let facts = CustomRoleMigrationFacts { + migration_applied: true, + access_all_column_exists: false, + ..ready() }; - let decision = custom_role_preflight_decision(facts, false); - 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 ... - 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")); + assert_eq!(decide(facts), CustomRolePreflightDecision::Proceed); } #[test] - fn already_dropped_error_points_at_the_backup() { - let facts = Facts { - access_all_drop_migration_applied: true, - ..pending_repair() + fn a_pending_migration_without_the_legacy_column_is_refused() { + let facts = CustomRoleMigrationFacts { + access_all_column_exists: false, + ..ready() }; - let decision = custom_role_preflight_decision(facts, false); - assert_eq!(decision, Decision::RefuseAlreadyDropped); + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseMissingAccessAll); + } - 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("Restore the database backup")); + /// The state an interrupted migration leaves on MySQL/MariaDB: every ALTER TABLE committed on + /// its own, so the schema is final, but the process died before Diesel recorded the migration. + /// The database is already correct; only the ledger entry is missing. + #[test] + fn a_completed_migration_with_no_ledger_entry_is_recorded_instead_of_refused() { + assert_eq!(decide(completed_but_unrecorded()), CustomRolePreflightDecision::RecordCompletedMigration); } - /// 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. + /// Every individual condition has to hold. Each mutation below is a different way of arriving at + /// "the legacy column is gone" without the migration having finished, and each one must fall + /// back to refusing rather than recording a migration that did not happen. #[test] - fn legacy_user_access_all_blocks_the_upgrade_before_any_migration() { - assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); + fn an_incomplete_schema_is_never_mistaken_for_a_completed_migration() { + let complete = completed_but_unrecorded(); + let permission_columns = i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(); + let membership_columns = i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(); - 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 { - collection_permission_columns: 3, - collection_permissions_migration_applied: true, - manage_permission_columns: 3, - manage_permissions_migration_applied: true, - legacy_user_access_all_count: 1, - ..pending_repair() + let broken = [ + ( + "one permission column missing", + CustomRoleMigrationFacts { + permission_columns_present: permission_columns - 1, + permission_columns_not_null: permission_columns - 1, + membership_column_count: membership_columns - 1, + expected_membership_columns_present: membership_columns - 1, + ..complete }, - false, ), - Decision::RefuseLegacyUserAccessAll - ); - } - - #[test] - fn a_partial_permission_column_group_is_refused_with_an_actionable_message() { - // Every group is checked, not just the collection one: an interrupted MySQL migration can - // leave `manage_*` or `access_*` columns behind, and re-running it would fail forever with - // `Duplicate column name`. - for (facts, group, expected) in [ ( - Facts { - manage_permission_columns: 2, - ..pending_repair() + "a permission column is nullable", + CustomRoleMigrationFacts { + permission_columns_not_null: permission_columns - 1, + ..complete }, - "manage_users", - Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Manage), ), ( - Facts { - manage_permission_columns: 3, - manage_permissions_migration_applied: true, - access_permission_columns: 3, - ..pending_repair() + "an unexpected extra column", + CustomRoleMigrationFacts { + membership_column_count: membership_columns + 1, + ..complete }, - "access_event_logs", - Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access), ), ( - Facts { - manage_permission_columns: 1, - manage_permissions_migration_applied: true, - ..pending_repair() + "an expected column is missing but the count matches", + CustomRoleMigrationFacts { + expected_membership_columns_present: membership_columns - 1, + ..complete }, - "manage_users", - Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Manage), ), - ] { - // `true` = MySQL: only the historical collection-group state is auto-completed, never these. - assert_eq!(custom_role_preflight_decision(facts, true), expected); - assert_eq!(custom_role_preflight_decision(facts, false), expected); - - let error = custom_role_preflight_error(expected, facts); - let message = error.source().expect("preflight error should retain its I/O error source").to_string(); - assert!(message.contains(group), "message should name the affected columns: {message}"); - assert!(message.contains("ALTER TABLE users_organizations DROP COLUMN")); - } - } + ( + "memberships still on the legacy Manager role", + CustomRoleMigrationFacts { + legacy_manager_rows: 1, + ..complete + }, + ), + ( + "the ledger records something newer", + CustomRoleMigrationFacts { + newer_migration_recorded: true, + ..complete + }, + ), + ( + "there is no ledger to record into", + CustomRoleMigrationFacts { + migration_ledger_exists: false, + ..complete + }, + ), + ]; - /// 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 a_group_derived_legacy_manager_needs_no_preflight_decision() { - assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); - for same_run_0716_marker in [false, true] { + for (label, facts) in broken { 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 + decide(facts), + CustomRolePreflightDecision::RefuseMissingAccessAll, + "{label} must not be treated as a completed migration" ); } } - /// 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. + /// Audit M-2: the two earlier MySQL/MariaDB interruption points still have `access_all`, so they + /// never reach the completed-schema check. They used to fall through as an ordinary pending + /// migration, which handed the file back to Diesel and aborted startup on the duplicate column. + /// Both are now recognised and finished instead. #[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() + fn both_earlier_interruption_points_are_resumed() { + let after_add_column = CustomRoleMigrationFacts { + permission_columns_present: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + permission_columns_not_null: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + membership_column_count: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap() + 1, + expected_membership_columns_present: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + legacy_manager_rows: 3, + ..ready() }; + assert_eq!(decide(after_add_column), CustomRolePreflightDecision::ResumeInterruptedMigration); - 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 after_update = CustomRoleMigrationFacts { + legacy_manager_rows: 0, + ..after_add_column }; - 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}"); + assert_eq!(decide(after_update), CustomRolePreflightDecision::ResumeInterruptedMigration); } - /// 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". + /// An already-recorded migration is never re-examined, so the repair cannot fire twice. #[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() + fn recording_the_migration_is_idempotent() { + let recorded = CustomRoleMigrationFacts { + migration_applied: true, + ..completed_but_unrecorded() }; - 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}"); + assert_eq!(decide(recorded), CustomRolePreflightDecision::Proceed); } + /// The refusal text used to claim this state could not occur, and sent the operator to a backup. #[test] - fn exact_mysql_partial_schema_uses_only_the_mysql_completion_path() { - let facts = Facts { - collection_permission_columns: 3, - collection_permissions_migration_applied: false, - ..pending_repair() - }; - assert_eq!(custom_role_preflight_decision(facts, true), Decision::CompleteMysqlCollectionMigration); - assert_eq!( - custom_role_preflight_decision(facts, false), - Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection) + fn the_missing_column_recovery_text_no_longer_denies_the_state_can_occur() { + let message = message( + CustomRolePreflightDecision::RefuseMissingAccessAll, + CustomRoleMigrationFacts { + access_all_column_exists: false, + ..ready() + }, ); + assert!(!message.contains("does not arise from any Vaultwarden version"), "{message}"); + assert!(message.contains("repaired automatically"), "{message}"); } + /// The three documented values, and nothing else. An unparsable value never reaches the + /// preflight -- `validate_config` rejects it at startup -- but it still has to fail closed. #[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() - }; + fn the_legacy_user_access_all_policy_parses_only_the_documented_values() { + assert_eq!(LegacyUserAccessAllPolicy::from_config("refuse"), Some(LegacyUserAccessAllPolicy::Refuse)); + assert_eq!(LegacyUserAccessAllPolicy::from_config("drop"), Some(LegacyUserAccessAllPolicy::Drop)); + assert_eq!(LegacyUserAccessAllPolicy::from_config("materialize"), Some(LegacyUserAccessAllPolicy::Materialize)); assert_eq!( - custom_role_preflight_decision(facts, true), - Decision::RefuseUnconfirmedPermanentCollectionAuthority, - "the owner decision must precede complete_partial_collection_migration()" + LegacyUserAccessAllPolicy::from_config(" MATERIALIZE "), + Some(LegacyUserAccessAllPolicy::Materialize) ); - 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" - ); + for value in ["", " ", "yes", "true", "1", "keep", "refuse-all", "dropall"] { + assert_eq!(LegacyUserAccessAllPolicy::from_config(value), None, "{value} must not parse"); + } + assert_eq!(LegacyUserAccessAllPolicy::default(), LegacyUserAccessAllPolicy::Refuse); } - /// 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. + /// The policy selects what happens to an affected membership, and does nothing at all when + /// there is none -- a database without the legacy flag must upgrade identically whatever it is + /// set to. #[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] { + fn the_configured_policy_only_decides_what_happens_to_an_affected_membership() { + let mut affected = ready(); + affected.legacy_user_access_all_count = 2; + + for (policy, expected) in [ + (LegacyUserAccessAllPolicy::Refuse, CustomRolePreflightDecision::RefuseLegacyUserAccessAll), + (LegacyUserAccessAllPolicy::Drop, CustomRolePreflightDecision::DropLegacyUserAccessAll), + (LegacyUserAccessAllPolicy::Materialize, CustomRolePreflightDecision::MaterializeLegacyUserAccessAll), + ] { + assert_eq!(custom_role_preflight_decision(affected, policy, true), expected, "{policy:?}"); + // nothing to resolve -> the policy is inert 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" + custom_role_preflight_decision(ready(), policy, true), + CustomRolePreflightDecision::Proceed, + "{policy:?} must not change an unaffected database" ); } } + /// A damaged schema still outranks the flag, whatever the policy says: the resolution statements + /// read `access_all`, so they cannot run once the column is gone. #[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; + fn the_policy_never_overrides_a_refusal_about_the_schema() { + let mut broken = ready(); + broken.access_all_column_exists = false; + broken.legacy_user_access_all_count = 3; - assert!(function.contains("connection.transaction"), "the ledger repair must commit with autocommit=0"); - assert!(function.contains("super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION")); + for policy in + [LegacyUserAccessAllPolicy::Refuse, LegacyUserAccessAllPolicy::Drop, LegacyUserAccessAllPolicy::Materialize] + { + assert_eq!( + custom_role_preflight_decision(broken, policy, true), + CustomRolePreflightDecision::RefuseMissingAccessAll, + "{policy:?}" + ); + } } + /// `materialize` writes, then clears; `drop` only clears. The clear is always last, because the + /// statements before it select on the flag -- and its row count is what gets logged. #[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, + fn the_resolution_statements_end_with_the_clear() { + let materialize = + legacy_user_access_all_statements(CustomRolePreflightDecision::MaterializeLegacyUserAccessAll); + assert_eq!(materialize.len(), 3); + assert_eq!(materialize[0], LEGACY_USER_ACCESS_ALL_RELAX_SQL); + assert_eq!(materialize[1], LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL); + assert_eq!(materialize[2], LEGACY_USER_ACCESS_ALL_CLEAR_SQL); + + let drop = legacy_user_access_all_statements(CustomRolePreflightDecision::DropLegacyUserAccessAll); + assert_eq!(drop, [LEGACY_USER_ACCESS_ALL_CLEAR_SQL]); + + for decision in [ + CustomRolePreflightDecision::Proceed, + CustomRolePreflightDecision::RecordCompletedMigration, + CustomRolePreflightDecision::RefuseMissingAccessAll, + CustomRolePreflightDecision::RefuseLegacyUserAccessAll, ] { - assert!(query.contains(relation), "namespace guard does not bind {relation}: {query}"); + assert!(legacy_user_access_all_statements(decision).is_empty(), "{decision:?}"); } - 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. + /// The refusal has to point at the setting that resolves it, and keep both manual procedures. #[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() - }; + fn the_recovery_text_offers_the_automatic_resolution() { + let mut facts = ready(); + facts.legacy_user_access_all_count = 1; + let text = message(CustomRolePreflightDecision::RefuseLegacyUserAccessAll, facts); - 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" - ); - } + assert!(text.contains("LEGACY_USER_ACCESS_ALL_MIGRATION")); + assert!(text.contains("materialize")); + assert!(text.contains("drop")); + // access_all overrode both flags, so the manual path has to relax existing rows too + assert!(text.contains("SET read_only = FALSE, hide_passwords = FALSE")); + assert!(text.contains("INSERT INTO users_collections")); } #[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)); + fn legacy_user_access_all_is_refused_with_a_recovery_path() { + let facts = CustomRoleMigrationFacts { + legacy_user_access_all_count: 2, + ..ready() + }; + let decision = decide(facts); + assert_eq!(decision, CustomRolePreflightDecision::RefuseLegacyUserAccessAll); + + let message = message(decision, facts); + assert!(message.contains("Nothing has been changed."), "{message}"); + assert!(message.contains("Found 2 membership(s)"), "{message}"); + assert!(message.contains("SET access_all = FALSE"), "{message}"); + assert!(message.contains("INSERT INTO users_collections"), "{message}"); } + /// The state this revision stopped refusing: a legacy Manager reaching every collection through + /// an organization-local `access_all` group. The preflight has no fact for it any more, so there + /// is nothing left that could stop an ordinary upgrade. #[test] - fn same_run_mysql_partial_query_requires_the_current_group_source() { - let query = mysql_partial_unexpected_values_query(true); - assert!(query.contains("access_all = FALSE")); - assert!(query.contains("edit_any_collection = TRUE")); - assert!(query.contains("delete_any_collection = TRUE")); - assert!(query.contains("INNER JOIN `groups` AS g")); - assert!(query.contains("g.organizations_uuid = users_organizations.org_uuid")); - 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. + fn group_derived_collection_authority_never_stops_startup() { + assert_eq!(decide(ready()), CustomRolePreflightDecision::Proceed); + } + + /// A damaged legacy schema outranks the unrepresentable-state check: its answer would be + /// unreadable, and the migration cannot run either way. #[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}" - ); + fn a_damaged_schema_outranks_the_legacy_user_check() { + let facts = CustomRoleMigrationFacts { + access_all_column_exists: false, + legacy_user_access_all_count: 1, + ..ready() + }; + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseMissingAccessAll); } + /// Every refusal promises the operator that startup stopped before anything was touched. The + /// preflight only ever reads, so that promise holds by construction -- this pins the wording that + /// carries it. #[test] - fn incomplete_columns_and_ledger_mismatch_are_refused() { - assert_eq!( - custom_role_preflight_decision( - Facts { - collection_permission_columns: 2, - ..pending_repair() - }, - true, - ), - Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection) - ); - assert_eq!( - custom_role_preflight_decision( - Facts { - collection_permission_columns: 2, - collection_permissions_migration_applied: true, - ..pending_repair() - }, - true, - ), - Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Collection) - ); + fn every_refusal_says_nothing_has_been_changed() { + for decision in [ + CustomRolePreflightDecision::RefuseMissingAccessAll, + CustomRolePreflightDecision::RefuseLegacyUserAccessAll, + ] { + let message = message(decision, ready()); + assert!(message.contains("Nothing has been changed."), "{decision:?}: {message}"); + } } } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f1faede9..d688ec9e 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -601,17 +601,13 @@ impl Cipher { cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> Option<(bool, bool, bool)> { - // Security: central fail-closed check binding cipher -> organization -> confirmed membership. + // Security: central fail-closed check binding cipher -> organization -> *confirmed* membership. + // It denies access from assignment rows that outlived a revoke (or are still only + // invited/accepted) and from cross-organization assignments another path might have persisted; + // without it the queries below would keep honouring them. // - // In the direct (non-sync) authorization path an organization cipher is only accessible to a - // user who has a *confirmed* membership in that same organization. This denies access to - // members whose collection/group assignment rows still exist after they were revoked (or are - // still only invited/accepted), and to cross-organization collection/group assignments that - // another code path might have persisted. Without it, the queries below would keep granting - // access from those stale or cross-tenant rows (security audit findings H-1, H-2, H-3). - // - // The sync path (cipher_sync_data is Some) is intentionally left to the caller: it is built - // only from confirmed memberships and evaluated below against that cached data. + // The sync path (cipher_sync_data is Some) is left to the caller: it is built only from + // confirmed memberships and evaluated below against that cached data. if cipher_sync_data.is_none() && let Some(ref org_uuid) = self.organization_uuid && Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await.is_none() @@ -690,10 +686,9 @@ impl Cipher { // Check whether this cipher is in any collections accessible to the // user. If so, retrieve the access flags for each collection. // - // Security: bind the assignment to a *confirmed* membership in the same organization as - // both the cipher and the collection. Without this, a `users_collections` row left behind - // after a revoke, or an assignment pointing at a collection in a different organization, - // would keep granting access (defense in depth for audit findings H-1 and H-3). + // Security: bind the assignment to a *confirmed* membership in the same organization as both + // the cipher and the collection, so a row left behind by a revoke, or pointing at another + // organization's collection, grants nothing. Defense in depth. ciphers::table .filter(ciphers::uuid.eq(cipher_uuid)) .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) @@ -727,11 +722,9 @@ impl Cipher { let cipher_uuid = self.uuid.clone(); let user_uuid = user_uuid.clone(); conn.run(move |conn| { - // Security: bind the group assignment to a *confirmed* membership and require that the - // cipher, the collection, the group and the membership all belong to the same - // organization. The `collections` join in particular prevents a cross-organization - // collection<->group assignment from granting access to a foreign organization's ciphers - // (defense in depth for audit findings H-1, H-2 and H-3). + // Security: bind the group assignment to a *confirmed* membership and require cipher, + // collection, group and membership to share one organization. The `collections` join is what + // stops a cross-organization collection<->group assignment reaching foreign ciphers. ciphers::table .filter(ciphers::uuid.eq(cipher_uuid)) .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index b31ef9e8..bfa5006f 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -57,9 +57,8 @@ pub struct CollectionCipher { /// Serialize the assignment-level `manage` capability using the same role boundary as the /// collection mutation guards. Read/write access is deliberately not management authority. /// -/// This answers "may this member manage this collection?" and therefore belongs on the objects a -/// member receives about themselves. For the administrative lists that echo a *stored* grant back -/// to the client, use `stored_assignment_manage` instead. +/// Belongs on what a member receives about themselves; the administrative lists echo a *stored* +/// grant and use `stored_assignment_manage` instead. pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool { match MembershipType::from_i32(membership_type) { Some(MembershipType::Owner | MembershipType::Admin) => true, @@ -70,11 +69,10 @@ pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: /// Serialize a *stored* per-collection assignment row for the admin-console access lists. /// -/// These lists describe the grant an administrator configured, and the client writes the very same -/// value back when the dialog is saved. Reporting anything other than the persisted bit would make -/// an unrelated save silently strip it — for a plain User that would also revoke the cipher write -/// access `users_collections.manage` still grants (see `Cipher::get_access_restrictions`). Admins -/// and Owners manage implicitly, so they are reported as managing regardless of the stored row. +/// The client writes the same value back when the dialog is saved, so reporting anything other than +/// the persisted bit would make an unrelated save silently strip it — for a plain User that also +/// revokes the cipher write access `users_collections.manage` grants. Admins and Owners manage +/// implicitly and are reported as such regardless of the stored row. pub(super) fn stored_assignment_manage(membership_type: i32, stored_manage: bool) -> bool { matches!(MembershipType::from_i32(membership_type), Some(MembershipType::Owner | MembershipType::Admin)) || stored_manage @@ -133,14 +131,11 @@ impl Collection { let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data { match cipher_sync_data.members.get(&self.org_uuid) { Some(m) => { - // What the client is told here has to match what the collection guards actually - // allow, or it renders the wrong controls. A stored grant therefore counts even - // for a member who already reaches every collection: full visibility is not - // management authority, but it does not cancel out a real grant either. - // - // 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. + // What the client is told has to match what the collection guards allow, or it renders the + // wrong controls. A stored grant therefore counts even for a member who already reaches every + // collection. Reaching every collection through a group with `access_all` deliberately does + // not: the guards accept an explicit `users_collections.manage` / + // `collections_groups.manage` row only. let assignment = cipher_sync_data .user_collections .get(&self.uuid) diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 2f5348d7..b2b1aa20 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -269,10 +269,9 @@ impl Group { pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool { conn.run(move |conn| { - // Security: the membership linked through `groups_users` must itself belong to the same - // organization as the group and must be confirmed. Otherwise a cross-organization - // `groups_users` row (a member of org A linked to an access-all group of org B) would let - // that member pass as having full access to org B (audit finding H-2). + // Security: the membership linked through `groups_users` must be confirmed and belong to the + // same organization as the group, or a cross-organization row would pass as full access to + // that organization. groups::table .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) .inner_join( @@ -327,11 +326,9 @@ impl Group { impl CollectionGroup { pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { - // Security (audit H-3): never persist a cross-organization link between a collection and a - // group. Both must belong to the organization this assignment is scoped to; otherwise a - // caller could attach a foreign-tenant group to this organization's collection and thereby - // grant that group's members access to it. This is a defense-in-depth guard so no route can - // create such a link even if it fails to validate its inputs. + // Security: never persist a cross-organization link between a collection and a group -- + // attaching a foreign-tenant group to this organization's collection would grant its members + // access. Defense in depth, so no route can create one even if its own validation is wrong. if Collection::find_by_uuid_and_org(&self.collections_uuid, org_uuid, conn).await.is_none() || Group::find_by_uuid_and_org(&self.groups_uuid, org_uuid, conn).await.is_none() { @@ -512,11 +509,9 @@ impl CollectionGroup { impl GroupUser { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { - // Security (audit H-2): never persist a cross-organization link between a group and a - // membership. The group must belong to the same organization as the membership; otherwise a - // caller could grant a member of one organization full access to another organization's - // collections through an access-all group. This is a defense-in-depth guard so no route can - // create such a link even if it fails to validate its inputs. + // Security: never persist a cross-organization link between a group and a membership -- that + // would grant a member of one organization access to another's collections through an + // access-all group. Defense in depth, so no route can create one even if its own validation is wrong. let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else { err!("Member not found while assigning to group") }; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d41dd5df..00062832 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -151,11 +151,9 @@ 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. +/// Queries enumerate the two values instead of comparing `atype <= Admin`: `<=` also matches every +/// value *below* `Owner`, so a corrupt or negative `atype` would satisfy the SQL check while every +/// Rust guard rejects it. Enumerating 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 { @@ -869,6 +867,23 @@ impl Membership { self.atype >= MembershipType::Admin || self.has_edit_any_collection() } + /// Whether enabling an organization policy may revoke this membership as part of enforcing it. + /// + /// Two exclusions, both applying to every policy whose enforcement revokes non-compliant members + /// (Two-Factor Authentication and Single Organization): + /// + /// * Admins and Owners are never revoked. `atype < Admin` is deliberately the *ceiling* comparison + /// used everywhere else, so an unknown stored role stays sweepable. + /// * Nor is the member who made the change. Until the Custom role this was implied by the first + /// rule, since only Admins and Owners reached the policy endpoints; `managePolicies` can now be + /// held by a Custom member, who *is* sweepable and would otherwise revoke themselves mid-request. + /// Bitwarden excludes the acting user for the same reason. + /// + /// Peers are still revoked exactly as before. + pub fn is_policy_enforcement_target(&self, acting_user: &UserId) -> bool { + self.atype < MembershipType::Admin && &self.user_uuid != acting_user + } + // The granular custom permission flags are only meaningful while the membership is of // the Custom type. Gating them on the type here ensures that a stale flag left over from // a type change (e.g. via the admin panel) can never grant anything. @@ -912,14 +927,12 @@ impl Membership { /// 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. + /// There is deliberately no live exception 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") would also match every newly created flagless + /// Custom member, so joining one to an ordinary `access_all` group would hand out organization-wide + /// edit and delete. The migration writes 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(); @@ -1498,20 +1511,18 @@ 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. + /// A stored `atype` that no role maps to is *incomparable*, and the two directions resolve that + /// differently on purpose — both fail-closed, 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. + /// `MembershipType op i32` — "does the caller outrank this role?" — answers no: `gt`/`ge` are false + /// for an unknown value, so nothing is 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. + /// `i32 op MembershipType` — "is this membership at most that role?" — answers yes: `lt`/`le` are + /// true. Every use is a *ceiling* (`atype < Admin`), so treating an unrecognized value as low-ranked + /// is the restrictive reading, and the one place that phrases a permission this way + /// (`check_reset_password_applicable_and_permissions`) guards against `Owner`, whose discriminant is + /// 0 and therefore never unknown. #[test] #[expect( clippy::nonminimal_bool, @@ -1546,6 +1557,44 @@ mod tests { assert!(MembershipType::Custom >= MembershipType::Custom as i32); } + /// Policy enforcement revokes non-compliant peers, never Admins/Owners, and never the member + /// who enabled the policy. Before `managePolicies` existed the last rule was implied by the + /// second one; a Custom member can now trigger a sweep it would otherwise be caught by. + #[test] + fn policy_enforcement_never_targets_admins_or_the_acting_member() { + let actor: UserId = "actor".to_owned().into(); + let other: UserId = "other".to_owned().into(); + + for role in [MembershipType::Owner, MembershipType::Admin] { + let mut member = membership(role); + member.user_uuid = other.clone(); + assert!(!member.is_policy_enforcement_target(&actor), "admins and owners are never swept"); + member.user_uuid = actor.clone(); + assert!(!member.is_policy_enforcement_target(&actor)); + } + + for role in [MembershipType::User, MembershipType::Custom] { + let mut member = membership(role); + + // A peer of that role is still a target -- enforcement itself is unchanged. + member.user_uuid = other.clone(); + assert!(member.is_policy_enforcement_target(&actor), "peers must still be revoked"); + + // The member performing the policy change is not. + member.user_uuid = actor.clone(); + assert!(!member.is_policy_enforcement_target(&actor), "the acting member must be excluded"); + } + + // An unknown stored role keeps the pre-existing fail-closed behaviour of the `< Admin` + // ceiling: it is still a target, and the actor exclusion still applies to it. + let mut corrupt = membership(MembershipType::User); + corrupt.atype = 42; + corrupt.user_uuid = other; + assert!(corrupt.is_policy_enforcement_target(&actor)); + corrupt.user_uuid = actor.clone(); + assert!(!corrupt.is_policy_enforcement_target(&actor)); + } + #[test] fn custom_collection_permissions_are_independent_and_type_gated() { let mut member = membership(MembershipType::Custom); diff --git a/src/main.rs b/src/main.rs index 28645694..ea941753 100644 --- a/src/main.rs +++ b/src/main.rs @@ -553,10 +553,23 @@ fn check_web_vault() { } async fn create_db_pool() -> db::DbPool { - match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries()).await { + // A Custom-role preflight refusal is deterministic: it reads schema and ledger state that no + // amount of waiting changes. Retrying only reprinted the same answer up to + // `db_connection_retries` times, each time introduced by "Can't connect to database, retrying", + // which was never the problem. The full recovery procedure has already been logged at that + // point, so stop and report the one-line reason. + match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries(), |_| { + db::custom_role_preflight_refusal().is_none() + }) + .await + { Ok(p) => p, Err(e) => { - error!("Error creating database pool: {e:?}"); + if let Some(reason) = db::custom_role_preflight_refusal() { + error!("Not starting. {reason}"); + } else { + error!("Error creating database pool: {e:?}"); + } exit(1); } } diff --git a/src/util.rs b/src/util.rs index dd363e1d..1b15c4de 100644 --- a/src/util.rs +++ b/src/util.rs @@ -757,10 +757,17 @@ where } } -pub async fn retry_db(mut func: F, max_tries: u32) -> Result +/// Retry `func` while the database is unavailable. +/// +/// `should_retry` classifies a failure. Waiting only helps for a database that is not reachable +/// *yet*; an already-decided failure -- a migration preflight refusing this schema -- returns the +/// same answer every time, so retrying repeats its output under a misleading "Can't connect to +/// database" heading. Returning `false` stops immediately and leaves reporting to the caller. +pub async fn retry_db(mut func: F, max_tries: u32, should_retry: R) -> Result where F: FnMut() -> Result, E: std::error::Error, + R: Fn(&E) -> bool, { let mut tries = 0; @@ -770,6 +777,10 @@ where Err(e) => { tries += 1; + if !should_retry(&e) { + return Err(e); + } + if tries >= max_tries && max_tries > 0 { return Err(e); } diff --git a/tools/custom_role_rollback/README.md b/tools/custom_role_rollback/README.md index e9dbd0d9..7c75f224 100644 --- a/tools/custom_role_rollback/README.md +++ b/tools/custom_role_rollback/README.md @@ -39,19 +39,12 @@ 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. +The decision is deliberately taken **now**, from what each membership holds today, rather than from +any record of who was a Manager before the upgrade. Such a record would describe the state at the +time of the *first* upgrade and would never be 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, and the upgrade therefore keeps none. ## What is lost @@ -67,7 +60,10 @@ The old schema has nowhere to store the nine permissions, so they are dropped: Per-collection assignments (`users_collections`, `collections_groups`) and `groups.access_all` are untouched. Only `users_organizations` changes, so a member mapped to plain User keeps every grant -those tables carry and loses only the organization-wide powers the old schema cannot express. +those tables carry and loses only the organization-wide powers the old schema cannot express. A +member who comes back as Manager and is still in an organization-local `accessAll` group gets the +group-derived collection authority back automatically — the old binary derives it live from +`groups.access_all`, which the upgrade never touched. 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: @@ -88,55 +84,50 @@ Edit-any-collection deliberately does **not** become `access_all` on its own: in 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 +## How group-derived Manager authority is migrated + +Before the Custom role, a Manager could reach every collection of an organization in two ways: the +membership's own `access_all` bit, or membership of an organization-local group with `accessAll`. +The upgrade preserves both, deterministically and without asking: + +| Legacy Manager has | After the upgrade | +|---|---| +| membership `access_all = TRUE` | `createNewCollections` + `editAnyCollection` + `deleteAnyCollection` | +| only an organization-local `accessAll` group | `editAnyCollection` + `deleteAnyCollection` | +| neither | no collection permission | -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. +Collection *creation* is deliberately not granted in the second row: it always required the +membership bit, never the group. -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: +The second row is a policy choice worth knowing about. The group-derived capability used to be +dynamic — 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 is bound to a +group like that, so it becomes a membership permission and therefore: - 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. +That is accepted on purpose. The alternatives are worse: silently dropping the permission would +revoke access these members have today, and refusing to migrate would block an ordinary upgrade from +an official Vaultwarden database. After the upgrade the permission is visible in the member's +permission list and an owner can clear it with a checkbox — which is more than the old model offered, +where the same authority was invisible on the membership. + +`groups.access_all` and every `groups_users` row are left exactly as they are, so the group keeps +granting collection *access* to its members as before. A `groups_users` row pointing at another +organization's `accessAll` group conveys nothing, exactly as it conveys nothing today. + +Status is not part of the rule: an invited, accepted or revoked membership is converted like a +confirmed one. None of them holds authority in that state, and the permission is what the membership +would come back with if it is ever restored — which is exactly what `access_all` would have done. + +The only state the upgrade refuses outright is a plain **User** carrying membership `access_all`; see +above. That refusal can also be resolved without any SQL, once for the whole instance, by setting +`LEGACY_USER_ACCESS_ALL_MIGRATION` to `drop` (clear the flag; each member keeps the collections they +are explicitly assigned to) or `materialize` (write the reach out as explicit assignments first, +confirmed memberships only, then clear it). The setting is read only while that migration is pending. ## How to run it @@ -159,12 +150,10 @@ 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 +- the Custom-role migration `20260630120000` is recorded in `__diesel_schema_migrations`, +- **no migration newer than `20260630120000` is recorded** — this script does not know what a later + migration changed, and removing only the Custom-role version 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` @@ -179,21 +168,19 @@ ledger before it touches anything, and refuses unless all of these hold: 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. +permission columns were never added would get through the first `ADD COLUMN` and the value rewrites +before failing on the `DROP COLUMN` — ending up less consistent than before. -The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations`, -`__vw_rollback_manager_allowlist` and `__vw_custom_role_history_verified` once each, requires all of -them to live in the **same** schema, and addresses that schema explicitly from then on. An -unqualified name is otherwise resolved per statement through `search_path`, so a session with -`search_path = decoy, real` could have the table rewrite land in one schema and the ledger delete in -another. +The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations` and +`__vw_rollback_manager_allowlist` 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. +back on disconnect, leaving the schema old while the migration still counts as applied — and a later +upgrade would then skip it 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 @@ -210,72 +197,32 @@ The SQLite script rebuilds `users_organizations` instead of using `ALTER TABLE . only exists since SQLite 3.35 — the same reason the forward migration rebuilds the table. It therefore also works against the older system SQLite that `sqlite_system` builds link. -Afterwards start the older Vaultwarden version. Upgrading again later re-applies the nine -migrations from a clean state, and rebuilds `__vw_custom_role_legacy_manager` from the very -`atype = 3` rows the rollback restored — so the round trip converges. - -## Databases upgraded before the history marker existed - -`__vw_custom_role_history_verified` is created by `2026-06-30-120000`, and nothing else creates it. -A database upgraded by an earlier revision of this feature branch carries that migration's version in -its ledger without the table, and Diesel never re-runs a recorded version — so Vaultwarden refuses to -start and the rollback scripts refuse to run, rather than acting on migrations whose effects were -different. - -Start Vaultwarden once: it prints the full recovery, which depends on how far the earlier revision -got and covers up to three things — recording which memberships were legacy Managers, reviewing -permissions an earlier `20260809120000` granted in bulk to Custom members of `accessAll` groups, and -reviewing the direct collection assignments an earlier `20260723120000` wrote for a plain User that -carried membership `access_all`. If you still have the backup from before the first upgrade, -restoring it and upgrading again is simpler and needs no decision at all. - -The marker is created as a separate statement from the legacy-Manager record on purpose. That record -is data an operator has to be able to write during recovery, so its existence must not double as -evidence that the history behind it was reviewed — otherwise creating it empty to make the error -message go away would silently pass as the audit it is asking for. +Afterwards start the older Vaultwarden version. Upgrading again later re-applies the migration from a +clean state: it reads the restored `atype = 3` rows directly and converts them deterministically, +so the round trip converges. ## Reverting with the Diesel CLI instead -For development checkouts the down migrations do the same thing step by step. **Every one of them that -loses permission data refuses by default** — `2026-07-24-130000`, `2026-07-16-120000` and -`2026-06-30-120000` — and so does `2026-07-24-140000`, which loses nothing itself and exists to stop -the chain before the first destructive step. `2026-08-10-120000` and `2026-08-09-120000` are reverted -first and are no-ops. Acknowledge the downgrade once: +For a development checkout, `2026-06-30-120000/down.sql` does the same thing. It refuses by default +and needs the same two decisions the scripts above take, in the same order: ```sql CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); ``` -Then `diesel migration revert` works as usual for the whole chain. The acknowledgement is deliberately -*not* consumed by the first guard it satisfies: it is dropped by the oldest lossy migration -(`2026-06-30-120000`), so one decision covers one downgrade and a revert that stops halfway is still -guarded when it resumes. Re-upgrading clears a leftover acknowledgement -(`2026-07-24-140000/up.sql`), so consent never carries over into a later, unrelated revert. The -rollback scripts above drop the table as well. - -The down migrations use the same allowlist as the scripts above. Unlike the scripts they do not -refuse when `__vw_rollback_manager_allowlist` is missing — they create it empty, which means "nobody" -and maps every Custom member to plain User. Populate it first if that is not what you want. - -On SQLite the down migrations do use `ALTER TABLE ... DROP COLUMN` and therefore need SQLite 3.35 or -newer. That is fine for a development checkout with a bundled SQLite; operators on an older system -SQLite should use `sqlite.sql` above, which rebuilds the table instead. - -### MySQL/MariaDB: supported for development checkouts only - -On MySQL/MariaDB the Diesel revert chain **cannot be resumed**, and `2026-07-24-140000/down.sql` -requires a second, separate acknowledgement that says so: - -```sql -CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); -``` - -Every `ALTER TABLE` there commits on its own, while Diesel deletes the ledger row in a separate -statement afterwards. A crash in between leaves the columns gone and the migration still recorded as -applied; re-running it then fails forever with `Unknown column` (1091), the startup preflight refuses -the database — correctly — and the only way out is the backup. Making it resumable would need -conditional DDL, i.e. a stored procedure created before the checks have run. Each down migration -removes its three permission columns in a single `ALTER TABLE` rather than three, which is the -closest this backend gets to all-or-nothing, and temporary guard tables are removed with -`DROP TEMPORARY TABLE`, which is one implicit commit fewer and cannot hit a permanent table of the -same name by accident. Use `mysql.sql` above for anything you care about. +Then `diesel migration revert` works as usual. Both tables are dropped by the revert they authorize, +so one decision covers one downgrade, and `2026-06-30-120000/up.sql` clears a leftover +acknowledgement as well, so consent never carries over into a later, unrelated revert. + +Unlike the standalone scripts, the down migration has no precondition beyond those two guards: it is +a development path, and Diesel already knows the migration is recorded. + +On MySQL/MariaDB the revert **cannot be resumed**: every `ALTER TABLE` 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 with +`Unknown column` (1091) and the only way out is the backup. The down migration adds and drops its +columns in one `ALTER TABLE` each rather than one per column, 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 22756c7a..22e707fe 100644 --- a/tools/custom_role_rollback/mysql.sql +++ b/tools/custom_role_rollback/mysql.sql @@ -8,22 +8,16 @@ -- 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. +-- Precondition. Read-only and session-local: reads `information_schema` and the ledger, prints the +-- reason when the database does not fit, and aborts on a duplicate key in a TEMPORARY table. No +-- permanent object is touched, so a database this script does not fit keeps its exact state -- which +-- matters because DDL here cannot be rolled back. Without it a partially upgraded database would get +-- through several committed statements before failing on error 1091, ending up *less* consistent. -- -- 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. +-- answers a longer one with error 1648 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 run. -- --------------------------------------------------------------------------------------------- CREATE TEMPORARY TABLE __vw_rollback_precondition ( ok INTEGER NOT NULL PRIMARY KEY @@ -79,9 +73,9 @@ FROM ( ) AS c WHERE c.n <> 9; --- 3) All nine Custom-role migrations have to be recorded. +-- 3) The Custom-role migration has to be recorded. SELECT CONCAT( - 'REFUSED, nothing was changed: expected all nine Custom-role migrations in ', + 'REFUSED, nothing was changed: expected the Custom-role migration 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 @@ -89,39 +83,23 @@ FROM ( SELECT COUNT(*) AS n FROM __diesel_schema_migrations WHERE version IN ( - '20260630120000', - '20260715120000', - '20260716120000', - '20260723120000', - '20260724120000', - '20260724130000', - '20260724140000', - '20260809120000', - '20260810120000' + '20260630120000' ) ) AS c -WHERE c.n <> 9; +WHERE c.n <> 1; 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' + '20260630120000' ) ) AS c -WHERE c.n <> 9; +WHERE c.n <> 1; -- 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 +-- such a migration changed, and removing only the one version 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 ', @@ -130,7 +108,7 @@ SELECT CONCAT( FROM ( SELECT COUNT(*) AS n FROM __diesel_schema_migrations - WHERE version > '20260810120000' + WHERE version > '20260630120000' ) AS c WHERE c.n <> 0; INSERT INTO __vw_rollback_precondition (ok) @@ -138,36 +116,11 @@ SELECT 1 FROM ( SELECT COUNT(*) AS n FROM __diesel_schema_migrations - WHERE version > '20260810120000' + WHERE version > '20260630120000' ) 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 +-- 5) 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, ', @@ -190,12 +143,10 @@ FROM ( ) 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 CHAR(36) column of that name. Checking the --- type is part of the authorization boundary: MySQL/MariaDB compare a character UUID with a +-- 7) ...and it has to have the shape the role mapping reads: exactly one non-nullable, uniquely +-- indexed CHAR(36) `users_organizations_uuid`. A colliding table would otherwise pass every check +-- above and fail on the first SELECT, *after* the `ADD COLUMN` below has committed implicitly. The +-- type is part of the authorization boundary: MySQL/MariaDB compare a character UUID against a -- numeric allowlist as numbers, so an INT value such as 0 could match unrelated UUIDs. SELECT CONCAT( 'REFUSED, nothing was changed: __vw_rollback_manager_allowlist must have exactly one column ', @@ -243,22 +194,15 @@ DROP TEMPORARY TABLE __vw_rollback_precondition; ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; --- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a subset --- of what a Custom member holds -- it manages, and deletes, every collection reachable through --- `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and reads member --- and collection ACL details through `ManagerHeadersLoose`, none of which needs a permission flag in --- the old schema -- so handing it out on anything less than a current, deliberate decision would --- *grant* authority during a downgrade. `__vw_custom_role_legacy_manager` is not that decision: it --- records who was a Manager before the first upgrade and is never updated afterwards, so a member --- whose powers an owner has since reduced would get all of them back. --- --- Everything else becomes a plain User and keeps its per-collection assignments. +-- Only a membership on the allowlist comes back as Manager; everything else becomes a plain User and +-- keeps its per-collection assignments. The legacy role is not a subset of what a Custom member +-- holds, so handing it out on less than a current, deliberate decision would *grant* authority during +-- a downgrade -- and historical provenance is not that decision. See README.md. -- --- `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. +-- `access_all` follows the mapping the down migrations use: a Custom member has to hold all three +-- collection permissions, because in the old schema the bit also carried collection deletion. A +-- member mapped to plain User never keeps it: `User + access_all` is the one state the upgrade +-- refuses. UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); UPDATE users_organizations SET access_all = TRUE @@ -288,36 +232,20 @@ ALTER TABLE users_organizations DROP COLUMN access_import_export, DROP COLUMN access_reports; --- Bookkeeping tables this feature may have left behind. The legacy-Manager record goes too: a later --- re-upgrade rebuilds it from the very `atype = 3` rows this script just restored, so the round trip --- converges. -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; +-- The two decisions this rollback required. A later re-upgrade needs neither: it reads the restored +-- `atype = 3` rows directly and converts them deterministically. DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; -DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade; -DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; -DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; -DROP TABLE IF EXISTS __vw_custom_role_history_verified; --- Finally forget the nine migrations, so the older binary does not see a ledger from the future --- and a later upgrade applies them again from a clean state. +-- Finally forget the migration, so the older binary does not see a ledger from the future +-- and a later upgrade applies it again from a clean state. DELETE FROM __diesel_schema_migrations -WHERE version IN ( - '20260630120000', - '20260715120000', - '20260716120000', - '20260723120000', - '20260724120000', - '20260724130000', - '20260724140000', - '20260809120000', - '20260810120000' -); +WHERE version = '20260630120000'; -- 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 +-- schema rolled back but the migration still marked as applied. A later upgrade would then skip +-- it 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 3a4bc8ae..97b982af 100644 --- a/tools/custom_role_rollback/postgresql.sql +++ b/tools/custom_role_rollback/postgresql.sql @@ -18,7 +18,6 @@ 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; @@ -44,13 +43,6 @@ BEGIN '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 ' @@ -62,21 +54,17 @@ BEGIN 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 + OR (SELECT relnamespace FROM pg_class WHERE oid = allowlist) <> 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 ' + '__vw_rollback_manager_allowlist 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); + WHERE c.oid = allowlist); END IF; SELECT nspname INTO ns_name FROM pg_namespace WHERE oid = ns; @@ -109,15 +97,12 @@ BEGIN 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'')', + 'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version = ''20260630120000''', ns_name ) INTO ledger_rows; EXECUTE format( - 'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260810120000''', + 'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260630120000''', ns_name ) INTO future_rows; @@ -139,17 +124,17 @@ BEGIN '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.', + IF ledger_rows <> 1 THEN + RAISE EXCEPTION 'Rollback refused, nothing was changed: expected the Custom-role migration ' + '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 ' + 'changed, and removing only the Custom-role version would leave the ' 'ledger inconsistent. Use the rollback script shipped with that newer ' 'version.', future_rows; @@ -170,9 +155,9 @@ BEGIN -- 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. + -- Historical provenance would not be that decision either: a record of who was a Manager + -- before the first upgrade 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. -- @@ -219,23 +204,15 @@ BEGIN 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); + -- The two decisions this rollback required. A later re-upgrade needs neither: it reads the + -- restored `atype = 3` rows directly and converts them deterministically. 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. + -- Finally forget the migration, so the older binary does not see a ledger from the future and a + -- later upgrade applies it 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'')', + 'DELETE FROM %I.__diesel_schema_migrations WHERE version = ''20260630120000''', ns_name ); END $$; diff --git a/tools/custom_role_rollback/sqlite.sql b/tools/custom_role_rollback/sqlite.sql index 96799fa2..3128bb0c 100644 --- a/tools/custom_role_rollback/sqlite.sql +++ b/tools/custom_role_rollback/sqlite.sql @@ -2,9 +2,9 @@ -- change expects, so that older binary starts again. Read README.md in this directory first -- -- it lists exactly what is lost and how to run this safely. -- --- `ALTER TABLE ... DROP COLUMN` is avoided on purpose: it only exists since SQLite 3.35, and this --- script has to work on the same older system SQLite the forward migrations support. Rebuilding the --- table also recreates `access_all` and drops all nine permission columns in one step. +-- `ALTER TABLE ... DROP COLUMN` is avoided on purpose -- it needs SQLite 3.35, and this script has to +-- work on the same older system SQLite the forward migrations support. The rebuild recreates +-- `access_all` and drops all nine permission columns in one step. -- Stop at the first error. Without this the sqlite3 shell keeps going after a failed statement, -- and a second run -- where the SELECT below can no longer see the permission columns -- would @@ -99,27 +99,19 @@ 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) + refused_the_custom_role_migration_must_be_recorded_schema_and_ledger_disagree + CHECK (ok = 1) ); 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' + '20260630120000' ); 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 +-- A migration newer than the Custom-role one has run, so this script cannot know what it changed +-- or whether the rebuild below would undo it. Removing only that one version 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 @@ -127,23 +119,9 @@ CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger ( CHECK (ok = 0) ); INSERT INTO __vw_rollback_precondition_future_ledger (ok) -SELECT COUNT(*) FROM __diesel_schema_migrations WHERE version > '20260810120000'; +SELECT COUNT(*) FROM __diesel_schema_migrations WHERE version > '20260630120000'; 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 ( @@ -175,24 +153,16 @@ CREATE TABLE users_organizations_rollback ( -- 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. +-- Only a membership on the allowlist comes back as Manager; everything else becomes a plain User and +-- keeps every grant `users_collections` and `collections_groups` carry. The legacy role is not a +-- subset of what a Custom member holds, so handing it out on less than a current, deliberate decision +-- would *grant* authority during a downgrade -- and historical provenance is not that decision. See +-- README.md. -- --- `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. +-- `access_all` follows the mapping the down migrations use: a Custom member has to hold all three +-- collection permissions, because in the old schema the bit also carried collection deletion. A +-- member mapped to plain User never keeps it: `User + access_all` is the one state the upgrade +-- refuses, so leaving it set would strand this database. INSERT INTO users_organizations_rollback ( uuid, user_uuid, org_uuid, access_all, akey, status, atype, reset_password_key, external_id, invited_by_email @@ -224,29 +194,16 @@ DROP TABLE users_organizations; ALTER TABLE users_organizations_rollback RENAME TO users_organizations; --- Bookkeeping tables this feature may have left behind. A later re-upgrade rebuilds the provenance --- record and the history marker from the very `atype = 3` rows this script just restored, so the --- round trip converges. -DROP TABLE IF EXISTS __vw_custom_role_same_run_0716; +-- The two decisions this rollback required. A later re-upgrade needs neither: it reads the restored +-- `atype = 3` rows directly and converts them deterministically. DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; -DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; -DROP TABLE IF EXISTS __vw_custom_role_legacy_manager; -DROP TABLE IF EXISTS __vw_custom_role_history_verified; --- Finally forget the nine migrations, so the older binary does not see a ledger from the future --- and a later upgrade applies them again from a clean state. +-- Finally forget the migration, so the older binary does not see a ledger from the future and a +-- later upgrade applies it again from a clean state. DELETE FROM __diesel_schema_migrations WHERE version IN ( - '20260630120000', - '20260715120000', - '20260716120000', - '20260723120000', - '20260724120000', - '20260724130000', - '20260724140000', - '20260809120000', - '20260810120000' + '20260630120000' ); COMMIT;