Browse Source

Merge 570c385cb5 into 0cefa4cca7

pull/7397/merge
Tom 2 days ago
committed by GitHub
parent
commit
0ee17660d0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 75
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql
  2. 37
      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. 65
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql
  20. 38
      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. 68
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql
  38. 37
      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. 95
      src/api/admin.rs
  56. 54
      src/api/core/ciphers.rs
  57. 380
      src/api/core/events.rs
  58. 2002
      src/api/core/organizations.rs
  59. 1
      src/api/core/public.rs
  60. 501
      src/auth.rs
  61. 3369
      src/db/mod.rs
  62. 102
      src/db/models/cipher.rs
  63. 244
      src/db/models/collection.rs
  64. 121
      src/db/models/event.rs
  65. 31
      src/db/models/group.rs
  66. 709
      src/db/models/organization.rs
  67. 10
      src/db/schema.rs
  68. 16
      src/static/scripts/admin_users.js
  69. 4
      src/static/templates/admin/users.hbs
  70. 6
      src/static/templates/scss/vaultwarden.scss.hbs
  71. 10
      src/util.rs
  72. 281
      tools/custom_role_rollback/README.md
  73. 317
      tools/custom_role_rollback/mysql.sql
  74. 243
      tools/custom_role_rollback/postgresql.sql
  75. 252
      tools/custom_role_rollback/sqlite.sql

75
migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,75 @@
-- Lossy revert: this removes the three Custom management permissions and the Custom role itself,
-- which the legacy role/access_all schema cannot represent. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade'
);
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name.
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard;
-- Convert Custom members back to a role the older server can load -- it cannot represent type 4 and
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its
-- authority *now*, and it is not symmetric with the upgrade.
--
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not
-- authorization. Use a list written for this downgrade instead.
--
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe
-- direction: the legacy Manager role is not a subset of what a Custom member holds -- it manages, and
-- deletes, every collection reachable through `users_collections.manage`,
-- `collections_groups.manage` or `groups.access_all`, and reads member and collection ACL details
-- through `ManagerHeadersLoose`, none of which needs a permission flag in the old schema. To keep the
-- historical mapping, copy it over deliberately before reverting:
--
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY);
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid)
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist (
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY
);
UPDATE users_organizations SET atype = 3
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist);
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection
-- permissions, on the assumption they are about to become a Manager; left behind on a User it
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are
-- untouched, so these members keep every per-collection grant and lose only the organization-wide
-- powers the old schema cannot express.
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4;
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements
-- mean two intermediate states that survive a failure while Diesel still considers the migration
-- unapplied; one statement is the closest this backend gets to all-or-nothing.
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies;
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade
-- rebuilds all of it from the restored `atype = 3` rows.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist;
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager;
DROP TABLE IF EXISTS __vw_custom_role_history_verified;

37
migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,37 @@
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE;
-- Record which memberships were legacy Managers *before* anything converts them.
--
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table
-- instead of guessing, which is what stops them from handing legacy privileges to modern members.
--
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table
-- rebuild does not have to care about it.
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager (
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY
);
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid)
SELECT uuid FROM users_organizations WHERE atype = 3;
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup
-- preflight treat its absence as proof that an earlier revision of this chain ran instead.
--
-- Deliberately not the record table above: that one holds data an operator has to be able to write
-- during recovery, so its existence cannot also stand for "the history behind this data was
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for.
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified (
verified INTEGER NOT NULL PRIMARY KEY
);
-- Previously the server stored members created with the Custom role as Manager (3) and
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted
-- type, convert those members so clients (which no longer know the Manager role) keep
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE,
-- which matches the capabilities these members had.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;

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

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

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

@ -0,0 +1,13 @@
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data.
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 (
marker INTEGER NOT NULL PRIMARY KEY
);
INSERT IGNORE INTO __vw_custom_role_same_run_0716 (marker)
SELECT 1
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM __diesel_schema_migrations
WHERE version = '20260716120000'
);

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

@ -0,0 +1,34 @@
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade'
);
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name.
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard;
-- The previous schema exposes access_all as the three collection permissions together. Avoid
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back.
UPDATE users_organizations
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection
WHERE atype = 4;
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements
-- mean two intermediate states that survive a failure while Diesel still considers the migration
-- unapplied; one statement is the closest this backend gets to all-or-nothing.
ALTER TABLE users_organizations
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection;

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

@ -0,0 +1,68 @@
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it,
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE so a
-- refusal leaves no half-added column group behind -- every ALTER commits implicitly here, and a
-- partial group is what the startup preflight then has to recover from.
--
-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this whole check is
-- free of durable side effects.
--
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning.
-- This guard exists for a bare migration runner that never consulted the startup preflight.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager'
);
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard;
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
-- Before these permissions were persisted independently, access_all represented the legacy
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members.
--
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a
-- member carrying access_all held exactly this capability, whenever the row was created.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all
WHERE atype = 4;
-- A legacy Manager also managed every collection when one of their groups had access_all, even if
-- the membership itself did not. Preserve that existing edit/delete capability without granting
-- collection creation, which historically still required membership access_all.
--
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority:
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was
-- simply put into an ordinary access_all group, and granting on that shape hands them
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies
-- has_full_access() and therefore reaches every cipher in the organization.
--
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every
-- `atype = 3` row, which at this point is every Custom member there is.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users
-- `groups` is a reserved word in MySQL 8 and must be quoted, matching the existing
-- `2022-07-27-110000_add_group_support` migration. (PostgreSQL/SQLite do not reserve it.)
INNER JOIN `groups` ON `groups`.uuid = groups_users.groups_uuid
WHERE groups_users.users_organizations_uuid = users_organizations.uuid
AND `groups`.organizations_uuid = users_organizations.org_uuid
AND `groups`.access_all = TRUE
);

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

@ -0,0 +1,4 @@
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe
-- conversion.
SELECT 1;

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

@ -0,0 +1,101 @@
-- Repair the legacy role/permission state while membership `access_all` still exists.
--
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not
-- converted: that state grants dynamic reach over every collection *without* management authority,
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust:
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few
-- statements later. Repeating the check before this file's first mutation is what makes the silent
-- loss impossible rather than unlikely.
--
-- The duplicate key aborts the migration. It is only inserted when such a membership exists.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2
AND access_all = TRUE
LIMIT 1;
DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard;
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup
-- preflight refuses a database whose ledger carries that version without it. Creating it here would
-- manufacture an empty, apparently valid history for precisely the databases that need an operator
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never
-- consulted the preflight. Refusing also keeps this file free of DDL, which on MySQL/MariaDB would
-- commit implicitly and break this migration out of its transaction.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager'
);
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard;
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the
-- normal path.
INSERT IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid)
SELECT uuid FROM users_organizations WHERE atype = 3;
-- Step 1: a legacy Manager who managed every collection through an organization-local group with
-- `access_all` keeps that authority, materialized into the permission columns it now lives in.
--
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and
-- would hand them organization-wide collection edit and delete.
--
-- Earlier revisions derived this authority live from the group at request time instead, which was
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md.
--
-- Deliberately not `create_new_collections`: creating collections historically required
-- membership-level `access_all`, and it is an independent permission now.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities.
-- Set only TRUE values so this repair never removes independently configured permissions, and again
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a
-- modern Custom member carrying the old column as well.
UPDATE users_organizations
SET create_new_collections = TRUE,
edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND access_all = TRUE;
-- Convert only after the legacy bit has been copied.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;
-- Clear only the marker row as transactional DML. Keeping the empty bookkeeping table avoids
-- MySQL DDL implicit commits, so the permission repair, marker clear, and Diesel ledger insert
-- either commit together or are all retried.
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1;

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

@ -0,0 +1,13 @@
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring
-- the invariant the immediately preceding schema relies on: access_all == access to every collection.
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`.
--
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain,
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members,
-- because in that older schema access_all also meant the legacy Manager "Manage all collections"
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without*
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed;
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE;

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

@ -0,0 +1,5 @@
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column.
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature.
ALTER TABLE users_organizations DROP COLUMN access_all;

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

@ -0,0 +1,28 @@
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot
-- represent at all. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade'
);
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name.
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard;
-- One ALTER, not three. Each `ALTER TABLE` commits implicitly on MySQL/MariaDB, so three statements
-- mean two intermediate states that survive a failure while Diesel still considers the migration
-- unapplied; one statement is the closest this backend gets to all-or-nothing.
ALTER TABLE users_organizations
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;

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

@ -0,0 +1,5 @@
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members
-- (gated on the role in code); Owners/Admins hold every permission implicitly.
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;

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

@ -0,0 +1,60 @@
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create
-- the marker table below while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited.
--
-- Operators who only need the old server version to start again do not need Diesel at all --
-- tools/custom_role_rollback/ has a self-contained script per backend.
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_custom_role_downgrade');
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits
-- implicitly on MySQL/MariaDB, and it would happily drop a permanent table of the same name.
DROP TEMPORARY TABLE __vw_custom_role_downgrade_guard;
-- Second, MySQL/MariaDB-only guard: this revert chain cannot be resumed here.
--
-- Every `ALTER TABLE` in it commits on its own, while Diesel deletes the ledger row in a separate
-- statement afterwards. A crash in between leaves the columns gone and the migration still recorded
-- as applied, and re-running it fails forever with `Unknown column` (1091) -- the startup preflight
-- then refuses the database, correctly, and the only way out is the backup. Making it resumable
-- needs conditional DDL, i.e. a stored procedure built before the checks have run; the standalone
-- script in tools/custom_role_rollback/mysql.sql does the whole downgrade in one audited pass
-- instead, and is what operators should use.
--
-- So this is supported for development checkouts only, and it says so. Acknowledge separately from
-- the data-loss marker above -- that one is about what a downgrade discards, this one is about what
-- an interrupted downgrade cannot repair:
--
-- CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
CREATE TEMPORARY TABLE __vw_mysql_resume_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_mysql_resume_guard (blocked) VALUES (1);
INSERT INTO __vw_mysql_resume_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_allow_unresumable_mysql_downgrade'
);
DROP TEMPORARY TABLE __vw_mysql_resume_guard;
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here
-- when the next revert removes the first permission column, which is what this guard exists to
-- announce -- checking and dropping it in the same step would leave every following lossy revert
-- unguarded.
SELECT 1;

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

@ -0,0 +1,13 @@
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade
-- before any granular permission column is removed.
--
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is
-- never inherited across an upgrade. Both of them: this backend's revert chain needs a second one,
-- acknowledging that it cannot be resumed after a crash between a committed ALTER and the ledger.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade;

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

@ -0,0 +1,4 @@
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and
-- the original values are not recoverable. The permission columns themselves are removed further down
-- the chain by 2026-07-16-120000/down.sql, which is guarded.
SELECT 1;

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

@ -0,0 +1,111 @@
-- Follow-up repair for databases that already recorded 2026-07-23-120000.
--
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager
-- whose authority came from an organization-local `access_all` group, because the runtime derived the
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three
-- collection permissions, member of such a group" is also the shape of every newly created flagless
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the
-- authority into the permission columns.
--
-- Rewriting that file is not enough on its own: a database whose ledger already carries
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the
-- materialization here, in its own version, so both paths converge on the same state.
--
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape,
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`.
--
-- What this materialization *means* -- a group-bound capability becoming a permanent membership
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it.
--
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two
-- permission columns, so it is also safe after `access_all` has been dropped.
--
-- Deliberately not `create_new_collections`: collection creation historically required
-- membership-level `access_all`.
--
-- Every statement here is DML or TEMPORARY-table bookkeeping, so nothing commits implicitly and the
-- repair either lands with the ledger insert or not at all.
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather
-- than creating it.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1 FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager'
);
DROP TEMPORARY TABLE __vw_legacy_manager_record_guard;
-- Fail closed on a database whose legacy provenance was never recorded.
--
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the
-- membership really is a converted legacy Manager whose record was never written (a ledger from an
-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not
-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real
-- capability.
--
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator
-- creates it after auditing an older history, so its presence means the unrecorded memberships below
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup
-- preflight refuses that state before any migration runs; this guard is the backstop for a bare
-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships:
--
-- SELECT uo.uuid, uo.org_uuid, uo.status,
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection
-- FROM users_organizations uo
-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid
-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid
-- WHERE uo.atype = 4 AND g.access_all = 1
-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager);
--
-- The marker never grants anything by itself: the update below is always driven by the record table,
-- so an unrecorded membership keeps exactly the permissions it has.
CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_group_authority_guard (blocked)
SELECT 1
FROM users_organizations AS uo
WHERE uo.atype = 4
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = uo.uuid
AND g.organizations_uuid = uo.org_uuid
AND g.access_all = TRUE
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_custom_role_history_verified'
)
LIMIT 1;
DROP TEMPORARY TABLE __vw_legacy_group_authority_guard;
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);

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

@ -0,0 +1,4 @@
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next
-- upgrade has to ask again.
SELECT 1;

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

@ -0,0 +1,121 @@
-- Make the one semantic change this feature cannot express an owner's decision instead of a default.
--
-- Before the Custom role, a Manager who reached every collection through an organization-local group
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership.
--
-- So the earlier migrations in this chain write the authority onto the membership, and the result is
-- deliberately not identical to what it replaces:
--
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared;
-- * it applies even with the groups feature switched off;
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every
-- collection of the organization directly rather than through the group.
--
-- Materializing it silently would be a migration that grants durable organization-wide collection
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and
-- revokes nothing itself.
--
-- On a database with no Custom membership that both has edit/delete authority and belongs to an
-- organization-local `access_all` group, there is nothing to decide and this is a no-op.
--
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the
-- driver error, so on this path the question would arrive as `Duplicate entry '1' for key 'PRIMARY'`
-- and nothing else. Keep the two predicates identical.
--
-- Review the affected memberships:
--
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status,
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection,
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))
-- AS was_legacy_manager
-- FROM users_organizations uo
-- WHERE uo.atype = 4
-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1)
-- AND EXISTS (
-- SELECT 1 FROM groups_users gu
-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid
-- WHERE gu.users_organizations_uuid = uo.uuid
-- AND g.organizations_uuid = uo.org_uuid
-- AND g.access_all = 1);
--
-- Reading the result:
--
-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when
-- `create_new_collections = 1`: that independent permission can be changed after an earlier
-- revision materialized group-derived edit/delete, so its current value cannot prove where those
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may
-- therefore be listed conservatively even though its authority was already permanent.
-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can
-- distinguish from a deliberate grant any more -- check them against what you intended.
--
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today --
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked
-- value outright -- but the permission is what it would come back with if it is ever restored, and
-- by then the group it came from may be gone. Status is therefore not part of the predicate.
--
-- Clear whatever you do not want to keep, for example:
--
-- UPDATE users_organizations
-- SET edit_any_collection = 0, delete_any_collection = 0
-- WHERE uuid = '<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;

65
migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,65 @@
-- Lossy revert: this removes the three Custom management permissions and the Custom role itself,
-- which the legacy role/access_all schema cannot represent. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL;
DROP TABLE __vw_custom_role_downgrade_guard;
-- Convert Custom members back to a role the older server can load -- it cannot represent type 4 and
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its
-- authority *now*, and it is not symmetric with the upgrade.
--
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not
-- authorization. Use a list written for this downgrade instead.
--
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe
-- direction: the legacy Manager role is not a subset of what a Custom member holds -- it manages, and
-- deletes, every collection reachable through `users_collections.manage`,
-- `collections_groups.manage` or `groups.access_all`, and reads member and collection ACL details
-- through `ManagerHeadersLoose`, none of which needs a permission flag in the old schema. To keep the
-- historical mapping, copy it over deliberately before reverting:
--
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY);
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid)
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist (
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY
);
UPDATE users_organizations SET atype = 3
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist);
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection
-- permissions, on the assumption they are about to become a Manager; left behind on a User it
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are
-- untouched, so these members keep every per-collection grant and lose only the organization-wide
-- powers the old schema cannot express.
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade
-- rebuilds all of it from the restored `atype = 3` rows.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist;
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager;
DROP TABLE IF EXISTS __vw_custom_role_history_verified;

38
migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,38 @@
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE;
-- Record which memberships were legacy Managers *before* anything converts them.
--
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table
-- instead of guessing, which is what stops them from handing legacy privileges to modern members.
--
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table
-- rebuild does not have to care about it.
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager (
users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid)
SELECT uuid FROM users_organizations WHERE atype = 3
ON CONFLICT DO NOTHING;
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup
-- preflight treat its absence as proof that an earlier revision of this chain ran instead.
--
-- Deliberately not the record table above: that one holds data an operator has to be able to write
-- during recovery, so its existence cannot also stand for "the history behind this data was
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for.
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified (
verified INTEGER NOT NULL PRIMARY KEY
);
-- Previously the server stored members created with the Custom role as Manager (3) and
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted
-- type, convert those members so clients (which no longer know the Manager role) keep
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE,
-- which matches the capabilities these members had.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;

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

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

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

@ -0,0 +1,13 @@
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data.
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 (
marker INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_same_run_0716 (marker)
SELECT 1
WHERE NOT EXISTS (
SELECT 1
FROM __diesel_schema_migrations
WHERE version = '20260716120000'
)
ON CONFLICT (marker) DO NOTHING;

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

@ -0,0 +1,25 @@
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL;
DROP TABLE __vw_custom_role_downgrade_guard;
-- The previous schema exposes access_all as the three collection permissions together. Avoid
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back.
UPDATE users_organizations
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection
WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;

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

@ -0,0 +1,60 @@
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it,
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so
-- the refusal is symmetrical with the other backends -- PostgreSQL DDL is transactional, so nothing
-- would be left behind either way.
--
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning.
-- This guard exists for a bare migration runner that never consulted the startup preflight.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL;
DROP TABLE __vw_legacy_manager_record_guard;
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
-- Before these permissions were persisted independently, access_all represented the legacy
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members.
--
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a
-- member carrying access_all held exactly this capability, whenever the row was created.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all
WHERE atype = 4;
-- A legacy Manager also managed every collection when one of their groups had access_all, even if
-- the membership itself did not. Preserve that existing edit/delete capability without granting
-- collection creation, which historically still required membership access_all.
--
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority:
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was
-- simply put into an ordinary access_all group, and granting on that shape hands them
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies
-- has_full_access() and therefore reaches every cipher in the organization.
--
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every
-- `atype = 3` row, which at this point is every Custom member there is.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid
WHERE groups_users.users_organizations_uuid = users_organizations.uuid
AND groups.organizations_uuid = users_organizations.org_uuid
AND groups.access_all = TRUE
);

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

@ -0,0 +1,4 @@
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe
-- conversion.
SELECT 1;

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

@ -0,0 +1,96 @@
-- Repair the legacy role/permission state while membership `access_all` still exists.
--
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not
-- converted: that state grants dynamic reach over every collection *without* management authority,
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust:
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few
-- statements later. Repeating the check before this file's first mutation is what makes the silent
-- loss impossible rather than unlikely.
--
-- The duplicate key aborts the migration. It is only inserted when such a membership exists.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2
AND access_all = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_user_access_all_guard;
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup
-- preflight refuses a database whose ledger carries that version without it. Creating it here would
-- manufacture an empty, apparently valid history for precisely the databases that need an operator
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never
-- consulted the preflight.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL;
DROP TABLE __vw_legacy_manager_record_guard;
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the
-- normal path.
INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid)
SELECT uuid FROM users_organizations WHERE atype = 3
ON CONFLICT DO NOTHING;
-- Step 1: a legacy Manager who managed every collection through an organization-local group with
-- `access_all` keeps that authority, materialized into the permission columns it now lives in.
--
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and
-- would hand them organization-wide collection edit and delete.
--
-- Earlier revisions derived this authority live from the group at request time instead, which was
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md.
--
-- Deliberately not `create_new_collections`: creating collections historically required
-- membership-level `access_all`, and it is an independent permission now.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities.
-- Set only TRUE values so this repair never removes independently configured permissions, and again
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a
-- modern Custom member carrying the old column as well.
UPDATE users_organizations
SET create_new_collections = TRUE,
edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND access_all = TRUE;
-- Convert only after the legacy bit has been copied.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;
-- Clear the same-run marker only after every permission update succeeds.
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1;

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

@ -0,0 +1,13 @@
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring
-- the invariant the immediately preceding schema relies on: access_all == access to every collection.
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`.
--
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain,
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members,
-- because in that older schema access_all also meant the legacy Manager "Manage all collections"
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without*
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed;
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE;

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

@ -0,0 +1,5 @@
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column.
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature.
ALTER TABLE users_organizations DROP COLUMN access_all;

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

@ -0,0 +1,19 @@
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot
-- represent at all. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL;
DROP TABLE __vw_custom_role_downgrade_guard;
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;

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

@ -0,0 +1,5 @@
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members
-- (gated on the role in code); Owners/Admins hold every permission implicitly.
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;

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

@ -0,0 +1,27 @@
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create
-- the marker table below while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited.
--
-- Operators who only need the old server version to start again do not need Diesel at all --
-- tools/custom_role_rollback/ has a self-contained script per backend.
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE to_regclass('__vw_allow_custom_role_downgrade') IS NULL;
DROP TABLE __vw_custom_role_downgrade_guard;
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here
-- when the next revert removes the first permission column, which is what this guard exists to
-- announce -- checking and dropping it in the same step would leave every following lossy revert
-- unguarded.
SELECT 1;

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

@ -0,0 +1,11 @@
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade
-- before any granular permission column is removed.
--
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is
-- never inherited across an upgrade.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;

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

@ -0,0 +1,4 @@
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and
-- the original values are not recoverable. The permission columns themselves are removed further down
-- the chain by 2026-07-16-120000/down.sql, which is guarded.
SELECT 1;

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

@ -0,0 +1,103 @@
-- Follow-up repair for databases that already recorded 2026-07-23-120000.
--
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager
-- whose authority came from an organization-local `access_all` group, because the runtime derived the
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three
-- collection permissions, member of such a group" is also the shape of every newly created flagless
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the
-- authority into the permission columns.
--
-- Rewriting that file is not enough on its own: a database whose ledger already carries
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the
-- materialization here, in its own version, so both paths converge on the same state.
--
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape,
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`.
--
-- What this materialization *means* -- a group-bound capability becoming a permanent membership
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it.
--
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two
-- permission columns, so it is also safe after `access_all` has been dropped.
--
-- Deliberately not `create_new_collections`: collection creation historically required
-- membership-level `access_all`.
DO $$
DECLARE
undecidable int := 0;
BEGIN
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses
-- rather than creating it.
IF to_regclass('__vw_custom_role_legacy_manager') IS NULL THEN
RAISE EXCEPTION
'Upgrade refused, nothing was changed: __vw_custom_role_legacy_manager does not exist, '
'so which memberships were legacy Managers before the upgrade is unknown. Start '
'Vaultwarden once to get the full recovery instructions, or see '
'tools/custom_role_rollback/README.md.';
END IF;
-- Fail closed on a database whose legacy provenance was never recorded.
--
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the
-- membership really is a converted legacy Manager whose record was never written (a ledger from
-- an earlier revision of this feature branch), or it is an ordinary modern Custom member who must
-- not gain anything. Granting is a silent privilege escalation; skipping silently drops a real
-- capability.
--
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator
-- creates it after auditing an older history, so its presence means the unrecorded memberships
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup
-- preflight refuses that state before any migration runs; this is the backstop for a bare
-- migration runner.
--
-- The marker never grants anything by itself: the update below is always driven by the record
-- table, so an unrecorded membership keeps exactly the permissions it has.
IF to_regclass('__vw_custom_role_history_verified') IS NULL THEN
SELECT count(*) INTO undecidable
FROM users_organizations uo
WHERE uo.atype = 4
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users gu
INNER JOIN "groups" g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = uo.uuid
AND g.organizations_uuid = uo.org_uuid
AND g.access_all = TRUE
);
END IF;
IF undecidable <> 0 THEN
RAISE EXCEPTION
'Upgrade refused, nothing was changed: % Custom membership(s) belong to an access_all '
'group but are not on record as legacy Managers, and this database''s Custom-role '
'history has never been audited, so a converted legacy Manager cannot be told from an '
'ordinary Custom member. Review them with: SELECT uo.uuid, uo.org_uuid, uo.status, '
'uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection FROM '
'users_organizations uo JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid '
'JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid '
'WHERE uo.atype = 4 AND g.access_all AND uo.uuid NOT IN (SELECT '
'users_organizations_uuid FROM __vw_custom_role_legacy_manager); Start Vaultwarden once '
'for the full recovery instructions.',
undecidable;
END IF;
END $$;
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);

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

