diff --git a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..7b3c05be --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..09451eca --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql b/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql new file mode 100644 index 00000000..04346743 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql b/migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql new file mode 100644 index 00000000..1ba47e9d --- /dev/null +++ b/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' +); diff --git a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql b/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/down.sql new file mode 100644 index 00000000..b6f97540 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql b/migrations/mysql/2026-07-16-120000_add_custom_collection_permissions/up.sql new file mode 100644 index 00000000..487da5f8 --- /dev/null +++ b/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 + ); diff --git a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql b/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql new file mode 100644 index 00000000..4188886b --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql b/migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql new file mode 100644 index 00000000..bf20cf2c --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql b/migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql new file mode 100644 index 00000000..a2035691 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql b/migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql new file mode 100644 index 00000000..e11fb611 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql new file mode 100644 index 00000000..39a10e8b --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql b/migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql new file mode 100644 index 00000000..9d9c31ff --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql new file mode 100644 index 00000000..6db91a70 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql b/migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql new file mode 100644 index 00000000..1d0d86d3 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql new file mode 100644 index 00000000..613cc7e7 --- /dev/null +++ b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql @@ -0,0 +1,4 @@ +-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and +-- the original values are not recoverable. The permission columns themselves are removed further down +-- the chain by 2026-07-16-120000/down.sql, which is guarded. +SELECT 1; \ No newline at end of file diff --git a/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql new file mode 100644 index 00000000..4586bfdf --- /dev/null +++ b/migrations/mysql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql @@ -0,0 +1,111 @@ +-- Follow-up repair for databases that already recorded 2026-07-23-120000. +-- +-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager +-- whose authority came from an organization-local `access_all` group, because the runtime derived the +-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three +-- collection permissions, member of such a group" is also the shape of every newly created flagless +-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the +-- authority into the permission columns. +-- +-- Rewriting that file is not enough on its own: a database whose ledger already carries +-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the +-- materialization here, in its own version, so both paths converge on the same state. +-- +-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written +-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, +-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom +-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless +-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. +-- +-- What this materialization *means* -- a group-bound capability becoming a permanent membership +-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. +-- +-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already +-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two +-- permission columns, so it is also safe after `access_all` has been dropped. +-- +-- Deliberately not `create_new_collections`: collection creation historically required +-- membership-level `access_all`. +-- +-- Every statement here is DML or TEMPORARY-table bookkeeping, so nothing commits implicitly and the +-- repair either lands with the ledger insert or not at all. + +-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather +-- than creating it. +-- +-- The duplicate key aborts the migration. It is only inserted while the record table is absent. +CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_manager_record_guard (blocked) +SELECT 1 FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' +); +DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; + +-- Fail closed on a database whose legacy provenance was never recorded. +-- +-- If a Custom member sits in an organization-local `access_all` group but is not on record as a +-- legacy Manager, one of two things is true and this file cannot tell them apart: either the +-- membership really is a converted legacy Manager whose record was never written (a ledger from an +-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not +-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real +-- capability. +-- +-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator +-- creates it after auditing an older history, so its presence means the unrecorded memberships below +-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup +-- preflight refuses that state before any migration runs; this guard is the backstop for a bare +-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships: +-- +-- SELECT uo.uuid, uo.org_uuid, uo.status, +-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection +-- FROM users_organizations uo +-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid +-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid +-- WHERE uo.atype = 4 AND g.access_all = 1 +-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager); +-- +-- The marker never grants anything by itself: the update below is always driven by the record table, +-- so an unrecorded membership keeps exactly the permissions it has. +CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_group_authority_guard (blocked) +SELECT 1 +FROM users_organizations AS uo +WHERE uo.atype = 4 + AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = '__vw_custom_role_history_verified' + ) +LIMIT 1; +DROP TEMPORARY TABLE __vw_legacy_group_authority_guard; + +UPDATE users_organizations +SET edit_any_collection = TRUE, + delete_any_collection = TRUE +WHERE atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ); diff --git a/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql new file mode 100644 index 00000000..6fcda697 --- /dev/null +++ b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql @@ -0,0 +1,4 @@ +-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The +-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next +-- upgrade has to ask again. +SELECT 1; diff --git a/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql new file mode 100644 index 00000000..151b0812 --- /dev/null +++ b/migrations/mysql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql @@ -0,0 +1,121 @@ +-- Make the one semantic change this feature cannot express an owner's decision instead of a default. +-- +-- Before the Custom role, a Manager who reached every collection through an organization-local group +-- with `access_all` held that authority *while* the group relationship lasted. It ended when the +-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert +-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a +-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. +-- +-- So the earlier migrations in this chain write the authority onto the membership, and the result is +-- deliberately not identical to what it replaces: +-- +-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; +-- * it applies even with the groups feature switched off; +-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every +-- collection of the organization directly rather than through the group. +-- +-- Materializing it silently would be a migration that grants durable organization-wide collection +-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is +-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and +-- revokes nothing itself. +-- +-- On a database with no Custom membership that both has edit/delete authority and belongs to an +-- organization-local `access_all` group, there is nothing to decide and this is a no-op. +-- +-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the +-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy +-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the +-- driver error, so on this path the question would arrive as `Duplicate entry '1' for key 'PRIMARY'` +-- and nothing else. Keep the two predicates identical. +-- +-- Review the affected memberships: +-- +-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, +-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, +-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) +-- AS was_legacy_manager +-- FROM users_organizations uo +-- WHERE uo.atype = 4 +-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1) +-- AND EXISTS ( +-- SELECT 1 FROM groups_users gu +-- INNER JOIN `groups` g ON g.uuid = gu.groups_uuid +-- WHERE gu.users_organizations_uuid = uo.uuid +-- AND g.organizations_uuid = uo.org_uuid +-- AND g.access_all = 1); +-- +-- Reading the result: +-- +-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when +-- `create_new_collections = 1`: that independent permission can be changed after an earlier +-- revision materialized group-derived edit/delete, so its current value cannot prove where those +-- two permissions came from. A membership whose own legacy `access_all` supplied all three may +-- therefore be listed conservatively even though its authority was already permanent. +-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c +-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can +-- distinguish from a deliberate grant any more -- check them against what you intended. +-- +-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- +-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked +-- value outright -- but the permission is what it would come back with if it is ever restored, and +-- by then the group it came from may be gone. Status is therefore not part of the predicate. +-- +-- Clear whatever you do not want to keep, for example: +-- +-- UPDATE users_organizations +-- SET edit_any_collection = 0, delete_any_collection = 0 +-- WHERE uuid = ''; +-- +-- Then record the decision once, with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. +-- +-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the +-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why +-- this never creates it. +-- +-- `CREATE TEMPORARY TABLE` / `DROP TEMPORARY TABLE` do not commit implicitly, so this check is free +-- of durable side effects. +-- +-- The duplicate key aborts the migration. It is only inserted while the record table is absent. +CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_manager_record_guard (blocked) +SELECT 1 FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = '__vw_custom_role_legacy_manager' +); +DROP TEMPORARY TABLE __vw_legacy_manager_record_guard; + +-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. +CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); +INSERT INTO __vw_permanent_authority_guard (blocked) +SELECT 1 +FROM users_organizations AS uo +WHERE uo.atype = 4 + AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + ) + AND NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = '__vw_ack_permanent_collection_authority' + ) +LIMIT 1; +DROP TEMPORARY TABLE __vw_permanent_authority_guard; + +DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; diff --git a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..3879f340 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..4096fc6d --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql b/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql new file mode 100644 index 00000000..04346743 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql b/migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql new file mode 100644 index 00000000..f4f6862e --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql b/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/down.sql new file mode 100644 index 00000000..c278fa8e --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql b/migrations/postgresql/2026-07-16-120000_add_custom_collection_permissions/up.sql new file mode 100644 index 00000000..aab5f0cd --- /dev/null +++ b/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 + ); diff --git a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql b/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql new file mode 100644 index 00000000..4188886b --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql b/migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql new file mode 100644 index 00000000..7b9ad6b3 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql b/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql new file mode 100644 index 00000000..a2035691 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql b/migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql new file mode 100644 index 00000000..e11fb611 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql new file mode 100644 index 00000000..c9d1c95e --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql b/migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql new file mode 100644 index 00000000..9d9c31ff --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql new file mode 100644 index 00000000..d5a54d49 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql b/migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql new file mode 100644 index 00000000..9079f661 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql new file mode 100644 index 00000000..613cc7e7 --- /dev/null +++ b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql @@ -0,0 +1,4 @@ +-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and +-- the original values are not recoverable. The permission columns themselves are removed further down +-- the chain by 2026-07-16-120000/down.sql, which is guarded. +SELECT 1; \ No newline at end of file diff --git a/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql new file mode 100644 index 00000000..821a5314 --- /dev/null +++ b/migrations/postgresql/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql @@ -0,0 +1,103 @@ +-- Follow-up repair for databases that already recorded 2026-07-23-120000. +-- +-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager +-- whose authority came from an organization-local `access_all` group, because the runtime derived the +-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three +-- collection permissions, member of such a group" is also the shape of every newly created flagless +-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the +-- authority into the permission columns. +-- +-- Rewriting that file is not enough on its own: a database whose ledger already carries +-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the +-- materialization here, in its own version, so both paths converge on the same state. +-- +-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written +-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, +-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom +-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless +-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. +-- +-- What this materialization *means* -- a group-bound capability becoming a permanent membership +-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. +-- +-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already +-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two +-- permission columns, so it is also safe after `access_all` has been dropped. +-- +-- Deliberately not `create_new_collections`: collection creation historically required +-- membership-level `access_all`. +DO $$ +DECLARE + undecidable int := 0; +BEGIN + -- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses + -- rather than creating it. + IF to_regclass('__vw_custom_role_legacy_manager') IS NULL THEN + RAISE EXCEPTION + 'Upgrade refused, nothing was changed: __vw_custom_role_legacy_manager does not exist, ' + 'so which memberships were legacy Managers before the upgrade is unknown. Start ' + 'Vaultwarden once to get the full recovery instructions, or see ' + 'tools/custom_role_rollback/README.md.'; + END IF; + + -- Fail closed on a database whose legacy provenance was never recorded. + -- + -- If a Custom member sits in an organization-local `access_all` group but is not on record as a + -- legacy Manager, one of two things is true and this file cannot tell them apart: either the + -- membership really is a converted legacy Manager whose record was never written (a ledger from + -- an earlier revision of this feature branch), or it is an ordinary modern Custom member who must + -- not gain anything. Granting is a silent privilege escalation; skipping silently drops a real + -- capability. + -- + -- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator + -- creates it after auditing an older history, so its presence means the unrecorded memberships + -- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup + -- preflight refuses that state before any migration runs; this is the backstop for a bare + -- migration runner. + -- + -- The marker never grants anything by itself: the update below is always driven by the record + -- table, so an unrecorded membership keeps exactly the permissions it has. + IF to_regclass('__vw_custom_role_history_verified') IS NULL THEN + SELECT count(*) INTO undecidable + FROM users_organizations uo + WHERE uo.atype = 4 + AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) + AND EXISTS ( + SELECT 1 + FROM groups_users gu + INNER JOIN "groups" g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + ); + END IF; + + IF undecidable <> 0 THEN + RAISE EXCEPTION + 'Upgrade refused, nothing was changed: % Custom membership(s) belong to an access_all ' + 'group but are not on record as legacy Managers, and this database''s Custom-role ' + 'history has never been audited, so a converted legacy Manager cannot be told from an ' + 'ordinary Custom member. Review them with: SELECT uo.uuid, uo.org_uuid, uo.status, ' + 'uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection FROM ' + 'users_organizations uo JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid ' + 'JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid ' + 'WHERE uo.atype = 4 AND g.access_all AND uo.uuid NOT IN (SELECT ' + 'users_organizations_uuid FROM __vw_custom_role_legacy_manager); Start Vaultwarden once ' + 'for the full recovery instructions.', + undecidable; + END IF; +END $$; + +UPDATE users_organizations +SET edit_any_collection = TRUE, + delete_any_collection = TRUE +WHERE atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ); diff --git a/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql new file mode 100644 index 00000000..6fcda697 --- /dev/null +++ b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/down.sql @@ -0,0 +1,4 @@ +-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The +-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next +-- upgrade has to ask again. +SELECT 1; diff --git a/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql new file mode 100644 index 00000000..929450c3 --- /dev/null +++ b/migrations/postgresql/2026-08-10-120000_confirm_permanent_collection_authority/up.sql @@ -0,0 +1,111 @@ +-- Make the one semantic change this feature cannot express an owner's decision instead of a default. +-- +-- Before the Custom role, a Manager who reached every collection through an organization-local group +-- with `access_all` held that authority *while* the group relationship lasted. It ended when the +-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert +-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a +-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. +-- +-- So the earlier migrations in this chain write the authority onto the membership, and the result is +-- deliberately not identical to what it replaces: +-- +-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; +-- * it applies even with the groups feature switched off; +-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every +-- collection of the organization directly rather than through the group. +-- +-- Materializing it silently would be a migration that grants durable organization-wide collection +-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is +-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and +-- revokes nothing itself. +-- +-- On a database with no Custom membership that both has edit/delete authority and belongs to an +-- organization-local `access_all` group, there is nothing to decide and this is a no-op. +-- +-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the +-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy +-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the +-- driver error, so on this path the question would arrive as a bare duplicate-key violation on +-- `__vw_permanent_authority_guard` and nothing else. Keep the two predicates identical. +-- +-- Review the affected memberships: +-- +-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, +-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, +-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) +-- AS was_legacy_manager +-- FROM users_organizations uo +-- WHERE uo.atype = 4 +-- AND (uo.edit_any_collection OR uo.delete_any_collection) +-- AND EXISTS ( +-- SELECT 1 FROM groups_users gu +-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid +-- WHERE gu.users_organizations_uuid = uo.uuid +-- AND g.organizations_uuid = uo.org_uuid +-- AND g.access_all); +-- +-- Reading the result: +-- +-- * `was_legacy_manager = t` -- a converted Manager. Review it even when +-- `create_new_collections = t`: that independent permission can be changed after an earlier +-- revision materialized group-derived edit/delete, so its current value cannot prove where those +-- two permissions came from. A membership whose own legacy `access_all` supplied all three may +-- therefore be listed conservatively even though its authority was already permanent. +-- * `was_legacy_manager = f` -- never a Manager. On a database first upgraded by revision bf54088c +-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can +-- distinguish from a deliberate grant any more -- check them against what you intended. +-- +-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- +-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked +-- value outright -- but the permission is what it would come back with if it is ever restored, and +-- by then the group it came from may be gone. Status is therefore not part of the predicate. +-- +-- Clear whatever you do not want to keep, for example: +-- +-- UPDATE users_organizations +-- SET edit_any_collection = FALSE, delete_any_collection = FALSE +-- WHERE uuid = ''; +-- +-- Then record the decision once, with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. +-- +-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the +-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why +-- this never creates it. +-- +-- The duplicate key aborts the migration. It is only inserted while the record table is absent. +CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_manager_record_guard (blocked) +SELECT 1 +WHERE to_regclass('__vw_custom_role_legacy_manager') IS NULL; +DROP TABLE __vw_legacy_manager_record_guard; + +-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. +CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); +INSERT INTO __vw_permanent_authority_guard (blocked) +SELECT 1 +FROM users_organizations AS uo +WHERE uo.atype = 4 + AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + ) + AND to_regclass('__vw_ack_permanent_collection_authority') IS NULL +LIMIT 1; +DROP TABLE __vw_permanent_authority_guard; + +DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; diff --git a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..a8faf67b --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..7087c25c --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql b/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql new file mode 100644 index 00000000..04346743 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql b/migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql new file mode 100644 index 00000000..53fd7671 --- /dev/null +++ b/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' +); diff --git a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql b/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql new file mode 100644 index 00000000..41ad950e --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql b/migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql new file mode 100644 index 00000000..a819bad8 --- /dev/null +++ b/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 + ); diff --git a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql b/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql new file mode 100644 index 00000000..4188886b --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql b/migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql new file mode 100644 index 00000000..81c8e1e5 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql b/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql new file mode 100644 index 00000000..a2035691 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql b/migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql new file mode 100644 index 00000000..3638bc7a --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql new file mode 100644 index 00000000..31101986 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql b/migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql new file mode 100644 index 00000000..9d9c31ff --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql new file mode 100644 index 00000000..4e8f080f --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql b/migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql new file mode 100644 index 00000000..9079f661 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql new file mode 100644 index 00000000..613cc7e7 --- /dev/null +++ b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql @@ -0,0 +1,4 @@ +-- Nothing to undo: this migration only re-applies permissions that 2026-07-23-120000 also sets, and +-- the original values are not recoverable. The permission columns themselves are removed further down +-- the chain by 2026-07-16-120000/down.sql, which is guarded. +SELECT 1; \ No newline at end of file diff --git a/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql new file mode 100644 index 00000000..43c39b16 --- /dev/null +++ b/migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql @@ -0,0 +1,107 @@ +-- Follow-up repair for databases that already recorded 2026-07-23-120000. +-- +-- That migration originally *removed* the direct 0/1/1 collection permissions of a legacy Manager +-- whose authority came from an organization-local `access_all` group, because the runtime derived the +-- authority from the group instead. Deriving it turned out to be unsound -- "Custom, none of the three +-- collection permissions, member of such a group" is also the shape of every newly created flagless +-- Custom member -- so the runtime fallback is gone and 2026-07-23-120000 now materializes the +-- authority into the permission columns. +-- +-- Rewriting that file is not enough on its own: a database whose ledger already carries +-- 20260723120000 never runs it again, and would silently lose the capability. Repeat the +-- materialization here, in its own version, so both paths converge on the same state. +-- +-- Unlike an earlier revision of this file, the repair is driven by the legacy-Manager record written +-- by 2026-06-30-120000 rather than by role and group membership alone. Those two are the same shape, +-- so matching on them blanket-granted organization-wide collection edit and delete to modern Custom +-- members -- turning Create-only into Create+Edit+Delete, Edit-only into Edit+Delete, and a flagless +-- Custom into Edit+Delete, the last of which also implies `has_full_access()`. +-- +-- What this materialization *means* -- a group-bound capability becoming a permanent membership +-- permission -- is confirmed by an owner in 2026-08-10-120000, which runs immediately after it. +-- +-- Idempotent: on a database that ran the rewritten 2026-07-23-120000 every affected row already +-- holds these values. It only reads `groups` / `groups_users` and the record table and writes the two +-- permission columns, so it is also safe after `access_all` has been dropped. +-- +-- Deliberately not `create_new_collections`: collection creation historically required +-- membership-level `access_all`. + +-- The legacy-Manager record has to exist already; see 2026-07-23-120000 for why this refuses rather +-- than creating it. +-- +-- The duplicate key aborts the migration. It is only inserted while the record table is absent. +CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_manager_record_guard (blocked) +SELECT 1 +WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' +); +DROP TABLE __vw_legacy_manager_record_guard; + +-- Fail closed on a database whose legacy provenance was never recorded. +-- +-- If a Custom member sits in an organization-local `access_all` group but is not on record as a +-- legacy Manager, one of two things is true and this file cannot tell them apart: either the +-- membership really is a converted legacy Manager whose record was never written (a ledger from an +-- earlier revision of this feature branch), or it is an ordinary modern Custom member who must not +-- gain anything. Granting is a silent privilege escalation; skipping silently drops a real +-- capability. +-- +-- `__vw_custom_role_history_verified` settles it: 2026-06-30-120000 creates it, and an operator +-- creates it after auditing an older history, so its presence means the unrecorded memberships below +-- are unrecorded *on purpose*. Its absence means nobody has looked, and this stops. The startup +-- preflight refuses that state before any migration runs; this guard is the backstop for a bare +-- migration runner. `src/db/mod.rs` prints the full recovery, which lists these memberships: +-- +-- SELECT uo.uuid, uo.org_uuid, uo.status, +-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection +-- FROM users_organizations uo +-- INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid +-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid +-- WHERE uo.atype = 4 AND g.access_all = 1 +-- AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager); +-- +-- The marker never grants anything by itself: the update below is always driven by the record table, +-- so an unrecorded membership keeps exactly the permissions it has. +CREATE TEMPORARY TABLE __vw_legacy_group_authority_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_group_authority_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_group_authority_guard (blocked) +SELECT 1 +FROM users_organizations AS uo +WHERE uo.atype = 4 + AND uo.uuid NOT IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + ) + AND NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_custom_role_history_verified' + ) +LIMIT 1; +DROP TABLE __vw_legacy_group_authority_guard; + +UPDATE users_organizations +SET edit_any_collection = TRUE, + delete_any_collection = TRUE +WHERE atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ); diff --git a/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql new file mode 100644 index 00000000..6fcda697 --- /dev/null +++ b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql @@ -0,0 +1,4 @@ +-- Nothing to undo: this migration only asks for a decision, it never writes permissions. The +-- acknowledgement it consumes is deliberately not recreated -- a revert is not consent, and the next +-- upgrade has to ask again. +SELECT 1; diff --git a/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql new file mode 100644 index 00000000..8c1a579b --- /dev/null +++ b/migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql @@ -0,0 +1,117 @@ +-- Make the one semantic change this feature cannot express an owner's decision instead of a default. +-- +-- Before the Custom role, a Manager who reached every collection through an organization-local group +-- with `access_all` held that authority *while* the group relationship lasted. It ended when the +-- group was deleted, when its `accessAll` was switched off, when the member left it, and it was inert +-- whenever `ORG_GROUPS_ENABLED` was false. Nothing in the new model expresses a permission bound to a +-- group like that: `edit_any_collection` and `delete_any_collection` live on the membership. +-- +-- So the earlier migrations in this chain write the authority onto the membership, and the result is +-- deliberately not identical to what it replaces: +-- +-- * it no longer lapses when the last qualifying group disappears, or when `accessAll` is cleared; +-- * it applies even with the groups feature switched off; +-- * `edit_any_collection` additionally satisfies `has_full_access()`, so the member reaches every +-- collection of the organization directly rather than through the group. +-- +-- Materializing it silently would be a migration that grants durable organization-wide collection +-- edit and delete on its own authority. Dropping it silently would take a capability away. Neither is +-- ours to choose, so this migration stops and hands the decision to an owner. It grants nothing and +-- revokes nothing itself. +-- +-- On a database with no Custom membership that both has edit/delete authority and belongs to an +-- organization-local `access_all` group, there is nothing to decide and this is a no-op. +-- +-- Vaultwarden's startup preflight looks ahead for exactly the condition below and refuses with the +-- full text (`RefuseUnconfirmedPermanentCollectionAuthority` in `src/db/mod.rs`), from the legacy +-- schema as well, so an operator normally never reaches the abort here. Diesel reports only the +-- driver error, so on this path the question would arrive as `UNIQUE constraint failed: +-- __vw_permanent_authority_guard.blocked` and nothing else. Keep the two predicates identical. +-- +-- Review the affected memberships: +-- +-- SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, +-- uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection, +-- (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)) +-- AS was_legacy_manager +-- FROM users_organizations uo +-- WHERE uo.atype = 4 +-- AND (uo.edit_any_collection = 1 OR uo.delete_any_collection = 1) +-- AND EXISTS ( +-- SELECT 1 FROM groups_users gu +-- INNER JOIN "groups" g ON g.uuid = gu.groups_uuid +-- WHERE gu.users_organizations_uuid = uo.uuid +-- AND g.organizations_uuid = uo.org_uuid +-- AND g.access_all = 1); +-- +-- Reading the result: +-- +-- * `was_legacy_manager = 1` -- a converted Manager. Review it even when +-- `create_new_collections = 1`: that independent permission can be changed after an earlier +-- revision materialized group-derived edit/delete, so its current value cannot prove where those +-- two permissions came from. A membership whose own legacy `access_all` supplied all three may +-- therefore be listed conservatively even though its authority was already permanent. +-- * `was_legacy_manager = 0` -- never a Manager. On a database first upgraded by revision bf54088c +-- they may carry permissions that revision's 2026-08-09-120000 granted in bulk, which nothing can +-- distinguish from a deliberate grant any more -- check them against what you intended. +-- +-- An invited or revoked membership is listed too, and deliberately so. It holds no authority today -- +-- every guard requires a confirmed membership, and `MembershipStatus::from_i32` rejects the revoked +-- value outright -- but the permission is what it would come back with if it is ever restored, and +-- by then the group it came from may be gone. Status is therefore not part of the predicate. +-- +-- Clear whatever you do not want to keep, for example: +-- +-- UPDATE users_organizations +-- SET edit_any_collection = 0, delete_any_collection = 0 +-- WHERE uuid = ''; +-- +-- Then record the decision once, with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- The acknowledgement is consumed at the end of this file, so one decision covers one upgrade. +-- +-- The legacy-Manager record has to exist already: the chain and supported rollback use it as the +-- immutable role-provenance record. Refuse a damaged history here too; see 2026-07-23-120000 for why +-- this never creates it. +-- +-- The duplicate key aborts the migration. It is only inserted while the record table is absent. +CREATE TEMPORARY TABLE __vw_legacy_manager_record_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_manager_record_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_manager_record_guard (blocked) +SELECT 1 +WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_custom_role_legacy_manager' +); +DROP TABLE __vw_legacy_manager_record_guard; + +-- The duplicate key aborts the migration. It is only inserted while an unconfirmed membership exists. +CREATE TEMPORARY TABLE __vw_permanent_authority_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_permanent_authority_guard (blocked) VALUES (1); +INSERT INTO __vw_permanent_authority_guard (blocked) +SELECT 1 +FROM users_organizations AS uo +WHERE uo.atype = 4 + AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) + AND EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + ) + AND NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_ack_permanent_collection_authority' + ) +LIMIT 1; +DROP TABLE __vw_permanent_authority_guard; + +DROP TABLE IF EXISTS __vw_ack_permanent_collection_authority; diff --git a/src/api/admin.rs b/src/api/admin.rs index 48f36afd..218cbdcc 100644 --- a/src/api/admin.rs +++ b/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 { + 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 = "")] async fn update_membership_type(data: Json, token: AdminToken, conn: DbConn) -> EmptyResult { let data: MembershipTypeData = data.into_inner(); @@ -553,9 +579,7 @@ async fn update_membership_type(data: Json, 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, 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) + ); + } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 2b51fd0c..785dea1b 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -392,6 +392,16 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } +fn has_prevalidated_organization_write_authority( + allow_direct_organization_write: bool, + shared_to_collections: Option<&Vec>, + member_has_full_access: bool, +) -> bool { + allow_direct_organization_write + || shared_to_collections.is_some_and(|collections| !collections.is_empty()) + || member_has_full_access +} + pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, @@ -400,6 +410,23 @@ pub async fn update_cipher_from_data( conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, +) -> EmptyResult { + update_cipher_from_data_with_authority(cipher, data, headers, shared_to_collections, false, conn, nt, ut).await +} + +#[expect( + clippy::too_many_arguments, + reason = "The extra flag is a prevalidated route authority and must remain separate from client data" +)] +pub(super) async fn update_cipher_from_data_with_authority( + cipher: &mut Cipher, + data: CipherData, + headers: &Headers, + shared_to_collections: Option>, + allow_direct_organization_write: bool, + conn: &DbConn, + nt: &Notify<'_>, + ut: UpdateType, ) -> EmptyResult { // Cleanup cipher data, like removing the 'Response' key. // This key is somewhere generated during Javascript so no way for us this fix this. @@ -452,9 +479,11 @@ pub async fn update_cipher_from_data( Some(member) => { // A non-empty list of collections implies the caller already validated the user's write // access to them, so we can move the cipher into the organization on that basis. - if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) - || member.has_full_access() - || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await + if has_prevalidated_organization_write_authority( + allow_direct_organization_write, + shared_to_collections.as_ref(), + member.has_full_access(), + ) || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -577,6 +606,25 @@ pub async fn update_cipher_from_data( Ok(()) } +#[cfg(test)] +mod update_authority_tests { + use super::has_prevalidated_organization_write_authority; + + #[test] + fn direct_organization_write_is_an_explicit_import_authority() { + // Keep the organization-import shortcut independent from the old non-empty-collection + // sentinel. The route may import ciphers without collections when AccessImportExport grants + // organization-wide import authority; every other caller passes false. + let no_collections: Vec = Vec::new(); + assert!(has_prevalidated_organization_write_authority(true, Some(&no_collections), false)); + assert!(!has_prevalidated_organization_write_authority(false, Some(&no_collections), false)); + + let collections = vec!["collection".to_owned().into()]; + assert!(has_prevalidated_organization_write_authority(false, Some(&collections), false)); + assert!(has_prevalidated_organization_write_authority(false, None, true)); + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 5518fa3c..3d8d4808 100644 --- a/src/api/core/events.rs +++ b/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, } +fn parse_event_date(date: &str, field: &str) -> Result { + 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//events?")] -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 = 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 { + 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//events?")] 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 = 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 = 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 = 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, } +#[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 { + 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>, 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>, 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>, 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()); + } +} diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..d4c6fa06 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -11,7 +11,11 @@ use crate::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, - auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, + auth::{ + AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, + ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, + ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite, + }, db::{ DbConn, models::{ @@ -47,6 +51,7 @@ pub fn routes() -> Vec { post_organization_collection_delete, bulk_delete_organization_collections, post_bulk_collections, + get_assigned_org_details, get_org_details, get_org_domain_sso_verified, get_members, @@ -214,7 +219,6 @@ async fn create_organization(headers: Headers, data: Json, conn: DbConn let collection = Collection::new(org.uuid.clone(), data.collection_name, None); member.akey = data.key; - member.access_all = true; member.atype = MembershipType::Owner as i32; member.status = MembershipStatus::Confirmed as i32; @@ -390,12 +394,28 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos err!("Organization not found", "Organization id's do not match"); } - if !headers.membership.has_full_access() { + // Custom users with a user/group manage permission need to read the collection list + // (metadata only) to be able to assign collections to groups/members. This does NOT + // expose cipher contents. manage_policies does not need the collection list. + let can_read_collection_list = may_read_complete_collection_list(&headers.membership); + let all_collections = Collection::find_by_organization(&org_id, &conn).await; + let collections = if can_read_collection_list { + all_collections + } else { + let mut explicitly_managed = Vec::new(); + for collection in all_collections { + if headers.membership.has_explicit_collection_manage_access(&collection.uuid, &conn).await { + explicitly_managed.push(collection); + } + } + explicitly_managed + }; + if !can_read_collection_list && collections.is_empty() { err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ - "data": get_org_collections_impl(&org_id, &conn).await, + "data": collections.iter().map(Collection::to_json).collect::(), "object": "list", "continuationToken": null, }))) @@ -422,6 +442,14 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea let has_full_access_to_org = member.has_full_access() || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); + // Custom users with a user/group manage permission need the full collection list + // (metadata only) so the web client can render member/group collection assignments + // without crashing on collections it can't otherwise see. This exposes names/ids + // only, never cipher contents. manage_policies does not need the collection list. + let can_read_collection_list = member.has_manage_users() + || member.has_manage_groups() + || member.has_delete_any_collection() + || member.has_create_new_collections(); // Get all admins, owners and managers who can manage/access all // Those are currently not listed in the col_users but need to be listed too. let manage_all_members: Vec = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn) @@ -445,41 +473,58 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea || (CONFIG.org_groups_enabled() && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); - // If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection - if !assigned { - continue; - } - - // get the users assigned directly to the given collection - let mut users: Vec = col_users - .iter() - .filter(|collection_member| collection_member.collection_uuid == col.uuid) - .map(|collection_member| { - collection_member.to_json_details_for_member( - *membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)), - ) - }) - .collect(); - users.extend_from_slice(&manage_all_members); - - // get the group details for the given collection - let groups: Vec = if CONFIG.org_groups_enabled() { - CollectionGroup::find_by_collection(&col.uuid, &conn) - .await - .iter() - .map(CollectionGroup::to_json_details_for_group) - .collect() - } else { - Vec::new() - }; + // ACL mappings require the same authority as the single-collection details endpoint. + // Mere read access (`assigned`, including group `access_all`) is not Manage authority. + match collection_details_response_scope( + can_read_collection_access(&member, &col.uuid, &conn).await, + assigned, + can_read_collection_list, + ) { + CollectionDetailsResponseScope::MetadataOnly => { + let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; + json_object["assigned"] = json!(assigned); + json_object["users"] = json!(Vec::::new()); + json_object["groups"] = json!(Vec::::new()); + json_object["object"] = json!("collectionAccessDetails"); + json_object["unmanaged"] = json!(false); + data.push(json_object); + } + CollectionDetailsResponseScope::Hidden => {} + CollectionDetailsResponseScope::AccessDetails => { + // get the users assigned directly to the given collection + let mut users: Vec = col_users + .iter() + .filter(|collection_member| collection_member.collection_uuid == col.uuid) + .map(|collection_member| { + collection_member.to_json_details_for_member( + *membership_type + .get(&collection_member.membership_uuid) + .unwrap_or(&(MembershipType::User as i32)), + ) + }) + .collect(); + users.extend_from_slice(&manage_all_members); + + // get the group details for the given collection + let groups: Vec = if CONFIG.org_groups_enabled() { + CollectionGroup::find_by_collection(&col.uuid, &conn) + .await + .iter() + .map(CollectionGroup::to_json_details_for_group) + .collect() + } else { + Vec::new() + }; - let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; - json_object["assigned"] = json!(assigned); - json_object["users"] = json!(users); - json_object["groups"] = json!(groups); - json_object["object"] = json!("collectionAccessDetails"); - json_object["unmanaged"] = json!(false); - data.push(json_object); + let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; + json_object["assigned"] = json!(assigned); + json_object["users"] = json!(users); + json_object["groups"] = json!(groups); + json_object["object"] = json!("collectionAccessDetails"); + json_object["unmanaged"] = json!(false); + data.push(json_object); + } + } } Ok(Json(json!({ @@ -489,8 +534,35 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea }))) } -async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value { - Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::() +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CollectionDetailsResponseScope { + AccessDetails, + MetadataOnly, + Hidden, +} + +fn may_read_complete_collection_list(member: &Membership) -> bool { + member.has_full_access() + || member.has_manage_users() + || member.has_manage_groups() + || member.has_delete_any_collection() + // Create new collections needs the list too: the client resolves the parent of a nested + // collection against it and refreshes it after a create. + || member.has_create_new_collections() +} + +fn collection_details_response_scope( + can_read_access_details: bool, + has_collection_read_access: bool, + can_read_collection_list: bool, +) -> CollectionDetailsResponseScope { + if can_read_access_details { + CollectionDetailsResponseScope::AccessDetails + } else if has_collection_read_access || can_read_collection_list { + CollectionDetailsResponseScope::MetadataOnly + } else { + CollectionDetailsResponseScope::Hidden + } } #[post("/organizations//collections", data = "")] @@ -503,31 +575,62 @@ async fn post_organization_collections( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + + // Create is independent from Edit/Delete. In particular, Edit any collection (full access to + // every collection) must not implicitly grant this endpoint. + if !headers.membership.can_create_new_collections() { + err!("You don't have permission to create collections") + } + let data: FullCollectionData = data.into_inner(); data.validate(&org_id, &conn).await?; - if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { - err!("You don't have permission to create collections") + // Security (audit H-3): validate every referenced group and user against this organization + // *before* creating the collection or any assignment, so a foreign-tenant group can't be + // attached to the new collection and no partial state is left behind on rejection. + for group in &data.groups { + if Group::find_by_uuid_and_org(&group.id, &org_id, &conn).await.is_none() { + err!("Group not found in this organization") + } + } + for user in &data.users { + if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() { + err!("User is not part of organization") + } } let collection = Collection::new(org_id.clone(), data.name, data.external_id); collection.save(&conn).await?; - log_event( - EventType::CollectionCreated as i32, - &collection.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; + // Security (F-3): a `manage` grant carries collection administration authority + // (`has_explicit_collection_manage_access` -> ManagerHeaders), so only a caller who may already + // administer this collection may confer it — the same rule the collection-update and bulk-access + // endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom member + // holding only `create_new_collections` must not be able to hand a manage row to another member + // or to a group while creating the collection. For such callers the requested `manage` is forced + // to false. The creator's own object-scoped ownership is added separately below. Evaluated after + // the collection exists so the per-collection lookup sees it. + let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await; + let creator_needs_assignment = !headers.membership.has_full_access(); + + // Persist the creator's object-scoped ownership before secondary assignments. If a later + // assignment write fails, the otherwise non-transactional create path still leaves the new + // collection recoverably manageable by its creator. An explicit self-assignment below is + // skipped so it cannot weaken this grant. + if creator_needs_assignment { + CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, true, &conn).await?; + } for group in data.groups { - CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage) - .save(&org_id, &conn) - .await?; + CollectionGroup::new( + collection.uuid.clone(), + group.id, + group.read_only, + group.hide_passwords, + group.manage && may_grant_manage, + ) + .save(&org_id, &conn) + .await?; } for user in data.users { @@ -535,7 +638,10 @@ async fn post_organization_collections( err!("User is not part of organization") }; - if member.access_all { + if member.grants_access_to_all_collections() { + continue; + } + if member.user_uuid == headers.membership.user_uuid && creator_needs_assignment { continue; } @@ -544,12 +650,25 @@ async fn post_organization_collections( &collection.uuid, user.read_only, user.hide_passwords, - user.manage, + user.manage && may_grant_manage, &conn, ) .await?; } + // Emit the success event only after all requested assignments and the creator's object-scoped + // manage grant have been persisted. A later write failure must not leave a false audit record. + log_event( + EventType::CollectionCreated as i32, + &collection.uuid, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) } @@ -577,22 +696,52 @@ async fn post_bulk_access_collections( err!("Can't find organization details") } - // The collections and members are checked below, the groups only here. + // Security (F-1): authorization is enforced per collection below via `auth::can_edit_collection`, + // the exact same Custom-aware check the single-collection edit endpoint (`ManagerHeaders`) uses. + // Edit any collection (or Admin/Owner) may bulk-edit every collection; any other Custom member + // must hold a real per-collection Manage grant. In particular a Custom member's group + // `access_all` does NOT satisfy this here (it did under the previous `is_manageable_by_user` + // check, which diverged from the single-edit endpoint). A custom user with only manage_users / + // manage_groups / manage_policies holds no such grant and is rejected, while a member who manages + // some collections keeps the ability to bulk-edit exactly those. + + // Security (audit H-3) and atomicity (audit M-2): validate the whole request against this + // organization *before* mutating anything. Every collection must exist in the org and be + // manageable by the caller, and every referenced group and user must belong to the org. Only + // once the entire request is known-valid do we begin the destructive delete/replace of + // assignments, so a foreign-tenant group can never be linked and a later invalid element can no + // longer leave earlier collections with their assignments already wiped. let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id)) } - - for col_id in data.collection_ids { - let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { + for user in &data.users { + if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() { + err!("User is not part of organization") + } + } + let mut collections = Vec::with_capacity(data.collection_ids.len()); + for col_id in &data.collection_ids { + let Some(collection) = Collection::find_by_uuid_and_org(col_id, &org_id, &conn).await else { err!("Collection not found") }; - if !collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await { + if !crate::auth::can_edit_collection(&headers.membership, &collection.uuid, &conn).await { err!("Collection not found", "The current user isn't a manager for this collection") } + collections.push(collection); + } + + for collection in collections { + let col_id = &collection.uuid; + + // Security (F-1): only a caller who could delete this collection may confer a `manage` grant + // on it. Otherwise the requested `manage` is forced to false, so a caller whose access comes + // from Edit-any-collection cannot escalate into deletion by self-assigning a manage row. + let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, col_id, &conn).await; + // update collection modification date collection.save(&conn).await?; @@ -607,25 +756,38 @@ async fn post_bulk_access_collections( ) .await; - CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?; + CollectionGroup::delete_all_by_collection(col_id, &org_id, &conn).await?; for group in &data.groups { - CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage) - .save(&org_id, &conn) - .await?; + CollectionGroup::new( + col_id.clone(), + group.id.clone(), + group.read_only, + group.hide_passwords, + group.manage && may_grant_manage, + ) + .save(&org_id, &conn) + .await?; } - CollectionUser::delete_all_by_collection(&col_id, &conn).await?; + CollectionUser::delete_all_by_collection(col_id, &conn).await?; for user in &data.users { let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else { err!("User is not part of organization") }; - if member.access_all { + if member.grants_access_to_all_collections() { continue; } - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) - .await?; + CollectionUser::save( + &member.user_uuid, + col_id, + user.read_only, + user.hide_passwords, + user.manage && may_grant_manage, + &conn, + ) + .await?; } } @@ -684,12 +846,26 @@ async fn post_organization_collection_update( ) .await; + // Security (F-1): only a caller who could delete this collection may confer a `manage` grant on + // it (a `manage` row carries delete authority). For everyone else the requested `manage` is + // forced to false, so Edit-any-collection can rewrite access but never escalate into deletion. + let may_grant_manage = match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(caller) => caller_may_grant_collection_manage(&caller, &col_id, &conn).await, + None => false, + }; + CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?; for group in data.groups { - CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage) - .save(&org_id, &conn) - .await?; + CollectionGroup::new( + col_id.clone(), + group.id, + group.read_only, + group.hide_passwords, + group.manage && may_grant_manage, + ) + .save(&org_id, &conn) + .await?; } CollectionUser::delete_all_by_collection(&col_id, &conn).await?; @@ -699,12 +875,19 @@ async fn post_organization_collection_update( err!("User is not part of organization") }; - if member.access_all { + if member.grants_access_to_all_collections() { continue; } - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) - .await?; + CollectionUser::save( + &member.user_uuid, + &col_id, + user.read_only, + user.hide_passwords, + user.manage && may_grant_manage, + &conn, + ) + .await?; } Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await)) @@ -713,7 +896,7 @@ async fn post_organization_collection_update( async fn delete_organization_collection_impl( org_id: &OrganizationId, col_id: &CollectionId, - headers: &ManagerHeaders, + headers: &CollectionDeleteHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -739,7 +922,7 @@ async fn delete_organization_collection_impl( async fn delete_organization_collection( org_id: OrganizationId, col_id: CollectionId, - headers: ManagerHeaders, + headers: CollectionDeleteHeaders, conn: DbConn, ) -> EmptyResult { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await @@ -749,7 +932,7 @@ async fn delete_organization_collection( async fn post_organization_collection_delete( org_id: OrganizationId, col_id: CollectionId, - headers: ManagerHeaders, + headers: CollectionDeleteHeaders, conn: DbConn, ) -> EmptyResult { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await @@ -775,7 +958,7 @@ async fn bulk_delete_organization_collections( let collections = data.ids; - let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; + let headers = CollectionDeleteHeaders::from_loose(headers, &collections, &conn).await?; for col_id in collections { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?; @@ -787,23 +970,19 @@ async fn bulk_delete_organization_collections( async fn get_org_collection_detail( org_id: OrganizationId, col_id: CollectionId, - headers: ManagerHeaders, + headers: CollectionReadHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - match Collection::find_by_uuid_and_user(&col_id, headers.user.uuid.clone(), &conn).await { + match Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await { None => err!("Collection not found"), Some(collection) => { if collection.org_uuid != org_id { err!("Collection is not owned by organization") } - let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { - err!("User is not part of organization") - }; - let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&collection.uuid, &conn) .await @@ -837,7 +1016,7 @@ async fn get_org_collection_detail( }) .collect(); - let assigned = Collection::can_access_collection(&member, &collection.uuid, &conn).await; + let assigned = Collection::can_access_collection(&headers.membership, &collection.uuid, &conn).await; let mut json_object = collection.to_json_details(&headers.user.uuid, None, &conn).await; json_object["assigned"] = json!(assigned); @@ -854,7 +1033,7 @@ async fn get_org_collection_detail( async fn get_collection_users( org_id: OrganizationId, col_id: CollectionId, - headers: ManagerHeaders, + headers: CollectionReadHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -884,18 +1063,84 @@ struct OrgIdData { organization_id: OrganizationId, } +fn filter_ciphers_for_organization(ciphers: Vec, org_id: &OrganizationId) -> Vec { + ciphers.into_iter().filter(|cipher| cipher.organization_uuid.as_ref() == Some(org_id)).collect() +} + +// The Admin Console uses this endpoint when the acting member is not allowed to read every +// cipher in the organization. In particular, a Custom member with only DeleteAnyCollection +// needs an empty successful response so the collection list can finish loading and expose its +// collection-only delete controls. +// +// Security: start from the regular user-visible cipher query and then constrain the result to +// the requested organization. DeleteAnyCollection itself must never make cipher contents visible. +#[get("/ciphers/organization-details/assigned?")] +async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { + if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { + err_code!( + "Resource not found.", + "User is not a confirmed member of the organization", + rocket::http::Status::NotFound.code + ); + } + + Ok(Json(json!({ + "data": assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, + "object": "list", + "continuationToken": null, + }))) +} + +// Serialize exactly the organization ciphers the user is actually assigned to, directly or via a +// group. `CipherSyncType::User` keeps the per-cipher access restrictions in place, so nothing outside +// the caller's own collections is returned and every cipher carries its real `edit`/`viewPassword` +// flags. +// +// NOTE: as everywhere else in Vaultwarden (and in Bitwarden), a `hidePasswords` assignment is +// reported to the client as `viewPassword: false` rather than redacted server-side. This therefore +// returns exactly what the same member already receives from `/api/sync` — never more. +async fn assigned_org_ciphers_json( + org_id: &OrganizationId, + host: &str, + user_id: &UserId, + conn: &DbConn, +) -> Result { + let ciphers = filter_ciphers_for_organization(Cipher::find_by_user_visible(user_id, conn).await, org_id); + let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::User, conn).await; + + let mut ciphers_json = Vec::with_capacity(ciphers.len()); + for cipher in ciphers { + ciphers_json.push(cipher.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::User, conn).await?); + } + + Ok(Value::Array(ciphers_json)) +} + +// The organization cipher list the clients use for the admin vault view and for computing every +// report (Exposed/Reused/Weak Passwords, Unsecured Websites, Inactive 2FA, ...) locally — Vaultwarden +// has no server-side reports. +// +// Bitwarden computes organization reports locally from this list. `accessReports` therefore grants +// the full organization cipher list, just like Admin/Owner or `editAnyCollection`; limiting it to the +// caller's assignments makes organization-wide reports silently incomplete. #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); } - if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); - } + let ciphers_json = if may_read_all_organization_ciphers(&headers.membership) { + get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? + } else { + err_code!( + "Resource not found.", + "User does not have permission to read the organization ciphers", + rocket::http::Status::NotFound.code + ); + }; Ok(Json(json!({ - "data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, + "data": ciphers_json, "object": "list", "continuationToken": null, }))) @@ -907,7 +1152,18 @@ async fn get_org_details_impl( user_id: &UserId, conn: &DbConn, ) -> Result { - let ciphers = Cipher::find_by_org(org_id, conn).await; + ciphers_to_org_json(Cipher::find_by_org(org_id, conn).await, host, user_id, conn).await +} + +// Serialize an already-authorized set of organization ciphers. The caller decides which ciphers go +// in: `CipherSyncType::Organization` skips the per-cipher access restrictions, so this must never be +// handed a cipher the user is not allowed to see. +async fn ciphers_to_org_json( + ciphers: Vec, + host: &str, + user_id: &UserId, + conn: &DbConn, +) -> Result { let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); @@ -947,17 +1203,17 @@ struct GetOrgUserData { async fn get_members( data: GetOrgUserData, org_id: OrganizationId, - headers: ManagerHeadersLoose, + // Security (audit M-1): the full member list exposes each member's PII, 2FA/enrollment status, + // permission flags and (optionally) collection/group assignments. Reading it requires the + // 'Manage Users' permission (or Admin/Owner), matching Bitwarden. Members who only need to + // reference other users (e.g. the collection dialog) use the member-readable mini-details. + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { - if org_id != headers.membership.org_uuid { + if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); - } - let mut users_json = Vec::new(); for u in Membership::find_by_org(&org_id, &conn).await { users_json.push( @@ -1010,6 +1266,112 @@ async fn post_org_keys( }))) } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +// This is intentionally a permission bitmap: every field represents an independent API grant. +#[allow(clippy::struct_excessive_bools)] +struct CustomRolePermissions { + 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, +} + +impl CustomRolePermissions { + fn from_request(member_type: MembershipType, permissions: &HashMap) -> Self { + if member_type != MembershipType::Custom { + return Self::default(); + } + + let enabled = |key: &str| matches!(permissions.get(key), Some(Value::Bool(true))); + Self { + manage_users: enabled("manageUsers"), + manage_groups: enabled("manageGroups"), + manage_policies: enabled("managePolicies"), + create_new_collections: enabled("createNewCollections"), + edit_any_collection: enabled("editAnyCollection"), + delete_any_collection: enabled("deleteAnyCollection"), + access_event_logs: enabled("accessEventLogs"), + access_import_export: enabled("accessImportExport"), + access_reports: enabled("accessReports"), + } + } + + /// Whether the requested role/permissions give this member access to *every* collection in the + /// org: Admins/Owners implicitly, and a Custom member holding Edit any collection. Such members + /// do not need (and must not be given) individual per-collection assignments. Create and Delete + /// remain completely independent of this. + fn grants_full_collection_access(self, member_type: MembershipType) -> bool { + member_type >= MembershipType::Admin || (member_type == MembershipType::Custom && self.edit_any_collection) + } + + /// Parse permissions for an existing member without treating an omitted permissions object as + /// an instruction to clear every Custom-role grant. Older clients send legacy role value `3` + /// without the modern object; that value is normalized to Custom for compatibility. + fn from_edit_request( + member_type: MembershipType, + permissions: Option<&HashMap>, + membership: &Membership, + ) -> Self { + match permissions { + Some(permissions) => Self::from_request(member_type, permissions), + None if member_type == MembershipType::Custom && membership.atype == MembershipType::Custom as i32 => { + Self { + manage_users: membership.manage_users, + manage_groups: membership.manage_groups, + manage_policies: membership.manage_policies, + create_new_collections: membership.create_new_collections, + edit_any_collection: membership.edit_any_collection, + delete_any_collection: membership.delete_any_collection, + access_event_logs: membership.access_event_logs, + access_import_export: membership.access_import_export, + access_reports: membership.access_reports, + } + } + None => Self::default(), + } + } + + fn differs_from(self, membership: &Membership) -> bool { + let stored = if membership.atype == MembershipType::Custom as i32 { + Self { + manage_users: membership.manage_users, + manage_groups: membership.manage_groups, + manage_policies: membership.manage_policies, + create_new_collections: membership.create_new_collections, + edit_any_collection: membership.edit_any_collection, + delete_any_collection: membership.delete_any_collection, + access_event_logs: membership.access_event_logs, + access_import_export: membership.access_import_export, + access_reports: membership.access_reports, + } + } else { + // Permission bits outside the Custom role are stale, inert data. Clearing them while an + // ordinary member is edited is not an authority change and must not make a + // ManageUsers-only caller fail the "may not change custom permissions" check. + Self::default() + }; + + self != stored + } + + fn apply_to(self, membership: &mut Membership) { + membership.manage_users = self.manage_users; + membership.manage_groups = self.manage_groups; + membership.manage_policies = self.manage_policies; + membership.create_new_collections = self.create_new_collections; + membership.edit_any_collection = self.edit_any_collection; + membership.delete_any_collection = self.delete_any_collection; + membership.access_event_logs = self.access_event_logs; + membership.access_import_export = self.access_import_export; + membership.access_reports = self.access_reports; + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InviteData { @@ -1043,7 +1405,7 @@ impl InviteData { async fn send_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -1052,32 +1414,56 @@ async fn send_invite( let data: InviteData = data.into_inner(); data.validate(&org_id, &conn).await?; - // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - // Membership::from_str will convert custom (4) to manager (3) - let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) { - new_type as i32 - } else { + let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; - if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can invite Managers, Admins or Owners") + if !may_provision_member_type(headers.membership_type, new_type) { + err!("You don't have permission to invite this role") } - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag - // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes - // If the box is not checked, the user will still be a manager, but not with the access_all permission - let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") - && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); + // manageAllCollections is a client-only aggregate. Persist its three children independently. + // Whether the member reaches every collection (Admin/Owner, or Custom + Edit any collection) + // decides whether we skip creating individual per-collection assignments below. + let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions); + let grants_full_access = custom_permissions.grants_full_collection_access(new_type); + + // Security: only callers who can manage collections (Admins/Owners, or users with full access) + // may assign collection access when inviting. A custom user with only manage_users can invite + // members, but cannot grant them collection access. Assigning groups is gated separately, + // because a collection-bearing group grants that access indirectly. + let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; + let caller_can_manage_collections = + headers.membership_type >= MembershipType::Admin || caller.as_ref().is_some_and(Membership::has_full_access); + let caller_can_manage_groups = + headers.membership_type >= MembershipType::Admin || caller.as_ref().is_some_and(Membership::has_manage_groups); + + // API consistency: these fields used to be dropped silently while the invite still reported + // success, so the caller believed access had been granted. Reject the request instead, and do it + // before the loop below creates any user, invitation or membership row. + if !grants_full_access && !caller_can_manage_collections && data.collections.iter().flatten().next().is_some() { + err!("You don't have permission to assign collections to invited members") + } + if !caller_can_manage_groups && !data.groups.is_empty() { + err!("You don't have permission to assign groups to invited members") + } + if !caller_can_manage_collections { + for group_id in &data.groups { + if group_confers_collection_access(group_id, &org_id, &conn).await { + err!("You don't have permission to assign a group that grants collection access") + } + } + } - let mut user_created: bool = false; for email in &data.emails { let mut member_status = MembershipStatus::Invited as i32; + // Scoped to this iteration on purpose. A single flag hoisted out of the loop stays `true` + // for every later recipient once any account has been created, so a failing invite mail to + // an address that already had an account would delete that *existing* global user -- their + // personal ciphers, devices, 2FA, emergency access and memberships in unrelated + // organizations -- instead of only the membership this request just made. + let mut user_created: bool = false; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -1115,8 +1501,8 @@ async fn send_invite( }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); - new_member.access_all = access_all; - new_member.atype = new_type; + new_member.atype = new_type as i32; + custom_permissions.apply_to(&mut new_member); new_member.status = member_status; new_member.save(&conn).await?; @@ -1158,18 +1544,27 @@ async fn send_invite( ) .await; - // If no accessAll, add the collections received - if !access_all { + // If the member does not already reach every collection, add the collections received + if !grants_full_access && caller_can_manage_collections { + // Security (F-1): a per-collection `manage` grant carries delete authority, so the + // caller may only confer it on collections they could delete themselves. Otherwise a + // caller acting via Edit-any-collection could invite an account they control with a + // `manage` row and reach Delete-any-collection through it. for col in data.collections.iter().flatten() { match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { None => err!("Collection not found in Organization"), Some(collection) => { + let manage = col.manage + && match &caller { + Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, + None => false, + }; CollectionUser::save( &user.uuid, &collection.uuid, col.read_only, col.hide_passwords, - col.manage, + manage, &conn, ) .await?; @@ -1178,12 +1573,14 @@ async fn send_invite( } } - for group_id in &data.groups { - if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { - err!("Group not found in Organization") + // NOTE: every requested group was already validated against this organization in + // `InviteData::validate`, and both the manage_groups permission and the collection-bearing + // group restriction were rejected up front, before any record was created. + if caller_can_manage_groups { + for group_id in &data.groups { + let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); + group_entry.save(&conn).await?; } - let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); - group_entry.save(&conn).await?; } } @@ -1194,7 +1591,7 @@ async fn send_invite( async fn bulk_reinvite_members( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -1204,7 +1601,7 @@ async fn bulk_reinvite_members( let mut bulk_response = Vec::new(); for member_id in data.ids { - let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { + let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; @@ -1229,25 +1626,29 @@ async fn bulk_reinvite_members( async fn reinvite_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await + reinvite_member_impl(&org_id, &member_id, &headers, &conn).await } async fn reinvite_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - invited_by_email: &str, + headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The user hasn't been invited to the organization.") }; + if !may_manage_stored_member_type(headers.membership_type, member.atype) { + err!("You don't have permission to reinvite this user") + } + if member.status != MembershipStatus::Invited as i32 { err!("The user is already accepted or confirmed to the organization") } @@ -1267,7 +1668,7 @@ async fn reinvite_member_impl( }; if CONFIG.mail_enabled() { - mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_owned())).await?; + mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(headers.user.email.clone())).await?; } else if user.password_hash.is_empty() { let invitation = Invitation::new(&user.email); invitation.save(conn).await?; @@ -1360,7 +1761,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1404,7 +1805,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1417,7 +1818,7 @@ async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1432,8 +1833,8 @@ async fn confirm_invite_impl( err!("The specified user isn't a member of the organization") }; - if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can confirm Managers, Admins or Owners") + if !may_provision_stored_member_type(headers.membership_type, member_to_confirm.atype) { + err!("You don't have permission to confirm this user") } if member_to_confirm.status != MembershipStatus::Accepted as i32 { @@ -1502,7 +1903,7 @@ async fn get_user( org_id: OrganizationId, member_id: MembershipId, data: GetOrgUserData, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -1524,8 +1925,7 @@ struct EditUserData { r#type: NumberOrString, collections: Option>, groups: Option>, - #[serde(default)] - permissions: HashMap, + permissions: Option>, } #[put("/organizations//users/", data = "", rank = 1)] @@ -1533,7 +1933,7 @@ async fn put_member( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { edit_member(org_id, member_id, data, headers, conn).await @@ -1544,7 +1944,7 @@ async fn edit_member( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -1552,27 +1952,19 @@ async fn edit_member( } let data: EditUserData = data.into_inner(); - // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - // MembershipType::from_str will convert custom (4) to manager (3) let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag - // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes - // If the box is not checked, the user will still be a manager, but not with the access_all permission - let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") - && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); - let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("The specified user isn't member of the organization") }; + let custom_permissions = + CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit); + let grants_full_access = custom_permissions.grants_full_collection_access(new_type); + if new_type != member_to_edit.atype && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) && headers.membership_type != MembershipType::Owner @@ -1580,10 +1972,39 @@ async fn edit_member( err!("Only Owners can grant and remove Admin or Owner privileges") } + // Security: only Admins and Owners may change a member's role type at all. A Custom member + // with manage_users must not change roles: raising a member to Custom can activate existing + // explicit collection-Manage assignments and other Custom-only authorization paths, while + // lowering it revokes them. Those authority changes are outside Manage Users even though + // granular permission changes are independently gated below. Requests that leave the role + // unchanged are allowed, so such members can still use the regular edit dialog. The + // Admin/Owner guard above still governs Admin/Owner transitions for Owners. + if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) { + err!("Only Admins or Owners can change a member's role") + } + if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only Owners can edit Owner users") } + // Security: apply the same actor/target role matrix as every other member endpoint (reinvite, + // confirm, revoke, restore, delete). Without it `edit_member` was the only path on which a + // Custom member holding manage_users could aim at an Admin or at a fellow Custom membership, as + // long as the request left the role unchanged. + // + // NOTE: this is a deliberate, documented narrowing of upstream. Bitwarden lets Custom+ManageUsers + // administer *peer Custom* members too, and delegate a subset of the permissions the actor holds + // itself (`OrganizationUserValidationService`). Implementing that would put permission delegation + // -- the one operation that can raise another member's authority -- into the hands of a + // non-Admin, and correctness would then rest on a subset comparison being right on every path. + // Vaultwarden keeps role and permission changes with Admins/Owners instead: strictly less + // authority than upstream grants, and the failure mode is a refused request rather than an + // escalation. Change this only together with tests for every actor/target/permission-subset + // combination. + if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) { + err!("You don't have permission to edit this member") + } + if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner && member_to_edit.status == MembershipStatus::Confirmed as i32 @@ -1594,45 +2015,186 @@ async fn edit_member( } } - member_to_edit.access_all = access_all; + // Security: only Admins and Owners may change the granular custom-role permissions. A Custom + // member with manage_users must not be able to grant them to themselves or others (a + // privilege escalation), nor strip flags an Admin/Owner has granted to fellow Custom members. + // Requests that leave the flags unchanged are allowed, so such members can still use the + // regular edit dialog. + if headers.membership_type < MembershipType::Admin && custom_permissions.differs_from(&member_to_edit) { + err!("Only Admins or Owners can change custom permissions") + } + + // Security: only callers who can actually manage collections (Admins/Owners, or users + // with full access) may change a member's collection assignments. A custom user with only + // manage_users must not be able to add/remove collection access, so we leave the existing + // assignments untouched for them. + // + // NOTE: another deliberate narrowing of upstream, which resolves ModifyUserAccess per collection + // and accepts a per-collection Manage grant on every affected collection. Requiring blanket + // authority here is coarser -- a ManageUsers member holding Manage on exactly the collections in + // the request is refused -- but it keeps a *stored* grant from being reachable as a lever for + // handing out access, which is the same boundary `caller_may_grant_collection_manage` draws. The + // group paths below (`post_groups`, `put_group_members`, `delete_group`) are narrowed for the same + // reason. Widening this needs the per-collection check to cover the members' *current* assignments + // as well as the requested ones, or removal becomes the hole. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + + // API consistency: dropping these fields silently while still answering 200 let client and + // server drift apart after an apparently saved change. Reject the request instead — but only + // when it would actually add or remove an assignment, because the regular edit dialog echoes the + // current assignments back and has to keep working. Flag-only differences (readOnly, + // hidePasswords, manage) remain ignored for these callers. + if !caller_can_manage_collections && !grants_full_access { + let requested: HashSet = data.collections.iter().flatten().map(|c| c.id.clone()).collect(); + let current: HashSet = + CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn) + .await + .into_iter() + .map(|c| c.collection_uuid) + .collect(); + if requested != current { + err!("You don't have permission to change this member's collection assignments") + } + } + + // Edit any collection (the successor of the removed access_all flag) grants full access to + // every collection. It is part of the granular custom permissions applied here, and the + // differs_from guard above already prevents a non-Admin caller from changing it — so a Custom + // member with only manage_users can never grant themselves or others full collection access. + custom_permissions.apply_to(&mut member_to_edit); member_to_edit.atype = new_type as i32; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; - // Delete all the odd collections - for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { - c.delete(&conn).await?; + // --------------------------------------------------------------------------------------------- + // Validation phase. Nothing below this point may be written until every id, tenant binding and + // caller right in the request has been checked. + // + // This endpoint replaces a member's collection assignments and their group memberships, and + // Vaultwarden has no database transactions, so an error raised *between* those two replaces used + // to leave the request half-applied: the member's collection access already changed, their groups + // still the old ones, no `OrganizationUserUpdated` event written, and a 4xx on the wire telling + // the client that nothing happened. A foreign group id -- exactly the case the tenant check below + // exists for -- was enough to trigger it. Resolving everything first cannot make the two replaces + // atomic against a database error, but it does mean a *rejected* request changes nothing. + // --------------------------------------------------------------------------------------------- + + // Security (F-1): a per-collection `manage` grant is durable administration authority, so the + // caller may only confer it where they already hold it themselves. A caller acting via + // Edit-any-collection thus cannot hand another member a manage grant it lacks. + let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; + + // Resolve the requested assignments: every collection has to exist in *this* organization, and + // the effective `manage` bit is decided here rather than while writing. + let mut collection_assignments: Vec<(CollectionId, bool, bool, bool)> = Vec::new(); + if caller_can_manage_collections && !grants_full_access { + for col in data.collections.iter().flatten() { + let Some(collection) = Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await else { + err!("Collection not found in Organization") + }; + let manage = col.manage + && match &caller { + Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, + None => false, + }; + collection_assignments.push((collection.uuid, col.read_only, col.hide_passwords, manage)); + } } - // If no accessAll, add the collections received - if !access_all { - for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { - None => err!("Collection not found in Organization"), - Some(collection) => { - CollectionUser::save( - &member_to_edit.user_uuid, - &collection.uuid, - col.read_only, - col.hide_passwords, - col.manage, - &conn, - ) - .await?; - } + // Security: changing a member's group membership can indirectly grant collection access + // (via the groups' collections). Only callers who may manage groups (Admins/Owners or users + // with manage_groups) are allowed to change it. For others we leave group membership untouched. + let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin + || match &caller { + Some(m) => m.has_manage_groups(), + None => false, + }; + + // API consistency, as for the collection assignments above: reject group changes this caller may + // not make instead of silently dropping them. + let requested_groups: HashSet = data.groups.iter().flatten().cloned().collect(); + let current_groups: HashSet = + GroupUser::find_by_member(&member_to_edit.uuid, &conn).await.into_iter().map(|gu| gu.groups_uuid).collect(); + if !caller_can_manage_groups && requested_groups != current_groups { + err!("You don't have permission to change this member's group assignments") + } + if caller_can_manage_groups && !caller_can_manage_collections { + let mut collection_bearing: HashSet = HashSet::new(); + for group_id in requested_groups.union(¤t_groups) { + if group_confers_collection_access(group_id, &org_id, &conn).await { + collection_bearing.insert(group_id.clone()); + } + } + if !collection_bearing_membership_unchanged(&requested_groups, ¤t_groups, &collection_bearing) { + err!("You don't have permission to change memberships in groups that grant collection access") + } + } + + // Security (audit H-2): every requested group has to belong to this organization. Otherwise a + // caller could link the member to a group of a foreign tenant (e.g. an access-all group), which + // the direct cipher-access checks would then honor. Fail closed on the whole request. + if caller_can_manage_groups { + for group_id in data.groups.iter().flatten() { + if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { + err!("Group not found in this organization") + } + } + } + + // Decide the group changes while still not writing. A caller who may manage groups but *not* + // collections may only touch memberships in groups that confer no collection access; the others + // are preserved untouched (neither granted nor revoked), mirroring put_group_members and + // add_update_group. + let mut groups_to_remove: Vec = Vec::new(); + let mut groups_to_add: Vec = Vec::new(); + if caller_can_manage_groups { + for group_id in ¤t_groups { + if caller_can_manage_collections + || may_change_group_membership( + caller_can_manage_collections, + group_confers_collection_access(group_id, &org_id, &conn).await, + ) + { + groups_to_remove.push(group_id.clone()); + } + } + for group_id in data.groups.iter().flatten() { + if caller_can_manage_collections + || may_change_group_membership( + caller_can_manage_collections, + group_confers_collection_access(group_id, &org_id, &conn).await, + ) + { + groups_to_add.push(group_id.clone()); } } } - GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; + // --------------------------------------------------------------------------------------------- + // Write phase. + // --------------------------------------------------------------------------------------------- - for group_id in data.groups.iter().flatten() { - if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { - err!("Group not found in Organization") + if caller_can_manage_collections { + for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { + c.delete(&conn).await?; } - let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); + for (collection_uuid, read_only, hide_passwords, manage) in collection_assignments { + CollectionUser::save(&member_to_edit.user_uuid, &collection_uuid, read_only, hide_passwords, manage, &conn) + .await?; + } + } + + for group_id in groups_to_remove { + GroupUser::delete_by_group_and_member(&group_id, &member_to_edit.uuid, &conn).await?; + } + for group_id in groups_to_add { + let mut group_entry = GroupUser::new(group_id, member_to_edit.uuid.clone()); group_entry.save(&conn).await?; } @@ -1654,7 +2216,7 @@ async fn edit_member( async fn bulk_delete_member( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1690,7 +2252,7 @@ async fn bulk_delete_member( async fn delete_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1700,7 +2262,7 @@ async fn delete_member( async fn delete_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1711,8 +2273,8 @@ async fn delete_member_impl( err!("User to delete isn't member of the organization") }; - if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can delete Admins or Owners") + if !may_provision_stored_member_type(headers.membership_type, member_to_delete.atype) { + err!("You don't have permission to delete this user") } if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32 @@ -1754,7 +2316,7 @@ async fn delete_member_impl( async fn bulk_public_keys( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -1791,7 +2353,7 @@ async fn bulk_public_keys( } use super::ciphers::CipherData; -use super::ciphers::update_cipher_from_data; +use super::ciphers::update_cipher_from_data_with_authority; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -1823,6 +2385,19 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + + // Bitwarden authorizes an organization import on `AccessImportExport` *or* the regular + // per-collection Create/ImportCiphers authority. Keep the latter path for ordinary members while + // treating the named Custom permission as the organization-wide import shortcut it represents. + // + // A confirmed membership is required though: both checks below are confirmed-gated, so an + // invited/accepted member could otherwise only import ciphers without any collection — which lands + // unreachable, unmanaged ciphers in the organization. + if !headers.membership.has_status(MembershipStatus::Confirmed) { + err!("You need to be a confirmed member of this organization to import into it") + } + let has_org_wide_import_access = may_import_without_collection_access(&headers.membership); + let data: ImportData = data.into_inner(); // Validate the import before continuing @@ -1831,27 +2406,63 @@ async fn post_org_import( // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; + // Robustness/DoS (audit M-3): validate every collection<->cipher relationship index against the + // import payload *before* creating any collection or cipher. `key` indexes into `ciphers` and + // `value` into `collections`; an out-of-range index would otherwise cause an out-of-bounds panic + // when the relations are applied below — a 500 (or a process abort under panic="abort") that + // happens after rows have already been written, leaving partial state behind. + let import_cipher_count = data.ciphers.len(); + let import_collection_count = data.collections.len(); + for relation in &data.collection_relationships { + if relation.key >= import_cipher_count || relation.value >= import_collection_count { + err!( + "Invalid collection relationship", + "A collection relationship references a non-existent cipher or collection" + ) + } + } + + // Security (audit F8/upstream): index the existing collections by id so the per-collection + // authorization below can use the *write* predicate `is_writable_by_user`. A read-only + // assignment must not let an importer plant ciphers into a shared collection. let existing_collections: HashMap = Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); - let mut collections: Vec = Vec::with_capacity(data.collections.len()); - for col in data.collections { - let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); - let collection_uuid = if let Some(collection) = existing { - // When not an Owner or Admin, check if the member is allowed to write to the collection. - if headers.membership.atype < MembershipType::Admin + + // Finish every request-controlled collection authorization check before the first new collection + // is written. This matters for the PR's create-only Custom role: a payload may name a new + // collection first and an existing, non-writable collection later. Rejecting the latter only in + // the write loop left the former behind even though the request failed. + for col in &data.collections { + if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) { + if !has_org_wide_import_access + && headers.membership.atype < MembershipType::Admin && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await { err!(Compact, "The current user isn't allowed to manage this collection") } + } else if !has_org_wide_import_access && !headers.membership.can_create_new_collections() { + err!(Compact, "The current user isn't allowed to create new collections") + } + } + + let mut collections: Vec = Vec::with_capacity(data.collections.len()); + for col in data.collections { + let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); + let collection_uuid = if let Some(collection) = existing { collection.uuid.clone() } else { - // We do not allow users or managers which can not manage all collections to create new collections - // If there is any collection other than an existing import collection, abort the import. - if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { - err!(Compact, "The current user isn't allowed to create new collections") - } + // Collection creation through an organization import is governed by the same + // independent permission as the regular create endpoint. In particular, + // Edit any collection (full access to every collection) must not satisfy this check. let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); new_collection.save(&conn).await?; + // Import-created collections do not carry the regular create endpoint's user access + // selections. Give a create-only importer Manage access to the collection they just + // created, matching Bitwarden's organization-import behavior. + if !headers.membership.has_full_access() { + CollectionUser::save(&headers.membership.user_uuid, &new_collection.uuid, false, false, true, &conn) + .await?; + } new_collection.uuid }; @@ -1874,11 +2485,12 @@ async fn post_org_import( // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); - update_cipher_from_data( + update_cipher_from_data_with_authority( &mut cipher, cipher_data, &headers, Some(collections.clone()), + has_org_wide_import_access, &conn, &nt, UpdateType::None, @@ -1888,7 +2500,8 @@ async fn post_org_import( ciphers.push(cipher.uuid); } - // Assign the collections + // Assign the collections. Indices were bounds-validated above, but use `.get()` here as well so + // any future drift fails closed with an error instead of panicking. for (cipher_index, col_index) in relations { let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else { err!(Compact, "Invalid collection relationship") @@ -1966,12 +2579,23 @@ async fn post_bulk_collections(data: Json, headers: Headers } #[get("/organizations//policies")] -async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { +async fn list_policies(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } - let policies = OrgPolicy::find_by_org(&org_id, &conn).await; - let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); + + // Security: only Admins/Owners, or Custom members holding the manage_policies permission, + // may see the actual policy configuration. Other Managers/Custom members (e.g. manage_users + // or manage_groups only) are still allowed to call this endpoint so the Admin Console can + // load, but they receive an empty list instead of the policy contents. + let can_view_policies = + headers.membership.atype >= MembershipType::Admin || headers.membership.has_manage_policies(); + + let policies_json: Vec = if can_view_policies { + OrgPolicy::find_by_org(&org_id, &conn).await.iter().map(OrgPolicy::to_json).collect() + } else { + Vec::new() + }; Ok(Json(json!({ "data": policies_json, @@ -2032,7 +2656,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH } #[get("/organizations//policies/", rank = 3)] -async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -2069,7 +2693,7 @@ async fn put_policy( org_id: OrganizationId, pol_type: i32, data: Json, - headers: AdminHeaders, + headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2189,7 +2813,7 @@ async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, data: Json, - headers: AdminHeaders, + headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { put_policy(org_id, pol_type, data, headers, conn).await @@ -2266,7 +2890,7 @@ struct BulkRevokeMembershipIds { async fn revoke_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { revoke_member_impl(&org_id, &member_id, &headers, &conn).await @@ -2276,7 +2900,7 @@ async fn revoke_member( async fn bulk_revoke_members( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2315,7 +2939,7 @@ async fn bulk_revoke_members( async fn revoke_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -2326,8 +2950,8 @@ async fn revoke_member_impl( if member.user_uuid == headers.user.uuid { err!("You cannot revoke yourself") } - if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { - err!("Only owners can revoke other owners") + if !may_manage_stored_member_type(headers.membership_type, member.atype) { + err!("You don't have permission to revoke this user") } if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 @@ -2359,7 +2983,7 @@ async fn revoke_member_impl( async fn restore_member_vnext( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { // Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. @@ -2371,7 +2995,7 @@ async fn restore_member_vnext( async fn restore_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { restore_member_impl(&org_id, &member_id, &headers, &conn).await @@ -2381,7 +3005,7 @@ async fn restore_member( async fn bulk_restore_members( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2415,7 +3039,7 @@ async fn bulk_restore_members( async fn restore_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -2426,8 +3050,8 @@ async fn restore_member_impl( if member.user_uuid == headers.user.uuid { err!("You cannot restore yourself") } - if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { - err!("Only owners can restore other owners") + if !may_manage_stored_member_type(headers.membership_type, member.atype) { + err!("You don't have permission to restore this user") } member.restore(); @@ -2453,27 +3077,23 @@ async fn restore_member_impl( Ok(()) } -async fn get_groups_data( - details: bool, - org_id: OrganizationId, - headers: ManagerHeadersLoose, - conn: DbConn, -) -> JsonResult { - if org_id != headers.membership.org_uuid { - err!("Organization not found", "Organization id's do not match"); - } - +async fn get_groups_data(details: bool, org_id: OrganizationId, membership: &Membership, conn: DbConn) -> JsonResult { // The details view (group→collection/user mappings) needs full org access; the plain list only // needs manage access to a collection, so a manager of a collection (directly or via a group) // can load it to assign groups. - let has_full_access = headers.membership.has_full_access() + // Custom roles: the 'Manage Users'/'Manage Groups' permissions are the authority for reading the + // group mappings (they are what the route guards enforce for the details view), so they satisfy + // this check as well even when the member reaches no collection of their own. + let has_full_access = membership.has_full_access() || (CONFIG.org_groups_enabled() - && GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await); + && GroupUser::has_full_access_by_member(&org_id, &membership.uuid, &conn).await); + let can_manage_users_or_groups = membership.has_manage_users() || membership.has_manage_groups(); let allowed = if details { - has_full_access + has_full_access || can_manage_users_or_groups } else { has_full_access - || Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await + || can_manage_users_or_groups + || Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await }; if !allowed { err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); @@ -2506,14 +3126,26 @@ async fn get_groups_data( }))) } +// The plain group list (id, name, externalId) exposes no access mappings, so it stays readable for +// members who have a reason to see it — the web vault needs it to render group names. The exact +// condition is enforced in `get_groups_data`. #[get("/organizations//groups")] async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - get_groups_data(false, org_id, headers, conn).await + if org_id != headers.membership.org_uuid { + err!("Organization not found", "Organization id's do not match"); + } + get_groups_data(false, org_id, &headers.membership, conn).await } +// Security (audit M-1): group *details* expose accessAll, external IDs and collection mappings, so +// reading them requires the 'Manage Users' or 'Manage Groups' permission (or Admin/Owner), matching +// Bitwarden's ReadAll/ReadAllWithAccess authorization. #[get("/organizations//groups/details", rank = 1)] -async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - get_groups_data(true, org_id, headers, conn).await +async fn get_groups_details(org_id: OrganizationId, headers: ManageUsersOrGroupsHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + get_groups_data(true, org_id, &headers.membership, conn).await } #[derive(Deserialize)] @@ -2579,7 +3211,7 @@ async fn post_group( org_id: OrganizationId, group_id: GroupId, data: Json, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { put_group(org_id, group_id, data, headers, conn).await @@ -2588,7 +3220,7 @@ async fn post_group( #[post("/organizations//groups", data = "")] async fn post_groups( org_id: OrganizationId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, data: Json, conn: DbConn, ) -> JsonResult { @@ -2602,6 +3234,31 @@ async fn post_groups( let group_request = data.into_inner(); group_request.validate(&org_id, &conn).await?; + // Security: only callers who can manage collections may assign collections to a new group. + // A custom user with only manage_groups can create the group, but without collection access. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + + // Security: `access_all` grants the group access to every collection, so it is a + // collection-access grant just like assigning collections. A custom user without + // collection-management rights must not be able to create an access_all group. + // + // API consistency: reject instead of silently creating a group without the requested access, so a + // caller never believes it granted something the server dropped. A request that grants nothing + // (no access_all, no collections) is still accepted, which is what the plain "new group" dialog + // sends for such a caller. + if !caller_can_manage_collections { + if group_request.access_all { + err!("You don't have permission to create a group with access to all collections") + } + if !group_request.collections.is_empty() { + err!("You don't have permission to assign collections to a group") + } + } + let group = group_request.to_group(&org_id); log_event( @@ -2615,7 +3272,22 @@ async fn post_groups( ) .await; - add_update_group(group, group_request.collections, group_request.users, org_id, &headers, &conn).await + let collections_to_apply = if caller_can_manage_collections { + group_request.collections + } else { + Vec::new() + }; + + add_update_group( + group, + collections_to_apply, + group_request.users, + org_id, + &headers, + &conn, + caller_can_manage_collections, + ) + .await } #[put("/organizations//groups/", data = "")] @@ -2623,7 +3295,7 @@ async fn put_group( org_id: OrganizationId, group_id: GroupId, data: Json, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2640,10 +3312,42 @@ async fn put_group( let group_request = data.into_inner(); group_request.validate(&org_id, &conn).await?; + // Security: only callers who can actually manage collections (Admins/Owners, or users with + // full access) may change a group's collection assignments. A custom user with only + // manage_groups must not be able to add/remove collection access. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + + // API consistency: reject a collection-access change this caller may not make instead of + // answering 200 and silently keeping the old value — the same rule `edit_member` and + // `send_invite` follow. Only an actual difference is rejected (the regular group dialog echoes + // the current assignments back and has to keep working), and per-assignment flag differences + // (readOnly, hidePasswords, manage) stay ignored, exactly as for a member's assignments. + if !caller_can_manage_collections { + if group_request.access_all != group.access_all { + err!("You don't have permission to change a group's access to all collections") + } + + let requested: HashSet = group_request.collections.iter().map(|c| c.id.clone()).collect(); + let current: HashSet = CollectionGroup::find_by_group(&group_id, &org_id, &conn) + .await + .into_iter() + .map(|cg| cg.collections_uuid) + .collect(); + if requested != current { + err!("You don't have permission to change this group's collection assignments") + } + } + let updated_group = group_request.update_group(group); - CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?; - GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; + if caller_can_manage_collections { + CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?; + } + // NOTE: group membership is replaced (and access-gated) inside add_update_group. log_event( EventType::GroupUpdated as i32, @@ -2656,7 +3360,187 @@ async fn put_group( ) .await; - add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &conn).await + // Only pass collection changes through if the caller is allowed to manage collections. + let collections_to_apply = if caller_can_manage_collections { + group_request.collections + } else { + Vec::new() + }; + add_update_group( + updated_group, + collections_to_apply, + group_request.users, + org_id, + &headers, + &conn, + caller_can_manage_collections, + ) + .await +} + +/// Whether a caller may change (add OR remove) a member's membership in a group. +/// +/// A caller who cannot manage collections must never touch a *collection-bearing* group's +/// membership: adding would indirectly grant collection access, removing would revoke it. +/// Callers who can manage collections may change any group's membership. This mirrors the +/// restriction already enforced inline in `put_group_members` and `add_update_group`. +fn may_change_group_membership(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool { + caller_can_manage_collections || !group_confers_collection_access +} + +/// Whether `requested` and `current` agree on every group that confers collection access. +/// +/// A caller who may manage groups but not collections may only change memberships in groups that +/// confer no collection access. Anything else has to be rejected with an error rather than skipped +/// silently, so a save that appears to succeed never means something different on the server. +fn collection_bearing_membership_unchanged( + requested: &HashSet, + current: &HashSet, + collection_bearing: &HashSet, +) -> bool { + let restrict = + |set: &HashSet| -> HashSet { set.intersection(collection_bearing).cloned().collect() }; + restrict(requested) == restrict(current) +} + +/// Whether a caller of `edit_member` may change a member's role type. +/// +/// Only Admins and Owners may change a member's role at all. A Custom member with `manage_users` +/// must not, because the role type changes organization-wide collection reach and which granular +/// permissions are effective. `manage_users` covers the user lifecycle, not the data plane, so +/// role changes are reserved for Admins/Owners. Leaving the role unchanged is always allowed so +/// `manage_users` members can still use the regular edit dialog. Admin/Owner transitions are +/// additionally governed by the dedicated Owner-only guard in `edit_member`. +fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool { + caller_type >= MembershipType::Admin || new_type == current_atype +} + +/// Whether a caller with user-management access may perform lifecycle actions on a target role. +/// +/// Owners may manage every role. Admins may manage Admin, Custom, and User memberships, but never +/// Owners. Custom members holding `manage_users` are limited to ordinary Users. +fn may_manage_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { + match caller_type { + MembershipType::Owner => true, + MembershipType::Admin => target_type != MembershipType::Owner, + MembershipType::Custom => target_type == MembershipType::User, + MembershipType::User => false, + } +} + +fn may_manage_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { + MembershipType::from_i32(target_atype).is_some_and(|target_type| may_manage_member_type(caller_type, target_type)) +} + +/// Whether a caller may *provision* a membership of `target_type` — create it (invite), activate it +/// (confirm) or remove it (delete). +/// +/// This is deliberately stricter than [`may_manage_member_type`] and preserves the pre-existing +/// Vaultwarden rule that only Owners bring Admin (or Owner) memberships into or out of existence +/// ("Only Owners can invite Managers, Admins or Owners" / "Only Owners can delete Admins or Owners"). +/// `edit_member` keeps that boundary too, via its dedicated Owner-only guard on Admin/Owner role +/// transitions, so an Admin must not be able to route around it by inviting a fresh Admin instead. +/// State changes that leave the membership in place (reinvite, revoke, restore, edit) keep using +/// [`may_manage_member_type`], which is what Vaultwarden allowed for them before this feature. +fn may_provision_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { + match caller_type { + MembershipType::Owner => true, + MembershipType::Admin => target_type < MembershipType::Admin, + MembershipType::Custom => target_type == MembershipType::User, + MembershipType::User => false, + } +} + +fn may_provision_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { + MembershipType::from_i32(target_atype) + .is_some_and(|target_type| may_provision_member_type(caller_type, target_type)) +} + +/// Returns true if being a member of `group_id` confers collection access — either because the +/// group has `access_all` set, or because it has collections assigned. +async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { + match Group::find_by_uuid_and_org(group_id, org_id, conn).await { + Some(group) => group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty(), + None => false, + } +} + +/// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` / +/// `collections_groups.manage`) on `col_id`. +/// +/// Security (F-1): a `manage` grant is per-collection administration authority — `ManagerHeaders` +/// accepts it via `has_explicit_collection_manage_access`, and it survives every later change to the +/// grantee's role. Without this gate a Custom member holding only `edit_any_collection` (which grants +/// full access to every collection, but is meant to be revocable by clearing one flag) could, through +/// the collection-access / group endpoints, hand a permanent `manage` row to a group they belong to +/// and keep that authority after the flag is gone. +/// +/// We therefore allow granting `manage` on a collection only to a caller who already holds blanket +/// collection authority or a real stored manage grant on that same collection: Admin/Owner and +/// Custom-with-`delete_any_collection` always qualify; any other Custom member must hold an explicit +/// manage grant. This is strictly subtractive — it can only ever downgrade a requested `manage` to +/// `false`, never grant it — so it opens no new access, and Admins/Owners are unaffected. +async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { + match caller_manage_grant_role_check(caller) { + // Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no). + Some(decision) => decision, + // Custom without delete_any: the answer is per-collection and must reflect a *real* stored + // manage grant. Edit any collection deliberately does not count here — it is revocable by + // clearing a flag, while a `manage` row written here outlives it. Accepting it would let + // temporary authority be laundered into a permanent grant, which is exactly the escalation + // this clamp exists to prevent. + None => match MembershipType::from_i32(caller.atype) { + Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await, + _ => false, + }, + } +} + +/// Whether a caller may import throughout the organization without proving Create/Write authority +/// for every target collection. This is the server-side meaning of Bitwarden's +/// `accessImportExport` Custom permission; Admins and Owners already have equivalent authority. +fn may_import_without_collection_access(caller: &Membership) -> bool { + caller.has_status(MembershipStatus::Confirmed) + && (caller.atype >= MembershipType::Admin || caller.has_access_import_export()) +} + +/// Organization reports are computed client-side and require every organization cipher. Match +/// Bitwarden's `AccessReports` semantics instead of silently producing assignment-scoped reports. +fn may_read_all_organization_ciphers(caller: &Membership) -> bool { + caller.has_full_access() || (caller.has_status(MembershipStatus::Confirmed) && caller.has_access_reports()) +} + +/// Whether `caller` may export the *entire* organization instead of only their own assignments. +/// +/// Security (audit F1): the `AccessImportExportHeaders` guard on `get_org_export` decides whether a +/// member may export at all; it must not decide *what* they get. Only members who already reach +/// every collection — Admins/Owners, and Custom members holding `edit_any_collection` — may receive +/// the full organization dump. For anyone else the export is built from their own assigned +/// collections/ciphers, so 'Access Import/Export' can never turn into a full vault read. +fn may_export_entire_organization(caller: &Membership) -> bool { + caller.has_full_access() +} + +/// Pure, collection-independent part of `caller_may_grant_collection_manage`. +/// +/// `Some(true)` -> the caller may grant `manage` on *any* collection (Admin/Owner, or a Custom +/// member holding `delete_any_collection`). +/// `Some(false)` -> the caller may never grant `manage` (unconfirmed, plain User, or unknown type). +/// `None` -> depends on a real per-collection manage grant, resolved against the database. +/// +/// Kept separate so the role gating — in particular that `edit_any_collection` alone yields `None` +/// (a DB check for a genuine grant) rather than `Some(true)` — is unit-testable without a DB. +fn caller_manage_grant_role_check(caller: &Membership) -> Option { + if caller.can_delete_any_collection() { + return Some(true); // Admin/Owner, or a Custom member holding delete_any_collection + } + if !caller.has_status(MembershipStatus::Confirmed) { + return Some(false); + } + match MembershipType::from_i32(caller.atype) { + Some(MembershipType::Custom) => None, + _ => Some(false), + } } async fn add_update_group( @@ -2664,30 +3548,74 @@ async fn add_update_group( collections: Vec, members: Vec, org_id: OrganizationId, - headers: &AdminHeaders, + headers: &ManageGroupsHeaders, conn: &DbConn, + caller_can_manage_collections: bool, ) -> JsonResult { + // Security: assigning members to a group that grants collection access (via `access_all` or + // assigned collections) would indirectly grant those members access to the collections' contents, + // and removing them would revoke it. Only callers who can manage collections may change the + // membership of such a group. + // + // API consistency: reject a membership change this caller may not make instead of answering 200 + // and keeping the old membership — same rule as `edit_member`/`send_invite`. Checked before the + // first write so a rejected request leaves nothing behind. On create the group is brand new, so + // it grants no collection access yet and this never triggers. + if !caller_can_manage_collections + && (group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty()) + { + let requested: HashSet<&MembershipId> = members.iter().collect(); + let current_members = GroupUser::find_by_group(&group.uuid, &org_id, conn).await; + let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); + if requested != current { + err!("You don't have permission to change the membership of a group that grants collection access") + } + } + group.save(conn).await?; + // Security (F-1): a `collections_groups.manage` grant carries collection delete authority, so a + // caller may only set it on a collection they could delete themselves. This stops a caller whose + // access derives from Edit-any-collection from creating a manage-bearing group and then joining + // it to reach Delete-any-collection. Fetched once; delete-capable callers keep `manage`. + let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await; for col_selection in collections { let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); + if collection_group.manage { + let may_grant_manage = match &caller { + Some(c) => caller_may_grant_collection_manage(c, &collection_group.collections_uuid, conn).await, + None => false, + }; + collection_group.manage = may_grant_manage; + } collection_group.save(&org_id, conn).await?; } - for assigned_member in members { - let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_member.clone()); - user_entry.save(conn).await?; + // Security: assigning members to a group that grants collection access (via `access_all` + // or assigned collections) would indirectly grant those members access to the collections' + // contents. Only callers who can manage collections may change the membership of such a + // group; for others we leave the group's membership untouched. + let group_grants_collection_access = + group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty(); - log_event( - EventType::OrganizationUserUpdatedGroups as i32, - &assigned_member, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - conn, - ) - .await; + if caller_can_manage_collections || !group_grants_collection_access { + GroupUser::delete_all_by_group(&group.uuid, &org_id, conn).await?; + + for assigned_member in members { + let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_member.clone()); + user_entry.save(conn).await?; + + log_event( + EventType::OrganizationUserUpdatedGroups as i32, + &assigned_member, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + conn, + ) + .await; + } } Ok(Json(json!({ @@ -2700,11 +3628,16 @@ async fn add_update_group( }))) } +// Reads a single group's details (accessAll, externalId, collection mappings). This is the same +// data the `/groups/details` list endpoint returns, so it uses the same guard: Manage Users OR +// Manage Groups (or Admin/Owner). Requiring Manage Groups here while the list only requires Manage +// Users-or-Groups would let a manage_users member read every group's details in bulk but be denied +// the single-group view of the same data. #[get("/organizations//groups//details")] async fn get_group_details( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageUsersOrGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2725,21 +3658,26 @@ async fn get_group_details( async fn post_delete_group( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await } #[delete("/organizations//groups/")] -async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { +async fn delete_group( + org_id: OrganizationId, + group_id: GroupId, + headers: ManageGroupsHeaders, + conn: DbConn, +) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await } async fn delete_group_impl( org_id: &OrganizationId, group_id: &GroupId, - headers: &AdminHeaders, + headers: &ManageGroupsHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -2753,6 +3691,23 @@ async fn delete_group_impl( err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; + // Security: deleting a group that grants collection access (via `access_all` or assigned + // collections) revokes that access for all its members. A custom user with only manage_groups + // must not be able to affect collection access, so only callers who can actually manage + // collections (Admins/Owners or users with full access) may delete such a group. Mirrors the + // restriction in put_group_members / post_delete_group_member. Also covers bulk_delete_groups, + // which funnels through this function. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, org_id, conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + if !caller_can_manage_collections + && (group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty()) + { + err!("You don't have permission to delete a group that grants collection access") + } + log_event( EventType::GroupDeleted as i32, &group.uuid, @@ -2771,7 +3726,7 @@ async fn delete_group_impl( async fn bulk_delete_groups( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -2790,7 +3745,12 @@ async fn bulk_delete_groups( } #[get("/organizations//groups/", rank = 2)] -async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_group( + org_id: OrganizationId, + group_id: GroupId, + headers: ManageGroupsHeaders, + conn: DbConn, +) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -2809,7 +3769,7 @@ async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHead async fn get_group_members( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2836,7 +3796,7 @@ async fn get_group_members( async fn put_group_members( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, data: Json>, conn: DbConn, ) -> EmptyResult { @@ -2847,8 +3807,24 @@ async fn put_group_members( err!("Group support is disabled"); } - if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { + let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") + }; + + // Security: changing the membership of a group that grants collection access (via + // `access_all` or assigned collections) indirectly grants those members access to the + // collections' contents. Only callers who can actually manage collections (Admins/Owners + // or users with full access) may do this. A custom user with only manage_groups may manage + // the membership of groups that grant no collection access, but not of collection-bearing ones. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + let group_grants_collection_access = + group.access_all || !CollectionGroup::find_by_group(&group_id, &org_id, &conn).await.is_empty(); + if !caller_can_manage_collections && group_grants_collection_access { + err!("You don't have permission to change the membership of a group that grants collection access") } let assigned_members = data.into_inner(); @@ -2884,7 +3860,7 @@ async fn post_delete_group_member( org_id: OrganizationId, group_id: GroupId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -2902,6 +3878,20 @@ async fn post_delete_group_member( err!("Group could not be found or does not belong to the organization."); } + // Security: removing a member from a group that grants collection access (via `access_all` + // or assigned collections) revokes that member's collection access. A custom user with only + // manage_groups must not be able to affect collection access, so only callers who can actually + // manage collections (Admins/Owners or users with full access) may do this. Mirrors the + // restriction enforced in put_group_members. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + if !caller_can_manage_collections && group_confers_collection_access(&group_id, &org_id, &conn).await { + err!("You don't have permission to change the membership of a group that grants collection access") + } + log_event( EventType::OrganizationUserUpdatedGroups as i32, &member_id, @@ -3180,18 +4170,34 @@ async fn put_reset_password_enrollment( // NOTE: It seems clients can't handle uppercase-first keys!! // We need to convert all keys so they have the first character to be a lowercase. // Else the export will be just an empty JSON file. -// We currently only support exports by members of the Admin or Owner status. -// Vaultwarden does not yet support exporting only managed collections! +// Members with full access to the organization (Admin/Owner, or a Custom member with +// 'Edit any collection') export the whole organization; everyone else exports only what they can +// actually reach, like Bitwarden's export controller does. // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 #[get("/organizations//export")] -async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } + // Security (audit F1): 'Access Import/Export' decides *whether* a member may export, it must not + // widen *what* they may read. Without this scoping a Custom member holding only this permission + // — assigned to no collection at all — would receive every cipher in the organization, because + // the organization sync type deliberately skips the per-cipher access restrictions. + let (collections, ciphers) = if may_export_entire_organization(&headers.membership) { + (Collection::find_by_organization(&org_id, &conn).await, Cipher::find_by_org(&org_id, &conn).await) + } else { + ( + Collection::find_by_organization_and_user_uuid(&org_id, &headers.user.uuid, &conn).await, + filter_ciphers_for_organization(Cipher::find_by_user_visible(&headers.user.uuid, &conn).await, &org_id), + ) + }; + + let collections_json: Value = collections.iter().map(Collection::to_json).collect(); + Ok(Json(json!({ - "collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await), - "ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?), + "collections": convert_json_key_lcase_first(collections_json), + "ciphers": convert_json_key_lcase_first(ciphers_to_org_json(ciphers, &headers.host, &headers.user.uuid, &conn).await?), }))) } @@ -3251,3 +4257,457 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use serde_json::{Value, json}; + + use super::{ + CollectionDetailsResponseScope, CustomRolePermissions, caller_manage_grant_role_check, + collection_bearing_membership_unchanged, collection_details_response_scope, filter_ciphers_for_organization, + may_change_group_membership, may_change_member_type, may_export_entire_organization, + may_import_without_collection_access, may_manage_member_type, may_manage_stored_member_type, + may_provision_member_type, may_provision_stored_member_type, may_read_all_organization_ciphers, + may_read_complete_collection_list, + }; + use crate::db::models::{Cipher, GroupId, Membership, MembershipStatus, MembershipType, OrganizationId}; + + fn confirmed_member(member_type: MembershipType) -> Membership { + let mut m = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); + m.atype = member_type as i32; + m.status = MembershipStatus::Confirmed as i32; + m + } + + #[test] + fn bulk_collection_details_only_include_acls_for_manage_authority() { + // Ordinary collection assignment, including group access_all, keeps the collection metadata + // visible but must never reveal user/group ACL mappings. + assert_eq!(collection_details_response_scope(false, false, false), CollectionDetailsResponseScope::Hidden); + assert_eq!(collection_details_response_scope(false, true, false), CollectionDetailsResponseScope::MetadataOnly); + assert_eq!(collection_details_response_scope(false, false, true), CollectionDetailsResponseScope::MetadataOnly); + + // Admin/Owner, Edit-any/Delete-any, and explicit per-collection Manage all arrive here as + // `can_read_access_details = true`, matching CollectionReadHeaders on the single endpoint. + assert_eq!( + collection_details_response_scope(true, false, false), + CollectionDetailsResponseScope::AccessDetails + ); + assert_eq!(collection_details_response_scope(true, true, true), CollectionDetailsResponseScope::AccessDetails); + } + + #[test] + fn flagless_custom_uses_only_its_explicit_manage_collections_in_the_list() { + // `false` selects the route's per-collection explicit-Manage filtering path. Permissions that + // need metadata for every collection select the complete list instead. + assert!(!may_read_complete_collection_list(&confirmed_member(MembershipType::Custom))); + + let mut manage_users = confirmed_member(MembershipType::Custom); + manage_users.manage_users = true; + assert!(may_read_complete_collection_list(&manage_users)); + + let mut create = confirmed_member(MembershipType::Custom); + create.create_new_collections = true; + assert!(may_read_complete_collection_list(&create)); + + assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Admin))); + assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Owner))); + } + + #[test] + fn only_delete_capable_callers_may_grant_collection_manage() { + // Admin/Owner may always confer a per-collection `manage` (delete) grant. + assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Owner)), Some(true)); + assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Admin)), Some(true)); + + // A Custom member with `delete_any_collection` may also always grant it. + let mut delete_any = confirmed_member(MembershipType::Custom); + delete_any.delete_any_collection = true; + assert_eq!(caller_manage_grant_role_check(&delete_any), Some(true)); + + // REGRESSION (F-1): a Custom member with ONLY `edit_any_collection` must NOT get a blanket + // yes. The role check returns None so the decision falls through to a real per-collection + // manage grant in the DB — which a self-assigned group/user manage row is prevented from + // manufacturing. This is what stops edit-any from escalating into delete-any. + let mut edit_any = confirmed_member(MembershipType::Custom); + edit_any.edit_any_collection = true; + assert_eq!(caller_manage_grant_role_check(&edit_any), None); + + // A flagless Custom member (this is what a migrated legacy Manager becomes) also defers to + // the per-collection DB check. + assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Custom)), None); + + // Plain User never qualifies. + assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::User)), Some(false)); + + // An unconfirmed caller never qualifies, even with delete_any set. + let mut unconfirmed = confirmed_member(MembershipType::Custom); + unconfirmed.status = MembershipStatus::Accepted as i32; + unconfirmed.delete_any_collection = true; + assert_eq!(caller_manage_grant_role_check(&unconfirmed), Some(false)); + } + + #[test] + fn access_import_export_alone_does_not_widen_the_export() { + // REGRESSION (audit F1): 'Access Import/Export' opens the export endpoint, but a Custom + // member holding only that permission reaches no collection of their own, so the export + // must be built from their assignments — never from the whole organization. + let mut import_export_only = confirmed_member(MembershipType::Custom); + import_export_only.access_import_export = true; + assert!(!may_export_entire_organization(&import_export_only)); + + // Custom members who already reach every collection keep the full dump. + let mut edit_any = confirmed_member(MembershipType::Custom); + edit_any.edit_any_collection = true; + edit_any.access_import_export = true; + assert!(may_export_entire_organization(&edit_any)); + + // Admins and Owners are unaffected. + assert!(may_export_entire_organization(&confirmed_member(MembershipType::Admin))); + assert!(may_export_entire_organization(&confirmed_member(MembershipType::Owner))); + + // An unconfirmed membership never qualifies, whatever its flags say. + let mut unconfirmed = confirmed_member(MembershipType::Custom); + unconfirmed.edit_any_collection = true; + unconfirmed.status = MembershipStatus::Accepted as i32; + assert!(!may_export_entire_organization(&unconfirmed)); + } + + #[test] + fn access_import_export_opens_the_organization_import() { + let mut import_export = confirmed_member(MembershipType::Custom); + import_export.access_import_export = true; + assert!(may_import_without_collection_access(&import_export)); + + assert!(!may_import_without_collection_access(&confirmed_member(MembershipType::Custom))); + assert!(!may_import_without_collection_access(&confirmed_member(MembershipType::User))); + assert!(may_import_without_collection_access(&confirmed_member(MembershipType::Admin))); + assert!(may_import_without_collection_access(&confirmed_member(MembershipType::Owner))); + + import_export.status = MembershipStatus::Accepted as i32; + assert!(!may_import_without_collection_access(&import_export)); + } + + #[test] + fn access_reports_grants_the_complete_report_input() { + let mut reports = confirmed_member(MembershipType::Custom); + reports.access_reports = true; + assert!(may_read_all_organization_ciphers(&reports)); + + assert!(!may_read_all_organization_ciphers(&confirmed_member(MembershipType::Custom))); + assert!(may_read_all_organization_ciphers(&confirmed_member(MembershipType::Admin))); + assert!(may_read_all_organization_ciphers(&confirmed_member(MembershipType::Owner))); + + reports.status = MembershipStatus::Accepted as i32; + assert!(!may_read_all_organization_ciphers(&reports)); + + let mut stale_user = confirmed_member(MembershipType::User); + stale_user.access_reports = true; + assert!(!may_read_all_organization_ciphers(&stale_user)); + } + + #[test] + fn assigned_cipher_response_is_scoped_to_requested_organization() { + let requested_org: OrganizationId = "requested-org".to_owned().into(); + let other_org: OrganizationId = "other-org".to_owned().into(); + + let mut requested_cipher = Cipher::new(1, "requested".to_owned()); + requested_cipher.organization_uuid = Some(requested_org.clone()); + let requested_cipher_id = requested_cipher.uuid.clone(); + + let mut other_cipher = Cipher::new(1, "other".to_owned()); + other_cipher.organization_uuid = Some(other_org); + + let personal_cipher = Cipher::new(1, "personal".to_owned()); + + let filtered = + filter_ciphers_for_organization(vec![other_cipher, personal_cipher, requested_cipher], &requested_org); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].uuid, requested_cipher_id); + assert_eq!(filtered[0].organization_uuid.as_ref(), Some(&requested_org)); + } + + #[test] + fn manage_users_caller_cannot_change_member_role() { + let user = MembershipType::User as i32; + let custom = MembershipType::Custom as i32; + + // Admins and Owners may change a member's role. + assert!(may_change_member_type(MembershipType::Owner, user, MembershipType::Custom)); + assert!(may_change_member_type(MembershipType::Admin, user, MembershipType::Custom)); + + // A below-Admin caller (Custom-with-manage_users) may only submit an unchanged role, so the + // regular edit dialog keeps working. + assert!(may_change_member_type(MembershipType::Custom, user, MembershipType::User)); + assert!(may_change_member_type(MembershipType::Custom, custom, MembershipType::Custom)); + + // REGRESSION (privilege escalation, PR #7397 / finding F1): a caller below Admin must NOT + // be able to change a member's role. Promoting User -> Custom can activate explicit + // collection-Manage assignments and Custom-only authorization paths; demoting revokes + // them. A manage_users caller is not entitled to either authority change. + assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Custom)); + assert!(!may_change_member_type(MembershipType::Custom, custom, MembershipType::User)); + } + + #[test] + fn member_lifecycle_permissions_follow_the_role_hierarchy() { + let roles = [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User]; + + for target in roles { + assert!(may_manage_member_type(MembershipType::Owner, target)); + } + + assert!(!may_manage_member_type(MembershipType::Admin, MembershipType::Owner)); + assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin)); + assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Custom)); + assert!(may_manage_member_type(MembershipType::Admin, MembershipType::User)); + + assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Owner)); + assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Admin)); + assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Custom)); + assert!(may_manage_member_type(MembershipType::Custom, MembershipType::User)); + + for target in roles { + assert!(!may_manage_member_type(MembershipType::User, target)); + } + + assert!(may_manage_stored_member_type(MembershipType::Admin, MembershipType::Custom as i32)); + assert!(!may_manage_stored_member_type(MembershipType::Owner, i32::MAX)); + + // edit_member applies the same matrix as reinvite/confirm/revoke/restore/delete, so a + // Custom caller cannot target an Admin or a fellow Custom member even when the requested + // role equals the stored one. + for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom] { + assert!(may_change_member_type(MembershipType::Custom, target as i32, target)); + assert!(!may_manage_stored_member_type(MembershipType::Custom, target as i32)); + } + assert!(may_manage_stored_member_type(MembershipType::Custom, MembershipType::User as i32)); + } + + #[test] + fn only_owners_provision_admin_memberships() { + // REGRESSION: bringing an Admin (or Owner) membership into or out of existence stays + // Owner-only, exactly as before this feature ("Only Owners can invite Managers, Admins or + // Owners" / "Only Owners can delete Admins or Owners"). Otherwise an Admin could route around + // the Owner-only role-change guard in `edit_member` by inviting a fresh Admin instead. + for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert!(may_provision_member_type(MembershipType::Owner, target)); + } + + assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Owner)); + assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Admin)); + assert!(may_provision_member_type(MembershipType::Admin, MembershipType::Custom)); + assert!(may_provision_member_type(MembershipType::Admin, MembershipType::User)); + + // A Custom member with manage_users stays limited to ordinary Users, as for every other + // lifecycle action. + assert!(may_provision_member_type(MembershipType::Custom, MembershipType::User)); + for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom] { + assert!(!may_provision_member_type(MembershipType::Custom, target)); + } + for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert!(!may_provision_member_type(MembershipType::User, target)); + } + + // Provisioning is strictly narrower than the state-change matrix: an Admin may still revoke, + // restore or edit a peer Admin (which Vaultwarden allowed before), but no longer create, + // confirm or delete one. + assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin)); + assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Admin)); + + assert!(may_provision_stored_member_type(MembershipType::Admin, MembershipType::User as i32)); + assert!(!may_provision_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); + // An unknown stored role never qualifies. + assert!(!may_provision_stored_member_type(MembershipType::Owner, i32::MAX)); + } + + #[test] + fn only_collection_bearing_group_changes_are_rejected() { + let plain: GroupId = "plain".to_owned().into(); + let bearing: GroupId = "bearing".to_owned().into(); + let collection_bearing = HashSet::from([bearing.clone()]); + + let set = |ids: &[&GroupId]| -> HashSet { ids.iter().map(|id| (*id).clone()).collect() }; + + // Adding, removing or keeping a group without collections is fine. + for (requested, current) in + [(set(&[&plain]), set(&[])), (set(&[]), set(&[&plain])), (set(&[&plain, &bearing]), set(&[&bearing]))] + { + assert!(collection_bearing_membership_unchanged(&requested, ¤t, &collection_bearing)); + } + + // Adding or removing a collection-bearing group is not. + for (requested, current) in [(set(&[&bearing]), set(&[])), (set(&[&plain]), set(&[&plain, &bearing]))] { + assert!(!collection_bearing_membership_unchanged(&requested, ¤t, &collection_bearing)); + } + } + + #[test] + fn manage_groups_caller_cannot_grant_collection_access_via_groups() { + // A caller who can manage collections may change membership of any group. + assert!(may_change_group_membership(true, true)); + assert!(may_change_group_membership(true, false)); + + // A caller who cannot manage collections may change membership of groups that confer no + // collection access (plain groups). + assert!(may_change_group_membership(false, false)); + + // REGRESSION (privilege escalation, PR #7397): a caller who cannot manage collections must + // NOT be able to change membership of a collection-bearing / access_all group. This is the + // vector that let a Custom user with manage_users + manage_groups add themselves to an + // access_all group and read all collection contents via edit_member / send_invite. Adding + // AND removing such memberships must be denied. + assert!(!may_change_group_membership(false, true)); + } + + #[test] + fn collection_permission_request_combinations_remain_independent() { + for mask in 0_u8..8 { + let create = mask & 0b001 != 0; + let edit = mask & 0b010 != 0; + let delete = mask & 0b100 != 0; + let permissions = HashMap::from([ + ("createNewCollections".to_owned(), json!(create)), + ("editAnyCollection".to_owned(), json!(edit)), + ("deleteAnyCollection".to_owned(), json!(delete)), + ]); + + let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions); + assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}"); + assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}"); + assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}"); + // Only Edit any collection maps to all-collection access. Create/Delete must never do so. + assert_eq!(parsed.grants_full_collection_access(MembershipType::Custom), edit, "mask={mask:03b}"); + } + } + + #[test] + fn custom_permission_parser_is_strict_and_non_custom_roles_are_fail_closed() { + let permissions = HashMap::from([ + ("manageUsers".to_owned(), Value::String("true".to_owned())), + ("manageGroups".to_owned(), json!(true)), + ("managePolicies".to_owned(), json!(true)), + ("createNewCollections".to_owned(), json!(true)), + ("editAnyCollection".to_owned(), json!(true)), + ("deleteAnyCollection".to_owned(), json!(true)), + ("accessEventLogs".to_owned(), json!(true)), + ("accessImportExport".to_owned(), json!(true)), + ("accessReports".to_owned(), json!(true)), + ]); + + let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions); + assert!(!custom.manage_users, "string values must not be accepted as booleans"); + assert!(custom.manage_groups); + assert!(custom.manage_policies); + assert!(custom.create_new_collections); + assert!(custom.edit_any_collection); + assert!(custom.delete_any_collection); + assert!(custom.access_event_logs); + assert!(custom.access_import_export); + assert!(custom.access_reports); + + let user = CustomRolePermissions::from_request(MembershipType::User, &permissions); + assert_eq!(user, CustomRolePermissions::default()); + assert!(!user.grants_full_collection_access(MembershipType::User)); + + let admin = CustomRolePermissions::from_request(MembershipType::Admin, &permissions); + assert_eq!(admin, CustomRolePermissions::default()); + assert!(admin.grants_full_collection_access(MembershipType::Admin)); + } + + #[test] + fn custom_permission_change_detection_covers_collection_flags() { + let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); + membership.atype = MembershipType::Custom as i32; + membership.status = MembershipStatus::Confirmed as i32; + + let requested = CustomRolePermissions { + create_new_collections: true, + edit_any_collection: true, + delete_any_collection: true, + access_event_logs: true, + access_import_export: true, + access_reports: true, + ..CustomRolePermissions::default() + }; + + assert!(requested.differs_from(&membership)); + requested.apply_to(&mut membership); + assert!(!requested.differs_from(&membership)); + assert!(membership.create_new_collections); + assert!(membership.edit_any_collection); + assert!(membership.delete_any_collection); + assert!(membership.access_event_logs); + assert!(membership.access_import_export); + assert!(membership.access_reports); + } + + #[test] + fn omitted_edit_permissions_preserve_supported_custom_grants() { + let mut membership = confirmed_member(MembershipType::Custom); + membership.manage_users = true; + membership.manage_groups = true; + membership.manage_policies = true; + membership.create_new_collections = true; + membership.edit_any_collection = true; + membership.delete_any_collection = true; + membership.access_event_logs = true; + membership.access_import_export = true; + membership.access_reports = true; + + let preserved = CustomRolePermissions::from_edit_request(MembershipType::Custom, None, &membership); + assert!(preserved.manage_users); + assert!(preserved.manage_groups); + assert!(preserved.manage_policies); + assert!(preserved.create_new_collections); + assert!(preserved.edit_any_collection); + assert!(preserved.delete_any_collection); + assert!(preserved.access_event_logs); + assert!(preserved.access_import_export); + assert!(preserved.access_reports); + assert!(!preserved.differs_from(&membership)); + + let explicit_reset = HashMap::new(); + assert_eq!( + CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&explicit_reset), &membership), + CustomRolePermissions::default() + ); + assert_eq!( + CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership), + CustomRolePermissions::default() + ); + } + + #[test] + fn stale_permission_bits_on_non_custom_members_are_not_authority_changes() { + let mut membership = confirmed_member(MembershipType::User); + membership.manage_users = true; + membership.manage_groups = true; + membership.manage_policies = true; + membership.create_new_collections = true; + membership.edit_any_collection = true; + membership.delete_any_collection = true; + membership.access_event_logs = true; + membership.access_import_export = true; + membership.access_reports = true; + + let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership); + assert_eq!(requested, CustomRolePermissions::default()); + assert!(!requested.differs_from(&membership)); + + // Applying the effective request opportunistically clears the inert historical data. + requested.apply_to(&mut membership); + assert!(!membership.manage_users); + assert!(!membership.manage_groups); + assert!(!membership.manage_policies); + assert!(!membership.create_new_collections); + assert!(!membership.edit_any_collection); + assert!(!membership.delete_any_collection); + assert!(!membership.access_event_logs); + assert!(!membership.access_import_export); + assert!(!membership.access_reports); + } +} diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 3db25df9..09e76846 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -125,7 +125,6 @@ async fn ldap_import(data: Json, 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; diff --git a/src/auth.rs b/src/auth.rs index 762088e5..9c9bd7cc 100644 --- a/src/auth.rs +++ b/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/"), @@ -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 { + 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//collections/"), // 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 { None } -/// The ManagerHeaders are used to check if you are at least a Manager -/// and have access to the specific collection provided via the /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 { + 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 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 { + 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 for Headers { + fn from(h: CollectionDeleteHeaders) -> Headers { + Headers { + host: h.host, + device: h.device, + user: h.user, + ip: h.ip, + } + } +} + impl From for Headers { fn from(h: ManagerHeaders) -> Headers { Headers { @@ -957,22 +1311,28 @@ impl From for Headers { } } -impl ManagerHeaders { +impl CollectionDeleteHeaders { pub async fn from_loose( h: ManagerHeadersLoose, collections: &Vec, conn: &DbConn, - ) -> Result { + ) -> Result { + // 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); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 2eae3f3c..8dfe10c9 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -468,6 +468,875 @@ impl<'r> FromRequest<'r> for DbConn { } } +const CUSTOM_ROLE_REPAIR_MIGRATION: &str = "20260723120000"; +const CUSTOM_COLLECTION_PERMISSIONS_MIGRATION: &str = "20260716120000"; +const DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION: &str = "20260724120000"; +const CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION: &str = "20260630120000"; +const CUSTOM_ACCESS_PERMISSIONS_MIGRATION: &str = "20260724130000"; +const CONFIRM_PERMANENT_AUTHORITY_MIGRATION: &str = "20260810120000"; +const CUSTOM_ROLE_SAME_RUN_MARKER_TABLE: &str = "__vw_custom_role_same_run_0716"; +/// Records which memberships were legacy Managers, written by +/// {`CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION`} before it reuses `atype = 3` for the Custom role. +/// +/// Its *presence* doubles as the marker that {`CUSTOM_ROLE_REPAIR_MIGRATION`} ran in its current +/// form. Both files were rewritten after an earlier revision of this feature branch shipped, and +/// Diesel never re-runs a migration whose version is already in the ledger -- so a database upgraded +/// by that earlier revision carries the repair migration's version without any of the effects the +/// current one has. +const CUSTOM_ROLE_LEGACY_MANAGER_TABLE: &str = "__vw_custom_role_legacy_manager"; +/// Marks that this database's Custom-role history is accounted for. +/// +/// Created by {`CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION`} in its current form -- so every database +/// migrated by the code that ships today has it -- or by an operator who has audited an older +/// history by hand. Nothing else creates it, which is what makes it usable as evidence. +/// +/// It is deliberately separate from {`CUSTOM_ROLE_LEGACY_MANAGER_TABLE`}. That one holds data an +/// operator may legitimately have to write after the fact, so its existence cannot also stand for +/// "the history behind this data was reviewed" -- creating it empty to make an error message go away +/// would otherwise silently pass as an audit. +const CUSTOM_ROLE_HISTORY_VERIFIED_TABLE: &str = "__vw_custom_role_history_verified"; +/// An owner's decision that the group-derived collection authority +/// {`CUSTOM_ROLE_REPAIR_MIGRATION`} materializes onto the membership may become permanent. +/// +/// Written by an operator, read and consumed by {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`}. The +/// preflight looks ahead for the same condition that migration checks, so the decision is asked for +/// with the full recovery text instead of surfacing as its bare duplicate-key abort. +const PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE: &str = "__vw_ack_permanent_collection_authority"; + +/// One of the three groups of granular permission columns, each added by its own migration. +/// +/// A partially present group means the migration was interrupted between its `ALTER TABLE` +/// statements. On MySQL/MariaDB that is reachable because DDL commits implicitly, so the ledger entry +/// can be missing while some columns already exist; re-running the migration then fails forever with +/// `Duplicate column name`. Detect it and hand the operator an unambiguous fix instead. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PermissionColumnGroup { + Manage, + Collection, + Access, +} + +impl PermissionColumnGroup { + const fn migration(self) -> &'static str { + match self { + Self::Manage => CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION, + Self::Collection => CUSTOM_COLLECTION_PERMISSIONS_MIGRATION, + Self::Access => CUSTOM_ACCESS_PERMISSIONS_MIGRATION, + } + } + + /// SQL list literal of the group's column names, for the `IN (...)` lookups. + const fn column_list(self) -> &'static str { + match self { + Self::Manage => "'manage_users', 'manage_groups', 'manage_policies'", + Self::Collection => "'create_new_collections', 'edit_any_collection', 'delete_any_collection'", + Self::Access => "'access_event_logs', 'access_import_export', 'access_reports'", + } + } + + const fn description(self) -> &'static str { + match self { + Self::Manage => "custom management-permission", + Self::Collection => "custom collection-permission", + Self::Access => "custom access-permission", + } + } + + /// Whether this group's migration derives its values from the legacy `access_all` column. + /// + /// Only the collection group does (`create_new_collections = access_all` and friends). That makes + /// it the one group whose migration can no longer be executed once `access_all` has been dropped by + /// {`DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION`}, so it must never be recommended for a replay + /// afterwards. The other two only add columns (and convert the retired Manager type), which stays + /// valid at any point in the chain. + const fn reads_legacy_access_all(self) -> bool { + matches!(self, Self::Collection) + } +} + +const PARTIAL_PERMISSION_COLUMNS_RECOVERY: &str = concat!( + "\n\nThis happens when a migration was interrupted between its ALTER TABLE statements (on ", + "MySQL/MariaDB every DDL statement commits on its own, so columns can exist without the ledger ", + "entry). Because the migration never completed, Vaultwarden never wrote to these columns: they ", + "only hold their FALSE default, so dropping them loses nothing and lets the migration run again ", + "from a clean state.\n\n", + "List the columns that are already present:\n", + "SELECT column_name\n", + "FROM information_schema.columns\n", + "WHERE table_name = 'users_organizations'\n", + " AND column_name IN ('manage_users', 'manage_groups', 'manage_policies',\n", + " 'create_new_collections', 'edit_any_collection', 'delete_any_collection',\n", + " 'access_event_logs', 'access_import_export', 'access_reports');\n\n", + "(On SQLite: SELECT name FROM pragma_table_info('users_organizations');)\n\n", + "Then, with every Vaultwarden instance stopped and a backup taken, drop exactly the columns of ", + "the affected group that the message above names, e.g.:\n", + "ALTER TABLE users_organizations DROP COLUMN ;\n\n", + "Afterwards restart Vaultwarden so the migration applies the whole group in one go." +); + +/// Deliberately *not* the same advice as [`PARTIAL_PERMISSION_COLUMNS_RECOVERY`]. +/// +/// Here the ledger entry is present, so the migration did complete once and Vaultwarden has been +/// running with those columns: the ones that are still there can hold real granted permissions. The +/// missing columns cannot have been lost by an interrupted migration -- something dropped them +/// afterwards -- so telling the operator to drop the remainder would destroy live authorization data. +/// It would not even recover the instance: with the ledger entry in place, the next start finds zero +/// columns for a recorded migration and refuses again. +const PERMISSION_LEDGER_MISMATCH_RECOVERY: &str = concat!( + "\n\nUnlike an interrupted migration, this state means the migration already completed once, so ", + "the columns that are still present can hold real permissions that members were granted. Do not ", + "drop them: that destroys authorization data, and it does not fix the refusal either, because the ", + "ledger entry stays behind.\n\n", + "Restoring the database backup taken before the columns went missing is the only lossless fix. ", + "Run the upgrade again against that restored copy.\n\n", + "If the lost permissions are genuinely expendable, the migration can be replayed from scratch ", + "instead. With every Vaultwarden instance stopped and a backup taken, drop the remaining columns ", + "of the affected group that the message above names AND remove its ledger entry, so the migration ", + "is pending again rather than recorded-but-missing:\n", + "ALTER TABLE users_organizations DROP COLUMN ;\n", + "DELETE FROM __diesel_schema_migrations WHERE version = '';\n\n", + "Every member of the affected organizations then has to be re-checked, because the permissions ", + "come back as FALSE." +); + +/// Recovery for a damaged collection-permission group *after* `access_all` has been dropped. +/// +/// Neither of the two texts above applies there. Both ultimately rely on the migration running again -- +/// by leaving it pending, or by deleting its ledger row -- but `2026-07-16-120000` computes its three +/// columns *from* `access_all`, which `2026-07-24-120000` has already removed. A replay therefore fails +/// with "no such column: access_all" on every start, and on MySQL/MariaDB it fails *after* its three +/// `ADD COLUMN`s have committed, leaving the database stuck in the very state that was being repaired. +/// The way out is to reach the completed shape without executing that SQL at all. +const COLLECTION_PERMISSIONS_AFTER_DROP_RECOVERY: &str = concat!( + "\n\nThis group cannot be migrated again on this database: migration ", + "2026-07-16-120000 derives its three columns from the membership access_all column, and ", + "2026-07-24-120000 has already dropped that column. Leaving the migration pending, or deleting its ", + "ledger entry so it runs again, therefore fails on every start -- and on MySQL/MariaDB it fails only ", + "after its own ALTER TABLE statements have committed.\n\n", + "Restoring the database backup taken before these columns went missing is the only lossless fix. ", + "Run the upgrade again against that restored copy.\n\n", + "If the lost permissions are expendable, bring the group to its completed shape by hand instead, ", + "with every Vaultwarden instance stopped and a backup taken. Add whichever of the three columns the ", + "message above reports as missing:\n", + "ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;\n", + "ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE;\n", + "ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE;\n\n", + "Then make sure the migration counts as done, so it is never executed:\n", + "INSERT INTO __diesel_schema_migrations (version) VALUES ('20260716120000');\n\n", + "(Skip that INSERT if the entry is already there -- the message above says whether it is.)\n\n", + "Every Custom member of every organization then has to be re-checked, because the three collection ", + "permissions come back as FALSE and nothing can reconstruct their previous values." +); + +const LEGACY_USER_ACCESS_ALL_RECOVERY: &str = concat!( + "\n\nList the affected memberships:\n", + "SELECT uuid, user_uuid, org_uuid, status\n", + "FROM users_organizations\n", + "WHERE atype = 2\n", + " AND access_all = TRUE;\n\n", + "The bit gave these members read/write reach over every collection of the organization, including ", + "collections created later, but no collection-management authority -- and it stopped applying as ", + "soon as the membership was revoked. The new role model has no equivalent, so an owner has to pick ", + "one of the two meanings per membership, with every Vaultwarden instance stopped and a backup ", + "taken.\n\n", + "The reach is no longer wanted -- this is also the right choice for an invited, accepted or revoked ", + "membership: clear the bit. The member keeps every collection they are explicitly assigned to.\n", + "UPDATE users_organizations\n", + "SET access_all = FALSE\n", + "WHERE uuid = '';\n\n", + "The reach has to survive: write it out as explicit assignments first, then clear the bit. Do this ", + "only for a confirmed membership, and only if a snapshot is acceptable -- collections created after ", + "this point are not added, and unlike access_all these rows are not tied to the membership status.\n", + "INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage)\n", + "SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE\n", + "FROM users_organizations uo\n", + "INNER JOIN collections c ON c.org_uuid = uo.org_uuid\n", + "WHERE uo.uuid = ''\n", + " AND NOT EXISTS (\n", + " SELECT 1 FROM users_collections uc\n", + " WHERE uc.user_uuid = uo.user_uuid AND uc.collection_uuid = c.uuid\n", + " );\n\n", + "Existing assignments are left untouched by that statement, so re-check their read_only / ", + "hide_passwords values: access_all used to override both.\n\n", + "If the member genuinely needs organization-wide reach afterwards, give them the Custom role with ", + "the 'Edit any collection' permission from the web vault once the upgrade has completed. That is ", + "the supported, visible and revocable equivalent." +); + +const INTERRUPTED_ACCESS_ALL_DROP_RECOVERY: &str = concat!( + "\n\nThe drop itself carries no data, so the schema is already in its intended final state and ", + "only the ledger entry is missing. Vaultwarden completes this automatically on MySQL/MariaDB, ", + "where it is reachable because DDL commits implicitly. On this backend DDL is transactional, so ", + "the state points at a manual schema change. With every Vaultwarden instance stopped and a ", + "backup taken, record the migration:\n", + "INSERT INTO __diesel_schema_migrations (version) VALUES ('20260724120000');\n\n", + "Afterwards restart Vaultwarden so the remaining migrations run." +); + +const ACCESS_ALL_DROP_MISMATCH_RECOVERY: &str = concat!( + "\n\nThis state cannot arise from a normal upgrade -- the column is removed before the migration ", + "is recorded. Verify whether the column was re-added manually. If it was, and its values are no ", + "longer needed, drop it again with every Vaultwarden instance stopped and a backup taken:\n", + "ALTER TABLE users_organizations DROP COLUMN access_all;\n\n", + "Otherwise restore the database backup taken before the upgrade and run the upgrade again." +); + +const OUT_OF_ORDER_ACCESS_PERMISSIONS_RECOVERY: &str = concat!( + "\n\nDo not run the pending migrations on this database. In particular, the SQLite ", + "2026-07-24-120000 migration rebuilds users_organizations from the schema that existed before ", + "the three access-permission columns were added. If 2026-07-24-130000 already ran, that rebuild ", + "would drop access_event_logs, access_import_export and access_reports -- including any granted ", + "values -- while Diesel would skip the already-recorded migration that adds them.\n\n", + "Restoring the database backup taken before the migrations were applied out of order and running ", + "the upgrade again is the lossless fix. If no such backup exists, keep every Vaultwarden instance ", + "stopped and have a database administrator preserve the three access-permission values while ", + "bringing the schema and migration ledger back to the documented version order. Do not delete the ", + "20260724130000 ledger entry or run 20260724120000 without first preserving those values." +); + +const UNVERIFIED_CUSTOM_ROLE_HISTORY_RECOVERY: &str = concat!( + "\n\nIf you still have the backup from before this database was first upgraded, restoring it and ", + "upgrading again is simplest and needs no decision at all. Otherwise work through the three points ", + "below with every Vaultwarden instance stopped and a backup taken. Which of them apply depends on ", + "how far the earlier revision got, which its ledger entries tell you:\n", + "SELECT version FROM __diesel_schema_migrations WHERE version >= '20260630120000' ORDER BY version;\n\n", + "1) Which memberships were legacy Managers -- always. The upgrade reuses atype 3 for the Custom ", + "role, so after it has run a converted Manager and a Custom member created later are identical. ", + "Without this record the remaining migrations cannot repair legacy authority, and the rollback ", + "scripts in tools/custom_role_rollback/ cannot map roles back. Create the table and record every ", + "membership that held the Manager role before the first upgrade:\n", + "CREATE TABLE __vw_custom_role_legacy_manager (users_organizations_uuid TEXT NOT NULL PRIMARY KEY);\n", + "INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) VALUES ('');\n", + "Leaving it empty is a valid answer and means \"no membership was a legacy Manager\".\n\n", + "2) Permissions granted by an earlier 20260809120000 -- if that version is in your ledger. It set ", + "edit_any_collection and delete_any_collection on every Custom member of a group with access_all, ", + "including members that were never Managers, so Create-only became Create+Edit+Delete and a member ", + "with no permissions became Edit+Delete -- which also implies full collection access. Nothing can ", + "tell those apart from deliberate grants any more, so review them and clear what you did not ", + "intend:\n", + "SELECT uo.uuid, uo.org_uuid, uo.status, uo.create_new_collections, uo.edit_any_collection,\n", + " uo.delete_any_collection\n", + "FROM users_organizations uo\n", + "INNER JOIN groups_users gu ON gu.users_organizations_uuid = uo.uuid\n", + "INNER JOIN groups g ON g.uuid = gu.groups_uuid AND g.organizations_uuid = uo.org_uuid\n", + "WHERE uo.atype = 4 AND g.access_all = TRUE;\n\n", + "3) A plain User carrying membership access_all -- if 20260723120000 is in your ledger. The earlier ", + "revision converted that state into direct assignments to the collections that existed at the time ", + "and then dropped the column; the current one refuses it instead, because the reach also covered ", + "collections created later. Those assignments are indistinguishable from ordinary ones now:\n", + "SELECT uc.user_uuid, uc.collection_uuid, uc.read_only, uc.hide_passwords, uc.manage\n", + "FROM users_collections uc\n", + "INNER JOIN users_organizations uo ON uo.user_uuid = uc.user_uuid\n", + "INNER JOIN collections c ON c.uuid = uc.collection_uuid AND c.org_uuid = uo.org_uuid\n", + "WHERE uo.atype = 2;\n\n", + "Then record that the history was audited. This is a separate statement on purpose: creating the ", + "table in point 1 writes data, and data alone must not pass as a review of where it came from.\n", + "CREATE TABLE __vw_custom_role_history_verified (verified INTEGER NOT NULL PRIMARY KEY);\n\n", + "Use CHAR(36) instead of TEXT for the uuid column on MySQL/MariaDB and PostgreSQL." +); + +/// The one question this feature has to ask, phrased before the upgrade rather than during it. +/// +/// {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`} refuses the same condition from inside the migration, as +/// the backstop for a bare migration runner. On the normal startup path that abort would reach the +/// operator as nothing but `UNIQUE constraint failed: __vw_permanent_authority_guard.blocked` (or +/// `Duplicate entry '1' for key 'PRIMARY'` on MariaDB), because Diesel only reports the driver error +/// -- so the decision, the review query and the acknowledgement all have to be printed from here. +const PERMANENT_COLLECTION_AUTHORITY_RECOVERY: &str = concat!( + "\n\nBefore the Custom role, a Manager who reached every collection through an organization-local ", + "group with access_all held that authority *while* the group relationship lasted: it ended with ", + "the group, with its accessAll, and with the membership leaving it, and it was inert whenever ", + "ORG_GROUPS_ENABLED was false. The new model has no permission that is bound to a group like ", + "that -- edit_any_collection and delete_any_collection live on the membership -- so migration ", + "20260723120000 writes the authority onto the membership, and the result is deliberately not ", + "identical to what it replaces:\n", + " * it no longer lapses when the last qualifying group disappears, or when accessAll is ", + "cleared;\n", + " * it applies even with the groups feature switched off;\n", + " * edit_any_collection additionally satisfies has_full_access(), so the member reaches every ", + "collection of the organization directly rather than through the group.\n\n", + "Granting that silently would be a migration handing out durable organization-wide collection ", + "edit and delete on its own authority; skipping it silently would take a capability away. ", + "Neither is Vaultwarden's to choose, so an owner decides. Review the affected memberships with ", + "every Vaultwarden instance stopped and a backup taken.\n\n", + "Before migration 20260630120000 has run (legacy Manager is still atype 3):\n", + "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status\n", + "FROM users_organizations uo\n", + "WHERE uo.atype = 3\n", + " AND EXISTS (\n", + " SELECT 1 FROM groups_users gu\n", + " INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid\n", + " AND g.organizations_uuid = uo.org_uuid\n", + " WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE);\n\n", + "If 20260630120000 is already in the migration ledger but the three collection-permission ", + "columns do not exist yet, use this query instead. It includes recorded converted Managers and ", + "an unrecorded Custom membership whose own access_all bit 20260716120000 will turn into all ", + "three permissions:\n", + "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, uo.access_all,\n", + " (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))\n", + " AS was_legacy_manager\n", + "FROM users_organizations uo\n", + "WHERE (uo.atype = 3 OR (uo.atype = 4 AND (\n", + " uo.access_all = TRUE OR uo.uuid IN (\n", + " SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))))\n", + " AND EXISTS (\n", + " SELECT 1 FROM groups_users gu\n", + " INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid\n", + " AND g.organizations_uuid = uo.org_uuid\n", + " WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE);\n\n", + "After the permission columns exist:\n", + "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status,\n", + " uo.create_new_collections, uo.edit_any_collection, uo.delete_any_collection,\n", + " (uo.uuid IN (SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager))\n", + " AS was_legacy_manager\n", + "FROM users_organizations uo\n", + "WHERE uo.atype = 4\n", + " AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)\n", + " AND EXISTS (\n", + " SELECT 1 FROM groups_users gu\n", + " INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid\n", + " AND g.organizations_uuid = uo.org_uuid\n", + " WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE);\n\n", + "(Quote `groups` with backticks instead of double quotes on MySQL/MariaDB, here and below.)\n\n", + "Reading the result:\n", + " * was_legacy_manager = 1 -- a converted Manager. Review it even when create_new_collections is ", + "set. That permission can be changed independently after an earlier migration materialized the ", + "group-derived edit/delete grant, so its current value is not reliable historical provenance. A ", + "membership whose own legacy access_all supplied all three permissions may therefore be listed ", + "conservatively even though its authority was already permanent.\n", + " * was_legacy_manager = 0 -- never a Manager. Before the collection columns exist, its own ", + "membership access_all will become all three permissions in 20260716120000. After the columns ", + "exist on a database first upgraded by an earlier revision of this feature branch, ", + "20260809120000 may instead have granted edit_any_collection and delete_any_collection in bulk ", + "to every Custom member of an access_all group. Check either result against what you intended.\n", + " * An invited or revoked membership is listed as well. It holds no authority today -- every ", + "guard requires a confirmed membership -- but the permission is what it would come back with if ", + "it is ever restored, so the decision belongs here too.\n\n", + "Clearing what you do not want to keep differs according to whether the collection-permission ", + "columns exist yet.\n\n", + "Before those columns exist, the authority being reviewed is still tied to the qualifying group ", + "relationship, so end that -- for ", + "the one membership, or for the whole group at once:\n", + "DELETE FROM groups_users\n", + "WHERE users_organizations_uuid = ''\n", + " AND groups_uuid = '';\n", + "UPDATE \"groups\" SET access_all = FALSE WHERE uuid = '';\n", + "Whatever still matches the applicable pre-column query afterwards is what the acknowledgement ", + "below covers. ", + "Removing the membership from the group also takes away the access it has today, which clearing ", + "the permission columns after the upgrade would not -- that is the same decision either way, just ", + "made before rather than after.\n\n", + "Once the permission columns exist, clear them directly. Doing it after the upgrade is equally ", + "safe: Vaultwarden does not start until the acknowledgement is recorded, so nothing is ever live ", + "in between.\n", + "UPDATE users_organizations\n", + "SET edit_any_collection = FALSE, delete_any_collection = FALSE\n", + "WHERE uuid = '';\n\n", + "Then record the decision once, and restart:\n", + "CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY);\n\n", + "The acknowledgement is consumed by 20260810120000, so one decision covers one upgrade. It grants ", + "nothing and revokes nothing by itself -- whatever you leave set is what the members keep." +); + +const ALREADY_DROPPED_RECOVERY: &str = concat!( + "\n\nThe permission values cannot be recomputed from the current schema. Restore the database backup taken ", + "before the upgrade and run the upgrade again against that restored copy." +); + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[expect( + clippy::struct_excessive_bools, + reason = "These are independent facts read from a historical database schema and migration ledger" +)] +struct CustomRoleMigrationFacts { + memberships_table_exists: bool, + migration_table_exists: bool, + access_all_column_exists: bool, + manage_permission_columns: i64, + manage_permissions_migration_applied: bool, + collection_permission_columns: i64, + collection_permissions_migration_applied: bool, + access_permission_columns: i64, + access_permissions_migration_applied: bool, + repair_migration_applied: bool, + access_all_drop_migration_applied: bool, + legacy_user_access_all_count: i64, + same_run_0716_marker: bool, + legacy_manager_record_exists: bool, + history_verified: bool, + confirm_permanent_authority_migration_applied: bool, + permanent_collection_authority_ack: bool, + /// Memberships {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`} will stop the upgrade for, counted from + /// whichever schema shape this database currently has — see + /// [`permanent_authority_lookahead_query`]. + unconfirmed_permanent_authority_count: i64, +} + +impl CustomRoleMigrationFacts { + /// `(columns present, migration recorded)` for one permission column group. + const fn permission_columns(self, group: PermissionColumnGroup) -> (i64, bool) { + match group { + PermissionColumnGroup::Manage => { + (self.manage_permission_columns, self.manage_permissions_migration_applied) + } + PermissionColumnGroup::Collection => { + (self.collection_permission_columns, self.collection_permissions_migration_applied) + } + PermissionColumnGroup::Access => { + (self.access_permission_columns, self.access_permissions_migration_applied) + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CustomRolePreflightDecision { + Proceed, + CompleteMysqlCollectionMigration, + CompleteInterruptedAccessAllDrop, + RefuseAlreadyDropped, + RefuseMissingAccessAll, + RefuseMissingMigrationLedger, + RefuseLegacyUserAccessAll, + RefuseUnverifiedCustomRoleHistory, + RefuseUnconfirmedPermanentCollectionAuthority, + RefuseInterruptedAccessAllDrop, + RefuseAccessAllDropLedgerMismatch, + RefuseOutOfOrderAccessPermissionsMigration, + RefusePartialPermissionSchema(PermissionColumnGroup), + RefusePermissionLedgerMismatch(PermissionColumnGroup), +} + +const fn needs_permanent_collection_authority_decision(facts: CustomRoleMigrationFacts) -> bool { + !facts.confirm_permanent_authority_migration_applied + && !facts.permanent_collection_authority_ack + && facts.unconfirmed_permanent_authority_count != 0 +} + +fn custom_role_preflight_decision( + facts: CustomRoleMigrationFacts, + can_complete_mysql_partial_migration: bool, +) -> CustomRolePreflightDecision { + if !facts.memberships_table_exists { + return CustomRolePreflightDecision::Proceed; + } + if !facts.migration_table_exists { + return CustomRolePreflightDecision::RefuseMissingMigrationLedger; + } + + // The legacy reconstruction below only makes sense while the repair migration is still ahead of + // us. Everything *after* it -- the access_all drop and the third permission column group -- still + // has to be checked on every start: both run after the repair, and on MySQL/MariaDB each DDL + // statement commits on its own, so a crash between the statement and Diesel's ledger insert + // leaves a durable partial state. Returning early for every repaired database would hide exactly + // those states, and the generic Diesel retry then fails on every following start with + // `Unknown column` (1091) or `Duplicate column name` (1060). + // The first Custom-role migration is recorded, but not by the version of it that ships today: + // an earlier revision of this feature branch wrote that ledger entry, and Diesel never runs a + // recorded version again. Several things then differ silently from a fresh upgrade, none of them + // reconstructible from the schema afterwards, so stop before the remaining migrations run. + // + // Checked against the whole chain rather than only the repair migration, because the divergence + // starts at the very first one: `atype = 3` has already been reused for the Custom role, without + // anything recording which memberships that value used to mean "Manager" for. + // + // Checked against the history marker rather than the legacy-Manager record, because the record + // is data an operator has to be able to write during recovery -- gating on it would let the act + // of silencing the error double as the audit it is asking for. + // + // Both tables are required. The marker alone would leave the later migrations and the rollback + // scripts without the data they need; the record alone would mean the audit never happened. + if facts.manage_permissions_migration_applied && !(facts.history_verified && facts.legacy_manager_record_exists) { + return CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory; + } + + // The access-permission migration is ordered immediately after the membership access_all drop. + // A database carrying the later ledger entry while the drop is still pending is not a harmless + // gap: SQLite's portable drop rebuild has a fixed pre-access-permissions column list and would + // destroy those three columns and their values. Diesel would then skip the already-recorded + // migration that adds them. Refuse the non-prefix ledger before any automatic MySQL repair or + // pending migration can mutate the database. + if facts.access_permissions_migration_applied && !facts.access_all_drop_migration_applied { + return CustomRolePreflightDecision::RefuseOutOfOrderAccessPermissionsMigration; + } + + // Automatic MySQL repairs are mutations. Remember a repairable state here, but do not select it + // until every refusal below has been evaluated. In particular, recording a missing ledger row or + // completing 0716 before discovering another damaged permission group (or an unanswered owner + // decision) would make the eventual "Nothing has been changed" refusal false. + let mut automatic_repair = None; + + if facts.repair_migration_applied { + // The drop is a single statement with no data component, so it is all-or-nothing: either the + // column is still there and the migration is pending, or the column is gone and the + // migration is recorded. + if facts.access_all_column_exists == facts.access_all_drop_migration_applied { + if facts.access_all_drop_migration_applied { + return CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch; + } else if can_complete_mysql_partial_migration { + // Only reachable on MySQL/MariaDB, and the schema is already in its intended final + // state. Defer recording the migration until every refusal has been checked. + automatic_repair = Some(CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop); + } else { + return CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop; + } + } + } else { + // Once access_all has been dropped, its former value can no longer be reconstructed. Never + // guess at it. + if facts.access_all_drop_migration_applied { + return CustomRolePreflightDecision::RefuseAlreadyDropped; + } + if !facts.access_all_column_exists { + return CustomRolePreflightDecision::RefuseMissingAccessAll; + } + + // A plain User carrying membership `access_all` has no representation in the new model: the + // bit gave unlimited *reach* over every collection, present and future, without any + // management authority, and the role that replaces it cannot express that. Converting the + // reach into direct per-collection assignments would silently turn a dynamic guarantee into a + // point-in-time snapshot, and -- because a `users_collections` row is not bound to the + // membership status the way `access_all` was -- would hand a revoked or never-confirmed member + // durable assignments that outlive this schema. Refuse and let an owner decide. + if facts.legacy_user_access_all_count != 0 { + return CustomRolePreflightDecision::RefuseLegacyUserAccessAll; + } + } + + // Every permission column group must be either completely absent (its migration is still pending) + // or completely present with its ledger entry. Anything else is an interrupted migration whose + // re-run would fail with `Duplicate column name`, so refuse with an actionable message. The single + // historical exception is the collection group on MySQL, where the known-good partial state is + // completed in place. + for group in [PermissionColumnGroup::Manage, PermissionColumnGroup::Collection, PermissionColumnGroup::Access] { + match facts.permission_columns(group) { + (0, false) | (3, true) => {} + (3, false) + if group == PermissionColumnGroup::Collection + && can_complete_mysql_partial_migration + && !facts.repair_migration_applied + && facts.access_all_column_exists => + { + // This is the historical MySQL 0716 partial state: its DDL committed, while the + // ledger and the later 0723 repair are both still pending. `access_all` is required + // by both the validation and completion queries. Merely remember the repair here so + // a later permission group or the permanent-authority decision can still refuse + // without any preceding mutation. + automatic_repair = Some(CustomRolePreflightDecision::CompleteMysqlCollectionMigration); + } + (_, true) => return CustomRolePreflightDecision::RefusePermissionLedgerMismatch(group), + _ => return CustomRolePreflightDecision::RefusePartialPermissionSchema(group), + } + } + + // Last, because it is the only refusal that is not about a damaged database: the schema is fine + // and the upgrade is ready to run, but one step of it changes a meaning that nothing in the new + // model can express, and that is an owner's decision rather than a migration's. Checked here + // rather than left to the migration's own guard so the question arrives with the review query + // and the acknowledgement attached — Diesel would surface that guard as nothing but its + // driver-level duplicate-key error. + if needs_permanent_collection_authority_decision(facts) { + return CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority; + } + + automatic_repair.unwrap_or(CustomRolePreflightDecision::Proceed) +} + +fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> Error { + let detail = match decision { + CustomRolePreflightDecision::RefuseAlreadyDropped => format!( + "The membership access_all column was already dropped by migration \ + {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION}, but the required repair migration \ + {CUSTOM_ROLE_REPAIR_MIGRATION} is not recorded. The former permission values cannot \ + be reconstructed safely." + ), + CustomRolePreflightDecision::RefuseMissingAccessAll => format!( + "The membership access_all column is missing before repair migration \ + {CUSTOM_ROLE_REPAIR_MIGRATION}; refusing to infer deleted permissions." + ), + CustomRolePreflightDecision::RefuseMissingMigrationLedger => { + "The users_organizations table exists, but the Diesel migration ledger does not. \ + Refusing to guess which schema and data migrations were previously applied." + .to_owned() + } + CustomRolePreflightDecision::RefuseLegacyUserAccessAll => format!( + "Found {} membership(s) of the plain User type carrying the legacy access_all bit. That \ + combination has no representation in the Custom role model: it grants dynamic reach over \ + every collection without any management authority.", + facts.legacy_user_access_all_count + ), + CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory => format!( + "Migration {CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION} is recorded, but the tables it \ + creates today are not both present ({CUSTOM_ROLE_LEGACY_MANAGER_TABLE}: {}, \ + {CUSTOM_ROLE_HISTORY_VERIFIED_TABLE}: {}). This database was upgraded by an earlier \ + revision of the Custom-role change, whose migrations had different effects and which \ + Diesel will not re-run.", + if facts.legacy_manager_record_exists { + "present" + } else { + "missing" + }, + if facts.history_verified { + "present" + } else { + "missing" + } + ), + CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority => format!( + "Migration {CONFIRM_PERMANENT_AUTHORITY_MIGRATION} needs a decision before it can run: {} \ + membership(s) match collection authority that may have come from an organization-local \ + access_all group. The current permissions cannot distinguish every group-derived grant \ + from independently changed or legacy membership-level authority, so the check is \ + deliberately conservative rather than silently making a possible group-derived grant \ + permanent. Nothing has been changed.", + facts.unconfirmed_permanent_authority_count + ), + CustomRolePreflightDecision::RefusePartialPermissionSchema(group) => format!( + "Found {} of the three {} columns ({}) without a completed {} migration. The migration \ + was interrupted between its ALTER TABLE statements.", + facts.permission_columns(group).0, + group.description(), + group.column_list(), + group.migration() + ), + CustomRolePreflightDecision::RefusePermissionLedgerMismatch(group) => format!( + "Migration {} is recorded, but only {} of its three {} columns ({}) exist.", + group.migration(), + facts.permission_columns(group).0, + group.description(), + group.column_list() + ), + CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop => format!( + "The membership access_all column is already gone, but migration \ + {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION} is not recorded. The column was dropped without \ + its ledger entry, so re-running the migration would fail on every start." + ), + CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch => format!( + "Migration {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION} is recorded, but the membership \ + access_all column still exists. Schema and migration ledger disagree." + ), + CustomRolePreflightDecision::RefuseOutOfOrderAccessPermissionsMigration => format!( + "Migration {CUSTOM_ACCESS_PERMISSIONS_MIGRATION} is recorded while its required earlier \ + migration {DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION} is not. The Custom-role migration ledger \ + is not a valid prefix, and continuing could destroy stored access-permission values. \ + Nothing has been changed." + ), + CustomRolePreflightDecision::Proceed + | CustomRolePreflightDecision::CompleteMysqlCollectionMigration + | CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { + unreachable!("successful preflight decisions do not produce errors") + } + }; + let recovery = match decision { + CustomRolePreflightDecision::RefuseLegacyUserAccessAll => LEGACY_USER_ACCESS_ALL_RECOVERY, + CustomRolePreflightDecision::RefuseUnverifiedCustomRoleHistory => UNVERIFIED_CUSTOM_ROLE_HISTORY_RECOVERY, + CustomRolePreflightDecision::RefuseUnconfirmedPermanentCollectionAuthority => { + PERMANENT_COLLECTION_AUTHORITY_RECOVERY + } + // Once access_all is gone, the collection group's migration can no longer run at all, so + // neither of the two generic texts may be handed out -- both end in a replay. + CustomRolePreflightDecision::RefusePartialPermissionSchema(group) + | CustomRolePreflightDecision::RefusePermissionLedgerMismatch(group) + if group.reads_legacy_access_all() && !facts.access_all_column_exists => + { + COLLECTION_PERMISSIONS_AFTER_DROP_RECOVERY + } + CustomRolePreflightDecision::RefusePartialPermissionSchema(_) => PARTIAL_PERMISSION_COLUMNS_RECOVERY, + CustomRolePreflightDecision::RefusePermissionLedgerMismatch(_) => PERMISSION_LEDGER_MISMATCH_RECOVERY, + CustomRolePreflightDecision::RefuseAlreadyDropped => ALREADY_DROPPED_RECOVERY, + CustomRolePreflightDecision::RefuseInterruptedAccessAllDrop => INTERRUPTED_ACCESS_ALL_DROP_RECOVERY, + CustomRolePreflightDecision::RefuseAccessAllDropLedgerMismatch => ACCESS_ALL_DROP_MISMATCH_RECOVERY, + CustomRolePreflightDecision::RefuseOutOfOrderAccessPermissionsMigration => { + OUT_OF_ORDER_ACCESS_PERMISSIONS_RECOVERY + } + _ => "", + }; + + std::io::Error::other(format!( + "Custom-role migration preflight stopped startup: {detail} Back up the database and resolve \ + the legacy membership state manually before restarting.{recovery}" + )) + .into() +} + +/// Counts the memberships {`CONFIRM_PERMANENT_AUTHORITY_MIGRATION`} will refuse to convert without an +/// owner's acknowledgement — from whichever schema shape the database has *right now*. +/// +/// Two broad shapes, because the preflight runs before any migration does and the answer has to be +/// the same either way: +/// +/// * **After the collection columns exist** the authority is already materialized, so this is the +/// migration's own predicate verbatim. `create_new_collections` is deliberately not used as a +/// provenance proxy: owners can change that independent permission after an earlier revision +/// materialized group-derived edit/delete, so its current value cannot prove where those two +/// permissions came from. Keeping the two predicates textually parallel is the point. +/// * **Before them** — the ordinary upgrade from a release without this feature — the columns are +/// not there yet and the answer has to be predicted from the legacy schema. `atype = 3` is the +/// retired Manager role, which {`CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION`} both records and +/// converts to Custom. Between the two migrations `atype = 4` rows can exist without the columns; +/// they are only attributable through the record, which is guaranteed to be present by then (the +/// history refusal above requires it whenever `20260630120000` is recorded). A Manager that also +/// carried membership `access_all` is conservatively included: asking an owner again is safer than +/// treating a mutable modern permission as immutable historical evidence. An unrecorded Custom +/// membership carrying `access_all` is included too: 0716 will turn that stored bit into 1/1/1, +/// which the later materialized guard will preserve and ask about if the membership is also in an +/// organization-local `access_all` group. +/// +/// `groups` is the backend's quoting of the reserved identifier. Returns `None` when neither shape is +/// readable, which is also exactly when the migration cannot run yet. +#[expect( + clippy::fn_params_excessive_bools, + reason = "These independent booleans describe historical schema and migration-ledger facts" +)] +fn permanent_authority_lookahead_query( + collection_columns_present: bool, + access_all_column_exists: bool, + legacy_manager_record_exists: bool, + collection_permissions_migration_applied: bool, + repair_migration_applied: bool, + groups: &str, +) -> Option { + let in_access_all_group = format!( + "EXISTS ( \ + SELECT 1 \ + FROM groups_users AS gu \ + INNER JOIN {groups} AS g ON g.uuid = gu.groups_uuid \ + WHERE gu.users_organizations_uuid = uo.uuid \ + AND g.organizations_uuid = uo.org_uuid \ + AND g.access_all = TRUE \ + )" + ); + let on_record = format!("uo.uuid IN (SELECT users_organizations_uuid FROM {CUSTOM_ROLE_LEGACY_MANAGER_TABLE})"); + + if collection_columns_present && collection_permissions_migration_applied && repair_migration_applied { + // Do not infer provenance from `create_new_collections`. It is an independently mutable + // permission, so an owner can turn a group-derived 0/1/1 grant into 1/1/1 after an earlier + // revision ran. Excluding that current shape would silently accept the very permanent + // edit/delete authority this question exists to review. Conservatively ask about every + // materialized edit/delete grant that still has the qualifying group relationship. + Some(format!( + "SELECT COUNT(*) AS count FROM users_organizations AS uo \ + WHERE uo.atype = 4 \ + AND (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE) \ + AND {in_access_all_group}" + )) + } else if access_all_column_exists { + // This branch also covers both historical states in which the columns exist but the repair is + // still pending: MySQL DDL committed without the 0716 ledger, or an earlier recorded 0716 did + // not contain today's group update. In either case 20260723120000 will materialize the + // provenance-bound group authority. 0716 also turns an unrecorded Custom membership's own + // `access_all` bit into 1/1/1. Project both end states instead of trusting temporary 0/0/0 + // values, so the owner is asked before any automatic completion or pending migration. + let pending_conversion = if legacy_manager_record_exists { + format!( + "(uo.atype = 3 OR (uo.atype = 4 AND \ + ({on_record} OR uo.access_all = TRUE)))" + ) + } else { + "(uo.atype = 3 OR (uo.atype = 4 AND uo.access_all = TRUE))".to_owned() + }; + // When the collection columns already exist, also retain any materialized Custom grant that + // is not part of the legacy-Manager record. The pending repair does not create that grant, but + // the later confirmation migration will still preserve it permanently. `OR` keeps both sets + // in one membership-level count without double-counting recorded rows that already have 0/1/1. + let pending_or_materialized_authority = if collection_columns_present { + format!( + "({pending_conversion} OR (uo.atype = 4 AND \ + (uo.edit_any_collection = TRUE OR uo.delete_any_collection = TRUE)))" + ) + } else { + pending_conversion + }; + Some(format!( + "SELECT COUNT(*) AS count FROM users_organizations AS uo \ + WHERE {pending_or_materialized_authority} \ + AND {in_access_all_group}" + )) + } else { + None + } +} + +/// Requires every existing relation the PostgreSQL preflight and migration chain share to resolve to +/// the schema in which unqualified `CREATE TABLE` statements will create new bookkeeping objects. +/// +/// `to_regclass` correctly follows `search_path` for an existing relation, but `CREATE TABLE` uses +/// `current_schema()`. With `search_path = decoy, real` and Vaultwarden's tables in `real`, reading the +/// former while creating provenance in the latter splits one migration across schemas. Returning one +/// row is the only safe shape; zero means the caller must refuse before any migration runs. +#[cfg(any(postgresql, test))] +const fn postgresql_migration_namespace_query() -> &'static str { + "SELECT COUNT(*) AS count \ + FROM pg_class AS memberships \ + INNER JOIN pg_namespace AS current_ns ON current_ns.nspname = current_schema() \ + WHERE memberships.oid = to_regclass('users_organizations') \ + AND memberships.relnamespace = current_ns.oid \ + AND NOT EXISTS ( \ + SELECT 1 \ + FROM (VALUES \ + ('__diesel_schema_migrations'), \ + ('groups'), \ + ('groups_users'), \ + ('__vw_custom_role_legacy_manager'), \ + ('__vw_custom_role_history_verified'), \ + ('__vw_custom_role_same_run_0716'), \ + ('__vw_ack_permanent_collection_authority') \ + ) AS relation(name) \ + INNER JOIN pg_class AS resolved ON resolved.oid = to_regclass(relation.name) \ + WHERE resolved.relnamespace <> memberships.relnamespace \ + )" +} + +/// The shapes a half-applied {`CUSTOM_COLLECTION_PERMISSIONS_MIGRATION`} may legitimately have left +/// behind, expressed as a count of the rows that have any *other* shape. +/// +/// `allow_same_run_group_derived` additionally permits the result of that migration's second data +/// statement. That statement is driven by {`CUSTOM_ROLE_LEGACY_MANAGER_TABLE`}, so the allowance +/// carries the same condition: a 0/1/1 row belonging to a membership that is *not* on record as a +/// legacy Manager cannot have come from the migration that ships today, and counting it as expected +/// would let the automatic recovery adopt a grant nothing can account for. The caller therefore only +/// passes `true` when that record actually exists — without it the shape is undecidable, and the +/// recovery refuses rather than guessing. +#[cfg(any(mysql, test))] +fn mysql_partial_unexpected_values_query(allow_same_run_group_derived: bool) -> String { + let same_run_group_derived = if allow_same_run_group_derived { + format!( + " OR \ + (atype = 4 \ + AND access_all = FALSE \ + AND create_new_collections = FALSE \ + AND edit_any_collection = TRUE \ + AND delete_any_collection = TRUE \ + AND uuid IN (SELECT users_organizations_uuid FROM {CUSTOM_ROLE_LEGACY_MANAGER_TABLE}) \ + AND EXISTS ( \ + SELECT 1 \ + FROM groups_users AS gu \ + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid \ + WHERE gu.users_organizations_uuid = users_organizations.uuid \ + AND g.organizations_uuid = users_organizations.org_uuid \ + AND g.access_all = TRUE \ + ))" + ) + } else { + String::new() + }; + + format!( + "SELECT COUNT(*) AS count FROM users_organizations \ + WHERE NOT ( \ + (create_new_collections = FALSE \ + AND edit_any_collection = FALSE \ + AND delete_any_collection = FALSE) \ + OR \ + (atype = 4 \ + AND create_new_collections = access_all \ + AND edit_any_collection = access_all \ + AND delete_any_collection = access_all) \ + {same_run_group_derived} \ + )" + ) +} + // Embed the migrations from the migrations folder into the application // This way, the program automatically migrates the database to the latest version // https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html @@ -477,11 +1346,160 @@ mod sqlite_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite"); + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + fn count( + connection: &mut diesel::sqlite::SqliteConnection, + query: impl Into, + ) -> Result { + diesel::sql_query(query).get_result::(connection).map(|row| row.count) + } + + fn table_exists( + connection: &mut diesel::sqlite::SqliteConnection, + table: &str, + ) -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM sqlite_master \ + WHERE type = 'table' AND name = '{table}'" + ), + ) + .map(|value| value != 0) + } + + fn migration_applied( + connection: &mut diesel::sqlite::SqliteConnection, + version: &str, + ) -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{version}'" + ), + ) + .map(|value| value != 0) + } + + fn preflight(connection: &mut diesel::sqlite::SqliteConnection) -> Result<(), super::Error> { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + if !memberships_table_exists { + return Ok(()); + } + + let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ + WHERE name = 'access_all'", + )? != 0; + let permission_columns = |connection: &mut diesel::sqlite::SqliteConnection, + group: super::PermissionColumnGroup| + -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ + WHERE name IN ({})", + group.column_list() + ), + ) + }; + let manage_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Manage)?; + let collection_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Collection)?; + let access_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Access)?; + + let manage_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION)?; + let collection_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?; + let access_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION)?; + let repair_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?; + let access_all_drop_migration_applied = + migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?; + let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?; + let legacy_manager_record_exists = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)?; + let history_verified = table_exists(connection, super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE)?; + let same_run_0716_marker = same_run_marker_table_exists + && count( + connection, + format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), + )? != 0; + + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // it has to stop the upgrade as well. + let legacy_user_access_all_count = if access_all_column_exists { + count( + connection, + "SELECT COUNT(*) AS count FROM users_organizations \ + WHERE atype = 2 \ + AND access_all = TRUE", + )? + } else { + 0 + }; + + let confirm_permanent_authority_migration_applied = + migration_table_exists && migration_applied(connection, super::CONFIRM_PERMANENT_AUTHORITY_MIGRATION)?; + let permanent_collection_authority_ack = + table_exists(connection, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)?; + let unconfirmed_permanent_authority_count = match super::permanent_authority_lookahead_query( + collection_permission_columns == 3, + access_all_column_exists, + legacy_manager_record_exists, + collection_permissions_migration_applied, + repair_migration_applied, + "\"groups\"", + ) { + Some(query) => count(connection, query)?, + None => 0, + }; + + let facts = super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_table_exists, + access_all_column_exists, + manage_permission_columns, + manage_permissions_migration_applied, + collection_permission_columns, + collection_permissions_migration_applied, + access_permission_columns, + access_permissions_migration_applied, + repair_migration_applied, + access_all_drop_migration_applied, + legacy_user_access_all_count, + same_run_0716_marker, + legacy_manager_record_exists, + history_verified, + confirm_permanent_authority_migration_applied, + permanent_collection_authority_ack, + unconfirmed_permanent_authority_count, + }; + + let decision = super::custom_role_preflight_decision(facts, false); + if decision == super::CustomRolePreflightDecision::Proceed { + Ok(()) + } else { + Err(super::custom_role_preflight_error(decision, facts)) + } + } + pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // Establish a connection to the sqlite database (this will create a new one, if it does // not exist, and exit if there is an error). let mut connection = diesel::sqlite::SqliteConnection::establish(db_url)?; + preflight(&mut connection)?; + // Run the migrations after successfully establishing a connection // Disable Foreign Key Checks during migration // Scoped to a connection. @@ -505,10 +1523,294 @@ mod mysql_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql"); + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + fn count( + connection: &mut diesel::mysql::MysqlConnection, + query: impl Into, + ) -> Result { + diesel::sql_query(query).get_result::(connection).map(|row| row.count) + } + + fn table_exists( + connection: &mut diesel::mysql::MysqlConnection, + table: &str, + ) -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.tables \ + WHERE table_schema = DATABASE() AND table_name = '{table}'" + ), + ) + .map(|value| value != 0) + } + + fn migration_applied( + connection: &mut diesel::mysql::MysqlConnection, + version: &str, + ) -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{version}'" + ), + ) + .map(|value| value != 0) + } + + fn complete_partial_collection_migration( + connection: &mut diesel::mysql::MysqlConnection, + allow_same_run_group_derived: bool, + ) -> Result<(), super::Error> { + // MySQL implicitly committed the three historical ALTER TABLE statements before the + // unquoted `groups` identifier made the migration fail. Complete that exact, known state + // without dropping columns or inventing values. + let matching_column_definitions = count( + connection, + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN \ + ('create_new_collections', 'edit_any_collection', 'delete_any_collection') \ + AND data_type = 'tinyint' \ + AND is_nullable = 'NO' \ + AND LOWER(COALESCE(CAST(column_default AS CHAR), '')) IN ('0', 'false')", + )?; + let unexpected_values = + count(connection, super::mysql_partial_unexpected_values_query(allow_same_run_group_derived))?; + + if matching_column_definitions != 3 || unexpected_values != 0 { + return Err(std::io::Error::other(format!( + "Custom-role migration preflight found the historical MySQL partial \ + {version} schema, but its column definitions or data were modified \ + (matching columns: {matching_column_definitions}/3, unexpected rows: \ + {unexpected_values}). Refusing automatic recovery. Back up the database and \ + resolve the partial migration manually before restarting.", + version = super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION, + )) + .into()); + } + + connection.transaction::<(), diesel::result::Error, _>(|connection| { + // This is the first data statement from the canonical migration. It also resets an + // exact, same-run group-derived 0/1/1 row to 0/0/0. That is deliberate: this completion + // path is not where legacy group authority is decided. Nothing derives it at request + // time any more -- the live fallback is gone -- so the reset is not "the group still + // covers it"; it is "leave the columns at the value this statement defines, and let the + // repair migration re-establish the authority from the legacy-Manager record". The + // canonical file's second statement is deliberately *not* replayed here, because the + // record it has to be driven by is the same one 2026-07-23-120000 reads a moment later. + // + // The two runs therefore converge: a recorded legacy Manager in an access_all group gets + // its 0/1/1 back from 2026-07-23-120000, and a membership that is not on the record + // keeps 0/0/0 -- which is the whole point of driving the grant by provenance. + diesel::sql_query( + "UPDATE users_organizations \ + SET create_new_collections = access_all, \ + edit_any_collection = access_all, \ + delete_any_collection = access_all \ + WHERE atype = 4", + ) + .execute(connection)?; + + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version) \ + VALUES ('{}')", + super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION + )) + .execute(connection)?; + Ok(()) + })?; + + Ok(()) + } + + fn complete_interrupted_access_all_drop( + connection: &mut diesel::mysql::MysqlConnection, + ) -> Result<(), super::Error> { + // MySQL/MariaDB commit DDL implicitly, so the single `ALTER TABLE ... DROP COLUMN access_all` + // can be durable while Diesel's ledger insert that follows it is not. Re-running the + // migration would then fail with error 1091 (Unknown column) on every start. The statement + // has no data component and the preflight has just confirmed the column is gone, so the + // schema already is what the migration wanted: record it and let the rest of the chain run. + // Do not rely on the server/session autocommit setting or on a later pending migration to + // commit this repair. With autocommit=0 and no later migration, a plain INSERT is rolled back + // when this freshly established connection closes, so every start rediscovers the same + // interrupted drop. Diesel's transaction commits the ledger entry before preflight continues. + connection.transaction::<(), diesel::result::Error, _>(|connection| { + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('{}')", + super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION + )) + .execute(connection)?; + Ok(()) + })?; + + Ok(()) + } + + /// Read everything [`super::custom_role_preflight_decision`] answers from, once. + /// + /// Separate from `preflight` because two of its decisions repair the database instead of + /// refusing, and every fact below can change when they do. + fn inspect( + connection: &mut diesel::mysql::MysqlConnection, + ) -> Result { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + if !memberships_table_exists { + // Nothing to read, and nothing to decide: the default answers `Proceed`. + return Ok(super::CustomRoleMigrationFacts::default()); + } + + let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name = 'access_all'", + )? != 0; + let permission_columns = |connection: &mut diesel::mysql::MysqlConnection, + group: super::PermissionColumnGroup| + -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'users_organizations' AND column_name IN ({})", + group.column_list() + ), + ) + }; + let manage_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Manage)?; + let collection_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Collection)?; + let access_permission_columns = permission_columns(connection, super::PermissionColumnGroup::Access)?; + + let manage_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION)?; + let collection_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?; + let access_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION)?; + let repair_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?; + let access_all_drop_migration_applied = + migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?; + let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?; + let legacy_manager_record_exists = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)?; + let history_verified = table_exists(connection, super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE)?; + let same_run_0716_marker = same_run_marker_table_exists + && count( + connection, + format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), + )? != 0; + + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // it has to stop the upgrade as well. + let legacy_user_access_all_count = if access_all_column_exists { + count( + connection, + "SELECT COUNT(*) AS count FROM users_organizations \ + WHERE atype = 2 \ + AND access_all = TRUE", + )? + } else { + 0 + }; + + let confirm_permanent_authority_migration_applied = + migration_table_exists && migration_applied(connection, super::CONFIRM_PERMANENT_AUTHORITY_MIGRATION)?; + let permanent_collection_authority_ack = + table_exists(connection, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)?; + let unconfirmed_permanent_authority_count = match super::permanent_authority_lookahead_query( + collection_permission_columns == 3, + access_all_column_exists, + legacy_manager_record_exists, + collection_permissions_migration_applied, + repair_migration_applied, + "`groups`", + ) { + Some(query) => count(connection, query)?, + None => 0, + }; + + Ok(super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_table_exists, + access_all_column_exists, + manage_permission_columns, + manage_permissions_migration_applied, + collection_permission_columns, + collection_permissions_migration_applied, + access_permission_columns, + access_permissions_migration_applied, + repair_migration_applied, + access_all_drop_migration_applied, + legacy_user_access_all_count, + same_run_0716_marker, + legacy_manager_record_exists, + history_verified, + confirm_permanent_authority_migration_applied, + permanent_collection_authority_ack, + unconfirmed_permanent_authority_count, + }) + } + + /// The two repairs below each record exactly one migration, so neither can be chosen twice. + /// The bound is not load-bearing for them -- it is there so a future repair that forgets to + /// advance the ledger cannot spin here instead of failing. + const MAX_AUTOMATIC_REPAIRS: usize = 2; + + fn preflight(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { + // A repair is not the end of the preflight, it is the start of another pass. Both repairs + // record a migration, and 0716 completion also normalizes its permission values, so every + // fact has to be read again afterwards. `custom_role_preflight_decision` evaluates all + // refusals before it returns either repair action; the loop therefore mutates only a snapshot + // that has already passed the schema, history and owner checks, then verifies the resulting + // snapshot from scratch. + for _ in 0..=MAX_AUTOMATIC_REPAIRS { + let facts = inspect(connection)?; + match super::custom_role_preflight_decision(facts, true) { + super::CustomRolePreflightDecision::Proceed => return Ok(()), + super::CustomRolePreflightDecision::CompleteMysqlCollectionMigration => { + // The same-run allowance reads the legacy-Manager record, so it may only be + // offered when that record exists. Everywhere this decision is normally reachable + // it does -- the history refusal already requires it -- but the recovery must not + // depend on that: without the record the group-derived shape cannot be attributed + // to anything, and refusing is the correct answer. + complete_partial_collection_migration( + connection, + facts.same_run_0716_marker && facts.legacy_manager_record_exists, + )?; + } + super::CustomRolePreflightDecision::CompleteInterruptedAccessAllDrop => { + complete_interrupted_access_all_drop(connection)?; + } + decision => return Err(super::custom_role_preflight_error(decision, facts)), + } + } + + Err(std::io::Error::other( + "Custom-role migration preflight kept finding a state it had just repaired. Each \ + automatic repair records a migration and can only apply once, so this means the ledger \ + insert did not take effect. Back up the database and resolve the partial migration \ + manually before restarting.", + ) + .into()) + } + pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // Make sure the database is up to date (create if it doesn't exist, or run the migrations) let mut connection = diesel::mysql::MysqlConnection::establish(db_url)?; + preflight(&mut connection)?; + // Disable Foreign Key Checks during migration // Scoped to a connection/session. diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0") @@ -522,15 +1824,2080 @@ mod mysql_migrations { #[cfg(postgresql)] mod postgresql_migrations { - use diesel::Connection; + use diesel::{Connection, RunQueryDsl}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql"); + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + fn count( + connection: &mut diesel::pg::PgConnection, + query: impl Into, + ) -> Result { + diesel::sql_query(query).get_result::(connection).map(|row| row.count) + } + + /// Resolved through `to_regclass`, i.e. exactly the way an unqualified name in a migration is + /// resolved -- and deliberately *not* through `table_schema = current_schema()`. + /// + /// `current_schema()` is the first *existing* schema on the `search_path`, which is where new + /// objects are created. It is not necessarily the schema an existing table is found in: with + /// `search_path = decoy, real` and the tables in `real`, `current_schema()` answers `decoy`, the + /// lookup finds nothing, and `preflight` returns early on `!memberships_table_exists` -- silently + /// skipping every check while Diesel then runs the migrations against `real`. `to_regclass` + /// walks the same path the migrations do, so the preflight and the statements it is guarding can + /// no longer disagree about which table they mean. (`tools/custom_role_rollback/postgresql.sql` + /// defends against the same split by binding the namespace once.) + fn table_exists(connection: &mut diesel::pg::PgConnection, table: &str) -> Result { + count(connection, format!("SELECT COUNT(*) AS count FROM pg_class WHERE oid = to_regclass('{table}')")) + .map(|value| value != 0) + } + + /// Columns of `users_organizations`, resolved through the same `to_regclass` lookup as + /// [`table_exists`] so a `search_path` split cannot make the schema and the column checks + /// describe two different tables. + fn column_count( + connection: &mut diesel::pg::PgConnection, + column_list: &str, + ) -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 \ + AND NOT attisdropped \ + AND attname IN ({column_list})" + ), + ) + } + + fn migration_applied( + connection: &mut diesel::pg::PgConnection, + version: &str, + ) -> Result { + count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{version}'" + ), + ) + .map(|value| value != 0) + } + + fn preflight(connection: &mut diesel::pg::PgConnection) -> Result<(), super::Error> { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + if !memberships_table_exists { + return Ok(()); + } + + let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?; + if migration_table_exists && count(connection, super::postgresql_migration_namespace_query())? != 1 { + return Err(std::io::Error::other( + "Custom-role migration preflight stopped startup: PostgreSQL resolves Vaultwarden's \ + existing migration relations in a different schema from current_schema(). An \ + unqualified migration would read users_organizations from one schema and create its \ + provenance or acknowledgement tables in another. Nothing has been changed. Set the \ + connection search_path so the schema containing users_organizations, groups, \ + groups_users and __diesel_schema_migrations is first, remove any shadow relations, \ + then restart.", + ) + .into()); + } + let access_all_column_exists = column_count(connection, "'access_all'")? != 0; + let manage_permission_columns = column_count(connection, super::PermissionColumnGroup::Manage.column_list())?; + let collection_permission_columns = + column_count(connection, super::PermissionColumnGroup::Collection.column_list())?; + let access_permission_columns = column_count(connection, super::PermissionColumnGroup::Access.column_list())?; + + let manage_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_MANAGE_PERMISSIONS_MIGRATION)?; + let collection_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?; + let access_permissions_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION)?; + let repair_migration_applied = + migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?; + let access_all_drop_migration_applied = + migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?; + let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?; + let legacy_manager_record_exists = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)?; + let history_verified = table_exists(connection, super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE)?; + let same_run_0716_marker = same_run_marker_table_exists + && count( + connection, + format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE), + )? != 0; + + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // it has to stop the upgrade as well. + let legacy_user_access_all_count = if access_all_column_exists { + count( + connection, + "SELECT COUNT(*) AS count FROM users_organizations \ + WHERE atype = 2 \ + AND access_all = TRUE", + )? + } else { + 0 + }; + + let confirm_permanent_authority_migration_applied = + migration_table_exists && migration_applied(connection, super::CONFIRM_PERMANENT_AUTHORITY_MIGRATION)?; + let permanent_collection_authority_ack = + table_exists(connection, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)?; + let unconfirmed_permanent_authority_count = match super::permanent_authority_lookahead_query( + collection_permission_columns == 3, + access_all_column_exists, + legacy_manager_record_exists, + collection_permissions_migration_applied, + repair_migration_applied, + "\"groups\"", + ) { + Some(query) => count(connection, query)?, + None => 0, + }; + + let facts = super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_table_exists, + access_all_column_exists, + manage_permission_columns, + manage_permissions_migration_applied, + collection_permission_columns, + collection_permissions_migration_applied, + access_permission_columns, + access_permissions_migration_applied, + repair_migration_applied, + access_all_drop_migration_applied, + legacy_user_access_all_count, + same_run_0716_marker, + legacy_manager_record_exists, + history_verified, + confirm_permanent_authority_migration_applied, + permanent_collection_authority_ack, + unconfirmed_permanent_authority_count, + }; + + let decision = super::custom_role_preflight_decision(facts, false); + if decision == super::CustomRolePreflightDecision::Proceed { + Ok(()) + } else { + Err(super::custom_role_preflight_error(decision, facts)) + } + } + pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // Make sure the database is up to date (create if it doesn't exist, or run the migrations) let mut connection = diesel::pg::PgConnection::establish(db_url)?; + preflight(&mut connection)?; + connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations"); Ok(()) } } + +/// Executes the real migration files against a throwaway SQLite database. +/// +/// Everything else in this file tests the *decision* the preflight makes; nothing tested the SQL the +/// decision is protecting. The one rule those files encode -- legacy authority is granted from the +/// recorded provenance, never from the shape of a membership -- is invisible to a Rust test unless +/// the statements actually run, and it is a rule that was already lost once: `2026-07-16-120000` +/// kept granting `edit_any_collection` / `delete_any_collection` to every Custom member of an +/// `access_all` group after `2026-07-23-120000` and `2026-08-09-120000` had been narrowed to the +/// record. `edit_any_collection` satisfies `has_full_access()`, so that reached every cipher in the +/// organization. +#[cfg(all(test, sqlite))] +mod custom_role_migration_sql_tests { + use diesel::connection::SimpleConnection; + use diesel::{ + Connection, RunQueryDsl, + sql_types::{BigInt, Text}, + sqlite::SqliteConnection, + }; + + const ADD_COLLECTION_PERMISSIONS: &str = + include_str!("../../migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql"); + const DROP_MEMBERSHIP_ACCESS_ALL: &str = + include_str!("../../migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql"); + const MATERIALIZE_GROUP_AUTHORITY: &str = + include_str!("../../migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql"); + const CONFIRM_PERMANENT_AUTHORITY: &str = + include_str!("../../migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql"); + + const HISTORY_VERIFIED: &str = " + CREATE TABLE __vw_custom_role_history_verified (verified INTEGER NOT NULL PRIMARY KEY); + "; + const PERMANENT_AUTHORITY_ACK: &str = " + CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY); + "; + + /// The shape `users_organizations` has when `2026-07-16-120000` runs: `2026-06-30-120000` has + /// added the three management columns and converted `atype = 3` to `4`, and membership + /// `access_all` still exists (`2026-07-24-120000` drops it later). + const SCHEMA_BEFORE_0716: &str = " + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE, + akey TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 2, + atype INTEGER NOT NULL, + manage_users BOOLEAN NOT NULL DEFAULT FALSE, + manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + manage_policies BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups ( + uuid TEXT NOT NULL PRIMARY KEY, + organizations_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups_users ( + groups_uuid TEXT NOT NULL, + users_organizations_uuid TEXT NOT NULL, + PRIMARY KEY (groups_uuid, users_organizations_uuid) + ); + "; + + const LEGACY_MANAGER_RECORD: &str = " + CREATE TABLE __vw_custom_role_legacy_manager ( + users_organizations_uuid TEXT NOT NULL PRIMARY KEY + ); + "; + + /// `users_organizations` as the release *before* this feature leaves it: membership `access_all`, + /// the retired Manager role, and none of the nine permission columns. This is the schema the + /// preflight refuses from on an ordinary upgrade, which is the common path. + const LEGACY_SCHEMA: &str = " + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE, + status INTEGER NOT NULL DEFAULT 2, + atype INTEGER NOT NULL + ); + CREATE TABLE groups ( + uuid TEXT NOT NULL PRIMARY KEY, + organizations_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups_users ( + groups_uuid TEXT NOT NULL, + users_organizations_uuid TEXT NOT NULL, + PRIMARY KEY (groups_uuid, users_organizations_uuid) + ); + "; + + /// The one membership the question is actually about, in its pre-upgrade shape. + const LEGACY_GROUP_DERIVED_MANAGER: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_mgr', 'u1', 'org', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm_mgr'); + "; + + /// Two memberships that are byte-identical in role and group membership and differ only in their + /// recorded provenance, plus a recorded Manager that is in no group at all. + const MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_recorded', 'u1', 'org', FALSE, 4), + ('m_unrecorded', 'u2', 'org', FALSE, 4), + ('m_no_group', 'u3', 'org', FALSE, 4); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_recorded'), + ('g_all', 'm_unrecorded'); + "; + + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = BigInt)] + count: i64, + } + + #[derive(diesel::QueryableByName)] + struct ReviewMembership { + #[diesel(sql_type = Text)] + uuid: String, + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + diesel::sql_query(query).get_result::(connection).map(|row| row.count).unwrap() + } + + fn collection_permissions(connection: &mut SqliteConnection, membership: &str) -> (bool, bool, bool) { + let flag = |connection: &mut SqliteConnection, column: &str| { + count( + connection, + &format!( + "SELECT COUNT(*) AS count FROM users_organizations \ + WHERE uuid = '{membership}' AND {column} = TRUE" + ), + ) != 0 + }; + ( + flag(connection, "create_new_collections"), + flag(connection, "edit_any_collection"), + flag(connection, "delete_any_collection"), + ) + } + + fn connect(setup: &[&str]) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + for statements in setup { + connection.batch_execute(statements).unwrap(); + } + connection + } + + /// Everything up to and including `2026-07-16-120000`, so the collection permission columns hold + /// whatever the real migration put there. `record` lists the memberships written to + /// {`CUSTOM_ROLE_LEGACY_MANAGER_TABLE`} before it runs, which is what `2026-06-30-120000` does. + /// + /// `access_all` is left in place; `2026-07-24-120000` drops it, but neither of the two migrations + /// under test reads it and keeping it makes the fixtures legible. + fn connect_after_0716(memberships: &str, record: &[&str]) -> SqliteConnection { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, memberships]); + for uuid in record { + connection + .batch_execute(&format!( + "INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) VALUES ('{uuid}')" + )) + .unwrap(); + } + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + connection + } + + fn table_exists(connection: &mut SqliteConnection, table: &str) -> bool { + count( + connection, + &format!("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = '{table}'"), + ) != 0 + } + + /// What the startup preflight would answer for this database, through the very query it uses. + fn lookahead_count(connection: &mut SqliteConnection) -> i64 { + let record = table_exists(connection, super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE); + let query = super::permanent_authority_lookahead_query(true, true, record, true, true, "\"groups\"") + .expect("the collection columns exist in these fixtures"); + count(connection, &query) + } + + /// Membership `access_all` is a stored value, not a shape, so it carries its own evidence and is + /// converted for every Custom member that holds it. + #[test] + fn membership_access_all_becomes_all_three_collection_permissions() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, MEMBERSHIPS]); + connection + .batch_execute("UPDATE users_organizations SET access_all = TRUE WHERE uuid = 'm_unrecorded'") + .unwrap(); + + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + + assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (true, true, true)); + } + + /// 20260630120000 was available before 20260716120000, so this is a legitimate feature-branch + /// upgrade prefix: Managers are already converted and recorded, while a newer, unrecorded Custom + /// membership still carries its own legacy access_all bit and the collection columns are pending. + /// 0716 will turn that bit into 1/1/1, after which the conservative 0810 guard asks about it when + /// it also belongs to an organization-local access_all group. The startup lookahead must agree + /// before either migration runs, and its recovery query has to be executable on this exact shape. + #[test] + fn ledgered_0630_unrecorded_custom_access_all_matches_the_later_guard() { + let mut connection = connect(&[ + SCHEMA_BEFORE_0716, + LEGACY_MANAGER_RECORD, + HISTORY_VERIFIED, + "CREATE TABLE __diesel_schema_migrations (version TEXT NOT NULL PRIMARY KEY); + INSERT INTO __diesel_schema_migrations (version) VALUES ('20260630120000'); + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) + VALUES ('m_custom', 'u1', 'org', TRUE, 4); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) + VALUES ('g_all', 'm_custom');", + ]); + let lookahead = super::permanent_authority_lookahead_query(false, true, true, false, false, "\"groups\"") + .expect("access_all makes the pending 0716 result projectable"); + assert_eq!(count(&mut connection, &lookahead), 1); + + let review = "SELECT uo.uuid, uo.user_uuid, uo.org_uuid, uo.status, uo.access_all, + (uo.uuid IN (SELECT users_organizations_uuid + FROM __vw_custom_role_legacy_manager)) AS was_legacy_manager + FROM users_organizations uo + WHERE (uo.atype = 3 OR (uo.atype = 4 AND ( + uo.access_all = TRUE OR uo.uuid IN ( + SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager)))) + AND EXISTS ( + SELECT 1 FROM groups_users gu + INNER JOIN \"groups\" g ON g.uuid = gu.groups_uuid + AND g.organizations_uuid = uo.org_uuid + WHERE gu.users_organizations_uuid = uo.uuid AND g.access_all = TRUE)"; + let rows = diesel::sql_query(review).load::(&mut connection).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].uuid, "m_custom"); + assert!(super::PERMANENT_COLLECTION_AUTHORITY_RECOVERY.contains("If 20260630120000 is already")); + assert!(super::PERMANENT_COLLECTION_AUTHORITY_RECOVERY.contains("uo.access_all = TRUE OR uo.uuid IN")); + + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + assert_eq!(collection_permissions(&mut connection, "m_custom"), (true, true, true)); + assert!( + connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(), + "the preflight projection and the real 0810 guard must agree" + ); + } + + #[test] + fn out_of_order_access_permissions_would_be_destroyed_by_the_pending_sqlite_rebuild() { + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + connection + .batch_execute( + "CREATE TABLE users (uuid TEXT NOT NULL PRIMARY KEY); + CREATE TABLE organizations (uuid TEXT NOT NULL PRIMARY KEY); + INSERT INTO users (uuid) VALUES ('u1'), ('u2'), ('u3'); + INSERT INTO organizations (uuid) VALUES ('org'); + ALTER TABLE users_organizations ADD COLUMN reset_password_key TEXT; + ALTER TABLE users_organizations ADD COLUMN external_id TEXT; + ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL; + ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE; + ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE; + ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; + UPDATE users_organizations + SET access_event_logs = TRUE, access_import_export = TRUE, access_reports = TRUE + WHERE uuid = 'm_recorded';", + ) + .unwrap(); + + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM users_organizations + WHERE uuid = 'm_recorded' + AND access_event_logs = TRUE + AND access_import_export = TRUE + AND access_reports = TRUE" + ), + 1, + "the historical later migration can hold live grants" + ); + + connection.batch_execute(DROP_MEMBERSHIP_ACCESS_ALL).unwrap(); + + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') + WHERE name IN ('access_event_logs', 'access_import_export', 'access_reports')" + ), + 0, + "this pins why the preflight must refuse before running the unchanged migration file" + ); + } + + /// The regression this test exists for. `m_recorded` and `m_unrecorded` differ in nothing a + /// query at request time could see -- same role, same organization, same `access_all` group -- + /// so only the provenance record may decide, and it must not leak organization-wide collection + /// authority to the membership that has none. + #[test] + fn group_derived_authority_is_granted_only_to_recorded_legacy_managers() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, MEMBERSHIPS]); + connection + .batch_execute( + "INSERT INTO __vw_custom_role_legacy_manager (users_organizations_uuid) \ + VALUES ('m_recorded'), ('m_no_group')", + ) + .unwrap(); + + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + + // Edit and delete, never create: creating collections historically required membership + // `access_all`, which this member does not have. + assert_eq!(collection_permissions(&mut connection, "m_recorded"), (false, true, true)); + // Not on record: identical in shape, and it gets nothing. + assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (false, false, false)); + // On record, but its authority never came from a group. + assert_eq!(collection_permissions(&mut connection, "m_no_group"), (false, false, false)); + } + + /// Without the record the grant is undecidable, so the migration refuses -- and it has to refuse + /// *before* the `ALTER TABLE`s. On MySQL/MariaDB every one of them commits on its own, so a + /// guard placed after them would leave a half-added column group behind, which is exactly the + /// state `RefusePartialPermissionSchema` then has to talk an operator out of. + #[test] + fn the_migration_refuses_without_the_record_and_adds_no_column() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, MEMBERSHIPS]); + + assert!(connection.batch_execute(ADD_COLLECTION_PERMISSIONS).is_err()); + + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ + WHERE name IN ('create_new_collections', 'edit_any_collection', 'delete_any_collection')" + ), + 0, + "the guard has to run before the ALTER TABLE statements, or MySQL keeps the partial column group" + ); + } + + /// `2026-08-09-120000` repeats the materialization for databases that already recorded + /// `2026-07-23-120000`, and it is driven by the same record for the same reason. + #[test] + fn the_repeat_materialization_is_also_bound_to_the_record() { + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded", "m_no_group"]); + connection.batch_execute(HISTORY_VERIFIED).unwrap(); + + connection.batch_execute(MATERIALIZE_GROUP_AUTHORITY).unwrap(); + + assert_eq!(collection_permissions(&mut connection, "m_recorded"), (false, true, true)); + assert_eq!(collection_permissions(&mut connection, "m_unrecorded"), (false, false, false)); + assert_eq!(collection_permissions(&mut connection, "m_no_group"), (false, false, false)); + } + + /// Without the record the file cannot tell a converted legacy Manager from an ordinary Custom + /// member, and without the history marker nobody has said the unrecorded ones are unrecorded on + /// purpose. Granting would be a silent escalation, skipping would silently drop a capability, so + /// it stops -- and the marker itself never grants anything. + #[test] + fn the_repeat_materialization_refuses_an_unaudited_history() { + let mut refuses = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + assert!(refuses.batch_execute(MATERIALIZE_GROUP_AUTHORITY).is_err()); + + let mut audited = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + audited.batch_execute(HISTORY_VERIFIED).unwrap(); + audited.batch_execute(MATERIALIZE_GROUP_AUTHORITY).unwrap(); + assert_eq!( + collection_permissions(&mut audited, "m_unrecorded"), + (false, false, false), + "the marker settles who is undecidable, it never grants" + ); + } + + /// The one question the chain asks. `m_recorded` is the conversion it is about: its authority came + /// from the group and is about to outlive it. + /// + /// The two halves deliberately use separate connections. Every guard in this chain aborts by + /// leaving its `CREATE TEMPORARY TABLE` un-dropped, so a *retry on the same session* trips over + /// the leftover instead of the real condition. That is not reachable from Vaultwarden -- a failed + /// migration ends the process, and Diesel wraps each migration in a transaction on SQLite and + /// PostgreSQL, where temporary DDL rolls back with it -- but a test that reused the connection + /// would be asserting on the wrong error. + #[test] + fn permanent_collection_authority_needs_an_acknowledgement() { + let mut refuses = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + assert_eq!(collection_permissions(&mut refuses, "m_recorded"), (false, true, true)); + assert!(refuses.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + + // The answer lifts it, and is consumed so the next upgrade has to ask again. + let mut acknowledged = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + acknowledged.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + acknowledged.batch_execute(CONFIRM_PERMANENT_AUTHORITY).unwrap(); + assert!(!table_exists(&mut acknowledged, super::PERMANENT_COLLECTION_AUTHORITY_ACK_TABLE)); + + // It grants nothing and revokes nothing on the way through. + assert_eq!(collection_permissions(&mut acknowledged, "m_recorded"), (false, true, true)); + } + + /// `create_new_collections` is independently mutable. An owner can set it after an earlier + /// revision materialized a group-derived 0/1/1 grant, so the resulting 1/1/1 shape must not be + /// mistaken for immutable evidence that membership `access_all` supplied all three permissions. + #[test] + fn mutable_create_permission_does_not_hide_group_derived_authority() { + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + connection + .batch_execute("UPDATE users_organizations SET create_new_collections = TRUE WHERE uuid = 'm_recorded';") + .unwrap(); + assert_eq!(collection_permissions(&mut connection, "m_recorded"), (true, true, true)); + + assert!( + connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(), + "a current permission value is not historical provenance" + ); + } + + /// An unrecorded Custom member holding the permissions is *not* excluded: on a database first + /// upgraded by an earlier revision those may be the bulk grant its `20260809120000` wrote, and + /// nothing can tell them from a deliberate grant any more. + #[test] + fn an_unrecorded_grant_is_still_worth_asking_about() { + let mut connection = connect_after_0716(MEMBERSHIPS, &[]); + connection + .batch_execute( + "UPDATE users_organizations \ + SET create_new_collections = TRUE, edit_any_collection = TRUE, delete_any_collection = TRUE \ + WHERE uuid = 'm_unrecorded'", + ) + .unwrap(); + + assert!(connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + } + + /// The record is still a chain invariant used by repair and rollback even though the final + /// materialized-authority predicate no longer uses it to exclude rows. Refuse a damaged chain + /// explicitly rather than letting a later statement fail as `no such table`. + #[test] + fn the_confirmation_refuses_without_the_record() { + let mut connection = connect(&[SCHEMA_BEFORE_0716, LEGACY_MANAGER_RECORD, MEMBERSHIPS]); + connection.batch_execute(ADD_COLLECTION_PERMISSIONS).unwrap(); + connection.batch_execute("DROP TABLE __vw_custom_role_legacy_manager").unwrap(); + + assert!(connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err()); + } + + /// A refusal is only a decision if "no" can be carried out on the schema it is printed for, and + /// this one is printed from two of them. The migrated shape was always answerable; the legacy + /// shape -- the ordinary upgrade, and the common case -- was told to clear columns that do not + /// exist there yet, so the only statement an operator could actually run was the acknowledgement. + /// + /// Both halves are checked against the recovery text itself, so a future edit that drops one of + /// the two statements fails here rather than in an operator's terminal. + #[test] + fn the_recovery_can_be_declined_on_both_schema_shapes() { + let legacy_query = super::permanent_authority_lookahead_query(false, true, false, false, false, "\"groups\"") + .expect("membership access_all is still present in the legacy fixture"); + + // 1. Legacy shape. The migrated shape's statement cannot run here at all. + let mut connection = connect(&[LEGACY_SCHEMA, LEGACY_GROUP_DERIVED_MANAGER]); + assert_eq!(count(&mut connection, &legacy_query), 1, "the fixture has to raise the question"); + assert!( + connection + .batch_execute( + "UPDATE users_organizations \ + SET edit_any_collection = FALSE, delete_any_collection = FALSE \ + WHERE uuid = 'm_mgr'" + ) + .is_err(), + "the permission columns do not exist before the upgrade -- this is why the text needs two answers" + ); + + // What the text offers instead: end the group relationship, for one membership... + let mut connection = connect(&[LEGACY_SCHEMA, LEGACY_GROUP_DERIVED_MANAGER]); + connection + .batch_execute( + "DELETE FROM groups_users \ + WHERE users_organizations_uuid = 'm_mgr' AND groups_uuid = 'g_all'", + ) + .unwrap(); + assert_eq!(count(&mut connection, &legacy_query), 0, "declining has to answer the question"); + + // ...or for the whole group at once. + let mut connection = connect(&[LEGACY_SCHEMA, LEGACY_GROUP_DERIVED_MANAGER]); + connection.batch_execute("UPDATE \"groups\" SET access_all = FALSE WHERE uuid = 'g_all'").unwrap(); + assert_eq!(count(&mut connection, &legacy_query), 0, "declining has to answer the question"); + + // 2. Migrated shape: the statement the text prints for it runs, and answers the question. + let mut connection = connect_after_0716(MEMBERSHIPS, &["m_recorded"]); + assert_eq!(lookahead_count(&mut connection), 1); + connection + .batch_execute( + "UPDATE users_organizations \ + SET edit_any_collection = FALSE, delete_any_collection = FALSE \ + WHERE uuid = 'm_recorded'", + ) + .unwrap(); + assert_eq!(lookahead_count(&mut connection), 0); + + for statement in [ + "DELETE FROM groups_users", + "UPDATE \"groups\" SET access_all = FALSE", + "SET edit_any_collection = FALSE, delete_any_collection = FALSE", + ] { + assert!( + super::PERMANENT_COLLECTION_AUTHORITY_RECOVERY.contains(statement), + "the refusal has to print `{statement}`" + ); + } + } + + /// The reason the preflight exists: it has to reach the *same* verdict as the migration, or it + /// either refuses a database the migration would have let through, or lets one through that then + /// aborts with nothing but a duplicate-key error. Checked against the real files. + #[test] + fn the_preflight_lookahead_agrees_with_the_migration() { + // (name, record contents, extra setup) -> the migration decides, the lookahead has to match. + let cases: [(&str, &[&str], &str); 5] = [ + ("group-derived conversion", &["m_recorded"], ""), + ("nothing qualifies", &["m_no_group"], ""), + ( + "membership access_all, never group-bound", + &["m_recorded"], + "UPDATE users_organizations SET create_new_collections = TRUE WHERE uuid = 'm_recorded'", + ), + ( + "bulk grant to a membership that is not on the record", + &[], + "UPDATE users_organizations SET edit_any_collection = TRUE WHERE uuid = 'm_unrecorded'", + ), + ( + "revoked membership: no authority today, but it would come back with one", + &["m_recorded"], + "UPDATE users_organizations SET status = -1 WHERE uuid = 'm_recorded'", + ), + ]; + + for (name, record, extra) in cases { + let mut connection = connect_after_0716(MEMBERSHIPS, record); + if !extra.is_empty() { + connection.batch_execute(extra).unwrap(); + } + + let predicted = lookahead_count(&mut connection) != 0; + let refused = connection.batch_execute(CONFIRM_PERMANENT_AUTHORITY).is_err(); + assert_eq!(predicted, refused, "preflight and migration disagree for: {name}"); + } + } +} + +/// Runs the whole Custom-role chain, then `tools/custom_role_rollback/sqlite.sql`, then the chain +/// again — against a throwaway SQLite database, with the real files on both legs. +/// +/// The round trip is the claim the rollback tooling rests on: an operator who downgrades and later +/// upgrades again has to arrive at the same permissions, or the escape hatch quietly rewrites +/// authorization. It was only ever verified by hand. +#[cfg(all(test, sqlite))] +mod custom_role_rollback_sql_tests { + use diesel::connection::SimpleConnection; + use diesel::{Connection, RunQueryDsl, sql_types::Text, sqlite::SqliteConnection}; + + /// The nine files, in the order Diesel applies them. + const CHAIN: [&str; 9] = [ + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql"), + include_str!("../../migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql"), + include_str!("../../migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/up.sql"), + include_str!("../../migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql"), + include_str!("../../migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql"), + include_str!("../../migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql"), + include_str!("../../migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql"), + include_str!("../../migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/up.sql"), + include_str!("../../migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/up.sql"), + ]; + const CHAIN_VERSIONS: [&str; 9] = [ + "20260630120000", + "20260715120000", + "20260716120000", + "20260723120000", + "20260724120000", + "20260724130000", + "20260724140000", + "20260809120000", + "20260810120000", + ]; + + const ROLLBACK: &str = include_str!("../../tools/custom_role_rollback/sqlite.sql"); + + const PERMANENT_AUTHORITY_ACK: &str = + "CREATE TABLE __vw_ack_permanent_collection_authority (acknowledged INTEGER NOT NULL PRIMARY KEY)"; + + /// `users_organizations` exactly as the release before this feature leaves it — the rollback + /// script checks for *precisely* eighteen columns afterwards, so a reduced fixture would not + /// exercise the check it exists for. + const UPSTREAM_SCHEMA: &str = " + CREATE TABLE __diesel_schema_migrations ( + version VARCHAR(50) NOT NULL PRIMARY KEY, + run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE, + akey TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 2, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + UNIQUE (user_uuid, org_uuid) + ); + CREATE TABLE groups ( + uuid TEXT NOT NULL PRIMARY KEY, + organizations_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL DEFAULT FALSE + ); + CREATE TABLE groups_users ( + groups_uuid TEXT NOT NULL, + users_organizations_uuid TEXT NOT NULL, + PRIMARY KEY (groups_uuid, users_organizations_uuid) + ); + INSERT INTO __diesel_schema_migrations (version) VALUES ('20250109172300'); + "; + + /// One membership per legacy shape that the mapping treats differently. + const LEGACY_MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org', TRUE), + ('g_plain', 'org', FALSE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_owner', 'u1', 'org', FALSE, 2, 0), + ('m_admin', 'u2', 'org', FALSE, 2, 1), + ('m_user', 'u3', 'org', FALSE, 2, 2), + ('m_mgr_bare', 'u4', 'org', FALSE, 2, 3), + ('m_mgr_all', 'u5', 'org', TRUE, 2, 3), + ('m_mgr_group', 'u6', 'org', FALSE, 2, 3), + ('m_mgr_gone', 'u7', 'org', FALSE, -1, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_mgr_gone'), + ('g_plain', 'm_mgr_bare'); + "; + + #[derive(diesel::QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + value: String, + } + + fn rows(connection: &mut SqliteConnection, query: &str) -> Vec { + diesel::sql_query(query).load::(connection).unwrap().into_iter().map(|row| row.value).collect() + } + + /// Every membership's role plus its nine permissions, as one comparable line each. + fn permission_state(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT uuid || ' atype=' || atype || ' status=' || status \ + || ' ' || manage_users || manage_groups || manage_policies \ + || create_new_collections || edit_any_collection || delete_any_collection \ + || access_event_logs || access_import_export || access_reports AS value \ + FROM users_organizations ORDER BY uuid", + ) + } + + fn legacy_state(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT uuid || ' atype=' || atype || ' access_all=' || access_all AS value \ + FROM users_organizations ORDER BY uuid", + ) + } + + /// Applies the chain, recording each version the way Diesel would. + fn upgrade(connection: &mut SqliteConnection) -> Result<(), diesel::result::Error> { + for (sql, version) in CHAIN.iter().zip(CHAIN_VERSIONS) { + connection.batch_execute(sql)?; + connection + .batch_execute(&format!("INSERT INTO __diesel_schema_migrations (version) VALUES ('{version}')"))?; + } + Ok(()) + } + + /// `.bail on` is a sqlite3 shell command, not SQL. Dropping it is safe here — a failing statement + /// fails the whole `batch_execute` anyway — but the assertion keeps the test honest if another + /// dot-command is ever added, because those the shell would act on and this runner would not. + fn rollback_sql() -> String { + let (dot, sql): (Vec<&str>, Vec<&str>) = ROLLBACK.lines().partition(|line| line.starts_with('.')); + assert_eq!(dot, [".bail on"], "unexpected sqlite3 shell command in the rollback script"); + sql.join("\n") + } + + fn connect() -> SqliteConnection { + connect_with(LEGACY_MEMBERSHIPS) + } + + fn connect_with(memberships: &str) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + connection.batch_execute("PRAGMA foreign_keys = OFF").unwrap(); + connection.batch_execute(UPSTREAM_SCHEMA).unwrap(); + connection.batch_execute(memberships).unwrap(); + connection + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + rows(connection, &format!("SELECT ({query}) || '' AS value"))[0].parse().unwrap() + } + + /// The nine `down.sql` files, in the order `diesel migration revert` applies them. + const REVERT_CHAIN: [&str; 9] = [ + include_str!("../../migrations/sqlite/2026-08-10-120000_confirm_permanent_collection_authority/down.sql"), + include_str!( + "../../migrations/sqlite/2026-08-09-120000_materialize_legacy_group_collection_authority/down.sql" + ), + include_str!("../../migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql"), + include_str!("../../migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql"), + include_str!("../../migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql"), + include_str!("../../migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql"), + include_str!("../../migrations/sqlite/2026-07-16-120000_add_custom_collection_permissions/down.sql"), + include_str!("../../migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql"), + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql"), + ]; + + const DOWNGRADE_ACK: &str = + "CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY)"; + + /// The revert chain the rollback README offers as the Diesel alternative to `sqlite.sql`, run + /// end to end. It was only ever verified by hand, and it is where the acknowledgement's lifetime + /// lives: consuming it at the guard instead of at the oldest lossy step leaves every following + /// destructive revert unguarded and strands the chain halfway. + #[test] + fn the_diesel_revert_chain_runs_with_one_acknowledgement() { + let mut connection = connect(); + let before = legacy_state(&mut connection); + + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + + // One decision, plus the historical provenance as the allowlist -- what the README suggests. + connection.batch_execute(DOWNGRADE_ACK).unwrap(); + connection + .batch_execute( + "CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); + INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) + SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;", + ) + .unwrap(); + + for (step, down) in REVERT_CHAIN.iter().enumerate() { + connection.batch_execute(down).unwrap_or_else(|e| panic!("revert step {step} failed: {e}")); + } + + assert_eq!( + legacy_state(&mut connection), + before + .iter() + .map(|row| { + // Same documented exception as the standalone script: the upgrade dropped the + // column because the role already reaches every collection, so the original + // value no longer exists. + if row.starts_with("m_owner") || row.starts_with("m_admin") { + row.replace("access_all=0", "access_all=1") + } else { + row.clone() + } + }) + .collect::>(), + "the revert chain has to land on the same legacy shape as tools/custom_role_rollback/" + ); + } + + /// Without the acknowledgement the chain stops at the guard, before the first destructive step, + /// and changes nothing. + #[test] + fn the_revert_chain_stops_at_the_guard_and_mutates_nothing() { + let mut connection = connect(); + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + let upgraded = permission_state(&mut connection); + + // 2026-08-10 and 2026-08-09 revert cleanly; they are no-ops by design. + connection.batch_execute(REVERT_CHAIN[0]).unwrap(); + connection.batch_execute(REVERT_CHAIN[1]).unwrap(); + assert!(connection.batch_execute(REVERT_CHAIN[2]).is_err(), "the downgrade guard has to refuse"); + + assert_eq!(permission_state(&mut connection), upgraded, "a refused revert must not mutate"); + } + + /// The migrated-schema half of this is covered in `custom_role_migration_sql_tests`. This is the + /// other half, and the one an ordinary upgrade actually meets: the preflight has to predict from + /// the *legacy* schema exactly whether the chain will stop for the permanent-authority decision. + #[test] + fn the_legacy_shape_lookahead_agrees_with_the_whole_chain() { + let query = super::permanent_authority_lookahead_query(false, true, false, false, false, "\"groups\"") + .expect("membership access_all is still present before the upgrade"); + + let cases: [(&str, &str); 5] = [ + ("group-derived Manager: the question", LEGACY_MEMBERSHIPS), + ( + "membership access_all too: conservatively ask without immutable provenance", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', TRUE, 2, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ( + "the access_all group belongs to another organization", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'other', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', FALSE, 2, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ( + "a plain User in the group is not converted and not asked about", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', FALSE, 2, 2); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ( + "an invited Manager holds nothing today, but would come back with it", + "INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) + VALUES ('m', 'u', 'org', FALSE, 0, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm');", + ), + ]; + + for (name, memberships) in cases { + let mut connection = connect_with(memberships); + let predicted = count(&mut connection, &query) != 0; + let refused = upgrade(&mut connection).is_err(); + assert_eq!(predicted, refused, "preflight and chain disagree on the legacy schema for: {name}"); + } + } + + #[test] + fn upgrade_rollback_and_upgrade_again_converge() { + let mut connection = connect(); + let before = legacy_state(&mut connection); + + // `m_mgr_group` and `m_mgr_gone` reach every collection through an access_all group, so the + // chain stops for the decision 2026-08-10-120000 exists to ask. + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + let upgraded = permission_state(&mut connection); + + // The legacy Manager whose authority came from the group carries it in the columns now; the + // one whose membership held access_all gets all three; a bare Manager gets nothing. + assert!(upgraded.contains(&"m_mgr_group atype=4 status=2 000011000".to_owned()), "{upgraded:?}"); + assert!(upgraded.contains(&"m_mgr_all atype=4 status=2 000111000".to_owned()), "{upgraded:?}"); + assert!(upgraded.contains(&"m_mgr_bare atype=4 status=2 000000000".to_owned()), "{upgraded:?}"); + assert!(upgraded.contains(&"m_user atype=2 status=2 000000000".to_owned()), "{upgraded:?}"); + + // Roll back with the historical provenance as the allowlist, which is what the README offers + // as the starting point. + connection + .batch_execute( + "CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); + INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) + SELECT users_organizations_uuid FROM __vw_custom_role_legacy_manager;", + ) + .unwrap(); + connection.batch_execute(&rollback_sql()).unwrap(); + + assert_eq!( + legacy_state(&mut connection), + before + .iter() + .map(|row| { + // Owner and Admin always come back with access_all set: the upgrade dropped the + // column precisely because their role already reaches every collection, so the + // original value no longer exists. Documented in the rollback README. + if row.starts_with("m_owner") || row.starts_with("m_admin") { + row.replace("access_all=0", "access_all=1") + } else { + row.clone() + } + }) + .collect::>(), + "the rollback has to restore the legacy roles it was given an allowlist for" + ); + assert_eq!( + rows( + &mut connection, + "SELECT version AS value FROM __diesel_schema_migrations WHERE version >= '20260630120000'" + ), + Vec::::new(), + "the older binary must not see a ledger from the future" + ); + + // A re-upgrade has to ask again -- the acknowledgement is consumed, and a revert is not + // consent -- and then land on exactly the state it produced the first time. + assert!(upgrade(&mut connect_from(&mut connection)).is_err(), "the question has to be asked again"); + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + + assert_eq!(permission_state(&mut connection), upgraded, "the round trip has to converge"); + } + + /// A second connection onto the same rolled-back content, so the "asks again" probe can fail + /// without leaving its aborted guard behind on the connection the test continues with. + fn connect_from(source: &mut SqliteConnection) -> SqliteConnection { + let mut copy = SqliteConnection::establish(":memory:").unwrap(); + copy.batch_execute("PRAGMA foreign_keys = OFF").unwrap(); + copy.batch_execute(UPSTREAM_SCHEMA).unwrap(); + copy.batch_execute("DELETE FROM __diesel_schema_migrations").unwrap(); + for statement in rows( + source, + "SELECT 'INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES (''' \ + || uuid || ''',''' || user_uuid || ''',''' || org_uuid || ''',' || access_all || ',''' || akey \ + || ''',' || status || ',' || atype || ')' AS value FROM users_organizations", + ) { + copy.batch_execute(&statement).unwrap(); + } + copy.batch_execute(LEGACY_GROUPS_ONLY).unwrap(); + copy + } + + const LEGACY_GROUPS_ONLY: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org', TRUE), + ('g_plain', 'org', FALSE); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_mgr_gone'), + ('g_plain', 'm_mgr_bare'); + "; + + /// The precondition is the only thing standing between a mismatched database and an irreversible + /// rewrite, so it has to refuse before touching anything. + #[test] + fn the_rollback_refuses_without_an_allowlist_and_changes_nothing() { + let mut connection = connect(); + connection.batch_execute(PERMANENT_AUTHORITY_ACK).unwrap(); + upgrade(&mut connection).unwrap(); + let upgraded = permission_state(&mut connection); + + assert!(connection.batch_execute(&rollback_sql()).is_err()); + + assert_eq!(permission_state(&mut connection), upgraded, "a refused rollback must not mutate"); + assert_eq!( + rows( + &mut connection, + "SELECT COUNT(*) || '' AS value FROM __diesel_schema_migrations WHERE version >= '20260630120000'" + ), + vec!["9".to_owned()], + "and it must not touch the ledger either" + ); + } +} + +#[cfg(test)] +mod custom_role_migration_preflight_tests { + use std::error::Error as _; + + use super::{ + CustomRoleMigrationFacts as Facts, CustomRolePreflightDecision as Decision, custom_role_preflight_decision, + custom_role_preflight_error, mysql_partial_unexpected_values_query, permanent_authority_lookahead_query, + }; + + fn pending_repair() -> Facts { + Facts { + memberships_table_exists: true, + migration_table_exists: true, + access_all_column_exists: true, + // Any database on which the chain has started under the code that ships today carries + // both of these, because its first migration writes them. Where it has not started, + // `manage_permissions_migration_applied` is false and neither is read. + legacy_manager_record_exists: true, + history_verified: true, + ..Facts::default() + } + } + + #[test] + fn empty_database_can_run_normal_migrations() { + assert_eq!(custom_role_preflight_decision(Facts::default(), false), Decision::Proceed); + } + + #[test] + fn existing_schema_without_a_ledger_is_not_guessed() { + assert_eq!( + custom_role_preflight_decision( + Facts { + memberships_table_exists: true, + access_all_column_exists: true, + ..Facts::default() + }, + false, + ), + Decision::RefuseMissingMigrationLedger + ); + } + + /// A database on which the whole chain has already run. + fn fully_migrated() -> Facts { + Facts { + memberships_table_exists: true, + migration_table_exists: true, + access_all_column_exists: false, + manage_permission_columns: 3, + manage_permissions_migration_applied: true, + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + access_permission_columns: 3, + access_permissions_migration_applied: true, + repair_migration_applied: true, + access_all_drop_migration_applied: true, + legacy_user_access_all_count: 0, + same_run_0716_marker: false, + legacy_manager_record_exists: true, + history_verified: true, + confirm_permanent_authority_migration_applied: true, + permanent_collection_authority_ack: false, + unconfirmed_permanent_authority_count: 0, + } + } + + /// A database ready for `20260810120000`, i.e. one memberships still awaiting the decision. + fn awaiting_permanent_authority_decision() -> Facts { + Facts { + confirm_permanent_authority_migration_applied: false, + permanent_collection_authority_ack: false, + unconfirmed_permanent_authority_count: 2, + ..fully_migrated() + } + } + + /// The refusal `20260810120000` exists for has to be reached *here*, with the review query and + /// the acknowledgement attached. Left to the migration's own guard it arrives as nothing but + /// `UNIQUE constraint failed: __vw_permanent_authority_guard.blocked`, on an upgrade that is + /// otherwise perfectly healthy. + #[test] + fn unconfirmed_permanent_collection_authority_is_refused_with_a_recovery_path() { + let facts = awaiting_permanent_authority_decision(); + let decision = custom_role_preflight_decision(facts, false); + assert_eq!(decision, Decision::RefuseUnconfirmedPermanentCollectionAuthority); + assert_eq!(custom_role_preflight_decision(facts, true), decision, "MySQL must not auto-complete this"); + + let error = custom_role_preflight_error(decision, facts); + let message = error.source().expect("preflight error should retain its I/O error source").to_string(); + assert!(message.contains("__vw_ack_permanent_collection_authority"), "{message}"); + assert!(message.contains("was_legacy_manager"), "{message}"); + assert!(message.contains("Nothing has been changed."), "{message}"); + // The count belongs in the message: it is what tells an operator whether the review query is + // expected to return one row or a hundred. + assert!(message.contains('2'), "{message}"); + } + + /// Three separate ways out, and each of them has to actually let the upgrade through. + #[test] + fn the_permanent_authority_question_is_asked_exactly_once() { + // The owner answered it. + assert_eq!( + custom_role_preflight_decision( + Facts { + permanent_collection_authority_ack: true, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::Proceed + ); + // Already answered on an earlier start: the migration is recorded, so it never runs again and + // the acknowledgement it consumed is gone. Asking a second time would deadlock the upgrade. + assert_eq!( + custom_role_preflight_decision( + Facts { + confirm_permanent_authority_migration_applied: true, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::Proceed + ); + // Nothing to decide -- the common case. + assert_eq!( + custom_role_preflight_decision( + Facts { + unconfirmed_permanent_authority_count: 0, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::Proceed + ); + } + + /// A damaged schema is the more urgent problem and its recovery is a different one, so it has to + /// be reported first. The question is only worth asking about a database that can actually run + /// the migration. + #[test] + fn a_damaged_schema_outranks_the_permanent_authority_question() { + assert_eq!( + custom_role_preflight_decision( + Facts { + access_permission_columns: 1, + access_permissions_migration_applied: false, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access) + ); + assert_eq!( + custom_role_preflight_decision( + Facts { + history_verified: false, + ..awaiting_permanent_authority_decision() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + } + + /// The lookahead has to answer the same question before and after the columns it would rather + /// read exist, because the preflight runs before any migration does. + #[test] + fn the_permanent_authority_lookahead_reads_whichever_schema_is_present() { + let materialized = permanent_authority_lookahead_query(true, false, true, true, true, "\"groups\"").unwrap(); + assert!(materialized.contains("uo.atype = 4")); + assert!(materialized.contains("edit_any_collection = TRUE OR uo.delete_any_collection = TRUE")); + assert!(!materialized.contains("create_new_collections")); + assert!(!materialized.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)); + + // The materialized predicate does not change with provenance availability: a mutable current + // permission is never treated as historical evidence. + let no_record = permanent_authority_lookahead_query(true, false, false, true, true, "\"groups\"").unwrap(); + assert_eq!(no_record, materialized); + + // The ordinary upgrade: nothing is materialized yet, so the answer comes from the retired + // Manager role plus the legacy bit that the first migration turns into all three permissions. + let legacy = permanent_authority_lookahead_query(false, true, false, false, false, "\"groups\"").unwrap(); + assert!(legacy.contains("uo.atype = 3")); + assert!(!legacy.contains("uo.access_all = FALSE")); + + // Both shapes bind the group to the membership's own organization. + for query in [&materialized, &no_record, &legacy] { + assert!(query.contains("g.organizations_uuid = uo.org_uuid"), "{query}"); + assert!(query.contains("g.access_all = TRUE"), "{query}"); + } + + // Neither column group is readable: the migration cannot run either, so there is nothing to + // look ahead to. + assert!(permanent_authority_lookahead_query(false, false, true, false, false, "\"groups\"").is_none()); + + // The reserved identifier is the caller's to quote. + assert!( + permanent_authority_lookahead_query(true, false, true, true, true, "`groups`") + .unwrap() + .contains("`groups`") + ); + } + + #[test] + fn repair_marker_makes_completed_state_idempotent() { + assert_eq!(custom_role_preflight_decision(fully_migrated(), false), Decision::Proceed); + } + + /// A database upgraded by an earlier revision of this feature branch carries the Custom-role + /// versions without the effects the current files have, and Diesel will not run them again. The + /// two tables the first migration creates today are the only durable evidence of that, so their + /// absence has to stop the upgrade -- before every check that assumes the chain did what it does + /// today. + #[test] + fn a_history_written_by_an_earlier_revision_is_refused() { + // Neither table: an untouched earlier-revision database. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: false, + history_verified: false, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // Recording provenance is data recovery, not an audit: writing the record table must not by + // itself pass as a review of the history that made it necessary. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: true, + history_verified: false, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // And the marker alone leaves the later migrations and the rollback scripts without the data + // they read. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: false, + history_verified: true, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // It is checked from the *first* Custom-role migration, not only from the repair one: the + // divergence starts where `atype = 3` is reused, which is before the repair runs. + assert_eq!( + custom_role_preflight_decision( + Facts { + repair_migration_applied: false, + access_all_drop_migration_applied: false, + access_all_column_exists: true, + legacy_manager_record_exists: false, + history_verified: false, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // It outranks the schema/ledger checks: those describe an interrupted migration whose replay + // is safe, which is not what this database needs. + assert_eq!( + custom_role_preflight_decision( + Facts { + legacy_manager_record_exists: false, + history_verified: false, + access_all_column_exists: true, + ..fully_migrated() + }, + false, + ), + Decision::RefuseUnverifiedCustomRoleHistory + ); + + // A database that has not started the chain at all is untouched by any of this. + assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); + } + + /// The repair migration runs *before* the access_all drop and the third permission column group, + /// so a partial state of either always carries `repair_migration_applied`. Skipping the schema + /// checks for repaired databases would make them unreachable in exactly the situation they were + /// written for. + #[test] + fn interrupted_migrations_after_the_repair_are_still_detected() { + // Crash after `DROP COLUMN access_all`, before the ledger insert. MySQL/MariaDB commit DDL + // implicitly, so the column is gone for good; a retry would fail with 1091. + let interrupted_drop = Facts { + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..fully_migrated() + }; + assert_eq!( + custom_role_preflight_decision(interrupted_drop, true), + Decision::CompleteInterruptedAccessAllDrop, + "MySQL/MariaDB can complete this in place" + ); + assert_eq!( + custom_role_preflight_decision(interrupted_drop, false), + Decision::RefuseInterruptedAccessAllDrop, + "backends with transactional DDL cannot reach this state by themselves" + ); + + // Crash after one of the three `ADD COLUMN` statements of the access group, before the + // ledger insert. A retry would fail with 1060. + for present in [1, 2] { + assert_eq!( + custom_role_preflight_decision( + Facts { + access_permission_columns: present, + access_permissions_migration_applied: false, + ..fully_migrated() + }, + true, + ), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access) + ); + } + + // Ledger recorded, columns missing. + assert_eq!( + custom_role_preflight_decision( + Facts { + access_permission_columns: 2, + ..fully_migrated() + }, + true + ), + Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Access) + ); + + // Drop recorded, but the column is back: schema and ledger disagree. + assert_eq!( + custom_role_preflight_decision( + Facts { + access_all_column_exists: true, + ..fully_migrated() + }, + true + ), + Decision::RefuseAccessAllDropLedgerMismatch + ); + } + + #[test] + fn a_pending_drop_after_the_repair_proceeds() { + // The repair ran, the drop is simply next in line: column present, migration not recorded. + assert_eq!( + custom_role_preflight_decision( + Facts { + access_all_column_exists: true, + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..fully_migrated() + }, + false, + ), + Decision::Proceed + ); + } + + #[test] + fn a_later_access_migration_before_the_pending_drop_is_refused() { + // This exact non-prefix history was deployable from the feature's former side branch: + // 20260724130000 and its columns exist, while 20260724120000 is still pending. SQLite's + // pending fixed-list rebuild would otherwise discard all three columns and their values. + for repair_migration_applied in [false, true] { + let facts = Facts { + repair_migration_applied, + access_all_column_exists: true, + access_all_drop_migration_applied: false, + access_permission_columns: 3, + access_permissions_migration_applied: true, + ..fully_migrated() + }; + let expected = Decision::RefuseOutOfOrderAccessPermissionsMigration; + + assert_eq!(custom_role_preflight_decision(facts, false), expected); + assert_eq!(custom_role_preflight_decision(facts, true), expected); + + let message = custom_role_preflight_error(expected, facts) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains(super::CUSTOM_ACCESS_PERMISSIONS_MIGRATION), "{message}"); + assert!(message.contains(super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION), "{message}"); + assert!(message.contains("would drop access_event_logs"), "{message}"); + assert!(message.contains("Nothing has been changed"), "{message}"); + } + } + + /// A repair is selected only after the same snapshot has passed every refusal. This pins the two + /// mutation-before-refusal orders that previously existed: 0716 completion before discovering a + /// damaged later column group, and interrupted-drop ledger repair before asking the owner. + #[test] + fn automatic_mysql_repairs_are_deferred_behind_all_refusals() { + let partial_0716_with_damaged_access_group = Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + access_permission_columns: 1, + access_permissions_migration_applied: false, + ..pending_repair() + }; + assert_eq!( + custom_role_preflight_decision(partial_0716_with_damaged_access_group, true), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access), + "0716 must not be completed before a later schema refusal" + ); + + let interrupted_drop_with_unanswered_authority = Facts { + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..awaiting_permanent_authority_decision() + }; + let decision = custom_role_preflight_decision(interrupted_drop_with_unanswered_authority, true); + assert_eq!(decision, Decision::RefuseUnconfirmedPermanentCollectionAuthority); + let message = custom_role_preflight_error(decision, interrupted_drop_with_unanswered_authority) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains("Nothing has been changed."), "{message}"); + + // The historical partial-completion query reads access_all. Once 0723 and its following drop + // are recorded, three columns without the earlier 0716 ledger are a non-prefix mismatch, not + // the repairable pre-0723 crash state. + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + ..fully_migrated() + }, + true, + ), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection) + ); + } + + #[test] + fn interrupted_access_all_drop_error_names_the_ledger_fix() { + let facts = Facts { + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..fully_migrated() + }; + let decision = custom_role_preflight_decision(facts, false); + let error = custom_role_preflight_error(decision, facts); + let message = error.source().expect("preflight error should retain its I/O error source").to_string(); + assert!(message.contains(super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)); + assert!(message.contains("INSERT INTO __diesel_schema_migrations")); + } + + #[test] + fn a_historical_drop_without_the_repair_is_refused() { + assert_eq!( + custom_role_preflight_decision( + Facts { + access_all_drop_migration_applied: true, + access_all_column_exists: false, + ..pending_repair() + }, + false, + ), + Decision::RefuseAlreadyDropped + ); + } + + #[test] + fn legacy_user_access_all_error_carries_a_recovery_path() { + let facts = Facts { + legacy_user_access_all_count: 2, + ..pending_repair() + }; + let decision = custom_role_preflight_decision(facts, false); + assert_eq!(decision, Decision::RefuseLegacyUserAccessAll); + + let error = custom_role_preflight_error(decision, facts); + let message = error.source().expect("preflight error should retain its I/O error source").to_string(); + assert!(message.contains("2 membership(s)")); + // The operator needs the affected memberships ... + assert!(message.contains("WHERE atype = 2\n AND access_all = TRUE;")); + // ... and both decisions: drop the reach, or write it out explicitly first. + assert!(message.contains("SET access_all = FALSE")); + assert!(message.contains("INSERT INTO users_collections")); + // Nothing here may present the snapshot as equivalent to the old dynamic reach. + assert!(message.contains("collections created after")); + } + + #[test] + fn already_dropped_error_points_at_the_backup() { + let facts = Facts { + access_all_drop_migration_applied: true, + ..pending_repair() + }; + let decision = custom_role_preflight_decision(facts, false); + assert_eq!(decision, Decision::RefuseAlreadyDropped); + + let error = custom_role_preflight_error(decision, facts); + let message = error.source().expect("preflight error should retain its I/O error source").to_string(); + assert!(message.contains("Restore the database backup")); + } + + /// A legacy `User` membership carrying the historical access_all bit stops the upgrade before any + /// migration runs, whatever its status is. Converting the bit into direct per-collection + /// assignments would turn a dynamic, status-bound reach into a durable snapshot -- and those rows + /// would still be there for an older binary after a rollback, which never checked the membership + /// status on that path. + #[test] + fn legacy_user_access_all_blocks_the_upgrade_before_any_migration() { + assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); + + let untouched_schema = Facts { + legacy_user_access_all_count: 1, + ..pending_repair() + }; + assert_eq!( + custom_role_preflight_decision(untouched_schema, false), + Decision::RefuseLegacyUserAccessAll, + "nothing may have been migrated yet when this is refused" + ); + // MySQL/MariaDB gets no exception: no partial state may be completed past this either. + assert_eq!(custom_role_preflight_decision(untouched_schema, true), Decision::RefuseLegacyUserAccessAll); + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + manage_permission_columns: 3, + manage_permissions_migration_applied: true, + legacy_user_access_all_count: 1, + ..pending_repair() + }, + false, + ), + Decision::RefuseLegacyUserAccessAll + ); + } + + #[test] + fn a_partial_permission_column_group_is_refused_with_an_actionable_message() { + // Every group is checked, not just the collection one: an interrupted MySQL migration can + // leave `manage_*` or `access_*` columns behind, and re-running it would fail forever with + // `Duplicate column name`. + for (facts, group, expected) in [ + ( + Facts { + manage_permission_columns: 2, + ..pending_repair() + }, + "manage_users", + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Manage), + ), + ( + Facts { + manage_permission_columns: 3, + manage_permissions_migration_applied: true, + access_permission_columns: 3, + ..pending_repair() + }, + "access_event_logs", + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access), + ), + ( + Facts { + manage_permission_columns: 1, + manage_permissions_migration_applied: true, + ..pending_repair() + }, + "manage_users", + Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Manage), + ), + ] { + // `true` = MySQL: only the historical collection-group state is auto-completed, never these. + assert_eq!(custom_role_preflight_decision(facts, true), expected); + assert_eq!(custom_role_preflight_decision(facts, false), expected); + + let error = custom_role_preflight_error(expected, facts); + let message = error.source().expect("preflight error should retain its I/O error source").to_string(); + assert!(message.contains(group), "message should name the affected columns: {message}"); + assert!(message.contains("ALTER TABLE users_organizations DROP COLUMN")); + } + } + + /// A group-derived legacy Manager is no longer a special case for the preflight: the repair + /// migration writes the authority into the permission columns, and nothing reads the 0/1/1 shape + /// afterwards, so no state of those columns has to be attributed or refused. + #[test] + fn a_group_derived_legacy_manager_needs_no_preflight_decision() { + assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed); + for same_run_0716_marker in [false, true] { + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + same_run_0716_marker, + ..pending_repair() + }, + false, + ), + Decision::Proceed + ); + } + } + + /// The two partial-column states need opposite advice. Without the ledger entry the migration + /// never completed, so the leftovers are untouched defaults and dropping them is free. With the + /// ledger entry the migration *did* run, so the remaining columns can hold granted permissions -- + /// and dropping them alone would not even clear the refusal, because the ledger row stays. + #[test] + fn the_two_partial_column_states_get_opposite_recovery_advice() { + let interrupted = Facts { + access_permission_columns: 1, + access_permissions_migration_applied: false, + ..fully_migrated() + }; + let vanished = Facts { + access_permission_columns: 1, + access_permissions_migration_applied: true, + ..fully_migrated() + }; + + let interrupted_decision = custom_role_preflight_decision(interrupted, false); + let vanished_decision = custom_role_preflight_decision(vanished, false); + assert_eq!(interrupted_decision, Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Access)); + assert_eq!(vanished_decision, Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Access)); + + let message_of = |decision| { + custom_role_preflight_error(decision, interrupted) + .source() + .expect("preflight error should retain its I/O error source") + .to_string() + }; + let interrupted_message = message_of(interrupted_decision); + let vanished_message = message_of(vanished_decision); + + assert!(interrupted_message.contains("dropping them"), "{interrupted_message}"); + assert!(!interrupted_message.contains("DELETE FROM __diesel_schema_migrations")); + + // The dangerous claim must not be repeated where it is false, and the operator has to be told + // to remove the ledger row as well if they accept the loss. + assert!(!vanished_message.contains("loses nothing"), "{vanished_message}"); + assert!(vanished_message.contains("Do not drop them"), "{vanished_message}"); + assert!(vanished_message.contains("Restoring the database backup"), "{vanished_message}"); + assert!(vanished_message.contains("DELETE FROM __diesel_schema_migrations"), "{vanished_message}"); + } + + /// Both generic texts end in the migration running again. For the collection group after the + /// access_all drop that is impossible -- 2026-07-16-120000 reads the dropped column -- so the advice + /// has to change to "reach the finished shape without executing it". + #[test] + fn the_collection_group_gets_replay_free_advice_once_access_all_is_gone() { + for (columns, applied, expected) in [ + (1, false, Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection)), + (1, true, Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Collection)), + ] { + let facts = Facts { + collection_permission_columns: columns, + collection_permissions_migration_applied: applied, + ..fully_migrated() + }; + let decision = custom_role_preflight_decision(facts, false); + assert_eq!(decision, expected); + + let message = custom_role_preflight_error(decision, facts) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains("cannot be migrated again on this database"), "{message}"); + assert!(message.contains("ADD COLUMN create_new_collections"), "{message}"); + assert!(message.contains("VALUES ('20260716120000')"), "{message}"); + // The replay-based advice must not leak through for this state. + assert!(!message.contains("DELETE FROM __diesel_schema_migrations"), "{message}"); + assert!(!message.contains("lets the migration run again"), "{message}"); + } + + // While access_all still exists a replay is fine, so the generic texts stay in place. + let before_drop = Facts { + access_all_column_exists: true, + access_all_drop_migration_applied: false, + access_permission_columns: 0, + access_permissions_migration_applied: false, + collection_permission_columns: 1, + collection_permissions_migration_applied: false, + ..fully_migrated() + }; + let decision = custom_role_preflight_decision(before_drop, false); + assert_eq!(decision, Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection)); + let message = custom_role_preflight_error(decision, before_drop) + .source() + .expect("preflight error should retain its I/O error source") + .to_string(); + assert!(message.contains("lets the migration run again"), "{message}"); + } + + #[test] + fn exact_mysql_partial_schema_uses_only_the_mysql_completion_path() { + let facts = Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + ..pending_repair() + }; + assert_eq!(custom_role_preflight_decision(facts, true), Decision::CompleteMysqlCollectionMigration); + assert_eq!( + custom_role_preflight_decision(facts, false), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection) + ); + } + + #[test] + fn mysql_partial_0716_projects_the_pending_group_authority_before_completion() { + let projected = permanent_authority_lookahead_query(true, true, true, false, false, "`groups`") + .expect("the partial schema still has access_all"); + + assert!(!projected.contains("uo.access_all = FALSE"), "{projected}"); + assert!(projected.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE), "{projected}"); + assert!(projected.contains("g.organizations_uuid = uo.org_uuid"), "{projected}"); + assert!( + projected.contains("uo.edit_any_collection = TRUE"), + "the projection must retain both pending and already-materialized grants: {projected}" + ); + + let facts = Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + unconfirmed_permanent_authority_count: 1, + ..pending_repair() + }; + assert_eq!( + custom_role_preflight_decision(facts, true), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "the owner decision must precede complete_partial_collection_migration()" + ); + + let acknowledged = Facts { + permanent_collection_authority_ack: true, + ..facts + }; + assert_eq!( + custom_role_preflight_decision(acknowledged, true), + Decision::CompleteMysqlCollectionMigration, + "the validated partial state is repairable after the owner answers" + ); + } + + /// Some earlier feature-branch snapshots recorded 0716 after adding its columns but before the + /// group-derived UPDATE was part of that migration. The later 0723 repair is what will write + /// 0/1/1 for those recorded Managers, so a recorded 0716 must not make the preflight trust the + /// temporary 0/0/0 values while that repair is still pending. + #[test] + fn recorded_old_0716_projects_the_pending_repair_before_migrations_run() { + let projected = permanent_authority_lookahead_query(true, true, true, true, false, "\"groups\"") + .expect("the pending repair can be projected from access_all and the Manager record"); + + assert!(projected.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE), "{projected}"); + assert!(projected.contains("uo.atype = 3 OR"), "{projected}"); + assert!(projected.contains("uo.edit_any_collection = TRUE"), "{projected}"); + + let facts = Facts { + manage_permission_columns: 3, + manage_permissions_migration_applied: true, + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + repair_migration_applied: false, + unconfirmed_permanent_authority_count: 1, + ..pending_repair() + }; + for mysql in [false, true] { + assert_eq!( + custom_role_preflight_decision(facts, mysql), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "backend flag {mysql}: the owner must decide before the pending repair writes 0/1/1" + ); + } + } + + #[test] + fn interrupted_mysql_drop_repair_has_an_explicit_transaction_boundary() { + let source = include_str!("mod.rs"); + let function = source + .split_once("fn complete_interrupted_access_all_drop(") + .expect("repair function must exist") + .1 + .split_once("/// Read everything") + .expect("repair function boundary must remain recognizable") + .0; + + assert!(function.contains("connection.transaction"), "the ledger repair must commit with autocommit=0"); + assert!(function.contains("super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION")); + } + + #[test] + fn postgresql_preflight_requires_one_migration_namespace() { + let query = super::postgresql_migration_namespace_query(); + for relation in [ + "users_organizations", + "__diesel_schema_migrations", + "groups", + "groups_users", + super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE, + super::CUSTOM_ROLE_HISTORY_VERIFIED_TABLE, + ] { + assert!(query.contains(relation), "namespace guard does not bind {relation}: {query}"); + } + assert!(query.contains("current_schema()")); + assert!(query.contains("resolved.relnamespace <> memberships.relnamespace")); + } + + /// A repair is not an answer to the permanent-authority question. Both automatic repairs are + /// deferred behind that refusal, and re-inspection after a permitted repair must reach the same + /// refusal if the database changes between the decision and the next pass. + #[test] + fn a_repair_does_not_answer_the_permanent_authority_question() { + // The interrupted drop is reachable only after the repair migration, and 20260724130000 + // cannot have run yet, so its columns are still absent on both sides of that repair. + let interrupted_drop = Facts { + access_permission_columns: 0, + access_permissions_migration_applied: false, + ..awaiting_permanent_authority_decision() + }; + + for (name, before_repair, after_repair) in [ + ( + "the historical MySQL partial collection-permission schema", + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: false, + unconfirmed_permanent_authority_count: 2, + ..pending_repair() + }, + // complete_partial_collection_migration() records 20260716120000, nothing else. + Facts { + collection_permission_columns: 3, + collection_permissions_migration_applied: true, + unconfirmed_permanent_authority_count: 2, + ..pending_repair() + }, + ), + ( + "an access_all drop that committed without its ledger entry", + Facts { + access_all_drop_migration_applied: false, + ..interrupted_drop + }, + // complete_interrupted_access_all_drop() records 20260724120000, nothing else. + interrupted_drop, + ), + ] { + assert_eq!( + custom_role_preflight_decision(before_repair, true), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "{name}: no repair may mutate the database before the owner decides" + ); + assert_eq!( + custom_role_preflight_decision(after_repair, true), + Decision::RefuseUnconfirmedPermanentCollectionAuthority, + "{name}: re-inspection must preserve the refusal" + ); + } + } + + #[test] + fn historical_mysql_partial_query_does_not_require_the_new_marker_table() { + let query = mysql_partial_unexpected_values_query(false); + assert!(!query.contains(super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)); + assert!(!query.contains("groups_users")); + // Without the allowance the query reads users_organizations only, so it stays answerable on + // a database that has no provenance record at all. + assert!(!query.contains(super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE)); + } + + #[test] + fn same_run_mysql_partial_query_requires_the_current_group_source() { + let query = mysql_partial_unexpected_values_query(true); + assert!(query.contains("access_all = FALSE")); + assert!(query.contains("edit_any_collection = TRUE")); + assert!(query.contains("delete_any_collection = TRUE")); + assert!(query.contains("INNER JOIN `groups` AS g")); + assert!(query.contains("g.organizations_uuid = users_organizations.org_uuid")); + assert!(query.contains("g.access_all = TRUE")); + } + + /// The allowance describes what 2026-07-16-120000 can produce, and that statement is driven by + /// the legacy-Manager record. A 0/1/1 row for a membership that is not on the record therefore + /// has no legitimate source, and must not be counted as an expected shape -- otherwise the + /// automatic MySQL recovery would adopt a grant nothing can account for. + #[test] + fn the_same_run_allowance_is_bound_to_the_legacy_manager_record() { + let query = mysql_partial_unexpected_values_query(true); + assert!( + query.contains(&format!( + "uuid IN (SELECT users_organizations_uuid FROM {})", + super::CUSTOM_ROLE_LEGACY_MANAGER_TABLE + )), + "{query}" + ); + } + + #[test] + fn incomplete_columns_and_ledger_mismatch_are_refused() { + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 2, + ..pending_repair() + }, + true, + ), + Decision::RefusePartialPermissionSchema(super::PermissionColumnGroup::Collection) + ); + assert_eq!( + custom_role_preflight_decision( + Facts { + collection_permission_columns: 2, + collection_permissions_migration_applied: true, + ..pending_repair() + }, + true, + ), + Decision::RefusePermissionLedgerMismatch(super::PermissionColumnGroup::Collection) + ); + } +} diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index eed5041d..f1faede9 100644 --- a/src/db/models/cipher.rs +++ b/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::(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::(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) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 8aec90ea..b31ef9e8 100644 --- a/src/db/models/collection.rs +++ b/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) -> 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::(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::(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 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)); + } +} diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 86cbf5d0..96991254 100644 --- a/src/db/models/event.rs +++ b/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 { - conn.run(move |conn| { - event::table - .filter(event::cipher_uuid.eq(cipher_uuid)) - .filter(event::event_date.between(start, end)) - .order_by(event::event_date.desc()) - .limit(Self::PAGE_SIZE) - .load::(conn) - .expect("Error filtering events") - }) - .await + conn.run(move |conn| Self::find_by_cipher_uuid_impl(cipher_uuid, org_uuid, start, end, conn)).await + } + + fn find_by_cipher_uuid_impl( + cipher_uuid: &CipherId, + org_uuid: Option<&OrganizationId>, + start: &NaiveDateTime, + end: &NaiveDateTime, + conn: &mut crate::db::DbConnInner, + ) -> Vec { + let query = event::table + .filter(event::cipher_uuid.eq(cipher_uuid)) + .filter(event::event_date.between(start, end)) + .into_boxed(); + + // A cipher event request is authorized for exactly one scope: either the cipher's + // current organization or its personal owner. Apply that scope before PAGE_SIZE so + // rows from another scope cannot consume the page and hide older authorized events. + match org_uuid { + Some(org_uuid) => query.filter(event::org_uuid.eq(org_uuid)), + None => query.filter(event::org_uuid.is_null()), + } + .order_by(event::event_date.desc()) + .limit(Self::PAGE_SIZE) + .load::(conn) + .expect("Error filtering events") } pub async fn clean_events(conn: &DbConn) -> EmptyResult { @@ -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())); + } +} diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 37037de6..2f5348d7 100644 --- a/src/db/models/group.rs +++ b/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: diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..d41dd5df 100644 --- a/src/db/models/organization.rs +++ b/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, - pub access_all: bool, pub akey: String, pub status: i32, pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + 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, + diesel::dsl::Eq, +> { + 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 { - #[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 = if include_collections && !(full_access_group || self.access_all) { - // Get all collections for the user here already to prevent more queries - let cu: HashMap = - CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + let collections: Vec = + 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 = + 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 = 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 = 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::(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::(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 { @@ -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 { 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::(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::(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::(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::(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::(&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()); } } diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..06023872 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -236,12 +236,20 @@ table! { user_uuid -> Text, org_uuid -> Text, invited_by_email -> Nullable, - access_all -> Bool, akey -> Text, status -> Integer, atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + 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, } } diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index a2a643c3..70ff4cd2 100644 --- a/src/static/scripts/admin_users.js +++ b/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; diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs index 4c91bc0e..d848d894 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -130,10 +130,10 @@