Browse Source

Remove unnecessary migrations and some security fixes

pull/7397/head
tom27052006 1 week ago
parent
commit
e750b5ccaf
  1. 154
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql
  2. 158
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql
  3. 1
      migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql
  4. 13
      migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql
  5. 34
      migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql
  6. 68
      migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql
  7. 4
      migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql
  8. 101
      migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql
  9. 13
      migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql
  10. 5
      migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql
  11. 28
      migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql
  12. 5
      migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql
  13. 60
      migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql
  14. 13
      migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql
  15. 4
      migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql
  16. 111
      migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql
  17. 4
      migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql
  18. 121
      migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql
  19. 111
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql
  20. 154
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql
  21. 1
      migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql
  22. 13
      migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql
  23. 25
      migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql
  24. 60
      migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql
  25. 4
      migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql
  26. 96
      migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql
  27. 13
      migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql
  28. 5
      migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql
  29. 19
      migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql
  30. 5
      migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql
  31. 27
      migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql
  32. 11
      migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql
  33. 4
      migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql
  34. 103
      migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql
  35. 4
      migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql
  36. 111
      migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql
  37. 124
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql
  38. 187
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql
  39. 1
      migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql
  40. 12
      migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql
  41. 28
      migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql
  42. 63
      migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql
  43. 4
      migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql
  44. 98
      migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql
  45. 13
      migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql
  46. 46
      migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql
  47. 22
      migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql
  48. 5
      migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql
  49. 29
      migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql
  50. 11
      migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql
  51. 4
      migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql
  52. 107
      migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql
  53. 4
      migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql
  54. 117
      migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql
  55. 887
      src/api/core/organizations.rs
  56. 8
      src/api/core/two_factor/mod.rs
  57. 72
      src/auth.rs
  58. 12
      src/config.rs
  59. 4477
      src/db/mod.rs
  60. 31
      src/db/models/cipher.rs
  61. 27
      src/db/models/collection.rs
  62. 23
      src/db/models/group.rs
  63. 99
      src/db/models/organization.rs
  64. 17
      src/main.rs
  65. 13
      src/util.rs
  66. 211
      tools/custom_role_rollback/README.md
  67. 146
      tools/custom_role_rollback/mysql.sql
  68. 59
      tools/custom_role_rollback/postgresql.sql
  69. 89
      tools/custom_role_rollback/sqlite.sql

154
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, -- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or
-- which the legacy role/access_all schema cannot represent. The revert therefore -- the Custom role. Two explicit operator decisions are required before anything is touched, and both
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, -- are consumed at the end, so one decision covers one downgrade. Operators who only need the older
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: -- 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 ( CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY blocked INTEGER NOT NULL PRIMARY KEY
); );
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked) INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 FROM DUAL SELECT 1 FROM DUAL
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables 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; 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 -- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its -- Custom member holds, so handing it out automatically would *grant* authority during a
-- authority *now*, and it is not symmetric with the upgrade. -- 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 -- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY);
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers -- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>');
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not
-- authorization. Use a list written for this downgrade instead.
-- --
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe -- The duplicate key aborts the revert. It is only inserted while the list is absent.
-- direction: the legacy Manager role is not a subset of what a Custom member holds -- it manages, and CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard (
-- 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 (
blocked INTEGER NOT NULL PRIMARY KEY blocked INTEGER NOT NULL PRIMARY KEY
); );
INSERT INTO __vw_rollback_manager_allowlist_guard (blocked) VALUES (1); INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1);
INSERT INTO __vw_rollback_manager_allowlist_guard (blocked) INSERT INTO __vw_rollback_allowlist_guard (blocked)
SELECT 1 FROM DUAL SELECT 1 FROM DUAL
WHERE (SELECT COUNT(*) WHERE NOT EXISTS (
FROM information_schema.columns SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_name = '__vw_rollback_manager_allowlist') <> 1 AND table_name = '__vw_rollback_manager_allowlist'
OR (SELECT COUNT(*) );
FROM information_schema.columns DROP TEMPORARY TABLE __vw_rollback_allowlist_guard;
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;
UPDATE users_organizations SET atype = 3 ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist);
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with -- Roles and `access_all` are recomputed together, because in the old schema they are not
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection -- independent:
-- 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 -- * Owners and Admins always carried the bit and it grants them nothing extra;
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are -- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three
-- untouched, so these members keep every per-collection grant and lose only the organization-wide -- collection permissions -- in the old schema `access_all` also carried collection deletion, so
-- powers the old schema cannot express. -- an Edit-only member must not silently gain it;
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4; -- * 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 ALTER TABLE users_organizations
DROP COLUMN manage_users, DROP COLUMN manage_users,
DROP COLUMN manage_groups, DROP COLUMN manage_groups,
DROP COLUMN manage_policies; 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 -- Both decisions authorized *this* downgrade, not the next one.
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade
-- rebuilds all of it from the restored `atype = 3` rows.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; DROP TABLE IF EXISTS __vw_allow_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_rollback_manager_allowlist;
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager;
DROP TABLE IF EXISTS __vw_custom_role_history_verified;

158
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; -- Replace the membership-level `access_all` flag with the persisted Custom role and its nine
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; -- granular permissions.
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; --
-- Record which memberships were legacy Managers *before* anything converts them. -- Two different columns are called `access_all`, and everything below depends on keeping them apart:
-- --
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom -- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy -- at the end of this file.
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason -- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table -- here, to decide a legacy Manager's permissions; never written, and it keeps granting group
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. -- members access to every collection afterwards exactly as before.
-- --
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is -- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table -- through either. Only the membership bit is going away, but the capability an owner configured
-- rebuild does not have to care about it. -- through either route is preserved, so both are read below. While this file runs the membership
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( -- column still exists and `atype = 3` still unambiguously means "legacy Manager".
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY --
); -- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid) -- the same condition at startup and prints the recovery text, because Diesel would surface the abort
SELECT uuid FROM users_organizations WHERE atype = 3; -- 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 -- A plain User carrying membership `access_all`, reachable only on databases written before the web
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup -- vault stopped sending the flag. The bit gave read/write reach over every collection, present and
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. -- 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.
-- Deliberately not the record table above: that one holds data an operator has to be able to write -- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when
-- during recovery, so its existence cannot also stand for "the history behind this data was -- such a membership exists.
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( blocked INTEGER NOT NULL PRIMARY KEY
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 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/<org>/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 -- Never inherit a downgrade acknowledgement left behind by an earlier revert.
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
-- 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;

1
migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql

@ -1 +0,0 @@
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;

13
migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql

@ -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'
);

34
migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql

@ -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;

68
migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql

@ -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
);

4
migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql

@ -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;

101
migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql

@ -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;

13
migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql

@ -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;

5
migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql

@ -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;

28
migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql

@ -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;

5
migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql

@ -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;

60
migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql

@ -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;

13
migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql

@ -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;

4
migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql

@ -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;

111
migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql

@ -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
);

4
migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql

@ -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;

121
migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql

@ -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 = '<MEMBERSHIP_UUID>';
--
-- Then record the decision once, with every Vaultwarden instance stopped:
--
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade.
--
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why
-- this never creates it.
--
-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this check is free
-- of durable side effects.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager'
);
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard;
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists.
CREATE TEMPORARY TABLE __vw_permanent_authority_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1);
INSERT INTO __vw_permanent_authority_guard (blocked)
SELECT 1
FROM users_organizations AS uo
WHERE uo.atype = 4
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = uo.uuid
AND g.organizations_uuid = uo.org_uuid
AND g.access_all = TRUE
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_ack_permanent_collection_authority'
)
LIMIT 1;
DROP TEMPORARY TABLE __vw_permanent_authority_guard;
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority;