@ -0,0 +1,4 @@
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next
-- upgrade has to ask again.
SELECT 1;

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

@ -0,0 +1,111 @@
-- Make the one semantic change this feature cannot express an owner's decision instead of a default.
--
-- Before the Custom role, a Manager who reached every collection through an organization-local group
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership.
--
-- So the earlier migrations in this chain write the authority onto the membership, and the result is
-- deliberately not identical to what it replaces:
--
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared;
-- * it applies even with the groups feature switched off;
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every
-- collection of the organization directly rather than through the group.
--
-- Materializing it silently would be a migration that grants durable organization-wide collection
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and
-- revokes nothing itself.
--
-- On a database with no Custom membership that both has edit/delete authority and belongs to an
-- organization-local `access_all` group, there is nothing to decide and this is a no-op.
--
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the
-- driver error, so on this path the question would arrive as a bare duplicate-key violation on
-- `__vw_permanent_authority_guard` and nothing else. Keep the two predicates identical.
--
-- Review the affected memberships:
--
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status,
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection,
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))
-- AS was_legacy_manager
-- FROM users_organizations uo
-- WHERE uo.atype = 4
-- AND (uo.edit_any_collection OR uo.delete_any_collection)
-- AND EXISTS (
-- SELECT 1 FROM groups_users gu
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid
-- WHERE gu.users_organizations_uuid = uo.uuid
-- AND g.organizations_uuid = uo.org_uuid
-- AND g.access_all);
--
-- Reading the result:
--
-- * `was_legacy_manager = t` -- a converted Manager. Review it even when
-- `create_new_collections = t`: that independent permission can be changed after an earlier
-- revision materialized group-derived edit/delete, so its current value cannot prove where those
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may
-- therefore be listed conservatively even though its authority was already permanent.
-- * `was_legacy_manager = f` -- never a Manager. On a database first upgraded by revision bf54088c
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can
-- distinguish from a deliberate grant any more -- check them against what you intended.
--
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today --
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked
-- value outright -- but the permission is what it would come back with if it is ever restored, and
-- by then the group it came from may be gone. Status is therefore not part of the predicate.
--
-- Clear whatever you do not want to keep, for example:
--
-- UPDATE users_organizations
-- SET edit_any_collection = FALSE, delete_any_collection = FALSE
-- WHERE uuid = '<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;

68
migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,68 @@
-- Lossy revert: this removes the three Custom management permissions and the Custom role itself,
-- which the legacy role/access_all schema cannot represent. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade'
);
DROP TABLE __vw_custom_role_downgrade_guard;
-- Convert Custom members back to a role the older server can load -- it cannot represent type 4 and
-- masquerades Manager as Custom in API responses. Which role each one gets is a decision about its
-- authority *now*, and it is not symmetric with the upgrade.
--
-- Deliberately not driven by `__vw_custom_role_legacy_manager`. That records who held the Manager
-- role before the *first* upgrade and is never updated afterwards, so a member whose Manager powers
-- an owner has since reduced -- or who was demoted to User and later re-created as a limited Custom
-- member -- would be handed the whole legacy role back. Historical provenance is evidence, not
-- authorization. Use a list written for this downgrade instead.
--
-- Absent, or empty, means "nobody", and everything below becomes a plain User. That is the safe
-- direction: the legacy Manager role is not a subset of what a Custom member holds -- it manages, and
-- deletes, every collection reachable through `users_collections.manage`,
-- `collections_groups.manage` or `groups.access_all`, and reads member and collection ACL details
-- through `ManagerHeadersLoose`, none of which needs a permission flag in the old schema. To keep the
-- historical mapping, copy it over deliberately before reverting:
--
-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY);
-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid)
-- SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;
CREATE TABLE IF NOT EXISTS __vw_rollback_manager_allowlist (
users_organizations_uuid TEXT NOT NULL PRIMARY KEY
);
UPDATE users_organizations SET atype = 3
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist);
-- Everything still on the Custom role becomes a plain User, and `access_all` has to be cleared with
-- it. 2026-07-16-120000/down.sql sets that flag for every Custom member holding all three collection
-- permissions, on the assumption they are about to become a Manager; left behind on a User it
-- produces `User + access_all`, the one legacy state the upgrade refuses outright -- which would
-- leave the database unable to move forward again. `users_collections` and `collections_groups` are
-- untouched, so these members keep every per-collection grant and lose only the organization-wide
-- powers the old schema cannot express.
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN manage_users;
ALTER TABLE users_organizations DROP COLUMN manage_groups;
ALTER TABLE users_organizations DROP COLUMN manage_policies;
-- Oldest lossy step of the chain: nothing below this can lose Custom-role data any more, so the
-- acknowledgement is consumed here. It authorized *this* downgrade, not every future one. The
-- Custom-role bookkeeping goes with it -- the roles it describes are back, and a later re-upgrade
-- rebuilds all of it from the restored `atype = 3` rows.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist;
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager;
DROP TABLE IF EXISTS __vw_custom_role_history_verified;

37
migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,37 @@
ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE;
-- Record which memberships were legacy Managers *before* anything converts them.
--
-- This is the only moment at which that is knowable. `atype = 3` means Manager here and Custom
-- afterwards -- the conversion below reuses the value -- so once it has run, a genuine legacy
-- Manager and a Custom member created later are byte-identical. Every later step that has to reason
-- about legacy authority (2026-07-23, 2026-08-09 and tools/custom_role_rollback/) reads this table
-- instead of guessing, which is what stops them from handing legacy privileges to modern members.
--
-- Deliberately not a Diesel model and not in schema.rs: no runtime code reads it. It is
-- migration/rollback bookkeeping, and it carries no foreign key so that 2026-07-24-120000's table
-- rebuild does not have to care about it.
CREATE TABLE IF NOT EXISTS __vw_custom_role_legacy_manager (
users_organizations_uuid TEXT NOT NULL PRIMARY KEY
);
INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid)
SELECT uuid FROM users_organizations WHERE atype = 3;
-- Separately, mark that this database's Custom-role history is accounted for -- it was produced by
-- the migrations that ship today. Nothing else creates this table, which is what lets the startup
-- preflight treat its absence as proof that an earlier revision of this chain ran instead.
--
-- Deliberately not the record table above: that one holds data an operator has to be able to write
-- during recovery, so its existence cannot also stand for "the history behind this data was
-- reviewed" -- creating it empty to silence an error would otherwise pass as the audit it asks for.
CREATE TABLE IF NOT EXISTS __vw_custom_role_history_verified (
verified INTEGER NOT NULL PRIMARY KEY
);
-- Previously the server stored members created with the Custom role as Manager (3) and
-- masqueraded them as Custom (4) in all API responses. Now that Custom is a real, persisted
-- type, convert those members so clients (which no longer know the Manager role) keep
-- seeing exactly what they saw before. access_all is preserved; the new flags stay FALSE,
-- which matches the capabilities these members had.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;

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

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

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

@ -0,0 +1,12 @@
-- Record whether 2026-07-16 is about to run in this migration sequence. The durable marker lets a
-- retry distinguish its deterministic group-derived 0/1/1 backfill from older, ambiguous data.
CREATE TABLE IF NOT EXISTS __vw_custom_role_same_run_0716 (
marker INTEGER NOT NULL PRIMARY KEY
);
INSERT OR IGNORE INTO __vw_custom_role_same_run_0716 (marker)
SELECT 1
WHERE NOT EXISTS (
SELECT 1
FROM __diesel_schema_migrations
WHERE version = '20260716120000'
);

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

@ -0,0 +1,28 @@
-- Lossy revert: this removes the three independent Custom collection permissions, which the legacy
-- role/access_all schema cannot represent -- it only knows all three together. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade'
);
DROP TABLE __vw_custom_role_downgrade_guard;
-- The previous schema exposes access_all as the three collection permissions together. Avoid
-- turning Edit-only memberships into Create/Edit/Delete grants when rolling back.
UPDATE users_organizations
SET access_all = create_new_collections AND edit_any_collection AND delete_any_collection
WHERE atype = 4;
ALTER TABLE users_organizations DROP COLUMN create_new_collections;
ALTER TABLE users_organizations DROP COLUMN edit_any_collection;
ALTER TABLE users_organizations DROP COLUMN delete_any_collection;

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

@ -0,0 +1,63 @@
-- The legacy-Manager record has to exist before anything below runs: 2026-06-30-120000 writes it,
-- and the group-derived step at the end of this file reads it. Checked *before* the ALTER TABLE statements so
-- a refusal leaves no half-added column group behind -- on MySQL/MariaDB every ALTER commits on its
-- own, and a partial group is what the startup preflight then has to recover from.
--
-- Creating the record here instead would manufacture an empty, apparently valid history for exactly
-- the databases that need an operator to look at them; see 2026-07-23-120000 for the full reasoning.
-- This guard exists for a bare migration runner that never consulted the startup preflight.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager'
);
DROP TABLE __vw_legacy_manager_record_guard;
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;
-- Before these permissions were persisted independently, access_all represented the legacy
-- "Manage all collections" checkbox. Preserve that capability for existing Custom members.
--
-- Driven by the stored value rather than by the membership's shape, so it needs no provenance: a
-- member carrying access_all held exactly this capability, whenever the row was created.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all
WHERE atype = 4;
-- A legacy Manager also managed every collection when one of their groups had access_all, even if
-- the membership itself did not. Preserve that existing edit/delete capability without granting
-- collection creation, which historically still required membership access_all.
--
-- Restricted to memberships recorded as legacy Managers, exactly like 2026-07-23-120000 and
-- 2026-08-09-120000. Role and group membership alone are *not* evidence of legacy authority:
-- "Custom, member of an access_all group" is also the shape of every modern Custom member who was
-- simply put into an ordinary access_all group, and granting on that shape hands them
-- organization-wide collection edit and delete -- which, through edit_any_collection, also satisfies
-- has_full_access() and therefore reaches every cipher in the organization.
--
-- On the normal upgrade path this changes nothing: 2026-06-30-120000 runs first and records every
-- `atype = 3` row, which at this point is every Custom member there is.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users
INNER JOIN groups ON groups.uuid = groups_users.groups_uuid
WHERE groups_users.users_organizations_uuid = users_organizations.uuid
AND groups.organizations_uuid = users_organizations.org_uuid
AND groups.access_all = TRUE
);

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

@ -0,0 +1,4 @@
-- This is an idempotent data repair, and it creates no rows: reverting it must not remove permissions
-- or recreate the invalid persisted Manager type. The older-schema migration performs its own safe
-- conversion.
SELECT 1;

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

@ -0,0 +1,98 @@
-- Repair the legacy role/permission state while membership `access_all` still exists.
--
-- A plain User carrying the historical membership-level `access_all` bit is deliberately not
-- converted: that state grants dynamic reach over every collection *without* management authority,
-- and the new model has no equivalent. It is refused instead -- and refused *here*, not only in Rust:
-- Vaultwarden's startup preflight already stops such a database before any migration runs and prints
-- the two explicit choices (`RefuseLegacyUserAccessAll` in `src/db/mod.rs`), but a migration run
-- outside that wrapper -- `diesel migration run`, a bare `MigrationHarness`, any other SQL runner
-- -- would not consult it, and 2026-07-24-120000 removes the only source of that reach a few
-- statements later. Repeating the check before this file's first mutation is what makes the silent
-- loss impossible rather than unlikely.
--
-- The duplicate key aborts the migration. It is only inserted when such a membership exists.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2
AND access_all = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_user_access_all_guard;
-- The legacy-Manager record has to exist already: 2026-06-30-120000 writes it, and the startup
-- preflight refuses a database whose ledger carries that version without it. Creating it here would
-- manufacture an empty, apparently valid history for precisely the databases that need an operator
-- to look at them, so refuse instead -- this guard exists for a bare migration runner that never
-- consulted the preflight.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager'
);
DROP TABLE __vw_legacy_manager_record_guard;
-- A database that reaches this file with memberships still at `atype = 3` never ran the rewritten
-- 2026-06-30-120000 -- for instance because a runner applied the files out of order. Those rows are
-- unambiguously legacy Managers *right now*, so record them before the conversion at the end of this
-- file makes them indistinguishable from modern Custom members. Idempotent, and a no-op on the
-- normal path where 2026-06-30-120000 already recorded them.
INSERT OR IGNORE INTO __vw_custom_role_legacy_manager (users_organizations_uuid)
SELECT uuid FROM users_organizations WHERE atype = 3;
-- Step 1: a legacy Manager who managed every collection through an organization-local group with
-- `access_all` keeps that authority, materialized into the permission columns it now lives in.
--
-- Restricted to memberships recorded as legacy Managers. Matching on role and group membership
-- alone -- which an earlier revision did -- also matches every *modern* flagless Custom member who
-- happens to sit in an ordinary `access_all` group, because the two states are the same shape, and
-- would hand them organization-wide collection edit and delete.
--
-- Earlier revisions derived this authority live from the group at request time instead, which was
-- unsound for exactly that reason. Materializing it makes it visible to an owner in the member's
-- permission list and revocable by clearing a checkbox. It is deliberately a one-time snapshot: the
-- permission no longer lapses when the source group does. See tools/custom_role_rollback/README.md.
--
-- Deliberately not `create_new_collections`: creating collections historically required
-- membership-level `access_all`, and it is an independent permission now.
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);
-- Step 2: membership `access_all` on a legacy Manager represented all three collection capabilities.
-- Set only TRUE values so this repair never removes independently configured permissions, and again
-- only for recorded legacy Managers -- an intermediate revision of this feature branch could leave a
-- modern Custom member carrying the old column as well.
UPDATE users_organizations
SET create_new_collections = TRUE,
edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND access_all = TRUE;
-- Convert only after the legacy bit has been copied.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;
-- Clear the same-run marker only after every permission update succeeds.
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1;

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

@ -0,0 +1,13 @@
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring
-- the invariant the immediately preceding schema relies on: access_all == access to every collection.
-- That is exactly Owners/Admins, plus Custom members holding `edit_any_collection`.
--
-- NOTE: this only holds for reverting *this* migration. Reverting further down the chain,
-- 2026-07-16 deliberately recomputes access_all as (create AND edit AND delete) for Custom members,
-- because in that older schema access_all also meant the legacy Manager "Manage all collections"
-- authority -- so a member who only held `edit_any_collection` comes out as a Manager *without*
-- access_all rather than silently gaining collection deletion. That is intentional and fail-closed;
-- the full rollback is blocked by 2026-07-24-140000/down.sql anyway.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE;

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