111
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, -- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or
-- which the legacy role/access_all schema cannot represent. The revert therefore -- the Custom role. Two explicit operator decisions are required before anything is touched, and both
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, -- are consumed at the end, so one decision covers one downgrade. Operators who only need the older
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: -- 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 ( CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY blocked INTEGER NOT NULL PRIMARY KEY
); );
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked) INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 SELECT 1
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL; WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL;
DROP TABLE __vw_custom_role_downgrade_guard; 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 -- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its -- Custom member holds, so handing it out automatically would *grant* authority during a
-- authority *now*, and it is not symmetric with the upgrade. -- 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 -- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY);
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers -- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>');
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not
-- authorization. Use a list written for this downgrade instead.
-- --
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe -- The duplicate key aborts the revert. It is only inserted while the list is absent.
-- direction: the legacy Manager role is not a subset of what a Custom member holds -- it manages, and CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard (
-- deletes, every collection reachable through `users_collections.manage`, blocked INTEGER NOT NULL PRIMARY KEY
-- `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
); );
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 ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist);
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with -- Roles and `access_all` are recomputed together, because in the old schema they are not
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection -- independent:
-- 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 -- * Owners and Admins always carried the bit and it grants them nothing extra;
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are -- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three
-- untouched, so these members keep every per-collection grant and lose only the organization-wide -- collection permissions -- in the old schema `access_all` also carried collection deletion, so
-- powers the old schema cannot express. -- an Edit-only member must not silently gain it;
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4; -- * 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
ALTER TABLE users_organizations DROP COLUMN manage_groups; DROP COLUMN manage_users,
ALTER TABLE users_organizations DROP COLUMN manage_policies; 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 -- Both decisions authorized *this* downgrade, not the next one.
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade
-- rebuilds all of it from the restored `atype = 3` rows.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; 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;

154
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; -- Replace the membership-level `access_all` flag with the persisted Custom role and its nine
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; -- granular permissions.
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; --
-- Record which memberships were legacy Managers *before* anything converts them. -- Two different columns are called `access_all`, and everything below depends on keeping them apart:
-- --
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom -- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy -- at the end of this file.
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason -- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table -- here, to decide a legacy Manager's permissions; never written, and it keeps granting group
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. -- members access to every collection afterwards exactly as before.
-- --
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is -- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table -- through either. Only the membership bit is going away, but the capability an owner configured
-- rebuild does not have to care about it. -- through either route is preserved, so both are read below. While this file runs the membership
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( -- column still exists and `atype = 3` still unambiguously means "legacy Manager".
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY --
); -- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) -- the same condition at startup and prints the recovery text, because Diesel would surface the abort
SELECT uuid FROM users_organizations WHERE atype = 3 -- below as nothing but a driver-level duplicate-key error.
ON CONFLICT DO NOTHING;
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by -- A plain User carrying membership `access_all`, reachable only on databases written before the web
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup -- vault stopped sending the flag. The bit gave read/write reach over every collection, present and
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. -- 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.
-- Deliberately not the record table above: that one holds data an operator has to be able to write -- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when
-- during recovery, so its existence cannot also stand for "the history behind this data was -- such a membership exists.
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for. CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( blocked INTEGER NOT NULL PRIMARY KEY
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;
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/<org>/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 -- Never inherit a downgrade acknowledgement left behind by an earlier revert.
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
-- 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;

1
migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql

@ -1 +0,0 @@
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;

13
migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql

@ -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;

25
migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql

@ -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;

60
migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql

@ -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
);

4
migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql

@ -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;

96
migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql

@ -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;

13
migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql

@ -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;

5
migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql

@ -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;

19
migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql

@ -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;

5
migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql

@ -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;

27
migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql

@ -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;

11
migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql

@ -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;

4
migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql

@ -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;

103
migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql

@ -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
);

4
migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql

@ -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;

111
migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql

@ -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 = '<MEMBERSHIP_UUID>';
--
-- Then record the decision once, with every Vaultwarden instance stopped:
--
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade.
--
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why
-- this never creates it.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL;
DROP TABLE __vw_legacy_manager_record_guard;
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists.
CREATE TEMPORARY TABLE __vw_permanent_authority_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1);
INSERT INTO __vw_permanent_authority_guard (blocked)
SELECT 1
FROM users_organizations AS uo
WHERE uo.atype = 4
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = uo.uuid
AND g.organizations_uuid = uo.org_uuid
AND g.access_all = TRUE
)
AND to_regclass('__vw_ack_permanent_collection_authority') IS NULL
LIMIT 1;
DROP TABLE __vw_permanent_authority_guard;
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority;

124
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, -- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or
-- which the legacy role/access_all schema cannot represent. The revert therefore -- the Custom role. Two explicit operator decisions are required before anything is touched, and both
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss, -- are consumed at the end, so one decision covers one downgrade. Operators who only need the older
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped: -- 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 ( CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY blocked INTEGER NOT NULL PRIMARY KEY
); );
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked) INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 SELECT 1
WHERE NOT EXISTS ( WHERE NOT EXISTS (
@ -17,52 +20,83 @@ WHERE NOT EXISTS (
); );
DROP TABLE __vw_custom_role_downgrade_guard; 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 -- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its -- Custom member holds, so handing it out automatically would *grant* authority during a
-- authority *now*, and it is not symmetric with the upgrade. -- 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 ('<MEMBERSHIP_UUID>');
-- --
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager -- The duplicate key aborts the revert. It is only inserted while the list is absent.
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard (
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom blocked INTEGER NOT NULL PRIMARY KEY
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not );
-- authorization. Use a list written for this downgrade instead. 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 -- * Owners and Admins always carried the bit and it grants them nothing extra;
-- direction: the legacy Manager role is not a subset of what a Custom member holds -- it manages, and -- * an allowlisted Custom member becomes a Manager, keeping the bit only with all three collection
-- deletes, every collection reachable through `users_collections.manage`, -- permissions -- `access_all` also carried collection deletion there, so an Edit-only member must
-- `collections_groups.manage` or `groups.access_all`, and reads member and collection ACL details -- not silently gain it;
-- through `ManagerHeadersLoose`, none of which needs a permission flag in the old schema. To keep the -- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy
-- historical mapping, copy it over deliberately before reverting: -- 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); -- Group-derived Manager authority needs no restoring: `groups.access_all` was never modified, so the
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) -- older binary derives it again for whoever comes back as Manager.
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager; CREATE TABLE users_organizations_old (
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist ( uuid TEXT NOT NULL PRIMARY KEY,
users_organizations_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 INSERT INTO users_organizations_old (
WHERE atype = 4 uuid, user_uuid, org_uuid, access_all, akey, status, atype,
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist); 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 DROP TABLE users_organizations;
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection
-- permissions, on the assumption they are about to become a Manager; left behind on a User it
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are
-- untouched, so these members keep every per-collection grant and lose only the organization-wide
-- powers the old schema cannot express.
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN manage_users; ALTER TABLE users_organizations_old RENAME TO users_organizations;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the -- Both decisions authorized *this* downgrade, not the next one.
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade
-- rebuilds all of it from the restored `atype = 3` rows.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; 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;

187
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; -- Replace the membership-level `access_all` flag with the persisted Custom role and its nine
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; -- granular permissions.
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; --
-- Record which memberships were legacy Managers *before* anything converts them. -- Two different columns are called `access_all`, and everything below depends on keeping them apart:
-- --
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom -- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy -- at the end of this file.
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason -- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table -- here, to decide a legacy Manager's permissions; never written, and it keeps granting group
-- instead of guessing, which is what stops them from handing legacy privileges to modern members. -- members access to every collection afterwards exactly as before.
-- --
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is -- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table -- through either. Only the membership bit is going away, but the capability an owner configured
-- rebuild does not have to care about it. -- through either route is preserved, so both are read below. While this file runs the membership
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager ( -- column still exists and `atype = 3` still unambiguously means "legacy Manager".
users_organizations_uuid TEXT NOT NULL PRIMARY KEY --
-- 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) INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
SELECT uuid FROM users_organizations WHERE atype = 3; INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by FROM users_organizations
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup WHERE atype = 2
-- preflight treat its absence as proof that an earlier revision of this chain ran instead. AND access_all = TRUE
-- LIMIT 1;
-- Deliberately not the record table above: that one holds data an operator has to be able to write DROP TABLE __vw_legacy_user_access_all_guard;
-- 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. -- Schema and data change in one table rebuild, which also keeps the conversion unambiguous:
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified ( -- `atype = 3` still means Manager while the permission values are computed from it.
verified INTEGER NOT NULL PRIMARY KEY --
-- `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 -- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted -- them every permission by role. A plain User cannot reach this point carrying the bit (the guard
-- type, convert those members so clients (which no longer know the Manager role) keep -- above), so only a Manager becomes Custom, keeping the organization-wide collection-management
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE, -- capability it is configured with right now:
-- which matches the capabilities these members had. --
UPDATE users_organizations SET atype = 4 WHERE atype = 3; -- * 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/<org>/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;

1
migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql

@ -1 +0,0 @@
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;

12
migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql

@ -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'
);

28
migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql

@ -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;

63
migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql

@ -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
);

4
migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql

@ -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;

98
migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql

@ -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;

13
migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql

@ -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;

46
migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql

@ -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;

22
migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql

@ -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;

5
migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql

@ -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;

29
migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql

@ -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;

11
migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql

@ -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;

4
migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql

@ -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;

107
migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql

@ -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
);

4
migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql

@ -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;

117
migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql

@ -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 = '<MEMBERSHIP_UUID>';
--
-- Then record the decision once, with every Vaultwarden instance stopped:
--
-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade.
--
-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the
-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why
-- this never creates it.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager'
);
DROP TABLE __vw_legacy_manager_record_guard;
-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists.
CREATE TEMPORARY TABLE __vw_permanent_authority_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1);
INSERT INTO __vw_permanent_authority_guard (blocked)
SELECT 1
FROM users_organizations AS uo
WHERE uo.atype = 4
AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = uo.uuid
AND g.organizations_uuid = uo.org_uuid
AND g.access_all = TRUE
)
AND NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_ack_permanent_collection_authority'
)
LIMIT 1;
DROP TABLE __vw_permanent_authority_guard;
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority;

887
src/api/core/organizations.rs

File diff suppressed because it is too large

8
src/api/core/two_factor/mod.rs