@ -0,0 +1,46 @@
-- The membership `access_all` flag was Vaultwarden's pre-permissions patch for "this member can
-- reach every collection". It is now fully represented by the role model: Owners/Admins hold it
-- implicitly, and a Custom member holds it via `edit_any_collection`. Drop the redundant column.
-- This only concerns users_organizations; groups.access_all is a separate, still-supported feature.
--
-- `ALTER TABLE ... DROP COLUMN` is deliberately NOT used here: it only exists since SQLite 3.35.0,
-- while a `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1
-- (which is what Debian 11 ships). Forward migrations have to run on every supported build, so use
-- the portable table rebuild instead -- the same pattern as
-- 2022-03-02-210038_update_devices_primary_key. Vaultwarden runs SQLite migrations with
-- `PRAGMA foreign_keys = OFF`, so dropping the old table does not cascade into groups_users.
CREATE TABLE users_organizations_new (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users (uuid),
org_uuid TEXT NOT NULL REFERENCES organizations (uuid),
akey TEXT NOT NULL,
status INTEGER NOT NULL,
atype INTEGER NOT NULL,
reset_password_key TEXT,
external_id TEXT,
invited_by_email TEXT DEFAULT NULL,
manage_users BOOLEAN NOT NULL DEFAULT FALSE,
manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (user_uuid, org_uuid)
);
INSERT INTO users_organizations_new (
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id,
invited_by_email, manage_users, manage_groups, manage_policies,
create_new_collections, edit_any_collection, delete_any_collection
)
SELECT
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id,
invited_by_email, manage_users, manage_groups, manage_policies,
create_new_collections, edit_any_collection, delete_any_collection
FROM users_organizations;
DROP TABLE users_organizations;
ALTER TABLE users_organizations_new RENAME TO users_organizations;

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

@ -0,0 +1,22 @@
-- Lossy revert: this removes the three Custom access permissions, which the legacy schema cannot
-- represent at all. The revert therefore
-- requires the same acknowledgement as 2026-07-24-140000/down.sql -- which only announces the loss,
-- it does not authorize it. Create the marker table while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade'
);
DROP TABLE __vw_custom_role_downgrade_guard;
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;

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

@ -0,0 +1,5 @@
-- Three additional Bitwarden Custom-role permissions. They are only meaningful for Custom members
-- (gated on the role in code); Owners/Admins hold every permission implicitly.
ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;

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

@ -0,0 +1,29 @@
-- Downgrade guard. Reverting this migration destroys Custom-role permission data that the legacy
-- role/access_all schema cannot represent, so it only runs with an explicit acknowledgement. Create
-- the marker table below while every Vaultwarden instance is stopped:
--
-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
--
-- The acknowledgement stays valid for the rest of the revert chain and is consumed by the oldest
-- lossy migration (2026-06-30-120000), so one decision covers one downgrade -- and a re-upgrade
-- clears it again (2026-07-24-140000/up.sql), so consent is never inherited.
--
-- Operators who only need the old server version to start again do not need Diesel at all --
-- tools/custom_role_rollback/ has a self-contained script per backend.
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent.
INSERT INTO __vw_custom_role_downgrade_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_allow_custom_role_downgrade');
DROP TABLE __vw_custom_role_downgrade_guard;
-- Nothing else to undo: the acknowledgement deliberately survives this step. It has to still be here
-- when the next revert removes the first permission column, which is what this guard exists to
-- announce -- checking and dropping it in the same step would leave every following lossy revert
-- unguarded.
SELECT 1;

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

@ -0,0 +1,11 @@
-- Forward migration marker: its down migration intentionally blocks an automatic lossy downgrade
-- before any granular permission column is removed.
--
-- It also cleans up after 2026-07-15: the same-run bookkeeping table has served its purpose by now
-- (2026-07-23 consumed the marker), so it is not left behind in every database. A single DDL
-- statement is safe even on MySQL, where DDL commits implicitly -- re-running it is a no-op.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
-- Also clear a downgrade acknowledgement left over from an earlier revert, so consent is
-- never inherited across an upgrade.
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;

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

@ -0,0 +1,4 @@
-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and
-- the original values are not recoverable. The permission columns themselves are removed further down
-- the chain by 2026-07-16-120000/down.sql, which is guarded.
SELECT 1;

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

@ -0,0 +1,107 @@
-- Follow-up repair for databases that already recorded 2026-07-23-120000.
--
-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager
-- whose authority came from an organization-local `access_all` group, because the runtime derived the
-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three
-- collection permissions, member of such a group" is also the shape of every newly created flagless
-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the
-- authority into the permission columns.
--
-- Rewriting that file is not enough on its own: a database whose ledger already carries
-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the
-- materialization here, in its own version, so both paths converge on the same state.
--
-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written
-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape,
-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom
-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless
-- Custom into Edit+Delete, the last of which also implies `has_full_access()`.
--
-- What this materialization *means* -- a group-bound capability becoming a permanent membership
-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it.
--
-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already
-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two
-- permission columns, so it is also safe after `access_all` has been dropped.
--
-- Deliberately not `create_new_collections`: collection creation historically required
-- membership-level `access_all`.
-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather
-- than creating it.
--
-- The duplicate key aborts the migration. It is only inserted while the record table is absent.
CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_manager_record_guard (blocked)
SELECT 1
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager'
);
DROP TABLE __vw_legacy_manager_record_guard;
-- Fail closed on a database whose legacy provenance was never recorded.
--
-- If a Custom member sits in an organization-local `access_all` group but is not on record as a
-- legacy Manager, one of two things is true and this file cannot tell them apart: either the
-- membership really is a converted legacy Manager whose record was never written (a ledger from an
-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not
-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real
-- capability.
--
-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator
-- creates it after auditing an older history, so its presence means the unrecorded memberships below
-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup
-- preflight refuses that state before any migration runs; this guard is the backstop for a bare
-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships:
--
-- SELECT uo.uuid, uo.org_uuid, uo.status,
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection
-- FROM users_organizations uo
-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid
-- WHERE uo.atype = 4 AND g.access_all = 1
-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager);
--
-- The marker never grants anything by itself: the update below is always driven by the record table,
-- so an unrecorded membership keeps exactly the permissions it has.
CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_group_authority_guard (blocked)
SELECT 1
FROM users_organizations AS uo
WHERE uo.atype = 4
AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = uo.uuid
AND g.organizations_uuid = uo.org_uuid
AND g.access_all = TRUE
)
AND NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND name = '__vw_custom_role_history_verified'
)
LIMIT 1;
DROP TABLE __vw_legacy_group_authority_guard;
UPDATE users_organizations
SET edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);

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

@ -0,0 +1,4 @@
-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The
-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next
-- upgrade has to ask again.
SELECT 1;

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

@ -0,0 +1,117 @@
-- Make the one semantic change this feature cannot express an owner's decision instead of a default.
--
-- Before the Custom role, a Manager who reached every collection through an organization-local group
-- with `access_all` held that authority *while* the group relationship lasted. It ended when the
-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert
-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a
-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership.
--
-- So the earlier migrations in this chain write the authority onto the membership, and the result is
-- deliberately not identical to what it replaces:
--
-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared;
-- * it applies even with the groups feature switched off;
-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every
-- collection of the organization directly rather than through the group.
--
-- Materializing it silently would be a migration that grants durable organization-wide collection
-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is
-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and
-- revokes nothing itself.
--
-- On a database with no Custom membership that both has edit/delete authority and belongs to an
-- organization-local `access_all` group, there is nothing to decide and this is a no-op.
--
-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the
-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy
-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the
-- driver error, so on this path the question would arrive as `UNIQUE constraint failed:
-- __vw_permanent_authority_guard.blocked` and nothing else. Keep the two predicates identical.
--
-- Review the affected memberships:
--
-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status,
-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection,
-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))
-- AS was_legacy_manager
-- FROM users_organizations uo
-- WHERE uo.atype = 4
-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1)
-- AND EXISTS (
-- SELECT 1 FROM groups_users gu
-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid
-- WHERE gu.users_organizations_uuid = uo.uuid
-- AND g.organizations_uuid = uo.org_uuid
-- AND g.access_all = 1);
--
-- Reading the result:
--
-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when
-- `create_new_collections = 1`: that independent permission can be changed after an earlier
-- revision materialized group-derived edit/delete, so its current value cannot prove where those
-- two permissions came from. A membership whose own legacy `access_all` supplied all three may
-- therefore be listed conservatively even though its authority was already permanent.
-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c
-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can
-- distinguish from a deliberate grant any more -- check them against what you intended.
--
-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today --
-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked
-- value outright -- but the permission is what it would come back with if it is ever restored, and
-- by then the group it came from may be gone. Status is therefore not part of the predicate.
--
-- Clear whatever you do not want to keep, for example:
--
-- UPDATE users_organizations
-- SET edit_any_collection = 0, delete_any_collection = 0
-- WHERE uuid = '<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;

95
src/api/admin.rs

@ -544,6 +544,32 @@ struct MembershipTypeData {
org_uuid: OrganizationId,
}
fn apply_membership_type_change(membership: &mut Membership, new_type: MembershipType) {
// Entering Custom through the Vaultwarden admin panel is deliberately fail-closed because that
// UI cannot select granular permissions; they can be granted later through the regular
// organization member dialog. Any non-Custom role carries no custom flags at all. Only a member
// that is already Custom and stays Custom keeps its existing flags.
let stays_custom = new_type == MembershipType::Custom && membership.atype == MembershipType::Custom;
if !stays_custom {
membership.clear_custom_permissions();
}
membership.atype = new_type as i32;
}
fn parse_admin_membership_type(user_type: NumberOrString) -> Option<MembershipType> {
let raw_type = user_type.into_string();
// The public API still accepts the legacy Manager representation for compatibility and folds
// it into Custom. The admin panel must not do that: treating an apparent Manager demotion as a
// Custom-to-Custom update would preserve the member's existing granular permissions.
if matches!(raw_type.as_str(), "3" | "Manager") {
return None;
}
MembershipType::from_str(&raw_type)
}
#[post("/users/org_type", format = "application/json", data = "<data>")]
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, conn: DbConn) -> EmptyResult {
let data: MembershipTypeData = data.into_inner();
@ -553,9 +579,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization")
};
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32
} else {
let Some(new_type) = parse_admin_membership_type(data.user_type) else {
err!("Invalid type")
};
@ -566,7 +590,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
}
}
member_to_edit.atype = new_type;
apply_membership_type_change(&mut member_to_edit, new_type);
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
@ -900,6 +924,14 @@ impl<'r> FromRequest<'r> for AdminToken {
#[cfg(test)]
mod tests {
use super::*;
use crate::db::models::MembershipStatus;
fn membership(member_type: MembershipType) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = MembershipStatus::Confirmed as i32;
membership
}
#[test]
fn validate_web_vault_compare() {
@ -924,4 +956,59 @@ mod tests {
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
}
#[test]
fn admin_type_changes_clear_custom_permissions() {
let mut custom = membership(MembershipType::Custom);
custom.manage_users = true;
custom.create_new_collections = true;
custom.edit_any_collection = true;
custom.delete_any_collection = true;
apply_membership_type_change(&mut custom, MembershipType::User);
assert_eq!(custom.atype, MembershipType::User as i32);
assert!(!custom.manage_users);
assert!(!custom.create_new_collections);
assert!(!custom.edit_any_collection);
assert!(!custom.delete_any_collection);
// Entering Custom through the admin panel is fail-closed: no granular permissions are set.
let mut admin = membership(MembershipType::Admin);
apply_membership_type_change(&mut admin, MembershipType::Custom);
assert_eq!(admin.atype, MembershipType::Custom as i32);
assert!(!admin.has_manage_all_collections());
assert!(!admin.edit_any_collection);
}
#[test]
fn admin_custom_to_custom_keeps_flags_but_other_transitions_clear() {
// A member kept as Custom retains its granular flags: the admin panel does not touch them;
// they are managed through the regular organization member dialog.
let mut custom = membership(MembershipType::Custom);
custom.manage_users = true;
custom.edit_any_collection = true;
apply_membership_type_change(&mut custom, MembershipType::Custom);
assert_eq!(custom.atype, MembershipType::Custom as i32);
assert!(custom.manage_users);
assert!(custom.edit_any_collection);
// Promoting to Admin/Owner drops any stale custom flags.
let mut promo = membership(MembershipType::Custom);
promo.edit_any_collection = true;
apply_membership_type_change(&mut promo, MembershipType::Admin);
assert_eq!(promo.atype, MembershipType::Admin as i32);
assert!(!promo.edit_any_collection);
}
#[test]
fn admin_type_parser_rejects_legacy_manager_before_normalization() {
assert!(parse_admin_membership_type(NumberOrString::Number(3)).is_none());
assert!(parse_admin_membership_type(NumberOrString::String("3".to_owned())).is_none());
assert!(parse_admin_membership_type(NumberOrString::String("Manager".to_owned())).is_none());
assert!(parse_admin_membership_type(NumberOrString::Number(4)) == Some(MembershipType::Custom));
assert!(
parse_admin_membership_type(NumberOrString::String("Custom".to_owned())) == Some(MembershipType::Custom)
);
}
}

54
src/api/core/ciphers.rs

@ -392,6 +392,16 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(())
}
fn has_prevalidated_organization_write_authority(
allow_direct_organization_write: bool,
shared_to_collections: Option<&Vec<CollectionId>>,
member_has_full_access: bool,
) -> bool {
allow_direct_organization_write
|| shared_to_collections.is_some_and(|collections| !collections.is_empty())
|| member_has_full_access
}
pub async fn update_cipher_from_data(
cipher: &mut Cipher,
data: CipherData,
@ -400,6 +410,23 @@ pub async fn update_cipher_from_data(
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
update_cipher_from_data_with_authority(cipher, data, headers, shared_to_collections, false, conn, nt, ut).await
}
#[expect(
clippy::too_many_arguments,
reason = "The extra flag is a prevalidated route authority and must remain separate from client data"
)]
pub(super) async fn update_cipher_from_data_with_authority(
cipher: &mut Cipher,
data: CipherData,
headers: &Headers,
shared_to_collections: Option<Vec<CollectionId>>,
allow_direct_organization_write: bool,
conn: &DbConn,
nt: &Notify<'_>,
ut: UpdateType,
) -> EmptyResult {
// Cleanup cipher data, like removing the 'Response' key.
// This key is somewhere generated during Javascript so no way for us this fix this.
@ -452,9 +479,11 @@ pub async fn update_cipher_from_data(
Some(member) => {
// A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
if has_prevalidated_organization_write_authority(
allow_direct_organization_write,
shared_to_collections.as_ref(),
member.has_full_access(),
) || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
{
cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again.
@ -577,6 +606,25 @@ pub async fn update_cipher_from_data(
Ok(())
}
#[cfg(test)]
mod update_authority_tests {
use super::has_prevalidated_organization_write_authority;
#[test]
fn direct_organization_write_is_an_explicit_import_authority() {
// Keep the organization-import shortcut independent from the old non-empty-collection
// sentinel. The route may import ciphers without collections when AccessImportExport grants
// organization-wide import authority; every other caller passes false.
let no_collections: Vec<crate::db::models::CollectionId> = Vec::new();
assert!(has_prevalidated_organization_write_authority(true, Some(&no_collections), false));
assert!(!has_prevalidated_organization_write_authority(false, Some(&no_collections), false));
let collections = vec!["collection".to_owned().into()];
assert!(has_prevalidated_organization_write_authority(false, Some(&collections), false));
assert!(has_prevalidated_organization_write_authority(false, None, true));
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportData {

380
src/api/core/events.rs

@ -7,12 +7,15 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult},
auth::{AdminHeaders, Headers},
auth::{AccessEventLogsHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
models::{
Cipher, CipherId, Event, EventType, Membership, MembershipId, MembershipStatus, MembershipType,
OrganizationId, UserId,
},
},
util::parse_date,
util::try_parse_date,
};
/// ###############################################################################################################
@ -29,9 +32,36 @@ struct EventRange {
continuation_token: Option<String>,
}
fn parse_event_date(date: &str, field: &str) -> Result<NaiveDateTime, crate::Error> {
try_parse_date(date)
.map_err(|error| crate::Error::new("Invalid event date", format!("Invalid RFC 3339 {field}: {error}")))
}
fn parse_event_range(data: &EventRange) -> Result<(NaiveDateTime, NaiveDateTime), crate::Error> {
let start_date = parse_event_date(&data.start, "start date")?;
let end_date = if let Some(continuation_token) = &data.continuation_token {
try_parse_date(continuation_token).map_err(|error| {
crate::Error::new(
"Invalid continuation token",
format!("Continuation token is not a valid RFC 3339 date: {error}"),
)
})?
} else {
parse_event_date(&data.end, "end date")?
};
Ok((start_date, end_date))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
async fn get_org_events(
org_id: OrganizationId,
data: EventRange,
headers: AccessEventLogsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
@ -39,12 +69,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = parse_event_range(&data)?;
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await
@ -62,21 +87,69 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
})))
}
#[derive(Debug, Eq, PartialEq)]
enum CipherEventScope {
Organization(OrganizationId),
Personal,
}
impl CipherEventScope {
fn organization_id(&self) -> Option<&OrganizationId> {
match self {
Self::Organization(org_id) => Some(org_id),
Self::Personal => None,
}
}
}
fn membership_can_access_event_logs(membership: &Membership) -> bool {
membership.has_status(MembershipStatus::Confirmed)
&& (membership.atype >= MembershipType::Admin || membership.has_access_event_logs())
}
fn cipher_event_scope(cipher: &Cipher, user_id: &UserId, membership: Option<&Membership>) -> Option<CipherEventScope> {
match &cipher.organization_uuid {
Some(org_id)
if membership.is_some_and(|membership| {
membership.user_uuid == *user_id
&& membership.org_uuid == *org_id
&& membership_can_access_event_logs(membership)
}) =>
{
Some(CipherEventScope::Organization(org_id.clone()))
}
None if cipher.is_owned_by_user(user_id) => Some(CipherEventScope::Personal),
_ => None,
}
}
#[get("/ciphers/<cipher_id>/events?<data..>")]
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
// Return an empty vec when org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
{
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let (start_date, end_date) = parse_event_range(&data)?;
let scope = if let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await {
let membership = if let Some(org_id) = &cipher.organization_uuid {
Membership::find_by_user_and_org(&headers.user.uuid, org_id, &conn).await
} else {
None
};
cipher_event_scope(&cipher, &headers.user.uuid, membership.as_ref())
} else {
parse_date(&data.end)
None
};
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
if let Some(scope) = scope {
Event::find_by_cipher_uuid(&cipher_id, scope.organization_id(), &start_date, &end_date, &conn)
.await
.iter()
.map(Event::to_json)
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
};
@ -93,21 +166,17 @@ async fn get_user_events(
org_id: OrganizationId,
member_id: MembershipId,
data: EventRange,
headers: AdminHeaders,
headers: AccessEventLogsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = parse_event_range(&data)?;
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await
@ -158,6 +227,74 @@ struct EventCollection {
organization_id: Option<OrganizationId>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ClientEventKind {
User,
Cipher,
Organization,
}
const MAX_CLIENT_EVENT_BATCH_SIZE: usize = 1_000;
fn validate_client_event_batch_size(event_count: usize) -> Result<(), crate::Error> {
if event_count > MAX_CLIENT_EVENT_BATCH_SIZE {
return Err(crate::Error::new(
"Event batch is too large",
format!("At most {MAX_CLIENT_EVENT_BATCH_SIZE} events are accepted per request"),
));
}
Ok(())
}
/// The client-generated event types upstream's `/events/collect` accepts. Anything else is ignored,
/// so that an authenticated client cannot write arbitrary event types into an organization's audit
/// log. Keep this in sync with upstream's `CollectController`: a type missing here is silently not
/// logged, which is why the newer item-type events below are listed explicitly rather than matched
/// by range.
fn client_event_kind(event_type: i32) -> Option<ClientEventKind> {
match event_type {
event_type if event_type == EventType::UserClientExportedVault as i32 => Some(ClientEventKind::User),
event_type
if event_type == EventType::CipherClientViewed as i32
|| event_type == EventType::CipherClientToggledPasswordVisible as i32
|| event_type == EventType::CipherClientToggledHiddenFieldVisible as i32
|| event_type == EventType::CipherClientToggledCardCodeVisible as i32
|| event_type == EventType::CipherClientCopiedPassword as i32
|| event_type == EventType::CipherClientCopiedHiddenField as i32
|| event_type == EventType::CipherClientCopiedCardCode as i32
|| event_type == EventType::CipherClientAutofilled as i32
|| event_type == EventType::CipherClientToggledCardNumberVisible as i32
|| event_type == EventType::CipherClientCopiedBankAccountNumber as i32
|| event_type == EventType::CipherClientCopiedBankAccountPin as i32
|| event_type == EventType::CipherClientToggledBankAccountNumberVisible as i32
|| event_type == EventType::CipherClientToggledBankAccountPinVisible as i32
|| event_type == EventType::CipherClientCopiedLicenseNumber as i32
|| event_type == EventType::CipherClientToggledLicenseNumberVisible as i32
|| event_type == EventType::CipherClientCopiedPassportNumber as i32
|| event_type == EventType::CipherClientToggledPassportNumberVisible as i32
|| event_type == EventType::CipherClientCopiedSwiftCode as i32
|| event_type == EventType::CipherClientToggledSwiftCodeVisible as i32
|| event_type == EventType::CipherClientCopiedIban as i32
|| event_type == EventType::CipherClientToggledIbanVisible as i32
|| event_type == EventType::CipherClientCopiedNationalIdentificationNumber as i32
|| event_type == EventType::CipherClientToggledNationalIdentificationNumberVisible as i32 =>
{
Some(ClientEventKind::Cipher)
}
event_type
if event_type == EventType::OrganizationClientExportedVault as i32
|| event_type == EventType::OrganizationItemOrganizationAccepted as i32
|| event_type == EventType::OrganizationItemOrganizationDeclined as i32
|| event_type == EventType::OrganizationAutoConfirmEnabledAdmin as i32
|| event_type == EventType::OrganizationAutoConfirmDisabledAdmin as i32
|| event_type == EventType::OrganizationInviteLinkClientCopied as i32 =>
{
Some(ClientEventKind::Organization)
}
_ => None,
}
}
// Upstream:
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
@ -167,10 +304,25 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
return Ok(());
}
// Official clients normally submit small batches (upstream explicitly exercises batches of
// 100). Keep ample headroom while preventing one authenticated request from causing an
// effectively unbounded sequence of database reads and writes under the shared 20 MiB JSON
// limit.
validate_client_event_batch_size(data.len())?;
// Validate all accepted client events before writing any of them. Unsupported event types are
// ignored, matching upstream, while malformed dates on accepted events produce a controlled
// 400 response instead of panicking after a partially processed batch.
let mut accepted_events = Vec::new();
for event in data.iter() {
let event_date = parse_date(&event.date);
match event.r#type {
1000..=1099 => {
if let Some(kind) = client_event_kind(event.r#type) {
accepted_events.push((event, kind, parse_event_date(&event.date, "event date")?));
}
}
for (event, kind, event_date) in accepted_events {
match kind {
ClientEventKind::User => {
log_user_event_impl(
event.r#type,
&headers.user.uuid,
@ -181,7 +333,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
)
.await;
}
1600..=1699 => {
ClientEventKind::Organization => {
// Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
@ -199,7 +351,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {
ClientEventKind::Cipher => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id
@ -341,3 +493,171 @@ pub async fn event_cleanup_job(pool: DbPool) {
error!("Failed to get DB connection while trying to cleanup the events table");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn membership(member_type: MembershipType, status: MembershipStatus) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = status as i32;
membership
}
#[test]
fn cipher_event_access_requires_confirmed_admin_or_access_event_logs() {
for member_type in [MembershipType::Owner, MembershipType::Admin] {
assert!(membership_can_access_event_logs(&membership(member_type, MembershipStatus::Confirmed)));
assert!(!membership_can_access_event_logs(&membership(member_type, MembershipStatus::Invited)));
assert!(!membership_can_access_event_logs(&membership(member_type, MembershipStatus::Accepted)));
assert!(!membership_can_access_event_logs(&membership(member_type, MembershipStatus::Revoked)));
}
let mut custom = membership(MembershipType::Custom, MembershipStatus::Confirmed);
assert!(!membership_can_access_event_logs(&custom));
custom.access_event_logs = true;
assert!(membership_can_access_event_logs(&custom));
custom.status = MembershipStatus::Revoked as i32;
assert!(!membership_can_access_event_logs(&custom));
assert!(!membership_can_access_event_logs(&membership(MembershipType::User, MembershipStatus::Confirmed)));
}
#[test]
fn cipher_event_scope_is_bound_to_cipher_org_or_personal_owner() {
let user_id: UserId = "test-user".to_owned().into();
let org_id: OrganizationId = "test-org".to_owned().into();
let mut cipher = Cipher::new(1, "test-cipher".to_owned());
cipher.organization_uuid = Some(org_id.clone());
let admin = membership(MembershipType::Admin, MembershipStatus::Confirmed);
assert_eq!(cipher_event_scope(&cipher, &user_id, Some(&admin)), Some(CipherEventScope::Organization(org_id)));
let accepted_admin = membership(MembershipType::Admin, MembershipStatus::Accepted);
assert_eq!(cipher_event_scope(&cipher, &user_id, Some(&accepted_admin)), None);
let mut foreign_membership = membership(MembershipType::Admin, MembershipStatus::Confirmed);
foreign_membership.org_uuid = "other-org".to_owned().into();
assert_eq!(cipher_event_scope(&cipher, &user_id, Some(&foreign_membership)), None);
cipher.organization_uuid = None;
cipher.user_uuid = Some(user_id.clone());
assert_eq!(cipher_event_scope(&cipher, &user_id, None), Some(CipherEventScope::Personal));
assert_eq!(cipher_event_scope(&cipher, &"other-user".to_owned().into(), None), None);
}
#[test]
fn cipher_event_scope_selects_the_database_scope_filter() {
let org_id: OrganizationId = "test-org".to_owned().into();
assert_eq!(CipherEventScope::Personal.organization_id(), None);
assert_eq!(CipherEventScope::Organization(org_id.clone()).organization_id(), Some(&org_id));
}
#[test]
fn event_range_rejects_invalid_dates_and_continuation_tokens() {
let valid = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "2026-07-25T11:00:00Z".to_owned(),
continuation_token: None,
};
assert!(parse_event_range(&valid).is_ok());
let invalid_start = EventRange {
start: "not-a-date".to_owned(),
..valid
};
assert!(parse_event_range(&invalid_start).is_err());
let invalid_end = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "not-a-date".to_owned(),
continuation_token: None,
};
assert!(parse_event_range(&invalid_end).is_err());
let invalid_token = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "2026-07-25T11:00:00Z".to_owned(),
continuation_token: Some("not-a-date".to_owned()),
};
assert!(parse_event_range(&invalid_token).is_err());
let token_supersedes_end = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "legacy-client-value-that-is-not-used".to_owned(),
continuation_token: Some("2026-07-25T10:30:00Z".to_owned()),
};
assert!(parse_event_range(&token_supersedes_end).is_ok());
}
#[test]
fn collect_accepts_only_official_client_generated_event_types() {
assert_eq!(client_event_kind(EventType::UserClientExportedVault as i32), Some(ClientEventKind::User));
for event_type in [
EventType::CipherClientViewed,
EventType::CipherClientToggledPasswordVisible,
EventType::CipherClientToggledHiddenFieldVisible,
EventType::CipherClientToggledCardCodeVisible,
EventType::CipherClientCopiedPassword,
EventType::CipherClientCopiedHiddenField,
EventType::CipherClientCopiedCardCode,
EventType::CipherClientAutofilled,
EventType::CipherClientToggledCardNumberVisible,
EventType::CipherClientCopiedBankAccountNumber,
EventType::CipherClientCopiedBankAccountPin,
EventType::CipherClientToggledBankAccountNumberVisible,
EventType::CipherClientToggledBankAccountPinVisible,
EventType::CipherClientCopiedLicenseNumber,
EventType::CipherClientToggledLicenseNumberVisible,
EventType::CipherClientCopiedPassportNumber,
EventType::CipherClientToggledPassportNumberVisible,
EventType::CipherClientCopiedSwiftCode,
EventType::CipherClientToggledSwiftCodeVisible,
EventType::CipherClientCopiedIban,
EventType::CipherClientToggledIbanVisible,
EventType::CipherClientCopiedNationalIdentificationNumber,
EventType::CipherClientToggledNationalIdentificationNumberVisible,
] {
assert_eq!(client_event_kind(event_type as i32), Some(ClientEventKind::Cipher));
}
for event_type in [
EventType::OrganizationClientExportedVault,
EventType::OrganizationItemOrganizationAccepted,
EventType::OrganizationItemOrganizationDeclined,
EventType::OrganizationAutoConfirmEnabledAdmin,
EventType::OrganizationAutoConfirmDisabledAdmin,
EventType::OrganizationInviteLinkClientCopied,
] {
assert_eq!(client_event_kind(event_type as i32), Some(ClientEventKind::Organization));
}
// Upstream does not accept the TOTP seed toggle from clients either.
assert_eq!(client_event_kind(1118), None);
for event_type in [
EventType::UserLoggedIn,
EventType::UserChangedPassword,
EventType::CipherCreated,
EventType::CipherUpdated,
EventType::CipherDeleted,
EventType::OrganizationUpdated,
EventType::OrganizationPurgedVault,
EventType::PolicyUpdated,
] {
assert_eq!(client_event_kind(event_type as i32), None);
}
assert_eq!(client_event_kind(1099), None);
assert_eq!(client_event_kind(1199), None);
assert_eq!(client_event_kind(1699), None);
}
#[test]
fn collect_batch_limit_preserves_normal_batches_and_rejects_excess() {
assert!(validate_client_event_batch_size(0).is_ok());
assert!(validate_client_event_batch_size(100).is_ok());
assert!(validate_client_event_batch_size(MAX_CLIENT_EVENT_BATCH_SIZE).is_ok());
assert!(validate_client_event_batch_size(MAX_CLIENT_EVENT_BATCH_SIZE + 1).is_err());
}
}

2002
src/api/core/organizations.rs

File diff suppressed because it is too large

1
src/api/core/public.rs

@ -125,7 +125,6 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
new_member.set_external_id(Some(user_data.external_id.clone()));
new_member.access_all = false;
new_member.atype = MembershipType::User as i32;
new_member.status = member_status;

501
src/auth.rs

@ -709,6 +709,7 @@ pub struct OrgHeaders {
pub host: String,
pub device: Device,
pub user: User,
#[allow(dead_code)]
pub membership_type: MembershipType,
pub membership_status: MembershipStatus,
pub membership: Membership,
@ -724,12 +725,52 @@ impl OrgHeaders {
fn is_confirmed_and_admin(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
}
// "Manager-level or above": a confirmed Custom, Admin or Owner member. (The legacy Manager role
// has been folded into Custom, which shares the same authorization rank.)
fn is_confirmed_and_manager(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Manager
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Custom
}
fn is_confirmed_and_owner(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
}
fn is_confirmed(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed
}
// Custom-role permission checks. Admins and Owners implicitly hold every
// permission; a Custom member holds a permission only if the matching flag
// is set on their Membership. The has_* helpers gate the flags on the
// Custom type, so stale flags on other types can never grant anything.
fn can_manage_users(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_users())
}
fn can_manage_groups(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_groups())
}
fn can_manage_policies(&self) -> bool {
self.is_confirmed() && (self.membership_type >= MembershipType::Admin || self.membership.has_manage_policies())
}
// Reading the full member/group *details* (PII, 2FA status, permission flags, access mappings)
// requires the ability to manage users or groups, matching Bitwarden's `ReadAll`/`ReadAllWithAccess`
// authorization. Basic member mini-details and the plain group list remain member-readable.
fn can_manage_users_or_groups(&self) -> bool {
self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin
|| self.membership.has_manage_users()
|| self.membership.has_manage_groups())
}
fn can_access_event_logs(&self) -> bool {
self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin || self.membership.has_access_event_logs())
}
fn can_access_import_export(&self) -> bool {
self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export())
}
// NOTE: there is deliberately no `can_access_reports` guard helper. Vaultwarden has no
// server-side report endpoints — the clients compute every report locally from the
// organization cipher list — so `accessReports` is enforced inline where that list is served
// (`get_org_details`), not through a request guard. A guard here would be dead code that
// invites gating an endpoint on "may call reports" instead of "may read these ciphers".
}
// org_id is usually the second path param ("/organizations/<org_id>"),
@ -814,6 +855,9 @@ impl<'r> FromRequest<'r> for OrgHeaders {
}
pub struct AdminHeaders {
// Kept for parity with the other org header guards (and possible future use); the org export
// endpoint that used to read this now goes through `AccessImportExportHeaders` instead.
#[allow(dead_code)]
pub host: String,
pub device: Device,
pub user: User,
@ -843,6 +887,93 @@ impl<'r> FromRequest<'r> for AdminHeaders {
}
}
// Macro to generate a request guard that permits a confirmed Admin/Owner, or a
// confirmed Custom member holding the given permission. The generated struct
// mirrors AdminHeaders so it can be used as a drop-in replacement on endpoints.
macro_rules! generate_manage_headers {
($name:ident, $check:ident, $err:literal) => {
#[allow(dead_code)]
pub struct $name {
pub host: String,
pub device: Device,
pub user: User,
pub membership_type: MembershipType,
// The caller's membership record. Holding the permission that opens an endpoint says
// nothing about *which* data the caller may reach, so handlers need the membership to
// apply the regular full-access/per-collection checks on top of the guard.
pub membership: Membership,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.$check() {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
membership_type: headers.membership_type,
ip: headers.ip,
org_id: headers.membership.org_uuid.clone(),
membership: headers.membership,
})
} else {
err_handler!($err)
}
}
}
impl From<$name> for Headers {
fn from(h: $name) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
};
}
generate_manage_headers!(
ManageUsersHeaders,
can_manage_users,
"You need the 'Manage Users' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManageGroupsHeaders,
can_manage_groups,
"You need the 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManagePoliciesHeaders,
can_manage_policies,
"You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManageUsersOrGroupsHeaders,
can_manage_users_or_groups,
"You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
AccessEventLogsHeaders,
can_access_event_logs,
"You need the 'Access Event Logs' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
AccessImportExportHeaders,
can_access_import_export,
"You need the 'Access Import/Export' permission, or to be an Admin or Owner, to call this endpoint"
);
// NOTE: no `AccessReportsHeaders`. See the note next to `can_access_import_export` above:
// `accessReports` guards data (the organization cipher list), not a dedicated endpoint.
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
// but there could be cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values.
@ -862,9 +993,113 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
None
}
/// The ManagerHeaders are used to check if you are at least a Manager
/// and have access to the specific collection provided via the <col_id>/collections/collectionId.
/// This does strict checking on the collection_id, ManagerHeadersLoose does not.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CollectionManageAccess {
Any,
ExplicitManage,
Denied,
}
fn collection_access_by_role(membership: &Membership, custom_has_any_access: bool) -> CollectionManageAccess {
if !membership.has_status(MembershipStatus::Confirmed) {
return CollectionManageAccess::Denied;
}
match MembershipType::from_i32(membership.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any,
Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any,
// A Custom member must prove an actual users_collections.manage / collections_groups.manage
// assignment. Neither membership nor group `access_all` is ever counted as one.
Some(MembershipType::Custom) => CollectionManageAccess::ExplicitManage,
Some(MembershipType::User) | None => CollectionManageAccess::Denied,
}
}
fn collection_edit_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(membership, membership.has_edit_any_collection())
}
fn collection_read_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(
membership,
membership.has_edit_any_collection() || membership.has_delete_any_collection(),
)
}
/// Collection deletion never falls back to a per-collection Manage grant.
///
/// Vaultwarden serializes `limitCollectionDeletion = true` unconditionally, and upstream gates
/// manage-based deletion on that setting being *off* (`BulkCollectionAuthorizationHandler`): with the
/// limit active, only Owners, Admins and holders of `Delete any collection` may delete. Accepting a
/// stored `manage` grant here would break that promise and, worse, make the three collection
/// permissions dependent on each other — a Custom member holding only `Create new collections`
/// receives an automatic `users_collections.manage` row for the collection they just created, and
/// could delete it again without `Delete any collection`.
///
/// A Manage grant keeps its full meaning for editing a collection and rewriting its access
/// (`collection_edit_access`); it just is not a delete permission.
fn collection_delete_access(membership: &Membership) -> CollectionManageAccess {
if !membership.has_status(MembershipStatus::Confirmed) {
return CollectionManageAccess::Denied;
}
match MembershipType::from_i32(membership.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any,
Some(MembershipType::Custom) if membership.has_delete_any_collection() => CollectionManageAccess::Any,
Some(MembershipType::Custom | MembershipType::User) | None => CollectionManageAccess::Denied,
}
}
async fn can_manage_collection(
access: CollectionManageAccess,
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
match access {
CollectionManageAccess::Any => true,
CollectionManageAccess::ExplicitManage => {
membership.has_explicit_collection_manage_access(collection_uuid, conn).await
}
CollectionManageAccess::Denied => false,
}
}
/// Whether `membership` may edit (rewrite the access of) `collection_uuid`, using exactly the same
/// Custom-aware rules as the path-based `ManagerHeaders` guard (`collection_edit_access`): Edit any
/// collection (or Admin/Owner) may edit every collection, otherwise only collections on which the
/// member holds a real per-collection Manage grant. In particular, a Custom member's membership or
/// group `access_all` does NOT satisfy this — it must be an explicit `users_collections.manage` /
/// `collections_groups.manage` assignment, exactly as an in-path collection edit would require.
///
/// Body-param endpoints (e.g. bulk collection access) take collection ids in the request body and
/// therefore cannot use `ManagerHeaders`; they must run this per collection to stay consistent with
/// the single-collection edit endpoint.
pub(crate) async fn can_edit_collection(
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
can_manage_collection(collection_edit_access(membership), membership, collection_uuid, conn).await
}
/// Whether `membership` may read a collection's user/group access mappings.
///
/// Keep body/bulk endpoints on exactly the same authorization rule as `CollectionReadHeaders`:
/// Admin/Owner, Edit-any/Delete-any, or a real per-collection Manage assignment. Ordinary read
/// access and group `access_all` deliberately do not qualify.
pub(crate) async fn can_read_collection_access(
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
can_manage_collection(collection_read_access(membership), membership, collection_uuid, conn).await
}
/// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can
/// update every collection; otherwise the caller must be a Custom member (or above) holding the
/// per-collection Manage permission. Read and delete use separate guards so Edit cannot
/// accidentally imply Delete.
pub struct ManagerHeaders {
pub host: String,
pub device: Device,
@ -881,12 +1116,15 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_manager() {
if let Some(col_id) = get_col_id(request) {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await {
err_handler!("The current user isn't a manager for this collection")
let access = collection_edit_access(&headers.membership);
if access != CollectionManageAccess::Any {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !can_manage_collection(access, &headers.membership, &col_id, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
}
} else {
err_handler!("Error getting the collection id")
@ -905,6 +1143,122 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
}
}
/// Read access to collection metadata and assignment details. Delete any collection needs this
/// visibility to render the standard collection view, but it does not grant edit or cipher access.
pub struct CollectionReadHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub membership: Membership,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for CollectionReadHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if !headers.is_confirmed_and_manager() {
err_handler!("You need collection read permission to call this endpoint")
}
let Some(col_id) = get_col_id(request) else {
err_handler!("Error getting the collection id")
};
let access = collection_read_access(&headers.membership);
if access != CollectionManageAccess::Any {
let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB")
};
if !can_manage_collection(access, &headers.membership, &col_id, &conn).await {
err_handler!("The current user isn't a manager for this collection")
}
}
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid.clone(),
membership: headers.membership,
})
}
}
impl From<CollectionReadHeaders> for Headers {
fn from(h: CollectionReadHeaders) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
/// Delete is fully independent from the other two collection permissions. Vaultwarden advertises
/// `limitCollectionDeletion = true`, so deleting a collection requires Admin/Owner or the explicit
/// Delete any collection permission — see `collection_delete_access` for why a per-collection Manage
/// grant deliberately does not qualify.
pub struct CollectionDeleteHeaders {
pub host: String,
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for CollectionDeleteHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if !headers.is_confirmed_and_manager() {
err_handler!("You need collection delete permission to call this endpoint")
}
// Only used to keep this guard bound to routes that actually carry a collection id.
if get_col_id(request).is_none() {
err_handler!("Error getting the collection id")
}
match collection_delete_access(&headers.membership) {
CollectionManageAccess::Any => {}
// Custom is a distinct, fail-closed role: neither Edit any collection nor a stored
// per-collection Manage grant substitutes for Delete any collection.
CollectionManageAccess::ExplicitManage | CollectionManageAccess::Denied => {
err_handler!("You need the 'Delete any collection' permission to call this endpoint")
}
}
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
}
}
impl From<CollectionDeleteHeaders> for Headers {
fn from(h: CollectionDeleteHeaders) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
impl From<ManagerHeaders> for Headers {
fn from(h: ManagerHeaders) -> Headers {
Headers {
@ -957,22 +1311,28 @@ impl From<ManagerHeadersLoose> for Headers {
}
}
impl ManagerHeaders {
impl CollectionDeleteHeaders {
pub async fn from_loose(
h: ManagerHeadersLoose,
collections: &Vec<CollectionId>,
conn: &DbConn,
) -> Result<ManagerHeaders, Error> {
) -> Result<CollectionDeleteHeaders, Error> {
// Bulk delete answers to the same rule as the single-collection route: blanket authority or
// nothing. A per-collection Manage grant is not a delete permission.
if collection_delete_access(&h.membership) != CollectionManageAccess::Any {
err!("You need the 'Delete any collection' permission to call this endpoint")
}
for col_id in collections {
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
err!("Collection Id is malformed!");
}
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await {
err!("Collection not found", "The current user isn't a manager for this collection")
if Collection::find_by_uuid_and_org(col_id, &h.membership.org_uuid, conn).await.is_none() {
err!("Collection not found", "Collection does not exist or does not belong to this organization")
}
}
Ok(ManagerHeaders {
Ok(CollectionDeleteHeaders {
host: h.host,
device: h.device,
user: h.user,
@ -1339,3 +1699,114 @@ pub async fn refresh_tokens(
Ok((device, auth_tokens))
}
#[cfg(test)]
mod tests {
use super::{CollectionManageAccess, collection_delete_access, collection_edit_access, collection_read_access};
use crate::db::models::{Membership, MembershipStatus, MembershipType};
fn membership(member_type: MembershipType) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = MembershipStatus::Confirmed as i32;
membership
}
#[test]
fn flagless_custom_requires_explicit_manage_for_edit_and_read_and_cannot_delete() {
// A flagless Custom member never gets blanket collection authority from its role alone.
// Edit and read are answered per collection by `has_explicit_collection_manage_access`, which
// accepts a real users_collections.manage / collections_groups.manage grant and nothing else:
// membership access_all is gone, and a group's access_all is not a manage grant.
//
// Delete has no per-collection fallback at all, so the answer is Denied rather than
// ExplicitManage -- see `collection_delete_access`.
let custom = membership(MembershipType::Custom);
assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&custom), CollectionManageAccess::Denied);
}
#[test]
fn custom_any_permissions_remain_independent() {
let mut edit_any = membership(MembershipType::Custom);
edit_any.edit_any_collection = true;
assert_eq!(collection_edit_access(&edit_any), CollectionManageAccess::Any);
assert_eq!(collection_read_access(&edit_any), CollectionManageAccess::Any);
// Edit any collection is never a delete permission, not even for a collection the member
// holds an explicit Manage grant on.
assert_eq!(collection_delete_access(&edit_any), CollectionManageAccess::Denied);
let mut delete_any = membership(MembershipType::Custom);
delete_any.delete_any_collection = true;
assert_eq!(collection_edit_access(&delete_any), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&delete_any), CollectionManageAccess::Any);
assert_eq!(collection_delete_access(&delete_any), CollectionManageAccess::Any);
// Create new collections yields the automatic users_collections.manage row on the created
// collection. That row must not become a delete permission either.
let mut create_only = membership(MembershipType::Custom);
create_only.create_new_collections = true;
assert_eq!(collection_edit_access(&create_only), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&create_only), CollectionManageAccess::Denied);
}
/// A stored `atype` that is not one of the four known roles must never be treated as one, in
/// either direction. 3 is the retired Manager discriminant, and a negative value is what a
/// corrupt row or a hand-written UPDATE could leave behind -- it would satisfy a numeric
/// `atype <= Admin` SQL predicate, which is why the queries enumerate the two admin values
/// instead (`ORG_ADMIN_ATYPES`).
#[test]
fn unknown_stored_role_values_fail_closed() {
for atype in [-1, 3, 5, i32::MAX, i32::MIN] {
let mut unknown = membership(MembershipType::Custom);
unknown.atype = atype;
// Even with every permission set, an unrecognized role grants nothing.
unknown.edit_any_collection = true;
unknown.delete_any_collection = true;
unknown.create_new_collections = true;
assert_eq!(collection_edit_access(&unknown), CollectionManageAccess::Denied, "atype {atype}");
assert_eq!(collection_read_access(&unknown), CollectionManageAccess::Denied, "atype {atype}");
assert_eq!(collection_delete_access(&unknown), CollectionManageAccess::Denied, "atype {atype}");
}
}
#[test]
fn admin_and_user_collection_access_roles() {
let admin = membership(MembershipType::Admin);
assert_eq!(collection_edit_access(&admin), CollectionManageAccess::Any);
assert_eq!(collection_read_access(&admin), CollectionManageAccess::Any);
assert_eq!(collection_delete_access(&admin), CollectionManageAccess::Any);
let user = membership(MembershipType::User);
assert_eq!(collection_edit_access(&user), CollectionManageAccess::Denied);
assert_eq!(collection_read_access(&user), CollectionManageAccess::Denied);
assert_eq!(collection_delete_access(&user), CollectionManageAccess::Denied);
}
#[test]
fn a_migrated_legacy_manager_carries_its_authority_in_the_permission_columns() {
// A legacy Manager who managed every collection through a group with access_all is not
// recognized by its shape at runtime -- that shape is indistinguishable from a newly created
// flagless Custom member. The repair migration writes the authority into the permission
// columns instead, so the guard sees an ordinary Edit/Delete any collection holder.
let mut migrated_group_manager = membership(MembershipType::Custom);
migrated_group_manager.edit_any_collection = true;
migrated_group_manager.delete_any_collection = true;
assert_eq!(collection_edit_access(&migrated_group_manager), CollectionManageAccess::Any);
assert_eq!(collection_delete_access(&migrated_group_manager), CollectionManageAccess::Any);
// Without those columns nothing is derived, no matter which groups the member belongs to.
let flagless = membership(MembershipType::Custom);
assert_eq!(collection_edit_access(&flagless), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&flagless), CollectionManageAccess::Denied);
let mut unconfirmed = membership(MembershipType::Custom);
unconfirmed.status = MembershipStatus::Accepted as i32;
unconfirmed.edit_any_collection = true;
unconfirmed.delete_any_collection = true;
assert_eq!(collection_edit_access(&unconfirmed), CollectionManageAccess::Denied);
assert_eq!(collection_delete_access(&unconfirmed), CollectionManageAccess::Denied);
}
}

3369
src/db/mod.rs

File diff suppressed because it is too large

102
src/db/models/cipher.rs

@ -25,7 +25,8 @@ use macros::UuidFromParam;
use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId,
MembershipStatus, OrganizationId, User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection},
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -600,6 +601,24 @@ impl Cipher {
cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn,
) -> Option<(bool, bool, bool)> {
// Security: central fail-closed check binding cipher -> organization -> confirmed membership.
//
// In the direct (non-sync) authorization path an organization cipher is only accessible to a
// user who has a *confirmed* membership in that same organization. This denies access to
// members whose collection/group assignment rows still exist after they were revoked (or are
// still only invited/accepted), and to cross-organization collection/group assignments that
// another code path might have persisted. Without it, the queries below would keep granting
// access from those stale or cross-tenant rows (security audit findings H-1, H-2, H-3).
//
// The sync path (cipher_sync_data is Some) is intentionally left to the caller: it is built
// only from confirmed memberships and evaluated below against that cached data.
if cipher_sync_data.is_none()
&& let Some(ref org_uuid) = self.organization_uuid
&& Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await.is_none()
{
return None;
}
// Check whether this cipher is directly owned by the user, or is in
// a collection that the user has full access to. If so, there are no
// access restrictions.
@ -665,16 +684,34 @@ impl Cipher {
}
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
let cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone();
conn.run(move |conn| {
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
//
// Security: bind the assignment to a *confirmed* membership in the same organization as
// both the cipher and the collection. Without this, a `users_collections` row left behind
// after a revoke, or an assignment pointing at a collection in a different organization,
// would keep granting access (defense in depth for audit findings H-1 and H-3).
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
collections::table.on(collections::uuid
.eq(ciphers_collections::collection_uuid)
.and(collections::org_uuid.nullable().eq(ciphers::organization_uuid))),
)
.inner_join(
users_collections::table.on(ciphers_collections::collection_uuid
.eq(users_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid))),
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.inner_join(
users_organizations::table.on(users_organizations::user_uuid
.eq(user_uuid)
.and(users_organizations::org_uuid.eq(collections::org_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn)
@ -687,9 +724,16 @@ impl Cipher {
if !CONFIG.org_groups_enabled() {
return Vec::new();
}
let cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone();
conn.run(move |conn| {
// Security: bind the group assignment to a *confirmed* membership and require that the
// cipher, the collection, the group and the membership all belong to the same
// organization. The `collections` join in particular prevents a cross-organization
// collection<->group assignment from granting access to a foreign organization's ciphers
// (defense in depth for audit findings H-1, H-2 and H-3).
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
collections_groups::table
@ -697,13 +741,21 @@ impl Cipher {
)
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid)))
.inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.inner_join(
groups::table.on(groups::uuid
.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.inner_join(
collections::table.on(collections::uuid
.eq(ciphers_collections::collection_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
.and(collections::org_uuid.nullable().eq(ciphers::organization_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)
@ -838,7 +890,11 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
// Edit any collection (Custom) or org admin/owner — the successor of access_all
.or_filter(
custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
)
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.or_filter(groups::access_all.eq(true)) // Access via groups
.or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups
@ -846,7 +902,7 @@ impl Cipher {
if !visible_only {
query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
);
}
@ -875,13 +931,17 @@ impl Cipher {
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
)
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
// Edit any collection (Custom) or org admin/owner — the successor of access_all
.or_filter(
custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
)
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.into_boxed();
if !visible_only {
query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner
);
}
@ -998,8 +1058,8 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(
users_organizations::access_all
.eq(true) // User has access all
custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
.or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false)))
@ -1029,8 +1089,8 @@ impl Cipher {
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(
users_organizations::access_all
.eq(true) // User has access all
custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
.or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))),
@ -1073,8 +1133,8 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(
users_organizations::access_all
.eq(true) // User has access all
custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
.or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false)))
@ -1082,7 +1142,7 @@ impl Cipher {
.or(collections_groups::collections_uuid
.is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
)
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
@ -1105,12 +1165,12 @@ impl Cipher {
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(
users_organizations::access_all
.eq(true) // User has access all
custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
.or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
)
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
@ -1151,8 +1211,8 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
.or_filter(users_organizations::access_all.eq(true)) // User has access all
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
.or_filter(custom_membership_with_edit_any_collection()) // Custom "Edit any collection" (successor of access_all)
.or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.select(ciphers_collections::all_columns)

244
src/db/models/collection.rs

@ -1,5 +1,6 @@
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use num_traits::FromPrimitive;
use serde_json::Value;
use crate::{
@ -19,6 +20,7 @@ use macros::UuidFromParam;
use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection},
};
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@ -52,6 +54,32 @@ pub struct CollectionCipher {
pub collection_uuid: CollectionId,
}
/// Serialize the assignment-level `manage` capability using the same role boundary as the
/// collection mutation guards. Read/write access is deliberately not management authority.
///
/// This answers "may this member manage this collection?" and therefore belongs on the objects a
/// member receives about themselves. For the administrative lists that echo a *stored* grant back
/// to the client, use `stored_assignment_manage` instead.
pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool {
match MembershipType::from_i32(membership_type) {
Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Custom) => stored_manage,
Some(MembershipType::User) | None => false,
}
}
/// Serialize a *stored* per-collection assignment row for the admin-console access lists.
///
/// These lists describe the grant an administrator configured, and the client writes the very same
/// value back when the dialog is saved. Reporting anything other than the persisted bit would make
/// an unrelated save silently strip it — for a plain User that would also revoke the cipher write
/// access `users_collections.manage` still grants (see `Cipher::get_access_restrictions`). Admins
/// and Owners manage implicitly, so they are reported as managing regardless of the stored row.
pub(super) fn stored_assignment_manage(membership_type: i32, stored_manage: bool) -> bool {
matches!(MembershipType::from_i32(membership_type), Some(MembershipType::Owner | MembershipType::Admin))
|| stored_manage
}
/// Local methods
impl Collection {
pub fn new(org_uuid: OrganizationId, name: String, external_id: Option<String>) -> Self {
@ -104,41 +132,59 @@ impl Collection {
) -> Value {
let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data {
match cipher_sync_data.members.get(&self.org_uuid) {
// Only for Manager types Bitwarden returns true for the manage option
// Owners and Admins always have true. Users are not able to have full access
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => {
// Only let a manager manage collections when the have full read/write access
let is_manager = m.atype == MembershipType::Manager;
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
(
cu.read_only,
cu.hide_passwords,
is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)),
)
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
(
cg.read_only,
cg.hide_passwords,
is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)),
)
} else {
(false, false, false)
// What the client is told here has to match what the collection guards actually
// allow, or it renders the wrong controls. A stored grant therefore counts even
// for a member who already reaches every collection: full visibility is not
// management authority, but it does not cancel out a real grant either.
//
// Reaching every collection through a group with `access_all` is deliberately not
// management authority: the guards accept an explicit
// `users_collections.manage` / `collections_groups.manage` row only.
let assignment = cipher_sync_data
.user_collections
.get(&self.uuid)
.map(|cu| (cu.read_only, cu.hide_passwords, cu.manage))
.or_else(|| {
cipher_sync_data
.user_collections_groups
.get(&self.uuid)
.map(|cg| (cg.read_only, cg.hide_passwords, cg.manage))
});
let stored_manage = assignment.is_some_and(|(_, _, manage)| manage);
let manage = assignment_manage_for_member(m.atype, stored_manage);
match assignment {
Some((read_only, hide_passwords, _)) if !m.has_full_access() => {
(read_only, hide_passwords, manage)
}
// Reaching every collection means nothing is read-only or hidden here.
_ => (false, false, manage),
}
}
_ => (true, true, false),
}
} else {
match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await {
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
// Same rule as the cached branch above: a member who reaches every collection still
// reports a real stored grant, so the serialized value matches the guards.
Some(m) if m.has_full_access() => (
false,
false,
assignment_manage_for_member(
m.atype,
m.has_explicit_collection_manage_access(&self.uuid, conn).await,
),
),
Some(m)
if m.atype >= MembershipType::Custom
&& m.has_explicit_collection_manage_access(&self.uuid, conn).await =>
{
(false, false, true)
}
Some(m) => {
let is_manager = m.atype == MembershipType::Manager;
Some(_) => {
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await;
(read_only, hide_passwords, is_manager && !read_only && !hide_passwords)
(read_only, hide_passwords, false)
}
_ => (true, true, false),
}
@ -260,8 +306,10 @@ impl Collection {
users_collections::user_uuid
.eq(user_uuid)
.or(
// Directly accessed collection
users_organizations::access_all.eq(true), // access_all in Organization
// Full-access member: Custom "Edit any collection" or org admin/owner
// (successor of the removed membership access_all)
custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
)
.or(
groups::access_all.eq(true), // access_all in groups
@ -293,10 +341,14 @@ impl Collection {
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_collections::user_uuid.eq(user_uuid).or(
// Directly accessed collection
users_organizations::access_all.eq(true), // access_all in Organization
))
.filter(
users_collections::user_uuid.eq(user_uuid).or(
// Full-access member: Custom "Edit any collection" or org admin/owner
// (successor of the removed membership access_all)
custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
),
)
.select(collections::all_columns)
.distinct()
.load::<Self>(conn)
@ -380,9 +432,9 @@ impl Collection {
.eq(uuid)
.or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
custom_membership_with_edit_any_collection().or(
// Custom "Edit any collection" or org admin/owner (successor of access_all)
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
),
)
.or(
@ -416,9 +468,9 @@ impl Collection {
.filter(collections::uuid.eq(uuid))
.filter(users_collections::collection_uuid.eq(uuid).or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
custom_membership_with_edit_any_collection().or(
// Custom "Edit any collection" or org admin/owner (successor of access_all)
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
),
))
.select(collections::all_columns)
@ -460,8 +512,8 @@ impl Collection {
)
.filter(
users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(custom_membership_with_edit_any_collection()) // Custom "Edit any collection" (successor of access_all)
.or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection
.and(users_collections::read_only.eq(false)))
@ -493,8 +545,8 @@ impl Collection {
)
.filter(
users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership
.eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(custom_membership_with_edit_any_collection()) // Custom "Edit any collection" (successor of access_all)
.or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection
.and(users_collections::read_only.eq(false))),
@ -541,9 +593,9 @@ impl Collection {
.and(users_collections::hide_passwords.eq(true))
.or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
custom_membership_with_edit_any_collection().or(
// Custom "Edit any collection" or org admin/owner (successor of access_all)
users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
),
)
.or(
@ -567,71 +619,8 @@ impl Collection {
.await
}
pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool {
let uuid = uuid.to_string();
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
collections::table
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
.left_join(
groups::table.on(groups::uuid
.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.left_join(
collections_groups::table.on(collections_groups::groups_uuid
.eq(groups_users::groups_uuid)
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&uuid))
.filter(
users_collections::collection_uuid
.eq(&uuid)
.and(users_collections::manage.eq(true))
.or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
),
)
.or(
groups::access_all.eq(true), // access_all in groups
)
.or(
// access via groups
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
collections_groups::collections_uuid
.is_not_null()
.and(collections_groups::manage.eq(true)),
),
),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
}
// Whether the user has manage access to at least one collection in the org, directly or via a
// group. Org-scoped counterpart of is_coll_manageable_by_user.
// group.
pub async fn has_manageable_collection_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
@ -658,6 +647,8 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq(MembershipType::Custom as i32))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
@ -990,11 +981,7 @@ impl CollectionMembership {
"id": self.membership_uuid,
"readOnly": self.read_only,
"hidePasswords": self.hide_passwords,
"manage": membership_type >= MembershipType::Admin
|| self.manage
|| (membership_type == MembershipType::Manager
&& !self.read_only
&& !self.hide_passwords),
"manage": stored_assignment_manage(membership_type, self.manage),
})
}
}
@ -1028,3 +1015,36 @@ impl From<CollectionUser> for CollectionMembership {
UuidFromParam,
)]
pub struct CollectionId(String);
#[cfg(test)]
mod tests {
use super::{assignment_manage_for_member, stored_assignment_manage};
use crate::db::models::MembershipType;
// A stored `users_collections.manage` row must survive being listed in the admin console and
// written back unchanged. Reporting `false` for a plain User made an unrelated save strip the
// grant, which also revoked the cipher write access the row still confers.
#[test]
fn stored_assignment_manage_echoes_the_persisted_grant() {
for role in [MembershipType::Owner, MembershipType::Admin] {
assert!(stored_assignment_manage(role as i32, false));
}
for role in [MembershipType::Custom, MembershipType::User] {
assert!(stored_assignment_manage(role as i32, true));
assert!(!stored_assignment_manage(role as i32, false));
}
}
#[test]
fn assignment_manage_matches_collection_guard_role_boundaries() {
for role in [MembershipType::Owner, MembershipType::Admin] {
assert!(assignment_manage_for_member(role as i32, false));
}
assert!(assignment_manage_for_member(MembershipType::Custom as i32, true));
assert!(!assignment_manage_for_member(MembershipType::Custom as i32, false));
assert!(!assignment_manage_for_member(MembershipType::User as i32, true));
assert!(!assignment_manage_for_member(i32::MAX, true));
}
}

121
src/db/models/event.rs

@ -79,6 +79,21 @@ pub enum EventType {
CipherSoftDeleted = 1115,
CipherRestored = 1116,
CipherClientToggledCardNumberVisible = 1117,
// CipherClientToggledTOTPSeedVisible = 1118, // Not accepted from clients by upstream either
CipherClientCopiedBankAccountNumber = 1119,
CipherClientCopiedBankAccountPin = 1120,
CipherClientToggledBankAccountNumberVisible = 1121,
CipherClientToggledBankAccountPinVisible = 1122,
CipherClientCopiedLicenseNumber = 1123,
CipherClientToggledLicenseNumberVisible = 1124,
CipherClientCopiedPassportNumber = 1125,
CipherClientToggledPassportNumberVisible = 1126,
CipherClientCopiedSwiftCode = 1127,
CipherClientToggledSwiftCodeVisible = 1128,
CipherClientCopiedIban = 1129,
CipherClientToggledIbanVisible = 1130,
CipherClientCopiedNationalIdentificationNumber = 1131,
CipherClientToggledNationalIdentificationNumberVisible = 1132,
// Collection
CollectionCreated = 1300,
@ -120,6 +135,11 @@ pub enum EventType {
// OrganizationDisabledKeyConnector = 1607, // Not supported
// OrganizationSponsorshipsSynced = 1608, // Not supported
// OrganizationCollectionManagementUpdated = 1609, // Not supported
OrganizationItemOrganizationAccepted = 1618,
OrganizationItemOrganizationDeclined = 1619,
OrganizationAutoConfirmEnabledAdmin = 1620,
OrganizationAutoConfirmDisabledAdmin = 1621,
OrganizationInviteLinkClientCopied = 1627,
// Policy
PolicyUpdated = 1700,
@ -321,20 +341,37 @@ impl Event {
pub async fn find_by_cipher_uuid(
cipher_uuid: &CipherId,
org_uuid: Option<&OrganizationId>,
start: &NaiveDateTime,
end: &NaiveDateTime,
conn: &DbConn,
) -> Vec<Self> {
conn.run(move |conn| {
event::table
.filter(event::cipher_uuid.eq(cipher_uuid))
.filter(event::event_date.between(start, end))
.order_by(event::event_date.desc())
.limit(Self::PAGE_SIZE)
.load::<Self>(conn)
.expect("Error filtering events")
})
.await
conn.run(move |conn| Self::find_by_cipher_uuid_impl(cipher_uuid, org_uuid, start, end, conn)).await
}
fn find_by_cipher_uuid_impl(
cipher_uuid: &CipherId,
org_uuid: Option<&OrganizationId>,
start: &NaiveDateTime,
end: &NaiveDateTime,
conn: &mut crate::db::DbConnInner,
) -> Vec<Self> {
let query = event::table
.filter(event::cipher_uuid.eq(cipher_uuid))
.filter(event::event_date.between(start, end))
.into_boxed();
// A cipher event request is authorized for exactly one scope: either the cipher's
// current organization or its personal owner. Apply that scope before PAGE_SIZE so
// rows from another scope cannot consume the page and hide older authorized events.
match org_uuid {
Some(org_uuid) => query.filter(event::org_uuid.eq(org_uuid)),
None => query.filter(event::org_uuid.is_null()),
}
.order_by(event::event_date.desc())
.limit(Self::PAGE_SIZE)
.load::<Self>(conn)
.expect("Error filtering events")
}
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
@ -354,3 +391,67 @@ impl Event {
#[derive(Clone, Debug, DieselNewType, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventId(String);
#[cfg(all(test, sqlite))]
mod tests {
use diesel::{Connection, connection::SimpleConnection, sqlite::SqliteConnection};
use super::*;
use crate::db::DbConnInner;
#[test]
fn cipher_scope_is_applied_before_the_page_limit() {
let mut conn = DbConnInner::Sqlite(SqliteConnection::establish(":memory:").unwrap());
conn.batch_execute(
"CREATE TABLE event (
uuid TEXT NOT NULL PRIMARY KEY,
event_type INTEGER NOT NULL,
user_uuid TEXT,
org_uuid TEXT,
cipher_uuid TEXT,
collection_uuid TEXT,
group_uuid TEXT,
org_user_uuid TEXT,
act_user_uuid TEXT,
device_type INTEGER,
ip_address TEXT,
event_date DATETIME NOT NULL,
policy_uuid TEXT,
provider_uuid TEXT,
provider_user_uuid TEXT,
provider_org_uuid TEXT
);",
)
.unwrap();
// Fill an entire page with newer rows from a different scope. If scope filtering happens
// after LIMIT, the one older authorized row can never reach the API response.
for index in 0..Event::PAGE_SIZE {
conn.batch_execute(&format!(
"INSERT INTO event (uuid, event_type, org_uuid, cipher_uuid, event_date) VALUES \
('foreign-{index}', 1107, 'foreign-org', 'cipher', '2026-08-12 12:{index:02}:00');"
))
.unwrap();
}
conn.batch_execute(
"INSERT INTO event (uuid, event_type, org_uuid, cipher_uuid, event_date) VALUES
('authorized', 1107, 'authorized-org', 'cipher', '2026-08-12 11:00:00');
INSERT INTO event (uuid, event_type, org_uuid, cipher_uuid, event_date) VALUES
('personal', 1107, NULL, 'cipher', '2026-08-12 10:00:00');",
)
.unwrap();
let cipher_id: CipherId = "cipher".to_owned().into();
let org_id: OrganizationId = "authorized-org".to_owned().into();
let start = NaiveDateTime::parse_from_str("2026-08-12 00:00:00", "%F %T").unwrap();
let end = NaiveDateTime::parse_from_str("2026-08-13 00:00:00", "%F %T").unwrap();
let organization_events = Event::find_by_cipher_uuid_impl(&cipher_id, Some(&org_id), &start, &end, &mut conn);
assert_eq!(organization_events.len(), 1);
assert_eq!(organization_events[0].uuid, EventId("authorized".to_owned()));
let personal_events = Event::find_by_cipher_uuid_impl(&cipher_id, None, &start, &end, &mut conn);
assert_eq!(personal_events.len(), 1);
assert_eq!(personal_events[0].uuid, EventId("personal".to_owned()));
}
}

31
src/db/models/group.rs

@ -13,7 +13,7 @@ use crate::{
};
use macros::UuidFromParam;
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
use super::{Collection, CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = groups)]
@ -257,6 +257,7 @@ impl Group {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::access_all.eq(true))
.select(groups::organizations_uuid)
.distinct()
@ -268,6 +269,10 @@ impl Group {
pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool {
conn.run(move |conn| {
// Security: the membership linked through `groups_users` must itself belong to the same
// organization as the group and must be confirmed. Otherwise a cross-organization
// `groups_users` row (a member of org A linked to an access-all group of org B) would let
// that member pass as having full access to org B (audit finding H-2).
groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join(
@ -276,6 +281,7 @@ impl Group {
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::organizations_uuid.eq(org_uuid))
.filter(groups::access_all.eq(true))
.select(groups::access_all)
@ -321,6 +327,17 @@ impl Group {
impl CollectionGroup {
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
// Security (audit H-3): never persist a cross-organization link between a collection and a
// group. Both must belong to the organization this assignment is scoped to; otherwise a
// caller could attach a foreign-tenant group to this organization's collection and thereby
// grant that group's members access to it. This is a defense-in-depth guard so no route can
// create such a link even if it fails to validate its inputs.
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()
{
err!("Collection and group must belong to the same organization")
}
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
@ -495,6 +512,18 @@ impl CollectionGroup {
impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
// Security (audit H-2): never persist a cross-organization link between a group and a
// membership. The group must belong to the same organization as the membership; otherwise a
// caller could grant a member of one organization full access to another organization's
// collections through an access-all group. This is a defense-in-depth guard so no route can
// create such a link even if it fails to validate its inputs.
let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else {
err!("Member not found while assigning to group")
};
if Group::find_by_uuid_and_org(&self.groups_uuid, &member.org_uuid, conn).await.is_none() {
err!("Group and member must belong to the same organization")
}
self.update_user_revision(conn).await;
db_run! { conn:

709
src/db/models/organization.rs

@ -15,8 +15,8 @@ use crate::{
db::{
DbConn,
schema::{
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key,
organizations, users, users_collections, users_organizations,
ciphers_collections, collections, collections_groups, groups, groups_users, org_policies,
organization_api_key, organizations, users, users_collections, users_organizations,
},
},
error::MapResult,
@ -26,6 +26,7 @@ use macros::UuidFromParam;
use super::{
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
OrgPolicyType, TwoFactor, User, UserId,
collection::{assignment_manage_for_member as assignment_manage, stored_assignment_manage},
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -44,6 +45,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
#[allow(clippy::struct_excessive_bools)]
pub struct Membership {
pub uuid: MembershipId,
pub user_uuid: UserId,
@ -51,12 +53,31 @@ pub struct Membership {
pub invited_by_email: Option<String>,
pub access_all: bool,
pub akey: String,
pub status: i32,
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
pub manage_users: bool,
pub manage_groups: bool,
pub manage_policies: bool,
pub create_new_collections: bool,
pub edit_any_collection: bool,
pub delete_any_collection: bool,
pub access_event_logs: bool,
pub access_import_export: bool,
pub access_reports: bool,
}
/// Diesel equivalent of [`Membership::has_edit_any_collection`].
///
/// Keep the role check in this shared predicate so a stale flag on any non-Custom membership
/// remains inert in every collection-access query.
pub(super) fn custom_membership_with_edit_any_collection() -> diesel::dsl::And<
diesel::dsl::Eq<users_organizations::atype, i32>,
diesel::dsl::Eq<users_organizations::edit_any_collection, bool>,
> {
users_organizations::atype.eq(MembershipType::Custom as i32).and(users_organizations::edit_any_collection.eq(true))
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -97,37 +118,52 @@ pub enum MembershipType {
Owner = 0,
Admin = 1,
User = 2,
Manager = 3,
// NOTE: the legacy Manager role (wire value 3) has been folded into Custom. It is no longer a
// distinct variant: it is never persisted or emitted, and an incoming value 3 is mapped onto
// Custom for backward compatibility (see `from_str`). The Custom discriminant stays 4 because
// that is the only role modern Bitwarden clients understand as carrying custom permissions.
Custom = 4,
}
impl MembershipType {
pub fn from_str(s: &str) -> Option<Self> {
#[expect(
clippy::match_same_arms,
reason = "Specifically define `4|Custom` since this is a hack, not a default"
)]
match s {
"0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager),
// HACK: We convert the custom role to a manager role
"4" | "Custom" => Some(MembershipType::Manager),
// "3"/"Manager" is the legacy Manager role. Modern clients no longer offer it, but an old
// client or stored request may still send value 3. Custom supersedes Manager, so accept
// and fold it onto Custom.
"3" | "Manager" | "4" | "Custom" => Some(MembershipType::Custom),
_ => None,
}
}
const fn access_rank(self) -> u8 {
match self {
Self::User => 0,
Self::Custom => 1,
Self::Admin => 2,
Self::Owner => 3,
}
}
}
/// The stored `users_organizations.atype` values that carry organization-wide authority by role.
///
/// Queries use this set instead of the numeric `atype <= Admin` comparison the removal of
/// membership-level `access_all` would otherwise have left behind in them. `<=` also matches every
/// value *below* `Owner`, so a corrupt or hand-written negative `atype` would satisfy an SQL check
/// while every Rust guard rejects it -- `MembershipType::from_i32` returns `None` there and the
/// request guards fail closed. Enumerating the two values keeps both layers on the same answer.
pub(crate) const ORG_ADMIN_ATYPES: &[i32] = &[MembershipType::Owner as i32, MembershipType::Admin as i32];
impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest).
const ACCESS_LEVEL: [i32; 4] = [
3, // Owner
2, // Admin
0, // User
1, // Manager && Custom
];
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
// Roles are ordered by their authorization rank, not by their raw discriminant (Custom's
// discriminant is 4 but it ranks between User and Admin). The discriminant is kept as a
// stable tie-breaker so `Ord` never disagrees with `Eq`.
self.access_rank().cmp(&other.access_rank()).then_with(|| (*self as i32).cmp(&(*other as i32)))
}
}
@ -268,12 +304,20 @@ impl Membership {
org_uuid,
invited_by_email,
access_all: false,
akey: String::new(),
status: MembershipStatus::Accepted as i32,
atype: MembershipType::User as i32,
reset_password_key: None,
external_id: None,
manage_users: false,
manage_groups: false,
manage_policies: false,
create_new_collections: false,
edit_any_collection: false,
delete_any_collection: false,
access_event_logs: false,
access_import_export: false,
access_reports: false,
}
}
@ -313,15 +357,6 @@ impl Membership {
}
false
}
/// HACK: Convert the manager type to a custom type
/// It will be converted back on other locations
pub fn type_manager_as_custom(&self) -> i32 {
match self.atype {
3 => 4,
_ => self.atype,
}
}
}
impl OrganizationApiKey {
@ -450,29 +485,28 @@ impl Membership {
pub async fn to_json(&self, conn: &DbConn) -> Value {
let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap();
// HACK: Convert the manager type to a custom type
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
let membership_type = self.atype;
let permissions = json!({
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": membership_type == 4 && self.access_all,
"editAnyCollection": membership_type == 4 && self.access_all,
"deleteAnyCollection": membership_type == 4 && self.access_all,
"manageGroups": false,
"managePolicies": false,
"accessEventLogs": membership_type == MembershipType::Custom as i32 && self.access_event_logs,
"accessImportExport": membership_type == MembershipType::Custom as i32 && self.access_import_export,
"accessReports": membership_type == MembershipType::Custom as i32 && self.access_reports,
"createNewCollections": membership_type == MembershipType::Custom as i32 && self.create_new_collections,
"editAnyCollection": membership_type == MembershipType::Custom as i32 && self.edit_any_collection,
"deleteAnyCollection": membership_type == MembershipType::Custom as i32 && self.delete_any_collection,
"manageGroups": membership_type == MembershipType::Custom as i32 && self.manage_groups,
"managePolicies": membership_type == MembershipType::Custom as i32 && self.manage_policies,
"manageSso": false, // Not supported
"manageUsers": false,
"manageUsers": membership_type == MembershipType::Custom as i32 && self.manage_users,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
// Edit any collection grants full read/edit access to every collection, but it must not
// accidentally grant collection creation. The client treats limitCollectionCreation=false as
// an independent create grant, so compute it from the actual role/permission.
let limit_collection_creation = self.limit_collection_creation();
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({
"id": self.org_uuid,
@ -522,8 +556,7 @@ impl Membership {
"familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null,
"accessSecretsManager": false,
// limit collection creation to managers with access_all permission to prevent issues
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
"limitCollectionCreation": limit_collection_creation,
"limitCollectionDeletion": true,
"limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true,
@ -572,76 +605,67 @@ impl Membership {
CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await;
// If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) {
// Get all collections for the user here already to prevent more queries
let cu: HashMap<CollectionId, CollectionUser> =
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
let collections: Vec<Value> =
if include_collections && !(full_access_group || self.grants_access_to_all_collections()) {
// Get all collections for the user here already to prevent more queries
let cu: HashMap<CollectionId, CollectionUser> =
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.map(|cu| (cu.collection_uuid.clone(), cu))
.collect();
// Get all collection groups for this user to prevent there inclusion
let cg: HashSet<CollectionId> = CollectionGroup::find_by_user(&self.user_uuid, conn)
.await
.into_iter()
.map(|cu| (cu.collection_uuid.clone(), cu))
.map(|cg| cg.collections_uuid)
.collect();
// Get all collection groups for this user to prevent there inclusion
let cg: HashSet<CollectionId> = CollectionGroup::find_by_user(&self.user_uuid, conn)
.await
.into_iter()
.map(|cg| cg.collections_uuid)
.collect();
Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.filter_map(|c| {
let (read_only, hide_passwords, manage) = if self.has_full_access() {
(false, false, self.atype >= MembershipType::Manager)
} else if let Some(cu) = cu.get(&c.uuid) {
(
cu.read_only,
cu.hide_passwords,
cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords),
)
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here
// Those are returned via a special group endpoint
} else if cg.contains(&c.uuid) {
return None;
} else {
(true, true, false)
};
Some(json!({
"id": c.uuid,
"readOnly": read_only,
"hidePasswords": hide_passwords,
"manage": manage,
}))
})
.collect()
} else {
Vec::new()
};
Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.filter_map(|c| {
let (read_only, hide_passwords, manage) = if self.has_full_access() {
(false, false, assignment_manage(self.atype, false))
} else if let Some(cu) = cu.get(&c.uuid) {
(cu.read_only, cu.hide_passwords, stored_assignment_manage(self.atype, cu.manage))
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here
// Those are returned via a special group endpoint
} else if cg.contains(&c.uuid) {
return None;
} else {
(true, true, false)
};
Some(json!({
"id": c.uuid,
"readOnly": read_only,
"hidePasswords": hide_passwords,
"manage": manage,
}))
})
.collect()
} else {
Vec::new()
};
// HACK: Convert the manager type to a custom type
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
let membership_type = self.atype;
// HACK: Only return permissions if the user is of type custom and has access_all
// Else Bitwarden will assume the defaults of all false
let permissions = if membership_type == 4 && self.access_all {
// Only return a permissions object for custom-type members. Otherwise Bitwarden assumes
// all-false defaults and the role itself supplies any elevated capabilities.
let permissions = if membership_type == MembershipType::Custom as i32 {
json!({
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": true,
"editAnyCollection": true,
"deleteAnyCollection": true,
"manageGroups": false,
"managePolicies": false,
"accessEventLogs": self.access_event_logs,
"accessImportExport": self.access_import_export,
"accessReports": self.access_reports,
"createNewCollections": self.create_new_collections,
"editAnyCollection": self.edit_any_collection,
"deleteAnyCollection": self.delete_any_collection,
"manageGroups": self.manage_groups,
"managePolicies": self.manage_policies,
"manageSso": false, // Not supported
"manageUsers": false,
"manageUsers": self.manage_users,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
@ -661,7 +685,9 @@ impl Membership {
"status": status,
"type": membership_type,
"accessAll": self.access_all,
// `access_all` no longer exists as a stored flag; report the effective all-collection
// access so clients that still read this obsolete field keep seeing a consistent value.
"accessAll": self.grants_access_to_all_collections(),
"twoFactorEnabled": twofactor_enabled,
"resetPasswordEnrolled": self.reset_password_key.is_some(),
"hasMasterPassword": !user.password_hash.is_empty(),
@ -688,7 +714,7 @@ impl Membership {
}
pub async fn to_json_details(&self, conn: &DbConn) -> Value {
let coll_uuids = if self.access_all {
let coll_uuids = if self.grants_access_to_all_collections() {
vec![] // If we have complete access, no need to fill the array
} else {
let collections =
@ -720,7 +746,8 @@ impl Membership {
"status": status,
"type": self.atype,
"accessAll": self.access_all,
// Obsolete stored flag removed; report the effective all-collection access instead.
"accessAll": self.grants_access_to_all_collections(),
"collections": coll_uuids,
"object": "organizationUserDetails",
@ -741,7 +768,7 @@ impl Membership {
json!({
"id": self.uuid,
"userId": self.user_uuid,
"type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type
"type": self.atype,
"status": status,
"name": user.name,
"email": user.email,
@ -829,7 +856,175 @@ impl Membership {
}
pub fn has_full_access(&self) -> bool {
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed)
(self.has_edit_any_collection() || self.atype >= MembershipType::Admin)
&& self.has_status(MembershipStatus::Confirmed)
}
/// Whether this membership reaches every collection in the org regardless of per-collection
/// assignments — Admins/Owners implicitly, and Custom members holding `edit_any_collection`.
/// This is the successor of the removed `access_all` flag: it backs the `accessAll` field the
/// Bitwarden clients still read, and it intentionally does not gate on status, matching the old
/// column's semantics. Authorization decisions use the status-aware `has_full_access` instead.
pub fn grants_access_to_all_collections(&self) -> bool {
self.atype >= MembershipType::Admin || self.has_edit_any_collection()
}
// The granular custom permission flags are only meaningful while the membership is of
// the Custom type. Gating them on the type here ensures that a stale flag left over from
// a type change (e.g. via the admin panel) can never grant anything.
pub fn has_manage_users(&self) -> bool {
self.has_type(MembershipType::Custom) && self.manage_users
}
pub fn has_manage_groups(&self) -> bool {
self.has_type(MembershipType::Custom) && self.manage_groups
}
pub fn has_manage_policies(&self) -> bool {
self.has_type(MembershipType::Custom) && self.manage_policies
}
pub fn has_create_new_collections(&self) -> bool {
self.has_type(MembershipType::Custom) && self.create_new_collections
}
pub fn has_edit_any_collection(&self) -> bool {
self.has_type(MembershipType::Custom) && self.edit_any_collection
}
pub fn has_delete_any_collection(&self) -> bool {
self.has_type(MembershipType::Custom) && self.delete_any_collection
}
pub fn has_access_event_logs(&self) -> bool {
self.has_type(MembershipType::Custom) && self.access_event_logs
}
pub fn has_access_import_export(&self) -> bool {
self.has_type(MembershipType::Custom) && self.access_import_export
}
pub fn has_access_reports(&self) -> bool {
self.has_type(MembershipType::Custom) && self.access_reports
}
/// Check for an explicit per-collection Manage grant without treating any `access_all` value as
/// such a grant. This is the *only* per-collection authority a Custom member can hold: neither
/// membership nor group `access_all` may manufacture one.
///
/// No live exception exists for legacy Managers whose authority came from an organization-local
/// `access_all` group. Deriving one from the membership's shape ("Custom, no collection
/// permissions, member of such a group") was not sound — that shape is also what every newly
/// created flagless Custom member has, so assigning one to an ordinary `access_all` group handed
/// out organization-wide collection edit and delete, and *removing* a collection permission
/// activated it. The repair migration `2026-07-23-120000` materializes that authority into the
/// visible `edit_any_collection` / `delete_any_collection` columns instead, where an owner can
/// see and revoke it.
pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool {
let membership_uuid = self.uuid.clone();
let user_uuid = self.user_uuid.clone();
let org_uuid = self.org_uuid.clone();
let collection_uuid = collection_uuid.clone();
conn.run(move |conn| {
let has_direct_manage = users_organizations::table
.inner_join(
users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)),
)
.inner_join(
collections::table.on(collections::uuid
.eq(users_collections::collection_uuid)
.and(collections::org_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::uuid.eq(membership_uuid.clone()))
.filter(users_organizations::user_uuid.eq(user_uuid.clone()))
.filter(users_organizations::org_uuid.eq(org_uuid.clone()))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq(MembershipType::Custom as i32))
.filter(collections::uuid.eq(collection_uuid.clone()))
.filter(users_collections::manage.eq(true))
.count()
.first::<i64>(conn)
.unwrap_or(0)
!= 0;
if has_direct_manage {
return true;
}
users_organizations::table
.inner_join(
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
)
.inner_join(
groups::table.on(groups::uuid
.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.inner_join(collections_groups::table.on(collections_groups::groups_uuid.eq(groups_users::groups_uuid)))
.inner_join(
collections::table.on(collections::uuid
.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::uuid.eq(membership_uuid))
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq(MembershipType::Custom as i32))
.filter(collections::uuid.eq(collection_uuid))
.filter(collections_groups::manage.eq(true))
.count()
.first::<i64>(conn)
.unwrap_or(0)
!= 0
})
.await
}
/// `manageAllCollections` is a client-side aggregate checkbox, not a separately persisted
/// Bitwarden permission. It is selected exactly when all three child permissions are selected.
pub fn has_manage_all_collections(&self) -> bool {
self.has_create_new_collections() && self.has_edit_any_collection() && self.has_delete_any_collection()
}
/// Match Vaultwarden's existing collection-creation policy while keeping the Custom
/// permission independent from edit/delete.
pub fn can_create_new_collections(&self) -> bool {
if !self.has_status(MembershipStatus::Confirmed) {
return false;
}
match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Custom) => self.create_new_collections,
Some(MembershipType::User) | None => false,
}
}
pub fn limit_collection_creation(&self) -> bool {
match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => false,
Some(MembershipType::Custom) => !self.create_new_collections,
Some(MembershipType::User) | None => true,
}
}
pub fn can_delete_any_collection(&self) -> bool {
self.has_status(MembershipStatus::Confirmed)
&& (self.atype >= MembershipType::Admin || self.has_delete_any_collection())
}
pub fn clear_custom_permissions(&mut self) {
self.manage_users = false;
self.manage_groups = false;
self.manage_policies = false;
self.create_new_collections = false;
self.edit_any_collection = false;
self.delete_any_collection = false;
self.access_event_logs = false;
self.access_import_export = false;
self.access_reports = false;
}
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
@ -938,7 +1133,7 @@ impl Membership {
.await
}
// Get all users which are either owner or admin, or a manager which can manage/access all
// Get all users which are either owner or admin, or a Custom member which can access all collections
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
@ -946,10 +1141,8 @@ impl Membership {
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::atype
.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])
.or(users_organizations::atype
.eq(MembershipType::Manager as i32)
.and(users_organizations::access_all.eq(true))),
.eq_any(ORG_ADMIN_ATYPES)
.or(custom_membership_with_edit_any_collection()),
)
.load::<Self>(conn)
.unwrap_or_default()
@ -1073,10 +1266,11 @@ impl Membership {
.eq(users_collections::collection_uuid)
.and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))),
)
.filter(users_organizations::access_all.eq(true).or(
// AccessAll..
ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection with cipher
))
.filter(
custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
.or(ciphers_collections::cipher_uuid.eq(&cipher_uuid)), // ..or access to collection with cipher
)
.select(users_organizations::all_columns)
.distinct()
.load::<Self>(conn)
@ -1119,27 +1313,6 @@ impl Membership {
.await
}
pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool {
conn.run(move |conn| {
users_organizations::table
.inner_join(
ciphers::table.on(ciphers::uuid
.eq(cipher_uuid)
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
pub async fn find_by_collection_and_org(
collection_uuid: &CollectionId,
org_uuid: &OrganizationId,
@ -1149,10 +1322,11 @@ impl Membership {
users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid))
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
.filter(users_organizations::access_all.eq(true).or(
// AccessAll..
users_collections::collection_uuid.eq(&collection_uuid), // ..or access to collection with cipher
))
.filter(
custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
.or(users_collections::collection_uuid.eq(&collection_uuid)), // ..or access to collection
)
.select(users_organizations::all_columns)
.load::<Self>(conn)
.expect("Error loading user organizations")
@ -1277,12 +1451,241 @@ pub struct OrgApiKeyId(String);
mod tests {
use super::*;
fn membership(member_type: MembershipType) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = MembershipStatus::Confirmed as i32;
membership
}
/// The SQL-side admin set has to stay in step with the Rust-side role check, and it must not be a
/// range: `atype <= Admin` would also match a corrupt negative value that
/// `MembershipType::from_i32` rejects.
#[test]
fn the_sql_admin_atype_set_matches_the_two_admin_roles() {
assert_eq!(ORG_ADMIN_ATYPES, [MembershipType::Owner as i32, MembershipType::Admin as i32]);
for atype in [-1, 2, 3, 5, i32::MAX, i32::MIN] {
assert!(!ORG_ADMIN_ATYPES.contains(&atype), "atype {atype} must not count as an organization admin");
}
for atype in ORG_ADMIN_ATYPES {
assert!(
matches!(MembershipType::from_i32(*atype), Some(MembershipType::Owner | MembershipType::Admin)),
"every value in the set has to resolve to an admin role in Rust as well"
);
}
}
#[test]
#[allow(non_snake_case)]
fn partial_cmp_MembershipType() {
fn membership_type_order_preserves_access_rank_and_ord_contract() {
assert!(MembershipType::Owner > MembershipType::Admin);
assert!(MembershipType::Admin > MembershipType::Manager);
assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
assert!(MembershipType::Admin > MembershipType::Custom);
assert!(MembershipType::Custom > MembershipType::User);
assert!(MembershipType::Custom == MembershipType::from_str("4").unwrap());
// The legacy Manager wire value (3) is accepted and folded onto Custom.
assert!(MembershipType::Custom == MembershipType::from_str("3").unwrap());
// Permission comparisons continue to treat Custom as manager-level and below Admin.
let custom = MembershipType::Custom as i32;
assert!(custom >= MembershipType::Custom);
assert!(custom < MembershipType::Admin);
let types = [MembershipType::Owner, MembershipType::Admin, MembershipType::User, MembershipType::Custom];
for lhs in types {
for rhs in types {
assert_eq!(lhs.cmp(&rhs) == Ordering::Equal, lhs == rhs);
assert_eq!(lhs.cmp(&rhs), rhs.cmp(&lhs).reverse());
}
}
}
/// A stored `atype` that no role maps to is *incomparable*, and the two directions of the
/// comparison resolve that deliberately differently. Both overrides exist to keep the answer
/// fail-closed; neither was pinned by a test, and the asymmetry is easy to "tidy up" into a
/// silent authorization change.
///
/// `MembershipType op i32` — "does the caller outrank this role?" — answers no: `gt`/`ge` are
/// false for an unknown value, so nothing is ever granted on the strength of one.
///
/// `i32 op MembershipType` — "is this membership at most that role?" — answers yes: `lt`/`le`
/// are true. Every use of it is a *ceiling* (`atype < Admin`, `atype <= Admin`), so treating an
/// unrecognized value as low-ranked is the restrictive reading. It also cannot smuggle anything
/// past the one place that phrases a permission this way
/// (`check_reset_password_applicable_and_permissions`): the role an Admin must not reach is
/// `Owner`, whose discriminant is 0 and therefore never unknown.
#[test]
#[expect(
clippy::nonminimal_bool,
reason = "`!(role > atype)` must not become `role <= atype`: only `gt`/`ge` are overridden to \
answer false for an incomparable value, while `le`/`lt` fall through to the derived \
form. Clippy's rewrite would assert the opposite of what this test is for."
)]
fn an_unknown_stored_role_is_incomparable_and_resolves_fail_closed() {
for atype in [-1, 3, 5, i32::MAX, i32::MIN] {
assert_eq!(MembershipType::Admin.partial_cmp(&atype), None, "atype {atype}");
assert_eq!(atype.partial_cmp(&MembershipType::Admin), None, "atype {atype}");
// Never outranked by an unknown value: no permission is granted on its strength.
for role in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
let known = role as i32;
assert!(!(role > atype), "atype {atype} must not be outranked by role {known}");
assert!(!(role >= atype), "atype {atype} must not be outranked by role {known}");
}
// Always under the ceiling: an unknown value is treated as the lowest rank there is.
assert!(atype < MembershipType::Admin, "atype {atype}");
assert!(atype <= MembershipType::Admin, "atype {atype}");
// And it is equal to nothing, in either direction.
assert!(atype != MembershipType::Custom, "atype {atype}");
assert!(MembershipType::Custom != atype, "atype {atype}");
}
// The known values keep behaving by rank, not by discriminant: Custom's is 4, above Admin's.
assert!(MembershipType::Admin > MembershipType::Custom as i32);
assert!((MembershipType::Custom as i32) < MembershipType::Admin);
assert!(MembershipType::Custom >= MembershipType::Custom as i32);
}
#[test]
fn custom_collection_permissions_are_independent_and_type_gated() {
let mut member = membership(MembershipType::Custom);
member.create_new_collections = true;
assert!(member.has_create_new_collections());
assert!(member.can_create_new_collections());
assert!(!member.limit_collection_creation());
assert!(!member.has_full_access());
assert!(!member.can_delete_any_collection());
assert!(!member.has_manage_all_collections());
member.delete_any_collection = true;
assert!(member.has_delete_any_collection());
assert!(member.can_delete_any_collection());
assert!(!member.has_full_access());
assert!(!member.has_manage_all_collections());
member.edit_any_collection = true;
assert!(member.has_edit_any_collection());
assert!(member.has_full_access());
assert!(member.has_manage_all_collections());
// Stale flags on a non-Custom role are inert.
member.atype = MembershipType::User as i32;
assert!(!member.has_create_new_collections());
assert!(!member.has_edit_any_collection());
assert!(!member.has_delete_any_collection());
assert!(!member.can_create_new_collections());
assert!(!member.can_delete_any_collection());
assert!(!member.has_full_access());
}
#[cfg(sqlite)]
#[test]
fn diesel_edit_any_collection_predicate_is_custom_type_gated() {
use diesel::{Connection, connection::SimpleConnection, sqlite::SqliteConnection};
let mut conn = SqliteConnection::establish(":memory:").unwrap();
conn.batch_execute(
"CREATE TABLE users_organizations (
atype INTEGER NOT NULL,
edit_any_collection BOOLEAN NOT NULL
);
INSERT INTO users_organizations (atype, edit_any_collection) VALUES
(0, TRUE),
(1, TRUE),
(2, TRUE),
(3, TRUE),
(4, FALSE),
(4, TRUE),
(5, TRUE);",
)
.unwrap();
let matching_types = users_organizations::table
.select(users_organizations::atype)
.filter(custom_membership_with_edit_any_collection())
.load::<i32>(&mut conn)
.unwrap();
assert_eq!(matching_types, vec![MembershipType::Custom as i32]);
}
#[test]
fn edit_any_collection_does_not_imply_create_or_delete() {
let mut custom = membership(MembershipType::Custom);
custom.edit_any_collection = true;
// Edit any collection grants full (read/edit) access to every collection, but client-facing
// create and delete decisions must still use their own dedicated permissions.
assert!(custom.has_full_access());
assert!(custom.grants_access_to_all_collections());
assert!(!custom.can_create_new_collections());
assert!(custom.limit_collection_creation());
assert!(!custom.can_delete_any_collection());
let admin = membership(MembershipType::Admin);
assert!(admin.can_create_new_collections());
assert!(!admin.limit_collection_creation());
assert!(admin.can_delete_any_collection());
assert!(admin.grants_access_to_all_collections());
}
#[test]
fn custom_collection_permissions_require_confirmed_membership() {
let mut member = membership(MembershipType::Custom);
member.create_new_collections = true;
member.edit_any_collection = true;
member.delete_any_collection = true;
member.status = MembershipStatus::Accepted as i32;
assert!(!member.can_create_new_collections());
assert!(!member.can_delete_any_collection());
assert!(!member.has_full_access());
}
#[test]
fn clearing_custom_permissions_clears_every_flag() {
let mut member = membership(MembershipType::Custom);
member.manage_users = true;
member.manage_groups = true;
member.manage_policies = true;
member.create_new_collections = true;
member.edit_any_collection = true;
member.delete_any_collection = true;
member.access_event_logs = true;
member.access_import_export = true;
member.access_reports = true;
member.clear_custom_permissions();
assert!(!member.manage_users);
assert!(!member.manage_groups);
assert!(!member.manage_policies);
assert!(!member.create_new_collections);
assert!(!member.edit_any_collection);
assert!(!member.delete_any_collection);
assert!(!member.access_event_logs);
assert!(!member.access_import_export);
assert!(!member.access_reports);
}
#[test]
fn custom_access_permissions_are_independent_and_type_gated() {
let mut member = membership(MembershipType::Custom);
member.access_event_logs = true;
assert!(member.has_access_event_logs());
assert!(!member.has_access_import_export());
member.access_import_export = true;
member.access_reports = true;
assert!(member.has_access_import_export());
// None of them imply collection or management capabilities.
assert!(!member.has_full_access());
assert!(!member.has_manage_users());
// Stale flags on a non-Custom role grant nothing.
member.atype = MembershipType::User as i32;
assert!(!member.has_access_event_logs());
assert!(!member.has_access_import_export());
}
}

10
src/db/schema.rs

@ -236,12 +236,20 @@ table! {
user_uuid -> Text,
org_uuid -> Text,
invited_by_email -> Nullable<Text>,
access_all -> Bool,
akey -> Text,
status -> Integer,
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
create_new_collections -> Bool,
edit_any_collection -> Bool,
delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
}
}

16
src/static/scripts/admin_users.js

@ -174,8 +174,8 @@ const ORG_TYPES = {
"bg": "blue"
},
"4": {
"name": "Manager",
"bg": "green"
"name": "Custom",
"bg": "teal"
},
};
@ -210,12 +210,13 @@ jQuery.extend(jQuery.fn.dataTableExt.oSort, {
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
// Fill the form and title
userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
document.getElementById("userOrgTypeForm").reset();
// Get shared values
const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail;
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
@ -223,7 +224,9 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid;
document.getElementById("userOrgTypeOrgUuid").value = orgUuid;
document.getElementById(`userOrgType${userOrgTypeName}`).checked = true;
if (ORG_TYPES[userOrgType] !== undefined) {
document.getElementById(`userOrgType${ORG_TYPES[userOrgType].name}`).checked = true;
}
}, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden.
@ -250,7 +253,10 @@ function updateUserOrgType(event) {
function initUserTable() {
// Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) {
const orgType = ORG_TYPES[e.dataset.vwOrgType];
const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? {
"name": "Unknown membership type",
"bg": "gray"
};
e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) {
e.style.color = orgType.font;

4
src/static/templates/admin/users.hbs

@ -130,10 +130,10 @@
<input type="hidden" name="org_uuid" id="userOrgTypeOrgUuid" value="">
<div class="modal-body">
<div class="radio">
<label><input type="radio" value="2" class="form-radio-input" name="user_type" id="userOrgTypeUser">&nbsp;User</label>
<label><input type="radio" value="2" class="form-radio-input" name="user_type" id="userOrgTypeUser" required>&nbsp;User</label>
</div>
<div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label>
<label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div>
<div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>

6
src/static/templates/scss/vaultwarden.scss.hbs

@ -116,8 +116,10 @@ app-security > app-two-factor-setup > form {
}
/* Hide unsupported Custom Role options */
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
/* Collection permissions, manageUsers, manageGroups, managePolicies, accessEventLogs,
accessImportExport and accessReports are supported by Vaultwarden and are intentionally not hidden here. */
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]),
:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) {
@extend %vw-hide;
}

10
src/util.rs

@ -522,8 +522,16 @@ pub fn format_datetime_http(dt: &DateTime<Local>) -> String {
expiry_time.to_rfc2822().replace("+0000", "GMT")
}
pub fn try_parse_date(date: &str) -> Result<NaiveDateTime, chrono::ParseError> {
DateTime::parse_from_rfc3339(date).map(|date| date.naive_utc())
}
/// Parse an RFC 3339 date which is known to be valid.
///
/// Request data should use [`try_parse_date`] so malformed input can be returned as a controlled
/// client error instead of panicking.
pub fn parse_date(date: &str) -> NaiveDateTime {
DateTime::parse_from_rfc3339(date).unwrap().naive_utc()
try_parse_date(date).expect("trusted date must be valid RFC 3339")
}
/// Returns true or false if an email address is valid or not

281
tools/custom_role_rollback/README.md

@ -0,0 +1,281 @@
# Rolling back the Custom-role change
The Custom-role change removes the membership `access_all` column and adds nine permission columns.
A Vaultwarden version from before that change cannot start against the new schema, because its
`schema.rs` still expects `access_all` to exist.
Vaultwarden only ever applies *pending* migrations — it never reverts one on its own — so putting the
old image back is not enough. Run the script for your backend once and the old version starts
again.
## Choosing which members come back as Manager
The old and new role models are not ordered, so this is a decision, not a conversion. The legacy
Manager role is **not** a subset of what a Custom member holds: it manages — and deletes — every
collection reachable through `users_collections.manage`, `collections_groups.manage` or
`groups.access_all`, and it reads member and collection ACL details through `ManagerHeadersLoose`.
None of that needs a permission flag in the old schema. Mapping every Custom member to Manager would
therefore *grant* authority during a downgrade: a member with `deleteAnyCollection = false` but a
direct or group-based manage grant would come back able to delete those collections, and a member
with no permissions at all would come back able to read the organization's member list.
So the scripts map to Manager only what you list, and everything else to plain User. Create the list
with every Vaultwarden instance stopped, right before running the rollback:
```sql
CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY);
```
Use `CHAR(36)` instead of `TEXT` on MySQL/MariaDB and PostgreSQL. An empty list is a valid answer and
maps every Custom member to plain User. To add members, list the candidates and pick from them:
```sql
SELECT uuid, user_uuid, org_uuid, status,
manage_users, manage_groups, manage_policies,
create_new_collections, edit_any_collection, delete_any_collection,
access_event_logs, access_import_export, access_reports
FROM users_organizations WHERE atype = 4;
INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES ('<MEMBERSHIP_UUID>');
```
The upgrade records which memberships held the Manager role beforehand, in
`__vw_custom_role_legacy_manager`. That is useful evidence, and copying it over is a reasonable
starting point:
```sql
INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid)
SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;
```
But it is deliberately **not** used automatically. It records who was a Manager before the *first*
upgrade and is never updated afterwards, so a member whose Manager powers an owner has since reduced
— or who was demoted to User and later re-created as a limited Custom member — would be handed the
whole legacy role back. Historical provenance is evidence, not authorization.
## What is lost
The old schema has nowhere to store the nine permissions, so they are dropped:
| Before the rollback | After |
|---|---|
| Owner / Admin | Owner / Admin with `access_all = TRUE` |
| Custom **on the allowlist**, with all three collection permissions | Manager with `access_all = TRUE` |
| Custom **on the allowlist**, with only some collection permissions | Manager with `access_all = FALSE` |
| Custom not on the allowlist | plain User with `access_all = FALSE` |
| plain User | plain User with `access_all = FALSE` |
Per-collection assignments (`users_collections`, `collections_groups`) and `groups.access_all` are
untouched. Only `users_organizations` changes, so a member mapped to plain User keeps every grant
those tables carry and loses only the organization-wide powers the old schema cannot express.
One row does not come back byte-identical to what the database held before the *upgrade*, because
the information no longer exists to reconstruct it:
- **Owner/Admin always come back with `access_all = TRUE`**, even if the flag was `FALSE` for them
before. The upgrade dropped the column precisely because Owners and Admins reach every collection
through their role, so the original value is unknown afterwards. It grants them nothing they did
not already have as Owner/Admin; the visible difference is that unassigned collections show up in
their personal vault view again.
A plain User carrying `access_all` cannot reach this point at all: the upgrade refuses to start on
such a database and asks an owner to resolve it first, precisely so that no rollback has to guess
what the bit meant. For the same reason a Custom member mapped to plain User never keeps `access_all`
— that combination is the one legacy state the upgrade refuses, and leaving it behind would make the
database unable to move forward again.
Edit-any-collection deliberately does **not** become `access_all` on its own: in the old schema that
flag also carried the legacy "manage all collections" authority including deletion, so a member who
only held Edit must not come back with delete rights.
## The upgrade asks one question of its own
Migration `2026-08-10-120000` stops the *upgrade* — not the rollback — when a Custom member holds
`editAnyCollection` or `deleteAnyCollection` and belongs to an organization-local group with
`accessAll`. It grants nothing and revokes nothing; it exists because that combination is the one
place where the new model cannot reproduce the old semantics.
Before the Custom role, a Manager who reached every collection through such a group held that
authority *while* the group relationship lasted: it ended when the group was deleted, when its
`accessAll` was cleared, when the member left it, and it was inert whenever `ORG_GROUPS_ENABLED` was
false. Nothing in the new model expresses a permission bound to a group like that — the permissions
live on the membership. The earlier migrations in the chain therefore write the authority onto the
membership, and the result is deliberately not identical to what it replaces:
- it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared;
- it applies even with the groups feature switched off;
- `editAnyCollection` additionally satisfies `has_full_access()`, so the member reaches every
collection directly rather than through the group.
Doing that silently would be a migration granting durable organization-wide collection edit and
delete on its own authority; dropping it silently would take a capability away. Neither is the
migration's call, so it hands the decision to an owner. On a database with no Custom membership that
both has edit/delete authority and belongs to an organization-local `accessAll` group, there is
nothing to decide and it is a no-op.
**Start Vaultwarden once to get the question.** The startup preflight looks ahead for the same
condition, from the legacy schema as well as the migrated one, and refuses with the review query, the
three differences above and the acknowledgement statement
(`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`). The migration keeps its own
guard as the backstop for a bare `diesel migration run`, but Diesel reports only the driver error
there, so on that path the question arrives as nothing but a duplicate-key violation on
`__vw_permanent_authority_guard`.
Every matching membership is asked about, including a recorded legacy Manager with
`createNewCollections` set. That flag is an independent permission an owner can change after an
earlier revision materialized group-derived edit/delete, so its current value is not reliable
historical provenance. This deliberately prefers a conservative extra question over silently making
group-derived authority permanent. A membership whose own legacy `access_all` supplied all three
permissions may therefore be listed even though nothing changes meaning for it. An invited or revoked
membership is asked about too: it holds no authority today, but the permission is what it would come
back with if it is ever restored.
Answering the question is a different statement depending on when you are asked, because the
preflight looks ahead from both schemas. Before the upgrade has run there is nothing to clear — the
permission columns do not exist yet — so declining means ending the group relationship the authority
comes from, either for one membership (`DELETE FROM groups_users …`) or for the whole group
(`UPDATE groups SET access_all = FALSE …`). Once the columns exist, clear them directly. Doing that
after the upgrade is equally safe: Vaultwarden does not start until the acknowledgement is recorded,
so nothing is ever live in between. The refusal prints both statements.
## How to run it
Stop every Vaultwarden instance and take a backup first. Create the allowlist as described above.
Then:
```bash
# SQLite
sqlite3 -bail /path/to/data/db.sqlite3 < tools/custom_role_rollback/sqlite.sql
# MySQL / MariaDB
mysql -u <user> -p <database> < tools/custom_role_rollback/mysql.sql
# PostgreSQL
psql -U <user> -d <database> -v ON_ERROR_STOP=1 -f tools/custom_role_rollback/postgresql.sql
```
Every script begins with a **read-only precondition** that inspects the schema and the migration
ledger before it touches anything, and refuses unless all of these hold:
- membership `access_all` is gone (so the upgrade did run, and this script has not),
- all nine permission columns exist,
- all nine Custom-role migrations are recorded in `__diesel_schema_migrations`,
- **no migration newer than `20260810120000` is recorded** — this script does not know what a later
migration changed, and removing only the Custom-role versions would leave the ledger claiming a
migration whose schema objects may have been undone,
- **`__vw_custom_role_history_verified` exists**, i.e. this database's Custom-role history was
produced by the migrations that ship today (see the next section),
- **`__vw_rollback_manager_allowlist` exists**, and on MySQL/MariaDB has exactly one non-nullable,
uniquely indexed `users_organizations_uuid` column — a table of the right name but the wrong shape
would otherwise pass every check and then fail on the first read, *after* the first `ALTER TABLE`
has already committed implicitly,
- SQLite only: **`users_organizations` has exactly the eighteen expected columns, two indexes and no
triggers.** The SQLite script rebuilds the table from a fixed column list, so anything it does not
know about would be dropped along with its data. The column check uses `pragma_table_xinfo`, which
unlike `table_info` also reports generated columns, and the index check counts `pragma_index_list`
rather than `sqlite_master`, because the index behind a `UNIQUE` constraint has no SQL text and
would otherwise be invisible.
A second run, or a half-finished upgrade, is therefore refused with a message that names the reason
and leaves the database exactly as it was. This matters most on MySQL/MariaDB, where nothing can be
rolled back: without the check, a database whose `access_all` was already dropped but whose
access-permission columns were never added would get through the first `ADD COLUMN`, the value
rewrites, the type change and six `DROP COLUMN`s before failing on the seventh — ending up less
consistent than before.
The PostgreSQL script resolves `users_organizations`, `__diesel_schema_migrations`,
`__vw_rollback_manager_allowlist` and `__vw_custom_role_history_verified` once each, requires all of
them to live in the **same** schema, and addresses that schema explicitly from then on. An
unqualified name is otherwise resolved per statement through `search_path`, so a session with
`search_path = decoy, real` could have the table rewrite land in one schema and the ledger delete in
another.
The MySQL/MariaDB script ends with an explicit `COMMIT`. Everything before it is DDL and commits
implicitly, but the final ledger `DELETE` is plain DML: under `autocommit = 0` it would be rolled
back on disconnect, leaving the schema old while all nine migrations still count as applied — and a
later upgrade would then skip them and start new code against the old schema.
**Do not drop the `-bail` / `ON_ERROR_STOP=1` flags, do not pass `--force` to `mysql`, and do not run
these through a client that keeps going after a failed statement.** The sqlite3 shell continues after
errors by default; the script sets `.bail on` itself, but that is a shell command a different runner
will ignore. A runner that carries on past a failing statement would reach the `DROP TABLE` and commit
an empty `users_organizations`.
SQLite and PostgreSQL apply the script in a single transaction, so an aborted run leaves the
database untouched. On MySQL/MariaDB the statements cannot be wrapped in a transaction (DDL commits
implicitly there); the precondition is what keeps a mismatch from being mutated at all, but if the
script is interrupted *after* it passed, restore the backup and start over.
The SQLite script rebuilds `users_organizations` instead of using `ALTER TABLE ... DROP COLUMN`, which
only exists since SQLite 3.35 — the same reason the forward migration rebuilds the table. It therefore
also works against the older system SQLite that `sqlite_system` builds link.
Afterwards start the older Vaultwarden version. Upgrading again later re-applies the nine
migrations from a clean state, and rebuilds `__vw_custom_role_legacy_manager` from the very
`atype = 3` rows the rollback restored — so the round trip converges.
## Databases upgraded before the history marker existed
`__vw_custom_role_history_verified` is created by `2026-06-30-120000`, and nothing else creates it.
A database upgraded by an earlier revision of this feature branch carries that migration's version in
its ledger without the table, and Diesel never re-runs a recorded version — so Vaultwarden refuses to
start and the rollback scripts refuse to run, rather than acting on migrations whose effects were
different.
Start Vaultwarden once: it prints the full recovery, which depends on how far the earlier revision
got and covers up to three things — recording which memberships were legacy Managers, reviewing
permissions an earlier `20260809120000` granted in bulk to Custom members of `accessAll` groups, and
reviewing the direct collection assignments an earlier `20260723120000` wrote for a plain User that
carried membership `access_all`. If you still have the backup from before the first upgrade,
restoring it and upgrading again is simpler and needs no decision at all.
The marker is created as a separate statement from the legacy-Manager record on purpose. That record
is data an operator has to be able to write during recovery, so its existence must not double as
evidence that the history behind it was reviewed — otherwise creating it empty to make the error
message go away would silently pass as the audit it is asking for.
## Reverting with the Diesel CLI instead
For development checkouts the down migrations do the same thing step by step. **Every one of them that
loses permission data refuses by default** — `2026-07-24-130000`, `2026-07-16-120000` and
`2026-06-30-120000` — and so does `2026-07-24-140000`, which loses nothing itself and exists to stop
the chain before the first destructive step. `2026-08-10-120000` and `2026-08-09-120000` are reverted
first and are no-ops. Acknowledge the downgrade once:
```sql
CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
```
Then `diesel migration revert` works as usual for the whole chain. The acknowledgement is deliberately
*not* consumed by the first guard it satisfies: it is dropped by the oldest lossy migration
(`2026-06-30-120000`), so one decision covers one downgrade and a revert that stops halfway is still
guarded when it resumes. Re-upgrading clears a leftover acknowledgement
(`2026-07-24-140000/up.sql`), so consent never carries over into a later, unrelated revert. The
rollback scripts above drop the table as well.
The down migrations use the same allowlist as the scripts above. Unlike the scripts they do not
refuse when `__vw_rollback_manager_allowlist` is missing — they create it empty, which means "nobody"
and maps every Custom member to plain User. Populate it first if that is not what you want.
On SQLite the down migrations do use `ALTER TABLE ... DROP COLUMN` and therefore need SQLite 3.35 or
newer. That is fine for a development checkout with a bundled SQLite; operators on an older system
SQLite should use `sqlite.sql` above, which rebuilds the table instead.
### MySQL/MariaDB: supported for development checkouts only
On MySQL/MariaDB the Diesel revert chain **cannot be resumed**, and `2026-07-24-140000/down.sql`
requires a second, separate acknowledgement that says so:
```sql
CREATE TABLE __vw_allow_unresumable_mysql_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY);
```
Every `ALTER TABLE` there commits on its own, while Diesel deletes the ledger row in a separate
statement afterwards. A crash in between leaves the columns gone and the migration still recorded as
applied; re-running it then fails forever with `Unknown column` (1091), the startup preflight refuses
the database — correctly — and the only way out is the backup. Making it resumable would need
conditional DDL, i.e. a stored procedure created before the checks have run. Each down migration
removes its three permission columns in a single `ALTER TABLE` rather than three, which is the
closest this backend gets to all-or-nothing, and temporary guard tables are removed with
`DROP TEMPORARY TABLE`, which is one implicit commit fewer and cannot hit a permanent table of the
same name by accident. Use `mysql.sql` above for anything you care about.

317
tools/custom_role_rollback/mysql.sql

@ -0,0 +1,317 @@
-- Roll a MySQL/MariaDB database back to the schema the Vaultwarden version *before* the Custom-role
-- change expects, so that older binary starts again. Read README.md in this directory first --
-- it lists exactly what is lost and how to run this safely.
--
-- NOTE: MySQL/MariaDB commit every DDL statement implicitly, so this script cannot be wrapped in a
-- transaction. That is exactly why everything below the precondition has to be reached in a known
-- state: an ALTER that fails halfway leaves every earlier statement committed. Take a backup before
-- running it; if it is interrupted, restore and start over.
-- ---------------------------------------------------------------------------------------------
-- Precondition. Read-only and session-local: it reads `information_schema` and the migration ledger,
-- prints the reason when the database does not fit, and aborts on a duplicate key in a TEMPORARY
-- table. No permanent object is created, altered or dropped, so a database this script does not fit
-- keeps its exact state -- which matters here precisely because DDL cannot be rolled back.
--
-- Without it, a partially upgraded database -- for example one where `access_all` was already dropped
-- but the access-permission columns were never added, which DDL autocommit makes reachable -- would
-- get through the first ADD COLUMN, the value rewrites, the type change and six DROP COLUMN statements before
-- failing on the seventh with error 1091, ending up *less* consistent than before.
--
-- Deliberately not a stored procedure with SIGNAL: MySQL caps `MESSAGE_TEXT` at 128 characters and
-- answers a longer one with "ERROR 1648 Data too long for condition item 'MESSAGE_TEXT'" instead of
-- the diagnosis (MariaDB accepts it, so the difference is easy to miss), and CREATE PROCEDURE is a
-- permanent object that would have to be written *before* the checks have run -- replacing any
-- same-named routine, surviving a refusal, and requiring routine privileges this script otherwise
-- does not need.
-- ---------------------------------------------------------------------------------------------
CREATE TEMPORARY TABLE __vw_rollback_precondition (
ok INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_rollback_precondition (ok) VALUES (1);
-- 1) Membership `access_all` has to be gone already, i.e. the upgrade ran and this script did not.
SELECT CONCAT(
'REFUSED, nothing was changed: users_organizations.access_all still exists. This database was ',
'either never upgraded past the Custom-role migrations, or this script already ran.'
) AS rollback_precondition_failure
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'users_organizations'
AND column_name = 'access_all';
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'users_organizations'
AND column_name = 'access_all';
-- 2) All nine permission columns have to be present.
SELECT CONCAT(
'REFUSED, nothing was changed: expected all nine Custom-role permission columns on ',
'users_organizations, found ', c.n, '. The upgrade is incomplete, so restore the backup taken ',
'before it and start over.'
) AS rollback_precondition_failure
FROM (
SELECT COUNT(*) AS n
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'users_organizations'
AND column_name IN (
'manage_users', 'manage_groups', 'manage_policies',
'create_new_collections', 'edit_any_collection', 'delete_any_collection',
'access_event_logs', 'access_import_export', 'access_reports'
)
) AS c
WHERE c.n <> 9;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT COUNT(*) AS n
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'users_organizations'
AND column_name IN (
'manage_users', 'manage_groups', 'manage_policies',
'create_new_collections', 'edit_any_collection', 'delete_any_collection',
'access_event_logs', 'access_import_export', 'access_reports'
)
) AS c
WHERE c.n <> 9;
-- 3) All nine Custom-role migrations have to be recorded.
SELECT CONCAT(
'REFUSED, nothing was changed: expected all nine Custom-role migrations in ',
'__diesel_schema_migrations, found ', c.n, '. Schema and ledger disagree, so restore the backup ',
'taken before the upgrade and start over.'
) AS rollback_precondition_failure
FROM (
SELECT COUNT(*) AS n
FROM __diesel_schema_migrations
WHERE version IN (
'20260630120000',
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
)
) AS c
WHERE c.n <> 9;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT COUNT(*) AS n
FROM __diesel_schema_migrations
WHERE version IN (
'20260630120000',
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
)
) AS c
WHERE c.n <> 9;
-- 4) No migration newer than the Custom-role change may be recorded: this script does not know what
-- such a migration changed, and removing only the nine versions below would leave the ledger
-- claiming a migration whose schema objects this script may have undone.
SELECT CONCAT(
'REFUSED, nothing was changed: ', c.n, ' migration(s) newer than the Custom-role change are ',
'recorded. Use the rollback script shipped with that newer version.'
) AS rollback_precondition_failure
FROM (
SELECT COUNT(*) AS n
FROM __diesel_schema_migrations
WHERE version > '20260810120000'
) AS c
WHERE c.n <> 0;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT COUNT(*) AS n
FROM __diesel_schema_migrations
WHERE version > '20260810120000'
) AS c
WHERE c.n <> 0;
-- 5) The upgrade records that this database's Custom-role history is accounted for. Without that
-- marker the database was migrated by an earlier revision of the change, whose migrations had
-- different effects.
SELECT CONCAT(
'REFUSED, nothing was changed: __vw_custom_role_history_verified does not exist, so this ',
'database was migrated by an earlier revision of the Custom-role change. Start Vaultwarden once ',
'and follow the recovery it prints before rolling back.'
) AS rollback_precondition_failure
FROM (
SELECT COUNT(*) AS n
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_custom_role_history_verified'
) AS c
WHERE c.n <> 1;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT COUNT(*) AS n
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_custom_role_history_verified'
) AS c
WHERE c.n <> 1;
-- 6) Which memberships come back as legacy Manager has to be decided for *this* rollback. An empty
-- list is a valid answer and maps every Custom member to plain User.
SELECT CONCAT(
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist does not exist. See README.md, ',
'section "Choosing which members come back as Manager".'
) AS rollback_precondition_failure
FROM (
SELECT COUNT(*) AS n
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_rollback_manager_allowlist'
) AS c
WHERE c.n <> 1;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT COUNT(*) AS n
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '__vw_rollback_manager_allowlist'
) AS c
WHERE c.n <> 1;
-- 7) ...and it has to have the shape the role mapping reads. Existence alone is not enough: a
-- hand-written or colliding table without a usable `users_organizations_uuid` column would pass
-- every check above and then fail on the first SELECT against it -- which happens *after* the
-- `ADD COLUMN` below has already committed implicitly, leaving a half-converted database.
-- Require exactly one non-nullable, uniquely indexed column of that name.
SELECT CONCAT(
'REFUSED, nothing was changed: __vw_rollback_manager_allowlist must have exactly one column ',
'named users_organizations_uuid, NOT NULL and uniquely indexed. Create it as documented in ',
'README.md.'
) AS rollback_precondition_failure
FROM (
SELECT
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist') AS cols,
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist'
AND column_name = 'users_organizations_uuid' AND is_nullable = 'NO') AS usable,
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist'
AND column_name = 'users_organizations_uuid' AND non_unique = 0) AS uniq
) AS c
WHERE c.cols <> 1 OR c.usable <> 1 OR c.uniq < 1;
INSERT INTO __vw_rollback_precondition (ok)
SELECT 1
FROM (
SELECT
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist') AS cols,
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist'
AND column_name = 'users_organizations_uuid' AND is_nullable = 'NO') AS usable,
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = '__vw_rollback_manager_allowlist'
AND column_name = 'users_organizations_uuid' AND non_unique = 0) AS uniq
) AS c
WHERE c.cols <> 1 OR c.usable <> 1 OR c.uniq < 1;
-- `DROP TEMPORARY TABLE`, not `DROP TABLE`: the latter is one more statement that commits implicitly,
-- and it would happily drop a permanent table of the same name.
DROP TEMPORARY TABLE __vw_rollback_precondition;
-- ---------------------------------------------------------------------------------------------
-- From here on the database is known to be in the state this script converts *from*.
-- ---------------------------------------------------------------------------------------------
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
-- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a subset
-- of what a Custom member holds -- it manages, and deletes, every collection reachable through
-- `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and reads member
-- and collection ACL details through `ManagerHeadersLoose`, none of which needs a permission flag in
-- the old schema -- so handing it out on anything less than a current, deliberate decision would
-- *grant* authority during a downgrade. `__vw_custom_role_legacy_manager` is not that decision: it
-- records who was a Manager before the first upgrade and is never updated afterwards, so a member
-- whose powers an owner has since reduced would get all of them back.
--
-- Everything else becomes a plain User and keeps its per-collection assignments.
--
-- `access_all` follows the same mapping the down migrations use: everyone who reached every
-- collection keeps that reach, and a Custom member has to hold all three collection permissions --
-- Edit-only must not silently turn into the legacy "manage all collections" authority, which in that
-- older schema also carried collection deletion. A member mapped to plain User never keeps it:
-- `User + access_all` is the one legacy state the upgrade refuses.
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations
SET access_all = TRUE
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist)
AND create_new_collections = TRUE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE;
-- The old server cannot load type 4.
UPDATE users_organizations SET atype = 3
WHERE atype = 4
AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist);
UPDATE users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4;
-- One ALTER, not nine. Every `ALTER TABLE` commits implicitly here, so nine statements mean eight
-- intermediate states an interruption could leave behind; one statement is the closest this backend
-- gets to all-or-nothing.
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;
-- Bookkeeping tables this feature may have left behind. The legacy-Manager record goes too: a later
-- re-upgrade rebuilds it from the very `atype = 3` rows this script just restored, so the round trip
-- converges.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_allow_unresumable_mysql_downgrade;
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist;
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager;
DROP TABLE IF EXISTS __vw_custom_role_history_verified;
-- Finally forget the nine migrations, so the older binary does not see a ledger from the future
-- and a later upgrade applies them again from a clean state.
DELETE FROM __diesel_schema_migrations
WHERE version IN (
'20260630120000',
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
);
-- Every statement above except this DELETE is DDL and was therefore committed implicitly the moment
-- it ran. The DELETE is plain DML: under `autocommit = 0` -- which `mysql --init-command`, a my.cnf
-- default, or a connection pool can all set -- it would be rolled back on disconnect, leaving the
-- schema rolled back but all nine migrations still marked as applied. A later upgrade would then
-- skip them and start new code against the old schema. Commit it explicitly; harmless when
-- autocommit is already on.
COMMIT;

243
tools/custom_role_rollback/postgresql.sql

@ -0,0 +1,243 @@
-- Roll a PostgreSQL database back to the schema the Vaultwarden version *before* the Custom-role
-- change expects, so that older binary starts again. Read README.md in this directory first --
-- it lists exactly what is lost and how to run this safely.
--
-- PostgreSQL DDL is transactional, so this whole script either applies or it does not.
--
-- Everything runs inside one DO block against schema-qualified names. An unqualified relation is
-- resolved per statement through `search_path`, i.e. to the first schema that happens to contain a
-- matching name -- so a session with `search_path = decoy, real` could have the checks and the table
-- rewrite land in `decoy` while the ledger delete hits `real`, leaving the real database with a new
-- schema and a ledger claiming the old one. Resolving each relation once, requiring all of them to
-- live in the *same* namespace, and then addressing that namespace explicitly removes the ambiguity.
BEGIN;
DO $$
DECLARE
memberships regclass := to_regclass('users_organizations');
ledger regclass := to_regclass('__diesel_schema_migrations');
allowlist regclass := to_regclass('__vw_rollback_manager_allowlist');
history regclass := to_regclass('__vw_custom_role_history_verified');
ns oid;
ns_name text;
access_all_present int;
permission_columns int;
allowlist_columns int;
ledger_rows int;
future_rows int;
BEGIN
-- ---------------------------------------------------------------------------------------------
-- Bind the target. Read-only: this inspects the catalog and the migration ledger and changes
-- nothing, so a database this script does not fit keeps its exact state. The transaction would
-- roll back a mismatch anyway; this turns a raw "column does not exist" into a message that says
-- what to do, and it keeps all three backends' scripts symmetrical.
-- ---------------------------------------------------------------------------------------------
IF memberships IS NULL THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: no users_organizations table is '
'reachable through the current search_path. Connect to the database and '
'schema Vaultwarden uses.';
END IF;
IF ledger IS NULL THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: no __diesel_schema_migrations table '
'is reachable through the current search_path.';
END IF;
IF history IS NULL THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_custom_role_history_verified '
'does not exist, so this database was migrated by an earlier revision of the '
'Custom-role change, whose migrations had different effects. Start '
'Vaultwarden once and follow the recovery it prints before rolling back.';
END IF;
IF allowlist IS NULL THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist does '
'not exist. Which memberships come back as legacy Manager has to be decided '
'for this rollback -- an empty list is a valid answer and maps every Custom '
'member to plain User. See README.md, section "Choosing which members come '
'back as Manager".';
END IF;
SELECT relnamespace INTO ns FROM pg_class WHERE oid = memberships;
IF (SELECT relnamespace FROM pg_class WHERE oid = ledger) <> ns
OR (SELECT relnamespace FROM pg_class WHERE oid = allowlist) <> ns
OR (SELECT relnamespace FROM pg_class WHERE oid = history) <> ns THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: the tables this script needs resolve '
'to different schemas through the current search_path -- '
'users_organizations in "%", __diesel_schema_migrations in "%", '
'__vw_rollback_manager_allowlist in "%", '
'__vw_custom_role_history_verified in "%". Set search_path to exactly the '
'schema Vaultwarden uses and run this again.',
(SELECT nspname FROM pg_namespace WHERE oid = ns),
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = ledger),
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = allowlist),
(SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.oid = history);
END IF;
SELECT nspname INTO ns_name FROM pg_namespace WHERE oid = ns;
SELECT count(*) INTO access_all_present
FROM pg_attribute
WHERE attrelid = memberships
AND attnum > 0
AND NOT attisdropped
AND attname = 'access_all';
SELECT count(*) INTO permission_columns
FROM pg_attribute
WHERE attrelid = memberships
AND attnum > 0
AND NOT attisdropped
AND attname IN (
'manage_users', 'manage_groups', 'manage_policies',
'create_new_collections', 'edit_any_collection', 'delete_any_collection',
'access_event_logs', 'access_import_export', 'access_reports'
);
-- The allowlist is read by the role mapping below, so a hand-written table of the right name but
-- the wrong shape has to be caught here rather than mid-rewrite.
SELECT count(*) INTO allowlist_columns
FROM pg_attribute
WHERE attrelid = allowlist
AND attnum > 0
AND NOT attisdropped
AND attname = 'users_organizations_uuid';
EXECUTE format(
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version IN ('
'''20260630120000'', ''20260715120000'', ''20260716120000'', ''20260723120000'','
'''20260724120000'', ''20260724130000'', ''20260724140000'', ''20260809120000'','
'''20260810120000'')',
ns_name
) INTO ledger_rows;
EXECUTE format(
'SELECT count(*) FROM %I.__diesel_schema_migrations WHERE version > ''20260810120000''',
ns_name
) INTO future_rows;
IF access_all_present <> 0 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: users_organizations.access_all still '
'exists. This database was either never upgraded past the Custom-role '
'migrations, or this script already ran.';
END IF;
IF permission_columns <> 9 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected all nine Custom-role '
'permission columns on users_organizations, found %. The upgrade is '
'incomplete, so restore the backup taken before it and start over.',
permission_columns;
END IF;
IF allowlist_columns <> 1 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: __vw_rollback_manager_allowlist has '
'no users_organizations_uuid column. Create it as documented in README.md.';
END IF;
IF ledger_rows <> 9 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: expected all nine Custom-role '
'migrations in __diesel_schema_migrations, found %. Schema and ledger '
'disagree, so restore the backup taken before the upgrade and start over.',
ledger_rows;
END IF;
IF future_rows <> 0 THEN
RAISE EXCEPTION 'Rollback refused, nothing was changed: % migration(s) newer than the '
'Custom-role change are recorded. This script does not know what they '
'changed, and removing only the nine Custom-role versions would leave the '
'ledger inconsistent. Use the rollback script shipped with that newer '
'version.',
future_rows;
END IF;
-- ---------------------------------------------------------------------------------------------
-- From here on the database is known to be in the state this script converts *from*, and every
-- statement addresses the one namespace bound above.
-- ---------------------------------------------------------------------------------------------
EXECUTE format(
'ALTER TABLE %I.users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE',
ns_name
);
-- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a
-- subset of what a Custom member holds -- it manages, and deletes, every collection reachable
-- through `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and
-- reads member and collection ACL details through `ManagerHeadersLoose`, none of which needs a
-- permission flag in the old schema -- so handing it out on anything less than a current,
-- deliberate decision would *grant* authority during a downgrade.
-- `__vw_custom_role_legacy_manager` is not that decision: it records who was a Manager before the
-- first upgrade and is never updated afterwards, so a member whose powers an owner has since
-- reduced would get all of them back.
--
-- Everything else becomes a plain User and keeps its per-collection assignments.
--
-- `access_all` follows the same mapping the down migrations use: everyone who reached every
-- collection keeps that reach, and a Custom member has to hold all three collection permissions
-- -- Edit-only must not silently turn into the legacy "manage all collections" authority, which
-- in that older schema also carried collection deletion. A member mapped to plain User never
-- keeps it: `User + access_all` is the one legacy state the upgrade refuses.
EXECUTE format(
'UPDATE %I.users_organizations SET access_all = TRUE WHERE atype IN (0, 1)', ns_name
);
EXECUTE format(
'UPDATE %I.users_organizations SET access_all = TRUE '
'WHERE atype = 4 '
' AND uuid IN (SELECT users_organizations_uuid FROM %I.__vw_rollback_manager_allowlist) '
' AND create_new_collections = TRUE '
' AND edit_any_collection = TRUE '
' AND delete_any_collection = TRUE',
ns_name, ns_name
);
-- The old server cannot load type 4.
EXECUTE format(
'UPDATE %I.users_organizations SET atype = 3 '
'WHERE atype = 4 '
' AND uuid IN (SELECT users_organizations_uuid FROM %I.__vw_rollback_manager_allowlist)',
ns_name, ns_name
);
EXECUTE format(
'UPDATE %I.users_organizations SET atype = 2, access_all = FALSE WHERE atype = 4', ns_name
);
EXECUTE format(
'ALTER TABLE %I.users_organizations '
' DROP COLUMN manage_users, '
' DROP COLUMN manage_groups, '
' DROP COLUMN manage_policies, '
' DROP COLUMN create_new_collections, '
' DROP COLUMN edit_any_collection, '
' DROP COLUMN delete_any_collection, '
' DROP COLUMN access_event_logs, '
' DROP COLUMN access_import_export, '
' DROP COLUMN access_reports',
ns_name
);
-- Bookkeeping tables this feature may have left behind. A later re-upgrade rebuilds the
-- provenance record and the history marker from the very `atype = 3` rows this script just
-- restored, so the round trip converges.
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_same_run_0716', ns_name);
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_allow_custom_role_downgrade', ns_name);
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_ack_permanent_collection_authority', ns_name);
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_rollback_manager_allowlist', ns_name);
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_legacy_manager', ns_name);
EXECUTE format('DROP TABLE IF EXISTS %I.__vw_custom_role_history_verified', ns_name);
-- Finally forget the nine migrations, so the older binary does not see a ledger from the future
-- and a later upgrade applies them again from a clean state.
EXECUTE format(
'DELETE FROM %I.__diesel_schema_migrations WHERE version IN ('
'''20260630120000'', ''20260715120000'', ''20260716120000'', ''20260723120000'','
'''20260724120000'', ''20260724130000'', ''20260724140000'', ''20260809120000'','
'''20260810120000'')',
ns_name
);
END $$;
COMMIT;

252
tools/custom_role_rollback/sqlite.sql

@ -0,0 +1,252 @@
-- Roll a SQLite database back to the schema the Vaultwarden version *before* the Custom-role
-- change expects, so that older binary starts again. Read README.md in this directory first --
-- it lists exactly what is lost and how to run this safely.
--
-- `ALTER TABLE ... DROP COLUMN` is avoided on purpose: it only exists since SQLite 3.35, and this
-- script has to work on the same older system SQLite the forward migrations support. Rebuilding the
-- table also recreates `access_all` and drops all nine permission columns in one step.
-- Stop at the first error. Without this the sqlite3 shell keeps going after a failed statement,
-- and a second run -- where the SELECT below can no longer see the permission columns -- would
-- still reach DROP TABLE and commit an empty users_organizations. `.bail on` is a shell command;
-- a runner that is not the sqlite3 CLI has to abort on the first error and roll back by itself.
.bail on
PRAGMA foreign_keys = OFF;
BEGIN;
-- Refuse to start at all unless the database is in the exact state this script converts *from*. A
-- repeat run, or a half-finished upgrade, would otherwise only fail somewhere in the middle. Each
-- check is read-only, and the name of the failing CHECK constraint *is* the error message.
CREATE TEMPORARY TABLE __vw_rollback_precondition (
ok INTEGER NOT NULL CONSTRAINT
refused_membership_access_all_still_exists_so_this_database_was_not_upgraded_or_was_already_rolled_back
CHECK (ok = 1)
);
INSERT INTO __vw_rollback_precondition (ok)
SELECT CASE
WHEN NOT EXISTS (SELECT 1 FROM pragma_table_xinfo('users_organizations') WHERE name = 'access_all')
THEN 1
ELSE 0
END;
DROP TABLE __vw_rollback_precondition;
CREATE TEMPORARY TABLE __vw_rollback_precondition_columns (
ok INTEGER NOT NULL CONSTRAINT
refused_all_nine_custom_role_permission_columns_must_exist_restore_the_pre_upgrade_backup
CHECK (ok = 9)
);
INSERT INTO __vw_rollback_precondition_columns (ok)
SELECT COUNT(*)
FROM pragma_table_xinfo('users_organizations')
WHERE name IN (
'manage_users', 'manage_groups', 'manage_policies',
'create_new_collections', 'edit_any_collection', 'delete_any_collection',
'access_event_logs', 'access_import_export', 'access_reports'
);
DROP TABLE __vw_rollback_precondition_columns;
-- The rebuild below copies a fixed column list, so anything this script does not know about would be
-- silently dropped together with its data. Require the table to hold *exactly* the eighteen columns
-- the Custom-role upgrade leaves behind -- not merely to contain them. A newer migration that added a
-- column, or a local modification, therefore refuses here instead of being destroyed at COMMIT.
--
-- `table_xinfo`, not `table_info`: the latter omits generated columns entirely, so a STORED or
-- VIRTUAL column would pass the count unseen and then be lost in the rebuild.
CREATE TEMPORARY TABLE __vw_rollback_precondition_exact_columns (
ok INTEGER NOT NULL CONSTRAINT
refused_users_organizations_has_unexpected_columns_this_script_is_older_than_the_database
CHECK (ok = 1)
);
INSERT INTO __vw_rollback_precondition_exact_columns (ok)
SELECT CASE WHEN total = 18 AND known = 18 THEN 1 ELSE 0 END
FROM (
SELECT
COUNT(*) AS total,
SUM(CASE WHEN name IN (
'uuid', 'user_uuid', 'org_uuid', 'akey', 'status', 'atype',
'reset_password_key', 'external_id', 'invited_by_email',
'manage_users', 'manage_groups', 'manage_policies',
'create_new_collections', 'edit_any_collection', 'delete_any_collection',
'access_event_logs', 'access_import_export', 'access_reports'
) THEN 1 ELSE 0 END) AS known
FROM pragma_table_xinfo('users_organizations')
);
DROP TABLE __vw_rollback_precondition_exact_columns;
-- Same reasoning for everything else attached to the table: `DROP TABLE` takes its indexes and
-- triggers with it, and the rebuild recreates only the PRIMARY KEY and the UNIQUE pair.
--
-- Counting `index_list` rather than `sqlite_master` on purpose. An index that SQLite created for a
-- UNIQUE constraint has no SQL text, so `sqlite_master.sql IS NOT NULL` cannot see it -- an extra
-- `UNIQUE(external_id)` would pass unnoticed and be gone afterwards. `index_list` reports every
-- index, so the upgraded table's own two are the exact expected count.
CREATE TEMPORARY TABLE __vw_rollback_precondition_objects (
ok INTEGER NOT NULL CONSTRAINT
refused_users_organizations_has_extra_indexes_constraints_or_triggers_the_rebuild_would_destroy
CHECK (ok = 1)
);
INSERT INTO __vw_rollback_precondition_objects (ok)
SELECT CASE WHEN indexes = 2 AND triggers = 0 THEN 1 ELSE 0 END
FROM (
SELECT
(SELECT COUNT(*) FROM pragma_index_list('users_organizations')) AS indexes,
(SELECT COUNT(*) FROM sqlite_master
WHERE tbl_name = 'users_organizations' AND type = 'trigger') AS triggers
);
DROP TABLE __vw_rollback_precondition_objects;
CREATE TEMPORARY TABLE __vw_rollback_precondition_ledger (
ok INTEGER NOT NULL CONSTRAINT
refused_all_nine_custom_role_migrations_must_be_recorded_schema_and_ledger_disagree
CHECK (ok = 9)
);
INSERT INTO __vw_rollback_precondition_ledger (ok)
SELECT COUNT(*)
FROM __diesel_schema_migrations
WHERE version IN (
'20260630120000',
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
);
DROP TABLE __vw_rollback_precondition_ledger;
-- A migration newer than the last Custom-role one has run, so this script cannot know what it changed
-- or whether the rebuild below would undo it. Removing only the nine versions would also leave the
-- ledger claiming a migration whose schema objects are gone.
CREATE TEMPORARY TABLE __vw_rollback_precondition_future_ledger (
ok INTEGER NOT NULL CONSTRAINT
refused_migrations_newer_than_the_custom_role_change_are_recorded_use_a_newer_rollback_script
CHECK (ok = 0)
);
INSERT INTO __vw_rollback_precondition_future_ledger (ok)
SELECT COUNT(*) FROM __diesel_schema_migrations WHERE version > '20260810120000';
DROP TABLE __vw_rollback_precondition_future_ledger;
-- The upgrade records that this database's Custom-role history is accounted for. Without it the
-- database was migrated by an earlier revision of the change, whose migrations had different
-- effects -- start Vaultwarden once and follow the recovery it prints before rolling anything back.
CREATE TEMPORARY TABLE __vw_rollback_precondition_history (
ok INTEGER NOT NULL CONSTRAINT
refused_custom_role_history_not_verified_start_vaultwarden_once_and_follow_its_recovery
CHECK (ok = 1)
);
INSERT INTO __vw_rollback_precondition_history (ok)
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table' AND name = '__vw_custom_role_history_verified';
DROP TABLE __vw_rollback_precondition_history;
-- Which memberships come back as Manager has to be decided *for this rollback*. See README.md; an
-- empty list is a valid answer and maps every Custom member to plain User.
CREATE TEMPORARY TABLE __vw_rollback_precondition_allowlist (
ok INTEGER NOT NULL CONSTRAINT
refused_create_vw_rollback_manager_allowlist_first_see_readme_role_mapping
CHECK (ok = 1)
);
INSERT INTO __vw_rollback_precondition_allowlist (ok)
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table' AND name = '__vw_rollback_manager_allowlist';
DROP TABLE __vw_rollback_precondition_allowlist;
CREATE TABLE users_organizations_rollback (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users (uuid),
org_uuid TEXT NOT NULL REFERENCES organizations (uuid),
access_all BOOLEAN NOT NULL DEFAULT 0,
akey TEXT NOT NULL,
status INTEGER NOT NULL,
atype INTEGER NOT NULL,
reset_password_key TEXT,
external_id TEXT,
invited_by_email TEXT DEFAULT NULL,
UNIQUE (user_uuid, org_uuid)
);
-- Roles and the legacy flag are recomputed together, because in the old schema they are not
-- independent.
--
-- Only a membership on the allowlist comes back as Manager. The legacy Manager role is not a subset
-- of what a Custom member holds -- it manages, and deletes, every collection reachable through
-- `users_collections.manage`, `collections_groups.manage` or `groups.access_all`, and reads member
-- and collection ACL details through `ManagerHeadersLoose`, none of which needs a permission flag in
-- the old schema -- so handing it out on anything less than a current, deliberate decision would
-- *grant* authority during a downgrade. `__vw_custom_role_legacy_manager` is not that decision: it
-- records who was a Manager before the first upgrade and is never updated afterwards, so a member
-- whose powers an owner has since reduced would get all of them back.
--
-- Everything else becomes a plain User. Per-collection assignments are untouched, so those members
-- keep every grant `users_collections` and `collections_groups` carry.
--
-- `access_all` follows the same mapping the down migrations use: everyone who reached every
-- collection keeps that reach, and a Custom member has to hold all three collection permissions --
-- Edit-only must not silently turn into the legacy "manage all collections" authority, which in that
-- older schema also carried collection deletion. A member mapped to plain User never keeps it:
-- `User + access_all` is the one legacy state the upgrade refuses, so leaving it set would make this
-- database unable to move forward again.
INSERT INTO users_organizations_rollback (
uuid, user_uuid, org_uuid, access_all, akey, status, atype,
reset_password_key, external_id, invited_by_email
)
SELECT
uo.uuid, uo.user_uuid, uo.org_uuid,
CASE
WHEN uo.atype IN (0, 1) THEN 1
WHEN uo.atype = 4
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist)
AND uo.create_new_collections = 1
AND uo.edit_any_collection = 1
AND uo.delete_any_collection = 1 THEN 1
ELSE 0
END,
uo.akey, uo.status,
-- The old server cannot load type 4.
CASE
WHEN uo.atype = 4
AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist)
THEN 3
WHEN uo.atype = 4 THEN 2
ELSE uo.atype
END,
uo.reset_password_key, uo.external_id, uo.invited_by_email
FROM users_organizations AS uo;
DROP TABLE users_organizations;
ALTER TABLE users_organizations_rollback RENAME TO users_organizations;
-- Bookkeeping tables this feature may have left behind. A later re-upgrade rebuilds the provenance
-- record and the history marker from the very `atype = 3` rows this script just restored, so the
-- round trip converges.
DROP TABLE IF EXISTS __vw_custom_role_same_run_0716;
DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade;
DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority;
DROP TABLE IF EXISTS __vw_rollback_manager_allowlist;
DROP TABLE IF EXISTS __vw_custom_role_legacy_manager;
DROP TABLE IF EXISTS __vw_custom_role_history_verified;
-- Finally forget the nine migrations, so the older binary does not see a ledger from the future
-- and a later upgrade applies them again from a clean state.
DELETE FROM __diesel_schema_migrations
WHERE version IN (
'20260630120000',
'20260715120000',
'20260716120000',
'20260723120000',
'20260724120000',
'20260724130000',
'20260724140000',
'20260809120000',
'20260810120000'
);
COMMIT;
Loading…
Cancel
Save