@ -214,8 +214,12 @@ pub async fn enforce_2fa_policy_for_org(
) -> EmptyResult { ) -> EmptyResult {
let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
for member in Membership::find_confirmed_by_org(org_id, conn).await { for member in Membership::find_confirmed_by_org(org_id, conn).await {
// Don't enforce the policy for Admins and Owners. // Don't enforce the policy for Admins and Owners, nor for the member who just enabled it --
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { // 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() { if CONFIG.mail_enabled() {
let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap(); let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap();
mail::send_2fa_removed_from_org(&user.email, &org.name).await?; mail::send_2fa_removed_from_org(&user.email, &org.name).await?;

72
src/auth.rs

@ -749,15 +749,6 @@ impl OrgHeaders {
fn can_manage_policies(&self) -> bool { fn can_manage_policies(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_policies()) 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 { fn can_access_event_logs(&self) -> bool {
self.is_confirmed() self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin || self.membership.has_access_event_logs()) && (self.membership_type >= MembershipType::Admin || self.membership.has_access_event_logs())
@ -766,11 +757,10 @@ impl OrgHeaders {
self.is_confirmed() self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export()) && (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export())
} }
// NOTE: there is deliberately no `can_access_reports` guard helper. Vaultwarden has no // NOTE: no `can_access_reports` helper on purpose. Vaultwarden has no server-side report endpoints --
// server-side report endpoints — the clients compute every report locally from the // clients compute reports from the organization cipher list -- so `accessReports` is enforced where
// organization cipher list — so `accessReports` is enforced inline where that list is served // that list is served (`get_org_details`). A guard here would invite gating an endpoint on "may call
// (`get_org_details`), not through a request guard. A guard here would be dead code that // reports" instead of "may read these ciphers".
// invites gating an endpoint on "may call reports" instead of "may read these ciphers".
} }
// org_id is usually the second path param ("/organizations/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
@ -956,11 +946,9 @@ generate_manage_headers!(
can_manage_policies, can_manage_policies,
"You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint" "You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint"
); );
generate_manage_headers!( // NOTE: no `ManageUsersOrGroupsHeaders`. Reading group *details* is not a single-permission question
ManageUsersOrGroupsHeaders, // -- organization-wide collection reach grants it too -- so both routes take `ManagerHeadersLoose`
can_manage_users_or_groups, // and ask `can_read_group_details`. The full *member* list is, and keeps `ManageUsersHeaders`.
"You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!( generate_manage_headers!(
AccessEventLogsHeaders, AccessEventLogsHeaders,
can_access_event_logs, 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. /// Collection deletion never falls back to a per-collection Manage grant.
/// ///
/// Vaultwarden serializes `limitCollectionDeletion = true` unconditionally, and upstream gates /// Vaultwarden serializes `limitCollectionDeletion = true` unconditionally, and upstream gates
/// manage-based deletion on that setting being *off* (`BulkCollectionAuthorizationHandler`): with the /// manage-based deletion on that setting being *off*: with the limit active only Owners, Admins and
/// limit active, only Owners, Admins and holders of `Delete any collection` may delete. Accepting a /// holders of `Delete any collection` may delete. Accepting a stored `manage` grant here would break
/// stored `manage` grant here would break that promise and, worse, make the three collection /// that promise and make the three collection permissions depend on each other — `Create new
/// permissions dependent on each other — a Custom member holding only `Create new collections` /// collections` alone receives an automatic `manage` row for the collection it just created, and
/// receives an automatic `users_collections.manage` row for the collection they just created, and /// could then delete it. A Manage grant keeps its full meaning for editing (`collection_edit_access`);
/// could delete it again without `Delete any collection`. /// it just is not a delete permission.
///
/// A Manage grant keeps its full meaning for editing a collection and rewriting its access
/// (`collection_edit_access`); it just is not a delete permission.
fn collection_delete_access(membership: &Membership) -> CollectionManageAccess { fn collection_delete_access(membership: &Membership) -> CollectionManageAccess {
if !membership.has_status(MembershipStatus::Confirmed) { if !membership.has_status(MembershipStatus::Confirmed) {
return CollectionManageAccess::Denied; 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 /// Whether `membership` may edit (rewrite the access of) `collection_uuid`, on exactly the same rules
/// Custom-aware rules as the path-based `ManagerHeaders` guard (`collection_edit_access`): Edit any /// as the path-based `ManagerHeaders` guard: Edit-any (or Admin/Owner) reaches every collection,
/// collection (or Admin/Owner) may edit every collection, otherwise only collections on which the /// otherwise only those carrying a real per-collection Manage grant. Group `access_all` deliberately
/// member holds a real per-collection Manage grant. In particular, a Custom member's membership or /// does not qualify.
/// 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.
/// ///
/// Body-param endpoints (e.g. bulk collection access) take collection ids in the request body and /// Body-param endpoints take collection ids in the request body and so cannot use `ManagerHeaders`;
/// therefore cannot use `ManagerHeaders`; they must run this per collection to stay consistent with /// they run this per collection instead, so the two cannot diverge.
/// the single-collection edit endpoint.
pub(crate) async fn can_edit_collection( pub(crate) async fn can_edit_collection(
membership: &Membership, membership: &Membership,
collection_uuid: &CollectionId, 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. /// Whether `membership` may read a collection's user/group access mappings.
/// ///
/// Keep body/bulk endpoints on exactly the same authorization rule as `CollectionReadHeaders`: /// The same rule as `CollectionReadHeaders`: Admin/Owner, Edit-any/Delete-any, or a real
/// Admin/Owner, Edit-any/Delete-any, or a real per-collection Manage assignment. Ordinary read /// per-collection Manage assignment. Ordinary read access and group `access_all` do not qualify.
/// access and group `access_all` deliberately do not qualify.
pub(crate) async fn can_read_collection_access( pub(crate) async fn can_read_collection_access(
membership: &Membership, membership: &Membership,
collection_uuid: &CollectionId, collection_uuid: &CollectionId,
@ -1714,13 +1695,10 @@ mod tests {
#[test] #[test]
fn flagless_custom_requires_explicit_manage_for_edit_and_read_and_cannot_delete() { 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. // A flagless Custom member gets no blanket collection authority from its role. Edit and read are
// Edit and read are answered per collection by `has_explicit_collection_manage_access`, which // answered per collection by `has_explicit_collection_manage_access`, which accepts a real manage
// accepts a real users_collections.manage / collections_groups.manage grant and nothing else: // grant and nothing else -- a group's `access_all` is not one. Delete has no per-collection fallback
// membership access_all is gone, and a group's access_all is not a manage grant. // at all, hence Denied rather than ExplicitManage; see `collection_delete_access`.
//
// Delete has no per-collection fallback at all, so the answer is Denied rather than
// ExplicitManage -- see `collection_delete_access`.
let custom = membership(MembershipType::Custom); let custom = membership(MembershipType::Custom);
assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage); assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage); assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage);

12
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 /// 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; 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 /// Timeout when acquiring database connection
database_timeout: u64, false, def, 30; 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 { if cfg.password_iterations < 100_000 {
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
} }

4477
src/db/mod.rs

File diff suppressed because it is too large

31
src/db/models/cipher.rs

@ -601,17 +601,13 @@ impl Cipher {
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> Option<(bool, bool, bool)> { ) -> 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 // The sync path (cipher_sync_data is Some) is left to the caller: it is built only from
// user who has a *confirmed* membership in that same organization. This denies access to // confirmed memberships and evaluated below against that cached data.
// 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.
if cipher_sync_data.is_none() if cipher_sync_data.is_none()
&& let Some(ref org_uuid) = self.organization_uuid && let Some(ref org_uuid) = self.organization_uuid
&& Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await.is_none() && 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 // Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection. // user. If so, retrieve the access flags for each collection.
// //
// Security: bind the assignment to a *confirmed* membership in the same organization as // Security: bind the assignment to a *confirmed* membership in the same organization as both
// both the cipher and the collection. Without this, a `users_collections` row left behind // the cipher and the collection, so a row left behind by a revoke, or pointing at another
// after a revoke, or an assignment pointing at a collection in a different organization, // organization's collection, grants nothing. Defense in depth.
// would keep granting access (defense in depth for audit findings H-1 and H-3).
ciphers::table ciphers::table
.filter(ciphers::uuid.eq(cipher_uuid)) .filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::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 cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone(); let user_uuid = user_uuid.clone();
conn.run(move |conn| { conn.run(move |conn| {
// Security: bind the group assignment to a *confirmed* membership and require that the // Security: bind the group assignment to a *confirmed* membership and require cipher,
// cipher, the collection, the group and the membership all belong to the same // collection, group and membership to share one organization. The `collections` join is what
// organization. The `collections` join in particular prevents a cross-organization // stops a cross-organization collection<->group assignment reaching foreign ciphers.
// 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).
ciphers::table ciphers::table
.filter(ciphers::uuid.eq(cipher_uuid)) .filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))

27
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 /// Serialize the assignment-level `manage` capability using the same role boundary as the
/// collection mutation guards. Read/write access is deliberately not management authority. /// 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 /// Belongs on what a member receives about themselves; the administrative lists echo a *stored*
/// member receives about themselves. For the administrative lists that echo a *stored* grant back /// grant and use `stored_assignment_manage` instead.
/// to the client, use `stored_assignment_manage` instead.
pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool { pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool {
match MembershipType::from_i32(membership_type) { match MembershipType::from_i32(membership_type) {
Some(MembershipType::Owner | MembershipType::Admin) => true, 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. /// 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 /// The client writes the same value back when the dialog is saved, so reporting anything other than
/// value back when the dialog is saved. Reporting anything other than the persisted bit would make /// the persisted bit would make an unrelated save silently strip it — for a plain User that also
/// an unrelated save silently strip it — for a plain User that would also revoke the cipher write /// revokes the cipher write access `users_collections.manage` grants. Admins and Owners manage
/// access `users_collections.manage` still grants (see `Cipher::get_access_restrictions`). Admins /// implicitly and are reported as such regardless of the stored row.
/// and Owners manage implicitly, so they are reported as managing regardless of the stored row.
pub(super) fn stored_assignment_manage(membership_type: i32, stored_manage: bool) -> bool { pub(super) fn stored_assignment_manage(membership_type: i32, stored_manage: bool) -> bool {
matches!(MembershipType::from_i32(membership_type), Some(MembershipType::Owner | MembershipType::Admin)) matches!(MembershipType::from_i32(membership_type), Some(MembershipType::Owner | MembershipType::Admin))
|| stored_manage || stored_manage
@ -133,14 +131,11 @@ impl Collection {
let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data { let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data {
match cipher_sync_data.members.get(&self.org_uuid) { match cipher_sync_data.members.get(&self.org_uuid) {
Some(m) => { Some(m) => {
// What the client is told here has to match what the collection guards actually // What the client is told has to match what the collection guards allow, or it renders the
// allow, or it renders the wrong controls. A stored grant therefore counts even // wrong controls. A stored grant therefore counts even for a member who already reaches every
// for a member who already reaches every collection: full visibility is not // collection. Reaching every collection through a group with `access_all` deliberately does
// management authority, but it does not cancel out a real grant either. // not: the guards accept an explicit `users_collections.manage` /
// // `collections_groups.manage` row only.
// Reaching every collection through a group with `access_all` is deliberately not
// management authority: the guards accept an explicit
// `users_collections.manage` / `collections_groups.manage` row only.
let assignment = cipher_sync_data let assignment = cipher_sync_data
.user_collections .user_collections
.get(&self.uuid) .get(&self.uuid)

23
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 { pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool {
conn.run(move |conn| { conn.run(move |conn| {
// Security: the membership linked through `groups_users` must itself belong to the same // Security: the membership linked through `groups_users` must be confirmed and belong to the
// organization as the group and must be confirmed. Otherwise a cross-organization // same organization as the group, or a cross-organization row would pass as full access to
// `groups_users` row (a member of org A linked to an access-all group of org B) would let // that organization.
// that member pass as having full access to org B (audit finding H-2).
groups::table groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join( .inner_join(
@ -327,11 +326,9 @@ impl Group {
impl CollectionGroup { impl CollectionGroup {
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { 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 // Security: never persist a cross-organization link between a collection and a group --
// group. Both must belong to the organization this assignment is scoped to; otherwise a // attaching a foreign-tenant group to this organization's collection would grant its members
// caller could attach a foreign-tenant group to this organization's collection and thereby // access. Defense in depth, so no route can create one even if its own validation is wrong.
// 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.
if Collection::find_by_uuid_and_org(&self.collections_uuid, org_uuid, conn).await.is_none() 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() || Group::find_by_uuid_and_org(&self.groups_uuid, org_uuid, conn).await.is_none()
{ {
@ -512,11 +509,9 @@ impl CollectionGroup {
impl GroupUser { impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
// Security (audit H-2): never persist a cross-organization link between a group and a // Security: never persist a cross-organization link between a group and a membership -- that
// membership. The group must belong to the same organization as the membership; otherwise a // would grant a member of one organization access to another's collections through an
// caller could grant a member of one organization full access to another organization's // access-all group. Defense in depth, so no route can create one even if its own validation is wrong.
// 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.
let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else { let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else {
err!("Member not found while assigning to group") err!("Member not found while assigning to group")
}; };

99
src/db/models/organization.rs

@ -151,11 +151,9 @@ impl MembershipType {
/// The stored `users_organizations.atype` values that carry organization-wide authority by role. /// 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 /// Queries enumerate the two values instead of comparing `atype <= Admin`: `<=` also matches every
/// membership-level `access_all` would otherwise have left behind in them. `<=` also matches every /// value *below* `Owner`, so a corrupt or negative `atype` would satisfy the SQL check while every
/// value *below* `Owner`, so a corrupt or hand-written negative `atype` would satisfy an SQL check /// Rust guard rejects it. Enumerating keeps both layers on the same answer.
/// while every Rust guard rejects it -- `MembershipType::from_i32` returns `None` there and the
/// request guards fail closed. Enumerating the two values keeps both layers on the same answer.
pub(crate) const ORG_ADMIN_ATYPES: &[i32] = &[MembershipType::Owner as i32, MembershipType::Admin as i32]; pub(crate) const ORG_ADMIN_ATYPES: &[i32] = &[MembershipType::Owner as i32, MembershipType::Admin as i32];
impl Ord for MembershipType { impl Ord for MembershipType {
@ -869,6 +867,23 @@ impl Membership {
self.atype >= MembershipType::Admin || self.has_edit_any_collection() 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 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 // 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. // 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 /// such a grant. This is the *only* per-collection authority a Custom member can hold: neither
/// membership nor group `access_all` may manufacture one. /// membership nor group `access_all` may manufacture one.
/// ///
/// No live exception exists for legacy Managers whose authority came from an organization-local /// There is deliberately no live exception for legacy Managers whose authority came from an
/// `access_all` group. Deriving one from the membership's shape ("Custom, no collection /// organization-local `access_all` group: deriving one from the membership's shape ("Custom, no
/// permissions, member of such a group") was not sound — that shape is also what every newly /// collection permissions, member of such a group") would also match every newly created flagless
/// created flagless Custom member has, so assigning one to an ordinary `access_all` group handed /// Custom member, so joining one to an ordinary `access_all` group would hand out organization-wide
/// out organization-wide collection edit and delete, and *removing* a collection permission /// edit and delete. The migration writes that authority into the visible `edit_any_collection` /
/// activated it. The repair migration `2026-07-23-120000` materializes that authority into the /// `delete_any_collection` columns instead, where an owner can see and revoke it.
/// 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 { pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool {
let membership_uuid = self.uuid.clone(); let membership_uuid = self.uuid.clone();
let user_uuid = self.user_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 /// A stored `atype` that no role maps to is *incomparable*, and the two directions resolve that
/// comparison resolve that deliberately differently. Both overrides exist to keep the answer /// differently on purpose — both fail-closed, and the asymmetry is easy to "tidy up" into a silent
/// fail-closed; neither was pinned by a test, and the asymmetry is easy to "tidy up" into a /// authorization change.
/// silent authorization change.
/// ///
/// `MembershipType op i32` — "does the caller outrank this role?" — answers no: `gt`/`ge` are /// `MembershipType op i32` — "does the caller outrank this role?" — answers no: `gt`/`ge` are false
/// false for an unknown value, so nothing is ever granted on the strength of one. /// 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` /// `i32 op MembershipType` — "is this membership at most that role?" — answers yes: `lt`/`le` are
/// are true. Every use of it is a *ceiling* (`atype < Admin`, `atype <= Admin`), so treating an /// true. Every use is a *ceiling* (`atype < Admin`), so treating an unrecognized value as low-ranked
/// unrecognized value as low-ranked is the restrictive reading. It also cannot smuggle anything /// is the restrictive reading, and the one place that phrases a permission this way
/// past the one place that phrases a permission this way /// (`check_reset_password_applicable_and_permissions`) guards against `Owner`, whose discriminant is
/// (`check_reset_password_applicable_and_permissions`): the role an Admin must not reach is /// 0 and therefore never unknown.
/// `Owner`, whose discriminant is 0 and therefore never unknown.
#[test] #[test]
#[expect( #[expect(
clippy::nonminimal_bool, clippy::nonminimal_bool,
@ -1546,6 +1557,44 @@ mod tests {
assert!(MembershipType::Custom >= MembershipType::Custom as i32); 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] #[test]
fn custom_collection_permissions_are_independent_and_type_gated() { fn custom_collection_permissions_are_independent_and_type_gated() {
let mut member = membership(MembershipType::Custom); let mut member = membership(MembershipType::Custom);

17
src/main.rs

@ -553,10 +553,23 @@ fn check_web_vault() {
} }
async fn create_db_pool() -> db::DbPool { 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, Ok(p) => p,
Err(e) => { 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); exit(1);
} }
} }

13
src/util.rs

@ -757,10 +757,17 @@ where
} }
} }
pub async fn retry_db<F, T, E>(mut func: F, max_tries: u32) -> Result<T, E> /// 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<F, T, E, R>(mut func: F, max_tries: u32, should_retry: R) -> Result<T, E>
where where
F: FnMut() -> Result<T, E>, F: FnMut() -> Result<T, E>,
E: std::error::Error, E: std::error::Error,
R: Fn(&E) -> bool,
{ {
let mut tries = 0; let mut tries = 0;
@ -770,6 +777,10 @@ where
Err(e) => { Err(e) => {
tries += 1; tries += 1;
if !should_retry(&e) {
return Err(e);
}
if tries >= max_tries && max_tries > 0 { if tries >= max_tries && max_tries > 0 {
return Err(e); return Err(e);
} }

211
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 ('<MEMBERSHIP_UUID>'); INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>');
``` ```
The upgrade records which memberships held the Manager role beforehand, in The decision is deliberately taken **now**, from what each membership holds today, rather than from
`__vw_custom_role_legacy_manager`. That is useful evidence, and copying it over is a reasonable any record of who was a Manager before the upgrade. Such a record would describe the state at the
starting point: 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
```sql member — would be handed the whole legacy role back. Historical provenance is evidence, not
INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) authorization, and the upgrade therefore keeps none.
SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;
```
But it is deliberately **not** used automatically. It records who was a Manager before the *first*
upgrade and is never updated afterwards, so a member whose Manager powers an owner has since reduced
— or who was demoted to User and later re-created as a limited Custom member — would be handed the
whole legacy role back. Historical provenance is evidence, not authorization.
## What is lost ## 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 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 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 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: 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 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. 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 Collection *creation* is deliberately not granted in the second row: it always required the
`editAnyCollection` or `deleteAnyCollection` and belongs to an organization-local group with membership bit, never the group.
`accessAll`. It grants nothing and revokes nothing; it exists because that combination is the one
place where the new model cannot reproduce the old semantics.
Before the Custom role, a Manager who reached every collection through such a group held that The second row is a policy choice worth knowing about. The group-derived capability used to be
authority *while* the group relationship lasted: it ended when the group was deleted, when its dynamic — it ended when the group was deleted, when its `accessAll` was cleared, when the member left
`accessAll` was cleared, when the member left it, and it was inert whenever `ORG_GROUPS_ENABLED` was it, and it was inert whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model is bound to a
false. Nothing in the new model expresses a permission bound to a group like that — the permissions group like that, so it becomes a membership permission and therefore:
live on the membership. The earlier migrations in the chain therefore write the authority onto the
membership, and the result is deliberately not identical to what it replaces:
- it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; - it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared;
- it applies even with the groups feature switched off; - it applies even with the groups feature switched off;
- `editAnyCollection` additionally satisfies `has_full_access()`, so the member reaches every - `editAnyCollection` additionally satisfies `has_full_access()`, so the member reaches every
collection directly rather than through the group. collection directly rather than through the group.
Doing that silently would be a migration granting durable organization-wide collection edit and That is accepted on purpose. The alternatives are worse: silently dropping the permission would
delete on its own authority; dropping it silently would take a capability away. Neither is the revoke access these members have today, and refusing to migrate would block an ordinary upgrade from
migration's call, so it hands the decision to an owner. On a database with no Custom membership that an official Vaultwarden database. After the upgrade the permission is visible in the member's
both has edit/delete authority and belongs to an organization-local `accessAll` group, there is permission list and an owner can clear it with a checkbox — which is more than the old model offered,
nothing to decide and it is a no-op. where the same authority was invisible on the membership.
**Start Vaultwarden once to get the question.** The startup preflight looks ahead for the same `groups.access_all` and every `groups_users` row are left exactly as they are, so the group keeps
condition, from the legacy schema as well as the migrated one, and refuses with the review query, the granting collection *access* to its members as before. A `groups_users` row pointing at another
three differences above and the acknowledgement statement organization's `accessAll` group conveys nothing, exactly as it conveys nothing today.
(`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 Status is not part of the rule: an invited, accepted or revoked membership is converted like a
there, so on that path the question arrives as nothing but a duplicate-key violation on confirmed one. None of them holds authority in that state, and the permission is what the membership
`__vw_permanent_authority_guard`. would come back with if it is ever restored — which is exactly what `access_all` would have done.
Every matching membership is asked about, including a recorded legacy Manager with The only state the upgrade refuses outright is a plain **User** carrying membership `access_all`; see
`createNewCollections` set. That flag is an independent permission an owner can change after an above. That refusal can also be resolved without any SQL, once for the whole instance, by setting
earlier revision materialized group-derived edit/delete, so its current value is not reliable `LEGACY_USER_ACCESS_ALL_MIGRATION` to `drop` (clear the flag; each member keeps the collections they
historical provenance. This deliberately prefers a conservative extra question over silently making are explicitly assigned to) or `materialize` (write the reach out as explicit assignments first,
group-derived authority permanent. A membership whose own legacy `access_all` supplied all three confirmed memberships only, then clear it). The setting is read only while that migration is pending.
permissions may therefore be listed even though nothing changes meaning for it. An invited or revoked
membership is asked about too: it holds no authority today, but the permission is what it would come
back with if it is ever restored.
Answering the question is a different statement depending on when you are asked, because the
preflight looks ahead from both schemas. Before the upgrade has run there is nothing to clear — the
permission columns do not exist yet — so declining means ending the group relationship the authority
comes from, either for one membership (`DELETE FROM groups_users …`) or for the whole group
(`UPDATE groups SET access_all = FALSE …`). Once the columns exist, clear them directly. Doing that
after the upgrade is equally safe: Vaultwarden does not start until the acknowledgement is recorded,
so nothing is ever live in between. The refusal prints both statements.
## How to run it ## 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), - membership `access_all` is gone (so the upgrade did run, and this script has not),
- all nine permission columns exist, - all nine permission columns exist,
- all nine Custom-role migrations are recorded in `__diesel_schema_migrations`, - the Custom-role migration `20260630120000` is recorded in `__diesel_schema_migrations`,
- **no migration newer than `20260810120000` is recorded** — this script does not know what a later - **no migration newer than `20260630120000` is recorded** — this script does not know what a later
migration changed, and removing only the Custom-role versions would leave the ledger claiming a migration changed, and removing only the Custom-role version would leave the ledger claiming a
migration whose schema objects may have been undone, 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, - **`__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 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` 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 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 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 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 permission columns were never added would get through the first `ADD COLUMN` and the value rewrites
rewrites, the type change and six `DROP COLUMN`s before failing on the seventh — ending up less before failing on the `DROP COLUMN` — ending up less consistent than before.
consistent than before.
The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations`, The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations` and
`__vw_rollback_manager_allowlist` and `__vw_custom_role_history_verified` once each, requires all of `__vw_rollback_manager_allowlist` once each, requires all of them to live in the **same** schema, and
them to live in the **same** schema, and addresses that schema explicitly from then on. An addresses that schema explicitly from then on. An unqualified name is otherwise resolved per
unqualified name is otherwise resolved per statement through `search_path`, so a session with statement through `search_path`, so a session with `search_path = decoy, real` could have the table
`search_path = decoy, real` could have the table rewrite land in one schema and the ledger delete in rewrite land in one schema and the ledger delete in another.
another.
The MySQL/MariaDB script ends with an explicit `COMMIT`. Everything before it is DDL and commits 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 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 back on disconnect, leaving the schema old while the migration still counts as applied — and a later
later upgrade would then skip them and start new code against the old schema. 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 **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 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 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. 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 Afterwards start the older Vaultwarden version. Upgrading again later re-applies the migration from a
migrations from a clean state, and rebuilds `__vw_custom_role_legacy_manager` from the very clean state: it reads the restored `atype = 3` rows directly and converts them deterministically,
`atype = 3` rows the rollback restored — so the round trip converges. so the round trip converges.
## Databases upgraded before the history marker existed
`__vw_custom_role_history_verified` is created by `2026-06-30-120000`, and nothing else creates it.
A database upgraded by an earlier revision of this feature branch carries that migration's version in
its ledger without the table, and Diesel never re-runs a recorded version — so Vaultwarden refuses to
start and the rollback scripts refuse to run, rather than acting on migrations whose effects were
different.
Start Vaultwarden once: it prints the full recovery, which depends on how far the earlier revision
got and covers up to three things — recording which memberships were legacy Managers, reviewing
permissions an earlier `20260809120000` granted in bulk to Custom members of `accessAll` groups, and
reviewing the direct collection assignments an earlier `20260723120000` wrote for a plain User that
carried membership `access_all`. If you still have the backup from before the first upgrade,
restoring it and upgrading again is simpler and needs no decision at all.
The marker is created as a separate statement from the legacy-Manager record on purpose. That record
is data an operator has to be able to write during recovery, so its existence must not double as
evidence that the history behind it was reviewed — otherwise creating it empty to make the error
message go away would silently pass as the audit it is asking for.
## Reverting with the Diesel CLI instead ## Reverting with the Diesel CLI instead
For development checkouts the down migrations do the same thing step by step. **Every one of them that For a development checkout, `2026-06-30-120000/down.sql` does the same thing. It refuses by default
loses permission data refuses by default** — `2026-07-24-130000`, `2026-07-16-120000` and and needs the same two decisions the scripts above take, in the same order:
`2026-06-30-120000` — and so does `2026-07-24-140000`, which loses nothing itself and exists to stop
the chain before the first destructive step. `2026-08-10-120000` and `2026-08-09-120000` are reverted
first and are no-ops. Acknowledge the downgrade once:
```sql ```sql
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);
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 Then `diesel migration revert` works as usual. Both tables are dropped by the revert they authorize,
*not* consumed by the first guard it satisfies: it is dropped by the oldest lossy migration so one decision covers one downgrade, and `2026-06-30-120000/up.sql` clears a leftover
(`2026-06-30-120000`), so one decision covers one downgrade and a revert that stops halfway is still acknowledgement as well, so consent never carries over into a later, unrelated revert.
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 Unlike the standalone scripts, the down migration has no precondition beyond those two guards: it is
rollback scripts above drop the table as well. a development path, and Diesel already knows the migration is recorded.
The down migrations use the same allowlist as the scripts above. Unlike the scripts they do not On MySQL/MariaDB the revert **cannot be resumed**: every `ALTER TABLE` commits on its own, while
refuse when `__vw_rollback_manager_allowlist` is missing — they create it empty, which means "nobody" Diesel deletes the ledger row in a separate statement afterwards. A crash in between leaves the
and maps every Custom member to plain User. Populate it first if that is not what you want. 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
On SQLite the down migrations do use `ALTER TABLE ... DROP COLUMN` and therefore need SQLite 3.35 or columns in one `ALTER TABLE` each rather than one per column, which is the closest this backend gets
newer. That is fine for a development checkout with a bundled SQLite; operators on an older system to all-or-nothing, and temporary guard tables are removed with `DROP TEMPORARY TABLE`, which is one
SQLite should use `sqlite.sql` above, which rebuilds the table instead. implicit commit fewer and cannot hit a permanent table of the same name by accident. Use `mysql.sql`
above for anything you care about.
### 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.

146
tools/custom_role_rollback/mysql.sql

@ -8,22 +8,16 @@
-- running it; if it is interrupted, restore and start over. -- running it; if it is interrupted, restore and start over.
-- --------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------
-- Precondition. Read-only and session-local: it reads `information_schema` and the migration ledger, -- Precondition. Read-only and session-local: reads `information_schema` and the ledger, prints the
-- prints the reason when the database does not fit, and aborts on a duplicate key in a TEMPORARY -- reason when the database does not fit, and aborts on a duplicate key in a TEMPORARY table. No
-- table. No permanent object is created, altered or dropped, so a database this script does not fit -- permanent object is touched, so a database this script does not fit keeps its exact state -- which
-- keeps its exact state -- which matters here precisely because DDL cannot be rolled back. -- 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.
-- Without it, a partially upgraded database -- for example one where `access_all` was already dropped
-- but the access-permission columns were never added, which DDL autocommit makes reachable -- would
-- get through the first ADD COLUMN, the value rewrites, the type change and six DROP COLUMN statements before
-- failing on the seventh with error 1091, ending up *less* consistent than before.
-- --
-- Deliberately not a stored procedure with SIGNAL: MySQL caps `MESSAGE_TEXT` at 128 characters and -- 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 -- answers a longer one with error 1648 instead of the diagnosis (MariaDB accepts it, so the
-- the diagnosis (MariaDB accepts it, so the difference is easy to miss), and CREATE PROCEDURE is a -- difference is easy to miss), and CREATE PROCEDURE is a permanent object that would have to be
-- permanent object that would have to be written *before* the checks have run -- replacing any -- written before the checks run.
-- same-named routine, surviving a refusal, and requiring routine privileges this script otherwise
-- does not need.
-- --------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------
CREATE TEMPORARY TABLE __vw_rollback_precondition ( CREATE TEMPORARY TABLE __vw_rollback_precondition (
ok INTEGER NOT NULL PRIMARY KEY ok INTEGER NOT NULL PRIMARY KEY
@ -79,9 +73,9 @@ FROM (
) AS c ) AS c
WHERE c.n <> 9; 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( 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 ', '__diesel_schema_migrations, found ', c.n, '. Schema and ledger disagree, so restore the backup ',
'taken before the upgrade and start over.' 'taken before the upgrade and start over.'
) AS rollback_precondition_failure ) AS rollback_precondition_failure
@ -89,39 +83,23 @@ FROM (
SELECT COUNT(*) AS n SELECT COUNT(*) AS n
FROM __diesel_schema_migrations FROM __diesel_schema_migrations
WHERE version IN ( WHERE version IN (
'20260630120000', '20260630120000'
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
) )
) AS c ) AS c
WHERE c.n <> 9; WHERE c.n <> 1;
INSERT INTO __vw_rollback_precondition (ok) INSERT INTO __vw_rollback_precondition (ok)
SELECT 1 SELECT 1
FROM ( FROM (
SELECT COUNT(*) AS n SELECT COUNT(*) AS n
FROM __diesel_schema_migrations FROM __diesel_schema_migrations
WHERE version IN ( WHERE version IN (
'20260630120000', '20260630120000'
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
) )
) AS c ) 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 -- 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. -- claiming a migration whose schema objects this script may have undone.
SELECT CONCAT( SELECT CONCAT(
'REFUSED, nothing was changed: ', c.n, ' migration(s) newer than the Custom-role change are ', 'REFUSED, nothing was changed: ', c.n, ' migration(s) newer than the Custom-role change are ',
@ -130,7 +108,7 @@ SELECT CONCAT(
FROM ( FROM (
SELECT COUNT(*) AS n SELECT COUNT(*) AS n
FROM __diesel_schema_migrations FROM __diesel_schema_migrations
WHERE version > '20260810120000' WHERE version > '20260630120000'
) AS c ) AS c
WHERE c.n <> 0; WHERE c.n <> 0;
INSERT INTO __vw_rollback_precondition (ok) INSERT INTO __vw_rollback_precondition (ok)
@ -138,36 +116,11 @@ SELECT 1
FROM ( FROM (
SELECT COUNT(*) AS n SELECT COUNT(*) AS n
FROM __diesel_schema_migrations FROM __diesel_schema_migrations
WHERE version > '20260810120000' WHERE version > '20260630120000'
) AS c ) AS c
WHERE c.n <> 0; WHERE c.n <> 0;
-- 5) The upgrade records that this database's Custom-role history is accounted for. Without that -- 5) Which memberships come back as legacy Manager has to be decided for *this* rollback. An empty
-- marker the database was migrated by an earlier revision of the change, whose migrations had
-- different effects.
SELECT CONCAT(
'REFUSED, nothing was changed: __vw_custom_role_history_verified does not exist, so this ',
'database was migrated by an earlier revision of the Custom-role change. Start Vaultwarden once ',
'and follow the recovery it prints before rolling back.'
) AS rollback_precondition_failure
FROM (
SELECT COUNT(*) AS n
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_custom_role_history_verified'
) AS c
WHERE c.n <> 1;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT COUNT(*) AS n
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_custom_role_history_verified'
) AS c
WHERE c.n <> 1;
-- 6) Which memberships come back as legacy Manager has to be decided for *this* rollback. An empty
-- list is a valid answer and maps every Custom member to plain User. -- list is a valid answer and maps every Custom member to plain User.
SELECT CONCAT( SELECT CONCAT(
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist does not exist. See README.md, ', 'REFUSED, nothing was changed: __vw_rollback_manager_allowlist does not exist. See README.md, ',
@ -190,12 +143,10 @@ FROM (
) AS c ) AS c
WHERE c.n <> 1; WHERE c.n <> 1;
-- 7) ...and it has to have the shape the role mapping reads. Existence alone is not enough: a -- 7) ...and it has to have the shape the role mapping reads: exactly one non-nullable, uniquely
-- hand-written or colliding table without a usable `users_organizations_uuid` column would pass -- indexed CHAR(36) `users_organizations_uuid`. A colliding table would otherwise pass every check
-- every check above and then fail on the first SELECT against it -- which happens *after* the -- above and fail on the first SELECT, *after* the `ADD COLUMN` below has committed implicitly. The
-- `ADD COLUMN` below has already committed implicitly, leaving a half-converted database. -- type is part of the authorization boundary: MySQL/MariaDB compare a character UUID against a
-- 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
-- numeric allowlist as numbers, so an INT value such as 0 could match unrelated UUIDs. -- numeric allowlist as numbers, so an INT value such as 0 could match unrelated UUIDs.
SELECT CONCAT( SELECT CONCAT(
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist must have exactly one column ', '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; 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 -- Only a membership on the allowlist comes back as Manager; everything else becomes a plain User and
-- of what a Custom member holds -- it manages, and deletes, every collection reachable through -- keeps its per-collection assignments. The legacy role is not a subset of what a Custom member
-- `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and reads member -- holds, so handing it out on less than a current, deliberate decision would *grant* authority during
-- and collection ACL details through `ManagerHeadersLoose`, none of which needs a permission flag in -- a downgrade -- and historical provenance is not that decision. See README.md.
-- the old schema -- so handing it out on anything less than a current, deliberate decision would
-- *grant* authority during a downgrade. `__vw_custom_role_legacy_manager` is not that decision: it
-- records who was a Manager before the first upgrade and is never updated afterwards, so a member
-- whose powers an owner has since reduced would get all of them back.
--
-- Everything else becomes a plain User and keeps its per-collection assignments.
-- --
-- `access_all` follows the same mapping the down migrations use: everyone who reached every -- `access_all` follows the mapping the down migrations use: a Custom member has to hold all three
-- collection keeps that reach, and a Custom member has to hold all three collection permissions -- -- collection permissions, because in the old schema the bit also carried collection deletion. A
-- Edit-only must not silently turn into the legacy "manage all collections" authority, which in that -- member mapped to plain User never keeps it: `User + access_all` is the one state the upgrade
-- older schema also carried collection deletion. A member mapped to plain User never keeps it: -- refuses.
-- `User + access_all` is the one legacy state the upgrade refuses.
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1); UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations UPDATE users_organizations
SET access_all = TRUE SET access_all = TRUE
@ -288,36 +232,20 @@ ALTER TABLE users_organizations
DROP COLUMN access_import_export, DROP COLUMN access_import_export,
DROP COLUMN access_reports; DROP COLUMN access_reports;
-- Bookkeeping tables this feature may have left behind. The legacy-Manager record goes too: a later -- The two decisions this rollback required. A later re-upgrade needs neither: it reads the restored
-- re-upgrade rebuilds it from the very `atype = 3` rows this script just restored, so the round trip -- `atype = 3` rows directly and converts them deterministically.
-- converges.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; DROP TABLE IF EXISTS __vw_allow_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_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 -- Finally forget the migration, so the older binary does not see a ledger from the future
-- and a later upgrade applies them again from a clean state. -- and a later upgrade applies it again from a clean state.
DELETE FROM __diesel_schema_migrations DELETE FROM __diesel_schema_migrations
WHERE version IN ( WHERE version = '20260630120000';
'20260630120000',
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
);
-- Every statement above except this DELETE is DDL and was therefore committed implicitly the moment -- 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 -- 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 -- 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 -- schema rolled back but the migration still marked as applied. A later upgrade would then skip
-- skip them and start new code against the old schema. Commit it explicitly; harmless when -- it and start new code against the old schema. Commit it explicitly; harmless when
-- autocommit is already on. -- autocommit is already on.
COMMIT; COMMIT;

59
tools/custom_role_rollback/postgresql.sql

@ -18,7 +18,6 @@ DECLARE
memberships regclass := to_regclass('users_organizations'); memberships regclass := to_regclass('users_organizations');
ledger regclass := to_regclass('__diesel_schema_migrations'); ledger regclass := to_regclass('__diesel_schema_migrations');
allowlist regclass := to_regclass('__vw_rollback_manager_allowlist'); allowlist regclass := to_regclass('__vw_rollback_manager_allowlist');
history regclass := to_regclass('__vw_custom_role_history_verified');
ns oid; ns oid;
ns_name text; ns_name text;
access_all_present int; access_all_present int;
@ -44,13 +43,6 @@ BEGIN
'is reachable through the current search_path.'; 'is reachable through the current search_path.';
END IF; 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 IF allowlist IS NULL THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist does ' 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 ' '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; SELECT relnamespace INTO ns FROM pg_class WHERE oid = memberships;
IF (SELECT relnamespace FROM pg_class WHERE oid = ledger) <> ns 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 = allowlist) <> ns THEN
OR (SELECT relnamespace FROM pg_class WHERE oid = history) <> ns THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: the tables this script needs resolve ' RAISE EXCEPTION 'Rollback refused, nothing was changed: the tables this script needs resolve '
'to different schemas through the current search_path -- ' 'to different schemas through the current search_path -- '
'users_organizations in "%", __diesel_schema_migrations in "%", ' 'users_organizations in "%", __diesel_schema_migrations in "%", '
'__vw_rollback_manager_allowlist in "%", ' '__vw_rollback_manager_allowlist in "%". Set search_path to exactly the '
'__vw_custom_role_history_verified in "%". Set search_path to exactly the '
'schema Vaultwarden uses and run this again.', 'schema Vaultwarden uses and run this again.',
(SELECT nspname FROM pg_namespace WHERE oid = ns), (SELECT nspname FROM pg_namespace WHERE oid = ns),
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = ledger), WHERE c.oid = ledger),
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = allowlist), WHERE c.oid = allowlist);
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = history);
END IF; END IF;
SELECT nspname INTO ns_name FROM pg_namespace WHERE oid = ns; SELECT nspname INTO ns_name FROM pg_namespace WHERE oid = ns;
@ -109,15 +97,12 @@ BEGIN
AND attname = 'users_organizations_uuid'; AND attname = 'users_organizations_uuid';
EXECUTE format( EXECUTE format(
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version IN (' 'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version = ''20260630120000''',
'''20260630120000'', ''20260715120000'', ''20260716120000'', ''20260723120000'','
'''20260724120000'', ''20260724130000'', ''20260724140000'', ''20260809120000'','
'''20260810120000'')',
ns_name ns_name
) INTO ledger_rows; ) INTO ledger_rows;
EXECUTE format( EXECUTE format(
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260810120000''', 'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260630120000''',
ns_name ns_name
) INTO future_rows; ) INTO future_rows;
@ -139,17 +124,17 @@ BEGIN
'no users_organizations_uuid column. Create it as documented in README.md.'; 'no users_organizations_uuid column. Create it as documented in README.md.';
END IF; END IF;
IF ledger_rows <> 9 THEN IF ledger_rows <> 1 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected all nine Custom-role ' RAISE EXCEPTION 'Rollback refused, nothing was changed: expected the Custom-role migration '
'migrations in __diesel_schema_migrations, found %. Schema and ledger ' 'in __diesel_schema_migrations, found %. Schema and ledger disagree, so '
'disagree, so restore the backup taken before the upgrade and start over.', 'restore the backup taken before the upgrade and start over.',
ledger_rows; ledger_rows;
END IF; END IF;
IF future_rows <> 0 THEN IF future_rows <> 0 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: % migration(s) newer than the ' RAISE EXCEPTION 'Rollback refused, nothing was changed: % migration(s) newer than the '
'Custom-role change are recorded. This script does not know what they ' '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 ' 'ledger inconsistent. Use the rollback script shipped with that newer '
'version.', 'version.',
future_rows; future_rows;
@ -170,9 +155,9 @@ BEGIN
-- reads member and collection ACL details through `ManagerHeadersLoose`, none of which needs a -- 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, -- permission flag in the old schema -- so handing it out on anything less than a current,
-- deliberate decision would *grant* authority during a downgrade. -- 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 -- Historical provenance would not be that decision either: a record of who was a Manager
-- first upgrade and is never updated afterwards, so a member whose powers an owner has since -- before the first upgrade is never updated afterwards, so a member whose powers an owner has
-- reduced would get all of them back. -- since reduced would get all of them back.
-- --
-- Everything else becomes a plain User and keeps its per-collection assignments. -- Everything else becomes a plain User and keeps its per-collection assignments.
-- --
@ -219,23 +204,15 @@ BEGIN
ns_name ns_name
); );
-- Bookkeeping tables this feature may have left behind. A later re-upgrade rebuilds the -- The two decisions this rollback required. A later re-upgrade needs neither: it reads the
-- provenance record and the history marker from the very `atype = 3` rows this script just -- restored `atype = 3` rows directly and converts them deterministically.
-- restored, so the round trip converges.
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_same_run_0716', ns_name);
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_allow_custom_role_downgrade', ns_name); EXECUTE format('DROP TABLE IF EXISTS %I.__vw_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_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 -- Finally forget the migration, so the older binary does not see a ledger from the future and a
-- and a later upgrade applies them again from a clean state. -- later upgrade applies it again from a clean state.
EXECUTE format( EXECUTE format(
'DELETE FROM %I.__diesel_schema_migrations WHERE version IN (' 'DELETE FROM %I.__diesel_schema_migrations WHERE version = ''20260630120000''',
'''20260630120000'', ''20260715120000'', ''20260716120000'', ''20260723120000'','
'''20260724120000'', ''20260724130000'', ''20260724140000'', ''20260809120000'','
'''20260810120000'')',
ns_name ns_name
); );
END $$; END $$;

89
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 -- -- 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. -- 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 -- `ALTER TABLE ... DROP COLUMN` is avoided on purpose -- it needs SQLite 3.35, and this script has to
-- script has to work on the same older system SQLite the forward migrations support. Rebuilding the -- work on the same older system SQLite the forward migrations support. The rebuild recreates
-- table also recreates `access_all` and drops all nine permission columns in one step. -- `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, -- 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 -- 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 ( CREATE TEMPORARY TABLE __vw_rollback_precondition_ledger (
ok INTEGER NOT NULL CONSTRAINT ok INTEGER NOT NULL CONSTRAINT
refused_all_nine_custom_role_migrations_must_be_recorded_schema_and_ledger_disagree refused_the_custom_role_migration_must_be_recorded_schema_and_ledger_disagree
CHECK (ok = 9) CHECK (ok = 1)
); );
INSERT INTO __vw_rollback_precondition_ledger (ok) INSERT INTO __vw_rollback_precondition_ledger (ok)
SELECT COUNT(*) SELECT COUNT(*)
FROM __diesel_schema_migrations FROM __diesel_schema_migrations
WHERE version IN ( WHERE version IN (
'20260630120000', '20260630120000'
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
); );
DROP TABLE __vw_rollback_precondition_ledger; 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 -- 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 the nine versions would also leave the -- 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. -- ledger claiming a migration whose schema objects are gone.
CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger ( CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger (
ok INTEGER NOT NULL CONSTRAINT ok INTEGER NOT NULL CONSTRAINT
@ -127,23 +119,9 @@ CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger (
CHECK (ok = 0) CHECK (ok = 0)
); );
INSERT INTO __vw_rollback_precondition_future_ledger (ok) 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; 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 -- 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. -- empty list is a valid answer and maps every Custom member to plain User.
CREATE TEMPORARY TABLE __vw_rollback_precondition_allowlist ( 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 -- Roles and the legacy flag are recomputed together, because in the old schema they are not
-- independent. -- independent.
-- --
-- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a subset -- Only a membership on the allowlist comes back as Manager; everything else becomes a plain User and
-- of what a Custom member holds -- it manages, and deletes, every collection reachable through -- keeps every grant `users_collections` and `collections_groups` carry. The legacy role is not a
-- `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and reads member -- subset of what a Custom member holds, so handing it out on less than a current, deliberate decision
-- and collection ACL details through `ManagerHeadersLoose`, none of which needs a permission flag in -- would *grant* authority during a downgrade -- and historical provenance is not that decision. See
-- the old schema -- so handing it out on anything less than a current, deliberate decision would -- README.md.
-- *grant* authority during a downgrade. `__vw_custom_role_legacy_manager` is not that decision: it
-- records who was a Manager before the first upgrade and is never updated afterwards, so a member
-- whose powers an owner has since reduced would get all of them back.
--
-- Everything else becomes a plain User. Per-collection assignments are untouched, so those members
-- keep every grant `users_collections` and `collections_groups` carry.
-- --
-- `access_all` follows the same mapping the down migrations use: everyone who reached every -- `access_all` follows the mapping the down migrations use: a Custom member has to hold all three
-- collection keeps that reach, and a Custom member has to hold all three collection permissions -- -- collection permissions, because in the old schema the bit also carried collection deletion. A
-- Edit-only must not silently turn into the legacy "manage all collections" authority, which in that -- member mapped to plain User never keeps it: `User + access_all` is the one state the upgrade
-- older schema also carried collection deletion. A member mapped to plain User never keeps it: -- refuses, so leaving it set would strand this database.
-- `User + access_all` is the one legacy state the upgrade refuses, so leaving it set would make this
-- database unable to move forward again.
INSERT INTO users_organizations_rollback ( INSERT INTO users_organizations_rollback (
uuid, user_uuid, org_uuid, access_all, akey, status, atype, uuid, user_uuid, org_uuid, access_all, akey, status, atype,
reset_password_key, external_id, invited_by_email 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; ALTER TABLE users_organizations_rollback RENAME TO users_organizations;
-- Bookkeeping tables this feature may have left behind. A later re-upgrade rebuilds the provenance -- The two decisions this rollback required. A later re-upgrade needs neither: it reads the restored
-- record and the history marker from the very `atype = 3` rows this script just restored, so the -- `atype = 3` rows directly and converts them deterministically.
-- round trip converges.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; DROP TABLE IF EXISTS __vw_allow_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_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 -- Finally forget the migration, so the older binary does not see a ledger from the future and a
-- and a later upgrade applies them again from a clean state. -- later upgrade applies it again from a clean state.
DELETE FROM __diesel_schema_migrations DELETE FROM __diesel_schema_migrations
WHERE version IN ( WHERE version IN (
'20260630120000', '20260630120000'
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
); );
COMMIT; COMMIT;

Loading…
Cancel
Save