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..aed55087 --- /dev/null +++ b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,95 @@ +-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or +-- the Custom role. Two explicit operator decisions are required before anything is touched, and both +-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older +-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. +-- +-- Both guards use temporary tables on purpose: on MySQL/MariaDB temporary-table DDL is the only DDL +-- that does not commit implicitly, so a refusal here cannot leave a half-reverted schema behind. + +-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. +CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); +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 __vw_custom_role_downgrade_guard; + +-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a +-- Custom member holds, so handing it out automatically would *grant* authority during a +-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every +-- Custom member to plain User. See README.md in tools/custom_role_rollback/. +-- +-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); +-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES (''); +-- +-- The duplicate key aborts the revert. It is only inserted while the list is absent. +CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); +INSERT INTO __vw_rollback_allowlist_guard (blocked) +SELECT 1 FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = '__vw_rollback_manager_allowlist' +); +DROP TEMPORARY TABLE __vw_rollback_allowlist_guard; + +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; + +-- Roles and `access_all` are recomputed together, because in the old schema they are not +-- independent: +-- +-- * Owners and Admins always carried the bit and it grants them nothing extra; +-- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three +-- collection permissions -- in the old schema `access_all` also carried collection deletion, so +-- an Edit-only member must not silently gain it; +-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy +-- state the upgrade refuses, so leaving it set would make the database unable to move forward +-- again. +-- +-- Group-derived Manager authority is not restored here and does not need to be: `groups.access_all` +-- was never modified, so the older binary derives it again by itself for whoever comes back as +-- Manager. +UPDATE users_organizations +SET access_all = CASE + WHEN atype IN (0, 1) THEN TRUE + WHEN atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) + AND create_new_collections = TRUE + AND edit_any_collection = TRUE + AND delete_any_collection = TRUE THEN TRUE + ELSE FALSE + END, + atype = CASE + WHEN atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 + WHEN atype = 4 THEN 2 + ELSE atype + END; + +ALTER TABLE users_organizations + DROP COLUMN manage_users, + DROP COLUMN manage_groups, + DROP COLUMN manage_policies, + DROP COLUMN create_new_collections, + DROP COLUMN edit_any_collection, + DROP COLUMN delete_any_collection, + DROP COLUMN access_event_logs, + DROP COLUMN access_import_export, + DROP COLUMN access_reports; + +-- Both decisions authorized *this* downgrade, not the next one. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; +DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; 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..7a887df6 --- /dev/null +++ b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,127 @@ +-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine +-- granular permissions. +-- +-- Two different columns are called `access_all`, and everything below depends on keeping them apart: +-- +-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped +-- at the end of this file. +-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read +-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group +-- members access to every collection afterwards exactly as before. +-- +-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection +-- through either. Only the membership bit is going away, but the capability an owner configured +-- through either route is preserved, so both are read below. While this file runs the membership +-- column still exists and `atype = 3` still unambiguously means "legacy Manager". +-- +-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates +-- the same condition at startup and prints the recovery text, because Diesel would surface the abort +-- below as nothing but a driver-level duplicate-key error. +-- +-- A temporary table on purpose: on MySQL/MariaDB it is the only DDL that does not commit implicitly, +-- so a refusal cannot leave a half-applied migration behind. + +-- A plain User carrying membership `access_all`, reachable only on databases written before the web +-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and +-- future, with no management authority, and the new model has no permission for that: +-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. +-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when +-- such a membership exists. +CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_user_access_all_guard (blocked) +SELECT 1 +FROM users_organizations +WHERE atype = 2 + AND access_all = TRUE +LIMIT 1; +DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard; + +-- One ALTER TABLE for all nine columns: MySQL/MariaDB commit every DDL statement implicitly, so nine +-- separate statements would leave nine points at which a crash produces a partially migrated schema. +-- A single ALTER is one such point, and on MySQL 8 it is atomic. +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; + +-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives +-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard +-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management +-- capability it is configured with right now: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * an organization-local `access_all` group covered editing and deleting every collection, but +-- never creation -- that always required the membership bit; +-- * a Manager with neither keeps all three at FALSE. +-- +-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the +-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on +-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL +-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the +-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its +-- own. The alternative is silently revoking access these members have today, or refusing an ordinary +-- upgrade; the permission is visible in the member's permission list and an owner can clear it. +-- +-- The management (manage_users / manage_groups / manage_policies) and access (event logs / +-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager +-- capability -- every member mutation, every policy write, the organization export and both +-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. +-- +-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` +-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list +-- (`GET /organizations//users`) for them and for nobody whose reach came from a group. +-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, +-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and +-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` +-- satisfies `has_full_access()`, which opens the organization collection list and +-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach +-- through the group, so only the route is new. +-- +-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means +-- Manager everywhere it is read. +-- +-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is +-- converted like a confirmed one, since none holds authority in that state and the permissions are +-- what it would come back with -- the same thing `access_all` would have done. +-- +-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at +-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. +UPDATE users_organizations +SET create_new_collections = access_all, + edit_any_collection = access_all + OR EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ), + delete_any_collection = access_all + OR EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ), + atype = 4 +WHERE atype = 3; + +-- The flag is now fully represented by the role model, so drop the redundant column. This concerns +-- `users_organizations` only; `groups.access_all` stays. +ALTER TABLE users_organizations DROP COLUMN access_all; + +-- Never inherit a downgrade acknowledgement left behind by an earlier revert. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; diff --git a/migrations/postgresql/2026-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..bc8e41d5 --- /dev/null +++ b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,84 @@ +-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or +-- the Custom role. Two explicit operator decisions are required before anything is touched, and both +-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older +-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. + +-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. +CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); +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; + +-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a +-- Custom member holds, so handing it out automatically would *grant* authority during a +-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every +-- Custom member to plain User. See README.md in tools/custom_role_rollback/. +-- +-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid CHAR(36) NOT NULL PRIMARY KEY); +-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES (''); +-- +-- The duplicate key aborts the revert. It is only inserted while the list is absent. +CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); +INSERT INTO __vw_rollback_allowlist_guard (blocked) +SELECT 1 +WHERE to_regclass('__vw_rollback_manager_allowlist') IS NULL; +DROP TABLE __vw_rollback_allowlist_guard; + +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; + +-- Roles and `access_all` are recomputed together, because in the old schema they are not +-- independent: +-- +-- * Owners and Admins always carried the bit and it grants them nothing extra; +-- * an allowlisted Custom member becomes a Manager, and keeps the bit only if it holds all three +-- collection permissions -- in the old schema `access_all` also carried collection deletion, so +-- an Edit-only member must not silently gain it; +-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy +-- state the upgrade refuses, so leaving it set would make the database unable to move forward +-- again. +-- +-- Group-derived Manager authority is not restored here and does not need to be: `groups.access_all` +-- was never modified, so the older binary derives it again by itself for whoever comes back as +-- Manager. +UPDATE users_organizations +SET access_all = CASE + WHEN atype IN (0, 1) THEN TRUE + WHEN atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) + AND create_new_collections = TRUE + AND edit_any_collection = TRUE + AND delete_any_collection = TRUE THEN TRUE + ELSE FALSE + END, + atype = CASE + WHEN atype = 4 + AND uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 + WHEN atype = 4 THEN 2 + ELSE atype + END; + +ALTER TABLE users_organizations + DROP COLUMN manage_users, + DROP COLUMN manage_groups, + DROP COLUMN manage_policies, + DROP COLUMN create_new_collections, + DROP COLUMN edit_any_collection, + DROP COLUMN delete_any_collection, + DROP COLUMN access_event_logs, + DROP COLUMN access_import_export, + DROP COLUMN access_reports; + +-- Both decisions authorized *this* downgrade, not the next one. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; +DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; 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..53ae67cc --- /dev/null +++ b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,122 @@ +-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine +-- granular permissions. +-- +-- Two different columns are called `access_all`, and everything below depends on keeping them apart: +-- +-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped +-- at the end of this file. +-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read +-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group +-- members access to every collection afterwards exactly as before. +-- +-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection +-- through either. Only the membership bit is going away, but the capability an owner configured +-- through either route is preserved, so both are read below. While this file runs the membership +-- column still exists and `atype = 3` still unambiguously means "legacy Manager". +-- +-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates +-- the same condition at startup and prints the recovery text, because Diesel would surface the abort +-- below as nothing but a driver-level duplicate-key error. + +-- A plain User carrying membership `access_all`, reachable only on databases written before the web +-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and +-- future, with no management authority, and the new model has no permission for that: +-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. +-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when +-- such a membership exists. +CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_user_access_all_guard (blocked) +SELECT 1 +FROM users_organizations +WHERE atype = 2 + AND access_all = TRUE +LIMIT 1; +DROP TABLE __vw_legacy_user_access_all_guard; + +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; + +-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives +-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard +-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management +-- capability it is configured with right now: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * an organization-local `access_all` group covered editing and deleting every collection, but +-- never creation -- that always required the membership bit; +-- * a Manager with neither keeps all three at FALSE. +-- +-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the +-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on +-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL +-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the +-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its +-- own. The alternative is silently revoking access these members have today, or refusing an ordinary +-- upgrade; the permission is visible in the member's permission list and an owner can clear it. +-- +-- The management (manage_users / manage_groups / manage_policies) and access (event logs / +-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager +-- capability -- every member mutation, every policy write, the organization export and both +-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. +-- +-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` +-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list +-- (`GET /organizations//users`) for them and for nobody whose reach came from a group. +-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, +-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and +-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` +-- satisfies `has_full_access()`, which opens the organization collection list and +-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach +-- through the group, so only the route is new. +-- +-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means +-- Manager everywhere it is read. +-- +-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is +-- converted like a confirmed one, since none holds authority in that state and the permissions are +-- what it would come back with -- the same thing `access_all` would have done. +-- +-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at +-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. +UPDATE users_organizations +SET create_new_collections = access_all, + edit_any_collection = access_all + OR EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ), + delete_any_collection = access_all + OR EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = users_organizations.uuid + AND g.organizations_uuid = users_organizations.org_uuid + AND g.access_all = TRUE + ), + atype = 4 +WHERE atype = 3; + +-- The flag is now fully represented by the role model: Owners/Admins hold it implicitly, a Custom +-- member holds it through `edit_any_collection`. Drop the redundant column. This only concerns +-- users_organizations; `groups.access_all` stays. +ALTER TABLE users_organizations DROP COLUMN access_all; + +-- Never inherit a downgrade acknowledgement left behind by an earlier revert. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; diff --git a/migrations/sqlite/2026-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..fd17319e --- /dev/null +++ b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,102 @@ +-- Lossy revert: the legacy role/`access_all` schema cannot represent the nine Custom permissions or +-- the Custom role. Two explicit operator decisions are required before anything is touched, and both +-- are consumed at the end, so one decision covers one downgrade. Operators who only need the older +-- binary to start again can use the self-contained script per backend in tools/custom_role_rollback/. + +-- 1) Acknowledge the loss. Create this table with every Vaultwarden instance stopped: +-- +-- CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY); +-- +-- The duplicate key aborts the revert. It is only inserted while the acknowledgement is absent. +CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1); +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; + +-- 2) Decide which Custom memberships come back as Manager. The legacy role is not a subset of what a +-- Custom member holds, so handing it out automatically would *grant* authority during a +-- downgrade; it takes a current, deliberate list. An empty list is a valid answer and maps every +-- Custom member to plain User. See README.md in tools/custom_role_rollback/. +-- +-- CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY); +-- INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES (''); +-- +-- The duplicate key aborts the revert. It is only inserted while the list is absent. +CREATE TEMPORARY TABLE __vw_rollback_allowlist_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_rollback_allowlist_guard (blocked) VALUES (1); +INSERT INTO __vw_rollback_allowlist_guard (blocked) +SELECT 1 +WHERE NOT EXISTS ( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = '__vw_rollback_manager_allowlist' +); +DROP TABLE __vw_rollback_allowlist_guard; + +-- Roles and `access_all` are recomputed together, because in the old schema they are not independent: +-- +-- * Owners and Admins always carried the bit and it grants them nothing extra; +-- * an allowlisted Custom member becomes a Manager, keeping the bit only with all three collection +-- permissions -- `access_all` also carried collection deletion there, so an Edit-only member must +-- not silently gain it; +-- * everything else becomes a plain User without the bit. `User + access_all` is the one legacy +-- state the upgrade refuses, so leaving it set would strand the database. +-- +-- Group-derived Manager authority needs no restoring: `groups.access_all` was never modified, so the +-- older binary derives it again for whoever comes back as Manager. +CREATE TABLE users_organizations_old ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + org_uuid TEXT NOT NULL REFERENCES organizations (uuid), + + access_all BOOLEAN NOT NULL, + akey TEXT NOT NULL, + status INTEGER NOT NULL, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + + UNIQUE (user_uuid, org_uuid) +); + +INSERT INTO users_organizations_old ( + uuid, user_uuid, org_uuid, access_all, akey, status, atype, + reset_password_key, external_id, invited_by_email +) +SELECT + uo.uuid, uo.user_uuid, uo.org_uuid, + CASE + WHEN uo.atype IN (0, 1) THEN TRUE + WHEN uo.atype = 4 + AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) + AND uo.create_new_collections = TRUE + AND uo.edit_any_collection = TRUE + AND uo.delete_any_collection = TRUE THEN TRUE + ELSE FALSE + END, + uo.akey, uo.status, + CASE + WHEN uo.atype = 4 + AND uo.uuid IN (SELECT users_organizations_uuid FROM __vw_rollback_manager_allowlist) THEN 3 + WHEN uo.atype = 4 THEN 2 + ELSE uo.atype + END, + uo.reset_password_key, uo.external_id, uo.invited_by_email +FROM users_organizations AS uo; + +DROP TABLE users_organizations; + +ALTER TABLE users_organizations_old RENAME TO users_organizations; + +-- Both decisions authorized *this* downgrade, not the next one. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; +DROP TABLE IF EXISTS __vw_rollback_manager_allowlist; 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..93e3169c --- /dev/null +++ b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,156 @@ +-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine +-- granular permissions. +-- +-- Two different columns are called `access_all`, and everything below depends on keeping them apart: +-- +-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped +-- at the end of this file. +-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. Only read +-- here, to decide a legacy Manager's permissions; never written, and it keeps granting group +-- members access to every collection afterwards exactly as before. +-- +-- Base `Collection::is_coll_manageable_by_user` accepts either, so a Manager reached every collection +-- through either. Only the membership bit is going away, but the capability an owner configured +-- through either route is preserved, so both are read below. While this file runs the membership +-- column still exists and `atype = 3` still unambiguously means "legacy Manager". +-- +-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates +-- the same condition at startup and prints the recovery text, because Diesel would surface the abort +-- below as nothing but a driver-level duplicate-key error. + +-- A plain User carrying membership `access_all`, reachable only on databases written before the web +-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and +-- future, with no management authority, and the new model has no permission for that: +-- `edit_any_collection` would add management authority, dropping the bit would take the reach away. +-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when +-- such a membership exists. +CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard ( + blocked INTEGER NOT NULL PRIMARY KEY +); +INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1); +INSERT INTO __vw_legacy_user_access_all_guard (blocked) +SELECT 1 +FROM users_organizations +WHERE atype = 2 + AND access_all = TRUE +LIMIT 1; +DROP TABLE __vw_legacy_user_access_all_guard; + +-- Schema and data change in one table rebuild, which also keeps the conversion unambiguous: +-- `atype = 3` still means Manager while the permission values are computed from it. +-- +-- `ALTER TABLE ... DROP COLUMN` is deliberately not used -- it needs SQLite 3.35.0, while a +-- `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1. The +-- rebuild follows the existing 2022-03-02-210038_update_devices_primary_key pattern; Vaultwarden runs +-- SQLite migrations with `PRAGMA foreign_keys = OFF`, so the drop does not cascade into groups_users. +CREATE TABLE users_organizations_new ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL REFERENCES users (uuid), + org_uuid TEXT NOT NULL REFERENCES organizations (uuid), + + akey TEXT NOT NULL, + status INTEGER NOT NULL, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + invited_by_email TEXT DEFAULT NULL, + manage_users BOOLEAN NOT NULL DEFAULT FALSE, + manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + access_reports BOOLEAN NOT NULL DEFAULT FALSE, + + UNIQUE (user_uuid, org_uuid) +); + +-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives +-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard +-- above), so only a Manager becomes Custom, keeping the organization-wide collection-management +-- capability it is configured with right now: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * an organization-local `access_all` group covered editing and deleting every collection, but +-- never creation -- that always required the membership bit; +-- * a Manager with neither keeps all three at FALSE. +-- +-- The second case is a deliberate policy choice. That capability was dynamic: it ended with the +-- group, with the group's own `access_all`, and with the member leaving it. It was never gated on +-- ORG_GROUPS_ENABLED -- `Collection::is_coll_manageable_by_user` reads `groups.access_all` in SQL +-- with no configuration check -- so it applied even where groups were never enabled. Nothing in the +-- new model is bound to a group, so it becomes a membership permission and no longer lapses on its +-- own. The alternative is silently revoking access these members have today, or refusing an ordinary +-- upgrade; the permission is visible in the member's permission list and an owner can clear it. +-- +-- The management (manage_users / manage_groups / manage_policies) and access (event logs / +-- import-export / reports) permissions start out FALSE for everyone. Nothing they unlock was a Manager +-- capability -- every member mutation, every policy write, the organization export and both +-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege. +-- +-- One read is not carried over, and only for members who held the MEMBERSHIP bit: `has_full_access()` +-- read `self.access_all` and the role, never `groups.access_all`, so it gated the full member list +-- (`GET /organizations//users`) for them and for nobody whose reach came from a group. +-- `manage_users` is not granted to restore it, because it also carries invite, confirm, revoke, +-- restore and delete, which the Manager role never had; such members keep `/users/mini-details`, and +-- an owner can grant `manage_users` deliberately. In the other direction `edit_any_collection` +-- satisfies `has_full_access()`, which opens the organization collection list and +-- `GET /ciphers/organization-details` to the group-derived class -- data they could already reach +-- through the group, so only the route is new. +-- +-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is +-- converted like a confirmed one, since none holds authority in that state and the permissions are +-- what it would come back with -- the same thing `access_all` would have done. +-- +-- The group lookup is bound to the membership's own organization: a `groups_users` row pointing at +-- another organization's `access_all` group conveys nothing, exactly as it conveys nothing today. +INSERT INTO users_organizations_new ( + uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id, + invited_by_email, manage_users, manage_groups, manage_policies, + create_new_collections, edit_any_collection, delete_any_collection, + access_event_logs, access_import_export, access_reports +) +SELECT + uo.uuid, uo.user_uuid, uo.org_uuid, uo.akey, uo.status, + CASE WHEN uo.atype = 3 THEN 4 ELSE uo.atype END, + uo.reset_password_key, uo.external_id, uo.invited_by_email, + FALSE, FALSE, FALSE, + CASE WHEN uo.atype = 3 AND uo.access_all = TRUE THEN TRUE ELSE FALSE END, + CASE + WHEN uo.atype = 3 + AND (uo.access_all = TRUE + OR EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + )) + THEN TRUE ELSE FALSE + END, + CASE + WHEN uo.atype = 3 + AND (uo.access_all = TRUE + OR EXISTS ( + SELECT 1 + FROM groups_users AS gu + INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid + WHERE gu.users_organizations_uuid = uo.uuid + AND g.organizations_uuid = uo.org_uuid + AND g.access_all = TRUE + )) + THEN TRUE ELSE FALSE + END, + FALSE, FALSE, FALSE +FROM users_organizations AS uo; + +DROP TABLE users_organizations; + +ALTER TABLE users_organizations_new RENAME TO users_organizations; + +-- Never inherit a downgrade acknowledgement left behind by an earlier revert. +DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade; diff --git a/src/api/admin.rs b/src/api/admin.rs index eaa681dd..a861e0b7 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 13021ca3..a4a51216 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -392,6 +392,13 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: & Ok(()) } +fn has_prevalidated_organization_write_authority( + shared_to_collections: Option<&Vec>, + member_has_full_access: bool, +) -> bool { + 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, @@ -452,9 +459,10 @@ 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( + 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. @@ -569,6 +577,22 @@ pub async fn update_cipher_from_data( Ok(()) } +#[cfg(test)] +mod update_authority_tests { + use super::has_prevalidated_organization_write_authority; + + #[test] + fn organization_write_requires_a_validated_collection_or_full_access() { + let no_collections: Vec = Vec::new(); + assert!(!has_prevalidated_organization_write_authority(Some(&no_collections), false)); + assert!(!has_prevalidated_organization_write_authority(None, false)); + + let collections = vec!["collection".to_owned().into()]; + assert!(has_prevalidated_organization_write_authority(Some(&collections), false)); + assert!(has_prevalidated_organization_write_authority(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 2c437a36..57525f1e 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, EventType, 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 9082297f..e4a8217c 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, 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,58 @@ 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, - &collection.uuid, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; + // Security: a `manage` grant carries collection administration authority, so only a caller who may + // already administer this collection may confer it. Create is independent of Edit/Delete, so for + // `create_new_collections` alone the requested `manage` is forced to false; the creator's own grant + // is added separately below. Evaluated after the collection exists so the lookup sees it. + let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await; + let creator_needs_assignment = !headers.membership.has_full_access(); + + // 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 +634,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 +646,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, + &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 +692,45 @@ async fn post_bulk_access_collections( err!("Can't find organization details") } - // The collections and members are checked below, the groups only here. + // Security: authorization is per collection below, via the same `auth::can_edit_collection` the + // single-collection edit endpoint uses — a body-param endpoint cannot use `ManagerHeaders`, and the + // two must not diverge. Group `access_all` deliberately does not satisfy it (the previous + // `is_manageable_by_user` check accepted it, and disagreed with the single-edit endpoint). + + // Security and atomicity: validate the whole request against this organization before mutating + // anything — every collection, group and user must belong to it and be manageable by the caller. + // Only then does the destructive delete/replace begin, so a foreign-tenant group can never be linked + // and a later invalid element cannot leave earlier collections already wiped. let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { 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: only a caller who could delete this collection may confer a `manage` grant on it; + // otherwise the requested `manage` is forced to false. + let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, col_id, &conn).await; + // update collection modification date collection.save(&conn).await?; @@ -607,25 +745,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 +835,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 +864,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 +885,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 +911,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 +921,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 +947,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 +959,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 +1005,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 +1022,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 +1052,85 @@ 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 calls this when the acting member may not read every cipher: DeleteAnyCollection +// alone needs an empty successful response so the collection list can finish loading. +// +// Security: start from the regular user-visible cipher query and constrain it to the requested +// organization. DeleteAnyCollection must never make cipher contents visible. +#[get("/ciphers/organization-details/assigned?")] +async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { + if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { + 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 Bitwarden), `hidePasswords` is reported as +// `viewPassword: false` rather than redacted server-side, so this returns exactly what the same +// member already receives from `/api/sync` — never more. +async fn assigned_org_ciphers_json( + org_id: &OrganizationId, + host: &str, + 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 reports +// locally. Admins/Owners and Custom members with `editAnyCollection` already reach every cipher. +// `accessReports` alone only opens the endpoint for the caller's existing assignments: it must not +// turn permission to compute reports into read access to otherwise inaccessible organization data. #[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 = match organization_report_scope(&headers.membership) { + OrganizationReportScope::Complete => { + get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? + } + OrganizationReportScope::Assigned => { + assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? + } + OrganizationReportScope::Denied => { + 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 +1142,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 +1193,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 +1256,142 @@ 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 { + /// Read one known permission key. + /// + /// An absent key is `false`: the object is the complete set the caller wants. A key that *is* present + /// must be a JSON boolean — treating `"true"`, `1` or `null` as "not `Value::Bool(true)`" turned a + /// malformed request into a silent permission *removal* that still answered 200. + fn read_known(permissions: &HashMap, key: &str) -> Result { + match permissions.get(key) { + None => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(other) => { + let found = match other { + Value::Null => "null", + Value::String(_) => "a string", + Value::Number(_) => "a number", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + Value::Bool(_) => unreachable!("booleans are handled above"), + }; + err!(format!("Invalid permissions: '{key}' must be true or false, but is {found}")) + } + } + } + + /// Parse a permissions object. + /// + /// Every known key is type-checked even when the role makes the flags inert, so a malformed request + /// is rejected identically whatever role it names, and always before anything is mutated. Unknown + /// keys are ignored: Bitwarden sends `manageSso`, `manageScim` and `manageResetPassword`, and + /// rejecting them would break clients over permissions Vaultwarden does not implement. + fn from_request(member_type: MembershipType, permissions: &HashMap) -> Result { + let parsed = Self { + manage_users: Self::read_known(permissions, "manageUsers")?, + manage_groups: Self::read_known(permissions, "manageGroups")?, + manage_policies: Self::read_known(permissions, "managePolicies")?, + create_new_collections: Self::read_known(permissions, "createNewCollections")?, + edit_any_collection: Self::read_known(permissions, "editAnyCollection")?, + delete_any_collection: Self::read_known(permissions, "deleteAnyCollection")?, + access_event_logs: Self::read_known(permissions, "accessEventLogs")?, + access_import_export: Self::read_known(permissions, "accessImportExport")?, + access_reports: Self::read_known(permissions, "accessReports")?, + }; + + if member_type == MembershipType::Custom { + Ok(parsed) + } else { + Ok(Self::default()) + } + } + + /// 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, + ) -> Result { + Ok(match permissions { + Some(permissions) => Self::from_request(member_type, permissions)?, + None if member_type == MembershipType::Custom && membership.atype == MembershipType::Custom as i32 => { + Self { + manage_users: membership.manage_users, + 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 +1425,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 +1434,69 @@ 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") + } + + // manageAllCollections is a client-only aggregate; its three children are persisted independently. + // Parsed and type-checked before the loop below creates any user, invitation or membership, so a + // malformed value leaves nothing behind. Reaching every collection decides whether the individual + // per-collection assignments below are skipped. + let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions)?; + let grants_full_access = custom_permissions.grants_full_collection_access(new_type); + + // Security: only callers who can manage collections (Admins/Owners, or users with full access) + // 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") + } + } } - // 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))); + // Security: the membership does not exist yet, so every group named here is an addition — putting + // the invitee into an `access_all` group is the same durable grant the other paths reserve for + // Admins and Owners. `caller_can_manage_collections` above does not cover it: `editAnyCollection` + // satisfies that, and could plant the grant on an account outliving the flag it was made under. + if !may_grant_access_all_group(headers.membership_type) { + for group_id in &data.groups { + if group_grants_access_to_all_collections(group_id, &org_id, &conn).await { + err!("Only Admins and Owners can invite a member into a group with access to all collections") + } + } + } - 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 +1534,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 +1577,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 +1606,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 +1624,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 +1634,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 +1659,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 +1701,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 +1794,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1373,7 +1807,19 @@ async fn bulk_confirm_invite( match data.keys { Some(keys) => { for invite in keys { - let member_id = invite.id.unwrap(); + // The id is request-controlled and optional. Unwrapping it aborted the worker with a 500 and, because + // the panic unwound mid-loop, discarded the response for every entry already confirmed in the same + // batch. Report it as a per-entry error, like an id that is present but empty. + let Some(member_id) = invite.id else { + bulk_response.push(json!( + { + "object": "OrganizationBulkConfirmResponseModel", + "id": null, + "error": "Key or UserId is not set, unable to process request" + } + )); + continue; + }; let user_key = invite.key.unwrap_or_default(); let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), @@ -1404,7 +1850,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1417,7 +1863,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 +1878,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 +1948,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 +1970,7 @@ struct EditUserData { r#type: NumberOrString, collections: Option>, groups: Option>, - #[serde(default)] - permissions: HashMap, + permissions: Option>, } #[put("/organizations//users/", data = "", rank = 1)] @@ -1533,7 +1978,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 +1989,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 +1997,22 @@ 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") }; + // Parsed (and type-checked) here, long before the write phase further down, so a malformed + // permission value leaves the role, the permission flags, the collection assignments and the + // group memberships exactly as they were. + let custom_permissions = + CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit)?; + 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 +2020,29 @@ async fn edit_member( err!("Only Owners can grant and remove Admin or Owner privileges") } + // Security: raising a member to Custom activates existing explicit collection-Manage assignments and + // other Custom-only paths, and lowering it revokes them — authority changes outside Manage Users. An + // unchanged role is still allowed, so such members can use the regular edit dialog. + if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) { + err!("Only Admins or Owners can change a member's role") + } + if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only Owners can edit Owner users") } + // Security: the same actor/target role matrix as every other member endpoint. Without it + // `edit_member` was the only path on which Custom+manage_users could aim at an Admin or a peer + // Custom membership, as long as the role stayed unchanged. + // + // Deliberate narrowing of upstream: Bitwarden lets Custom+ManageUsers administer peer Custom members + // and delegate a subset of its own permissions. That hands permission delegation to a non-Admin and + // makes correctness rest on a subset comparison being right on every path, so Vaultwarden keeps role + // and permission changes with Admins/Owners instead. + if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) { + err!("You don't have permission to edit this member") + } + if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner && member_to_edit.status == MembershipStatus::Confirmed as i32 @@ -1594,45 +2053,182 @@ async fn edit_member( } } - member_to_edit.access_all = access_all; + // Security: only Admins and Owners may change the granular permissions — manage_users must not grant + // them to itself or others, nor strip flags an Admin/Owner granted. Unchanged flags are allowed, so + // such members can still use the regular edit dialog. + if headers.membership_type < MembershipType::Admin && custom_permissions.differs_from(&member_to_edit) { + err!("Only Admins or Owners can change custom permissions") + } + + // Security: only callers who can manage collections (Admin/Owner, or full access) may change a + // member's collection assignments; manage_users alone leaves them untouched. + // + // Narrowing of upstream, which resolves ModifyUserAccess per collection. Requiring blanket authority + // is coarser, but it keeps a stored `manage` grant from becoming a lever for handing out access — the + // same boundary `caller_may_grant_collection_manage` draws, and the group paths below follow it. + // Widening this needs the per-collection check to cover *current* assignments too, or removal becomes + // the hole. + let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin + || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { + Some(m) => m.has_full_access(), + None => false, + }; + + // API consistency: dropping these fields while answering 200 let client and server drift apart after + // an apparently saved change. Reject instead — but only for an actual add or removal, since the edit + // dialog echoes the current assignments back. Flag-only differences stay ignored. + if !caller_can_manage_collections && !grants_full_access { + let requested: HashSet = data.collections.iter().flatten().map(|c| c.id.clone()).collect(); + let current: HashSet = + 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 may be written until every id, tenant binding and caller right has + // been checked: this endpoint replaces both collection assignments and group memberships, and with + // no database transactions an error between the two replaces used to leave the request + // half-applied while answering 4xx. This cannot make them atomic, but a *rejected* request now + // changes nothing. + // --------------------------------------------------------------------------------------------- + + // Security: a per-collection `manage` grant is durable administration authority, so it may only be + // conferred where the caller already holds it. + let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; + + // Resolve the requested assignments: every collection has to exist in *this* organization, and + // 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)); + } + } + + // 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") + } } - // 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: adding this member to an `access_all` group grants durable organization-wide access, so + // a caller whose own reach comes from `editAnyCollection` must not hand it out. Removals are + // unrestricted, so only the groups this request adds are examined. + if caller_can_manage_groups && !may_grant_access_all_group(headers.membership_type) { + for group_id in requested_groups.difference(¤t_groups) { + if group_grants_access_to_all_collections(group_id, &org_id, &conn).await { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + } + } + + // Security (audit H-2): every requested group has to belong to this organization. Otherwise a + // caller could link the member to a group of a foreign tenant (e.g. an access-all group), which + // the direct cipher-access checks would then honor. Fail closed on the whole request. + 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?; + } + 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?; } - let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); + } + + 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 +2250,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 +2286,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 +2296,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 +2307,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_delete_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 +2350,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 { @@ -1823,6 +2419,15 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + + // Organization imports are authorized per target collection. `accessImportExport` gates export, + // but does not replace Write authority on an existing collection or Create authority for a new + // one. Require confirmation independently so a membership with no target collection cannot create + // an unreachable organization cipher. + if !headers.membership.has_status(MembershipStatus::Confirmed) { + err!("You need to be a confirmed member of this organization to import into it") + } + let data: ImportData = data.into_inner(); // Validate the import before continuing @@ -1831,27 +2436,64 @@ 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: validate every collection<->cipher relationship index against the payload *before* + // creating anything. `key` indexes into `ciphers` and `value` into `collections`, and an out-of-range + // index would otherwise panic when the relations are applied — after rows have already been written. + let import_cipher_count = data.ciphers.len(); + let import_collection_count = data.collections.len(); + for relation in &data.collection_relationships { + 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(); + + // 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)) { + let writable = collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await; + if !may_import_to_collection( + &headers.membership, + OrganizationImportTarget::Existing { + writable, + }, + ) { + err!(Compact, "The current user isn't allowed to manage this collection") + } + } else if !may_import_to_collection(&headers.membership, OrganizationImportTarget::New) { + 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 { - // When not an Owner or Admin, check if the member is allowed to write to the collection. - if 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") - } 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 }; @@ -1888,7 +2530,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 +2609,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 +2686,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 +2723,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 { @@ -2128,10 +2782,11 @@ async fn put_policy( // When enabling the SingleOrg policy, remove this org's members that are members of other orgs if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { for mut member in Membership::find_by_org(&org_id, &conn).await { - // Policy only applies to non-Owner/non-Admin members who have accepted joining the org + // Policy only applies to non-Owner/non-Admin members who have accepted joining the org, + // and never to the member enabling it -- see `Membership::is_policy_enforcement_target`. // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. - if member.atype < MembershipType::Admin + if member.is_policy_enforcement_target(&headers.user.uuid) && member.status != MembershipStatus::Invited as i32 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await > 0 @@ -2189,7 +2844,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 +2921,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 +2931,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 +2970,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 +2981,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_revoke_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 +3014,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 +3026,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 +3036,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 +3070,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 +3081,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 +3108,28 @@ 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"); - } +/// Whether `membership` may read group→collection/user mappings. +/// +/// Two independent routes to the same data: the Manage Users / Manage Groups permissions, which is +/// what Bitwarden gates ReadAll on, and organization-wide collection reach, which is what released +/// Vaultwarden gated it on. Both are kept so a legacy Manager with "Manage all collections" still +/// reads these mappings after the migration converts them. The single-group view returns exactly this +/// data and so asks exactly this question. +async fn can_read_group_details(org_id: &OrganizationId, membership: &Membership, conn: &DbConn) -> bool { + membership.has_manage_users() + || membership.has_manage_groups() + || membership.has_full_access() + || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(org_id, &membership.uuid, conn).await) +} - // 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() - || (CONFIG.org_groups_enabled() - && GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await); +async fn get_groups_data(details: bool, org_id: OrganizationId, membership: &Membership, conn: DbConn) -> JsonResult { + let can_read_details = can_read_group_details(&org_id, membership, &conn).await; + // The plain list (id, name, externalId) carries no access mappings, so it additionally opens to + // anyone who manages a single collection: they need the group names to assign groups to it. let allowed = if details { - has_full_access + can_read_details } else { - has_full_access - || Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await + can_read_details || Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await }; if !allowed { err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); @@ -2506,14 +3162,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 } +// Group *details* expose accessAll, external IDs and collection mappings. The condition is +// `can_read_group_details`, enforced in `get_groups_data`; keeping the guard loose and the condition +// in one place is what stops the list and single-group views from drifting apart, as they had. #[get("/organizations//groups/details", rank = 1)] async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - get_groups_data(true, 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(true, org_id, &headers.membership, conn).await } #[derive(Deserialize)] @@ -2579,7 +3247,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 +3256,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 +3270,27 @@ 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: creating an `access_all` group is reserved for Admins and Owners; `has_full_access()`, + // which `editAnyCollection` satisfies, is deliberately not enough. See `may_grant_access_all_group`. + if group_request.access_all && !may_grant_access_all_group(headers.membership_type) { + err!("Only Admins and Owners can create a group with access to all collections") + } + + // Assigning collections to a group is a collection-access grant. Rejected rather than silently + // created without it, so a caller never believes it granted something the server dropped; a request + // that grants nothing is still accepted, which is what the plain "new group" dialog sends. + if !caller_can_manage_collections && !group_request.collections.is_empty() { + err!("You don't have permission to assign collections to a group") + } + let group = group_request.to_group(&org_id); log_event( @@ -2615,7 +3304,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 +3327,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,14 +3344,81 @@ 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, + }; + + // Security: turning an ordinary group into an organization-wide one is the same durable grant as + // creating one. Clearing the flag is a reduction and keeps the rule below. + if group_request.access_all && !group.access_all && !may_grant_access_all_group(headers.membership_type) { + err!("Only Admins and Owners can give a group access to all collections") + } + + // API consistency: reject a collection-access change this caller may not make rather than answering + // 200 and keeping the old value, as `edit_member` and `send_invite` do. Only an actual difference is + // rejected, since the group dialog echoes the current assignments back; flag differences stay ignored. + if !caller_can_manage_collections { + if group_request.access_all != group.access_all { + err!("You don't have permission to change a group's access to all collections") + } + + 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?; + // Security (audit F-1): `add_update_group` asks this too, but it runs *after* the destructive + // `CollectionGroup::delete_all_by_group` below -- a refusal there answered 400 with the group's + // collection assignments already gone, revoking them for every member of the group. Ask it here, + // while nothing has been written. `updated_group.access_all` is the value this request leaves + // behind, which is what actually grants, and it is the same value the later check reads. + if !may_grant_access_all_group(headers.membership_type) + && adds_member_to_access_all_group(&updated_group, &group_request.users, &org_id, &conn).await + { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + + if caller_can_manage_collections { + CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?; + } + // NOTE: group membership is replaced (and access-gated) inside add_update_group. + // 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() + }; + let response = add_update_group( + updated_group, + collections_to_apply, + group_request.users, + org_id.clone(), + &headers, + &conn, + caller_can_manage_collections, + ) + .await?; + + // Logged once the update has actually been applied. `add_update_group` still refuses a membership + // change the caller may not make, and an event written before it recorded an update that never + // happened. log_event( EventType::GroupUpdated, - &updated_group.uuid, + &group_id, &org_id, &headers.user.uuid, headers.device.atype, @@ -2656,7 +3427,275 @@ async fn put_group( ) .await; - add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &conn).await + Ok(response) +} + +/// Whether a caller may change (add OR remove) a member's membership in a group. +/// +/// A caller who cannot manage collections must never touch a collection-bearing group's membership: +/// adding grants those collections, removing revokes them. Organization-wide (`access_all`) groups +/// are a separate, stricter question — see [`may_grant_access_all_group`]. +fn may_change_group_membership(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool { + caller_can_manage_collections || !group_confers_collection_access +} + +/// Whether `caller_type` may hand out organization-wide group access: create a group carrying +/// `groups.access_all`, turn one into it, or add a member to one. +/// +/// Security: `access_all` reaches every collection and is not bound to the grantee's role, so it +/// survives their Custom permissions being cleared — a durable grant like a `users_collections.manage` +/// row, which `caller_may_grant_collection_manage` already reserves. Gating on `has_full_access()` +/// instead would let `manageGroups` + `editAnyCollection` mint one and outlive the flag it came from, +/// so no Custom permission satisfies this, `deleteAnyCollection` included. +/// +/// Only granting is restricted; removals, clearing the flag and deleting the group all reduce access. +/// Asked by `post_groups`, `put_group`, `add_update_group`, `put_group_members`, `edit_member` and +/// `send_invite` — the last needs it because a new membership has no current groups to diff against, +/// so every group in an invite is an addition. +/// +/// A missing call site is invisible to `cargo test`, so that list is the thing to check when a new +/// group path is added. So is *where* the call sits: it has to precede every write the request makes, +/// or a refusal leaves the endpoint half-applied (audit F-1). +fn may_grant_access_all_group(caller_type: MembershipType) -> bool { + caller_type >= MembershipType::Admin +} + +/// Whether `requested` would add at least one member that `current` does not already contain. +/// +/// Separates an addition to an access-all group (restricted) from a pure removal (always allowed) on +/// the endpoints that replace a whole member list. +fn adds_group_member(requested: &HashSet<&MembershipId>, current: &HashSet<&MembershipId>) -> bool { + !requested.is_subset(current) +} + +/// Whether replacing `group`'s member list with `members` adds someone to an organization-wide group. +/// +/// The question `may_grant_access_all_group` gates, resolved against the database. Shared by +/// `put_group` and `add_update_group` so the pre-write check and the one next to the write can never +/// answer differently. Reads `group.access_all` first, so an ordinary group costs no query. +async fn adds_member_to_access_all_group( + group: &Group, + members: &[MembershipId], + org_id: &OrganizationId, + conn: &DbConn, +) -> bool { + if !group.access_all { + return false; + } + + let current_members = GroupUser::find_by_group(&group.uuid, org_id, conn).await; + let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); + adds_group_member(&members.iter().collect(), ¤t) +} + +/// Whether `requested` and `current` agree on every group that confers collection access. +/// +/// Such a change has to be rejected rather than skipped silently, so a save that appears to succeed +/// never means something different on the server. +fn collection_bearing_membership_unchanged( + requested: &HashSet, + current: &HashSet, + 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. +/// +/// The role type changes organization-wide collection reach and which granular permissions are +/// effective — the data plane, not the user lifecycle `manage_users` covers — so only Admins and +/// Owners may change it. An unchanged role is always allowed, so `manage_users` members can still use +/// the regular edit dialog; Admin/Owner transitions have their own guard in `edit_member`. +fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool { + caller_type >= MembershipType::Admin || new_type == current_atype +} + +/// 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). +/// +/// Stricter than [`may_manage_member_type`], preserving the rule that only Owners bring Admin/Owner +/// memberships into or out of existence — `edit_member` guards the same boundary on role transitions, +/// so an Admin must not route around it by inviting a fresh Admin. State changes that leave the +/// membership in place keep using [`may_manage_member_type`]. +fn may_provision_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { + match caller_type { + MembershipType::Owner => true, + 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)) +} + +/// Whether a caller may act on a membership whose stored `atype` this build cannot interpret. +/// +/// Such a row (a future build, a partial rollback, a hand edit) holds no authority — `OrgHeaders` +/// refuses it and every permission flag on it is inert — but the helpers above fail closed on the +/// unknown value, which left nobody able to remove it either, unlike Vaultwarden. So: an Owner only, +/// and only for the two actions that reduce what the row can become. Editing, confirming, restoring +/// and reinviting keep refusing, because they preserve or reactivate a role the server cannot reason +/// about. +fn may_act_on_unknown_stored_member_type(caller_type: MembershipType) -> bool { + caller_type == MembershipType::Owner +} + +/// Whether a caller may delete `target_atype`. Provisioning rules for a role this build knows; +/// Owner-only for one it does not (see [`may_act_on_unknown_stored_member_type`]). +fn may_delete_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { + match MembershipType::from_i32(target_atype) { + Some(role) => may_provision_member_type(caller_type, role), + None => may_act_on_unknown_stored_member_type(caller_type), + } +} + +/// Whether a caller may revoke `target_atype`. Management rules for a role this build knows; +/// Owner-only for one it does not. Kept separate from [`may_delete_stored_member_type`] so revoking +/// keeps the looser actor/target matrix it has always had (an Admin may revoke a peer Admin, which +/// provisioning does not allow). +fn may_revoke_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { + match MembershipType::from_i32(target_atype) { + Some(role) => may_manage_member_type(caller_type, role), + None => may_act_on_unknown_stored_member_type(caller_type), + } +} + +/// Returns true if being a member of `group_id` confers collection access — either because the +/// group has `access_all` set, or because it has collections assigned. +async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { + 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, + } +} + +/// Returns true if `group_id` carries `groups.access_all`, i.e. membership of it reaches every +/// collection of the organization. A group that does not exist in this organization confers +/// nothing. See [`may_grant_access_all_group`] for why this is asked separately from +/// [`group_confers_collection_access`]. +async fn group_grants_access_to_all_collections(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { + Group::find_by_uuid_and_org(group_id, org_id, conn).await.is_some_and(|group| group.access_all) +} + +/// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` / +/// `collections_groups.manage`) on `col_id`. +/// +/// Security: a `manage` grant is collection administration authority (`ManagerHeaders` accepts it via +/// `has_explicit_collection_manage_access`) and survives every later change to the grantee's role, so +/// a caller acting through `edit_any_collection` — revocable by clearing one flag — must not be able +/// to write one. Only blanket authority (Admin/Owner, or Custom with `delete_any_collection`) or a +/// real stored grant on that same collection qualifies. Strictly subtractive: it can only downgrade a +/// requested `manage` to false. +async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { + match caller_manage_grant_role_check(caller) { + // Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no). + 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, + }, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OrganizationImportTarget { + Existing { + writable: bool, + }, + New, +} + +/// Organization imports retain the pre-existing per-target authorization model. The +/// `accessImportExport` permission opens export, but it is deliberately not an organization-wide +/// Create/Write shortcut for imports. +fn may_import_to_collection(caller: &Membership, target: OrganizationImportTarget) -> bool { + if !caller.has_status(MembershipStatus::Confirmed) { + return false; + } + + match target { + OrganizationImportTarget::Existing { + writable, + } => caller.atype >= MembershipType::Admin || writable, + OrganizationImportTarget::New => caller.can_create_new_collections(), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OrganizationReportScope { + Complete, + Assigned, + Denied, +} + +/// Full-access members receive the complete organization view. `accessReports` alone receives the +/// same assignment-scoped, restriction-bearing cipher representation as the caller's normal sync. +fn organization_report_scope(caller: &Membership) -> OrganizationReportScope { + if caller.has_full_access() { + OrganizationReportScope::Complete + } else if caller.has_status(MembershipStatus::Confirmed) && caller.has_access_reports() { + OrganizationReportScope::Assigned + } else { + OrganizationReportScope::Denied + } +} + +/// Whether `caller` may export the *entire* organization instead of only their own assignments. +/// +/// Security: the `AccessImportExportHeaders` guard decides whether a member may export at all, not +/// *what* they get. Only members who already reach every collection get the full dump; for anyone +/// else it is built from their own assignments, so the permission never becomes a full vault read. +fn may_export_entire_organization(caller: &Membership) -> bool { + caller.has_full_access() +} + +/// 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. +/// +/// Separate so the role gating — in particular `edit_any_collection` alone yielding `None` rather +/// than `Some(true)` — is unit-testable without a DB. +fn caller_manage_grant_role_check(caller: &Membership) -> Option { + if caller.can_delete_any_collection() { + return Some(true); // Admin/Owner, or a Custom member holding delete_any_collection + } + 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 +3703,79 @@ 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: membership of a collection-bearing group grants (or revokes) access to those + // collections' contents, so only a caller who can manage collections may change it. Rejected rather + // than silently ignored, and checked before the first write so a refused request leaves nothing + // behind. On create the group is brand new and this never triggers. + // + // `group.access_all` is the value this request leaves behind, which is what actually grants: adding a + // member to it is Admin/Owner authority even for a caller who can manage collections. Removals stay + // under the collection rule below. `put_group` asks the same question before its own destructive + // delete (audit F-1); this call covers `post_groups`, where nothing has been written yet either. + if !may_grant_access_all_group(headers.membership_type) + && adds_member_to_access_all_group(&group, &members, &org_id, conn).await + { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + + if !caller_can_manage_collections + && (group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty()) + { + 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: a `collections_groups.manage` grant carries collection delete authority, so it may only + // be set on a collection the caller could delete themselves — otherwise Edit-any-collection reaches + // Delete-any-collection through a manage-bearing group the caller then joins. + let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await; for col_selection in collections { let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); + 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, - &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, + &assigned_member, + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + conn, + ) + .await; + } } Ok(Json(json!({ @@ -2700,19 +3788,26 @@ async fn add_update_group( }))) } +// Reads a single group's details (accessAll, externalId, collection mappings). This is the same data +// the `/groups/details` list endpoint returns, so it asks the same question — `can_read_group_details`. +// Any divergence would let a member read every group's details in bulk but be denied the single-group +// view of the same data, or the reverse. #[get("/organizations//groups//details")] async fn get_group_details( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManagerHeadersLoose, conn: DbConn, ) -> JsonResult { - if org_id != headers.org_id { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } + if !can_read_group_details(&org_id, &headers.membership, &conn).await { + err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); + } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") @@ -2725,21 +3820,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 { @@ -2749,10 +3849,46 @@ async fn delete_group_impl( err!("Group support is disabled"); } + let caller_can_manage_collections = + headers.membership_type >= MembershipType::Admin || headers.membership.has_full_access(); + let group = authorize_group_deletion(group_id, org_id, caller_can_manage_collections, conn).await?; + delete_authorized_group(&group, org_id, headers, conn).await +} + +async fn authorize_group_deletion( + group_id: &GroupId, + org_id: &OrganizationId, + caller_can_manage_collections: bool, + conn: &DbConn, +) -> Result { let Some(group) = Group::find_by_uuid_and_org(group_id, org_id, conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; + // 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. + let group_confers_collection_access = + group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty(); + if !may_delete_group(caller_can_manage_collections, group_confers_collection_access) { + err!("You don't have permission to delete a group that grants collection access") + } + + Ok(group) +} + +fn may_delete_group(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool { + caller_can_manage_collections || !group_confers_collection_access +} + +async fn delete_authorized_group( + group: &Group, + org_id: &OrganizationId, + headers: &ManageGroupsHeaders, + conn: &DbConn, +) -> EmptyResult { log_event( EventType::GroupDeleted, &group.uuid, @@ -2771,7 +3907,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 { @@ -2783,14 +3919,33 @@ async fn bulk_delete_groups( let data: BulkGroupIds = data.into_inner(); + // Authorize the complete request before the first event or deletion. In particular, a + // manageGroups-only caller may delete ordinary groups but not collection-bearing groups; a mixed + // batch must not delete an authorized prefix and then fail on a later item. + let caller_can_manage_collections = + headers.membership_type >= MembershipType::Admin || headers.membership.has_full_access(); + let mut groups = Vec::with_capacity(data.ids.len()); + let mut seen_group_ids = HashSet::with_capacity(data.ids.len()); for group_id in data.ids { - delete_group_impl(&org_id, &group_id, &headers, &conn).await?; + if !seen_group_ids.insert(group_id.clone()) { + err!("Duplicate group id in bulk delete request") + } + groups.push(authorize_group_deletion(&group_id, &org_id, caller_can_manage_collections, &conn).await?); + } + + for group in &groups { + delete_authorized_group(group, &org_id, &headers, &conn).await?; } Ok(()) } #[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 +3964,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 +3991,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,12 +4002,39 @@ 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(); + // Security: adding a member to an `access_all` group hands out durable organization-wide access, so + // it is Admin/Owner authority even for a caller who can manage collections. Removals stay allowed. + if group.access_all && !may_grant_access_all_group(headers.membership_type) { + let current_members = GroupUser::find_by_group(&group_id, &org_id, &conn).await; + let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); + let requested: HashSet<&MembershipId> = assigned_members.iter().collect(); + if adds_group_member(&requested, ¤t) { + err!("Only Admins and Owners can add a member to a group with access to all collections") + } + } + let org_memberships = Membership::find_by_org(&org_id, &conn).await; let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) { @@ -2884,7 +4066,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 +4084,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, &member_id, @@ -3180,18 +4376,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 +4463,732 @@ 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, OrganizationImportTarget, OrganizationReportScope, + adds_group_member, caller_manage_grant_role_check, collection_bearing_membership_unchanged, + collection_details_response_scope, filter_ciphers_for_organization, may_change_group_membership, + may_change_member_type, may_delete_group, may_delete_stored_member_type, may_export_entire_organization, + may_grant_access_all_group, may_import_to_collection, may_manage_member_type, may_manage_stored_member_type, + may_provision_member_type, may_provision_stored_member_type, may_read_complete_collection_list, + may_revoke_stored_member_type, organization_report_scope, + }; + use crate::db::models::{ + Cipher, GroupId, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, + }; + + 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 + } + + /// Handing out `groups.access_all` is Admin/Owner authority: the grant keeps working after the + /// grantee's Custom permissions are cleared. + #[test] + fn only_admins_and_owners_may_hand_out_access_all_group_authority() { + assert!(may_grant_access_all_group(MembershipType::Owner)); + assert!(may_grant_access_all_group(MembershipType::Admin)); + assert!(!may_grant_access_all_group(MembershipType::Custom)); + assert!(!may_grant_access_all_group(MembershipType::User)); + } + + /// No Custom permission opens this, and in particular not the two that come closest: + /// `editAnyCollection` satisfies `has_full_access()` (which gates every other group operation) and + /// `deleteAnyCollection` is accepted for a per-collection `manage` row. + #[test] + fn no_custom_permission_grants_access_all_group_authority() { + let mut edit_any = confirmed_member(MembershipType::Custom); + edit_any.edit_any_collection = true; + assert!(edit_any.has_full_access(), "the escalation starts from a member who has full access"); + + let mut delete_any = confirmed_member(MembershipType::Custom); + delete_any.delete_any_collection = true; + assert_eq!( + caller_manage_grant_role_check(&delete_any), + Some(true), + "delete-any may still confer a per-collection manage grant" + ); + + // Neither, nor any combination, is organization-wide group authority. + assert!(!may_grant_access_all_group(MembershipType::Custom)); + } + + /// The invite path's group gate is `caller_can_manage_collections`, which `editAnyCollection` + /// satisfies, so this caller clears every precondition checked before the access-all rule. + #[test] + fn inviting_into_an_access_all_group_is_admin_only() { + let mut inviter = confirmed_member(MembershipType::Custom); + inviter.manage_users = true; + inviter.manage_groups = true; + inviter.edit_any_collection = true; + + // Everything `send_invite` checks before the access-all rule passes for this caller ... + assert!(inviter.has_manage_users(), "reaches send_invite at all"); + assert!(inviter.has_manage_groups(), "satisfies caller_can_manage_groups"); + assert!(inviter.has_full_access(), "satisfies caller_can_manage_collections"); + assert!( + may_provision_member_type(MembershipType::Custom, MembershipType::User), + "and may invite the one role a Custom member can provision" + ); + + // ... and the organization-wide group is still out of reach. + assert!(!may_grant_access_all_group(MembershipType::Custom)); + assert!(may_grant_access_all_group(MembershipType::Admin)); + assert!(may_grant_access_all_group(MembershipType::Owner)); + } + + /// Only *adding* to an access-all group is restricted; removals and an unchanged set are allowed. + #[test] + fn only_additions_to_an_access_all_group_are_restricted() { + let a: MembershipId = "member-a".to_owned().into(); + let b: MembershipId = "member-b".to_owned().into(); + let c: MembershipId = "member-c".to_owned().into(); + let current: HashSet<&MembershipId> = HashSet::from([&a, &b]); + + // unchanged, and pure removals + assert!(!adds_group_member(&HashSet::from([&a, &b]), ¤t)); + assert!(!adds_group_member(&HashSet::from([&a]), ¤t)); + assert!(!adds_group_member(&HashSet::new(), ¤t)); + + // any new member, including alongside a removal + assert!(adds_group_member(&HashSet::from([&a, &b, &c]), ¤t)); + assert!(adds_group_member(&HashSet::from([&c]), ¤t)); + assert!(adds_group_member(&HashSet::from([&a, &c]), ¤t)); + } + + /// The collection-bearing rule still admits any caller who can manage collections. + #[test] + fn the_collection_bearing_group_rule_is_unchanged() { + assert!(may_change_group_membership(true, true)); + assert!(!may_change_group_membership(false, true)); + assert!(may_change_group_membership(false, false)); + } + + /// An unparsable stored role holds no authority but still has to be removable: an Owner may delete + /// or revoke it, nobody else may touch it, and no reactivating action opens up for anyone. + #[test] + fn an_owner_may_remove_a_membership_with_an_unknown_stored_role() { + // 3 is the retired Manager wire value, which this build never persists; the rest are values + // no Vaultwarden release writes at all. + for unknown in [3, 5, -1, i32::MAX, i32::MIN] { + // 0, 1, 2 and 4 are the only values `MembershipType::from_i32` accepts. + assert!(![0, 1, 2, 4].contains(&unknown), "{unknown} must not be a known role"); + + assert!(may_delete_stored_member_type(MembershipType::Owner, unknown), "{unknown}"); + assert!(may_revoke_stored_member_type(MembershipType::Owner, unknown), "{unknown}"); + + for caller in [MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert!(!may_delete_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); + assert!(!may_revoke_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); + } + + // Editing, confirming, restoring and reinviting all still refuse, for every caller. + for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert!(!may_manage_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); + assert!( + !may_provision_stored_member_type(caller, unknown), + "caller={} target={unknown}", + caller as i32 + ); + } + } + } + + /// For a role this build knows, delete keeps the provisioning matrix and revoke the looser + /// management one. + #[test] + fn known_roles_keep_their_existing_delete_and_revoke_matrices() { + for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { + assert_eq!( + may_delete_stored_member_type(caller, target as i32), + may_provision_member_type(caller, target), + "delete: caller={} target={}", + caller as i32, + target as i32 + ); + assert_eq!( + may_revoke_stored_member_type(caller, target as i32), + may_manage_member_type(caller, target), + "revoke: caller={} target={}", + caller as i32, + target as i32 + ); + } + } + + // The place the two matrices differ, kept intact: an Admin may revoke a peer Admin but may + // not delete one, while an Owner may do both. + assert!(may_revoke_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); + assert!(!may_delete_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); + assert!(may_revoke_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32)); + assert!(may_delete_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32)); + } + + #[test] + fn bulk_collection_details_only_include_acls_for_manage_authority() { + // Ordinary collection assignment, including group access_all, keeps the collection metadata + // 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_does_not_replace_import_collection_authority() { + let mut import_export = confirmed_member(MembershipType::Custom); + import_export.access_import_export = true; + assert!(!may_import_to_collection( + &import_export, + OrganizationImportTarget::Existing { + writable: false + } + )); + assert!(!may_import_to_collection(&import_export, OrganizationImportTarget::New)); + + assert!(may_import_to_collection( + &import_export, + OrganizationImportTarget::Existing { + writable: true + } + )); + + let mut create = confirmed_member(MembershipType::Custom); + create.create_new_collections = true; + assert!(may_import_to_collection(&create, OrganizationImportTarget::New)); + + let mut edit_any = confirmed_member(MembershipType::Custom); + edit_any.edit_any_collection = true; + assert!(!may_import_to_collection(&edit_any, OrganizationImportTarget::New)); + + assert!(may_import_to_collection( + &confirmed_member(MembershipType::User), + OrganizationImportTarget::Existing { + writable: true + } + )); + + assert!(may_import_to_collection( + &confirmed_member(MembershipType::Admin), + OrganizationImportTarget::Existing { + writable: false + } + )); + assert!(may_import_to_collection(&confirmed_member(MembershipType::Owner), OrganizationImportTarget::New)); + + import_export.status = MembershipStatus::Accepted as i32; + assert!(!may_import_to_collection( + &import_export, + OrganizationImportTarget::Existing { + writable: true + } + )); + } + + #[test] + fn access_reports_is_assignment_scoped_without_full_access() { + let mut reports = confirmed_member(MembershipType::Custom); + reports.access_reports = true; + assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Assigned); + + assert_eq!( + organization_report_scope(&confirmed_member(MembershipType::Custom)), + OrganizationReportScope::Denied + ); + assert_eq!( + organization_report_scope(&confirmed_member(MembershipType::Admin)), + OrganizationReportScope::Complete + ); + assert_eq!( + organization_report_scope(&confirmed_member(MembershipType::Owner)), + OrganizationReportScope::Complete + ); + + reports.edit_any_collection = true; + assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Complete); + reports.edit_any_collection = false; + + reports.status = MembershipStatus::Accepted as i32; + assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Denied); + + let mut stale_user = confirmed_member(MembershipType::User); + stale_user.access_reports = true; + assert_eq!(organization_report_scope(&stale_user), OrganizationReportScope::Denied); + } + + #[test] + fn collection_bearing_group_deletion_requires_collection_authority() { + assert!(may_delete_group(false, false)); + assert!(!may_delete_group(false, true)); + assert!(may_delete_group(true, false)); + assert!(may_delete_group(true, true)); + } + + #[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).unwrap(); + assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}"); + assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}"); + assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}"); + // 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}"); + } + } + + const KNOWN_PERMISSION_KEYS: [&str; 9] = [ + "manageUsers", + "manageGroups", + "managePolicies", + "createNewCollections", + "editAnyCollection", + "deleteAnyCollection", + "accessEventLogs", + "accessImportExport", + "accessReports", + ]; + + #[test] + fn custom_permission_parser_accepts_only_booleans_and_non_custom_roles_are_fail_closed() { + let all_true: HashMap = + KNOWN_PERMISSION_KEYS.iter().map(|key| ((*key).to_owned(), json!(true))).collect(); + + let custom = CustomRolePermissions::from_request(MembershipType::Custom, &all_true).unwrap(); + assert!(custom.manage_users); + 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, &all_true).unwrap(); + assert_eq!(user, CustomRolePermissions::default()); + assert!(!user.grants_full_collection_access(MembershipType::User)); + + let admin = CustomRolePermissions::from_request(MembershipType::Admin, &all_true).unwrap(); + assert_eq!(admin, CustomRolePermissions::default()); + assert!(admin.grants_full_collection_access(MembershipType::Admin)); + } + + /// A known key carrying anything other than a JSON boolean is a malformed request. It used to be + /// read as `false`, which turned a client bug into a silent permission removal. + #[test] + fn a_known_permission_with_a_non_boolean_value_is_rejected() { + let bad_values = [ + json!("true"), + json!("false"), + json!(""), + json!(1), + json!(0), + json!(1.5), + Value::Null, + json!({}), + json!([]), + json!(["manageUsers"]), + ]; + + for key in KNOWN_PERMISSION_KEYS { + for value in &bad_values { + let permissions = HashMap::from([(key.to_owned(), value.clone())]); + for member_type in + [MembershipType::Custom, MembershipType::User, MembershipType::Admin, MembershipType::Owner] + { + assert!( + CustomRolePermissions::from_request(member_type, &permissions).is_err(), + "{key} = {value} must be rejected for {}", + member_type as i32 + ); + } + + let membership = confirmed_member(MembershipType::Custom); + assert!( + CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&permissions), &membership) + .is_err(), + "{key} = {value} must be rejected on the edit path" + ); + } + + // ... while both booleans stay valid for the same key. + for value in [true, false] { + let permissions = HashMap::from([(key.to_owned(), json!(value))]); + let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions) + .expect("a boolean is always valid"); + assert_eq!(parsed != CustomRolePermissions::default(), value, "{key} = {value}"); + } + } + } + + /// Bitwarden already sends permission keys Vaultwarden does not implement (`manageSso`, + /// `manageScim`, `manageResetPassword`) and may add more. Unknown keys stay ignored, whatever + /// they contain, so the strictness above cannot break a newer client. + #[test] + fn unknown_permission_keys_are_ignored_whatever_they_contain() { + let permissions = HashMap::from([ + ("manageUsers".to_owned(), json!(true)), + ("manageSso".to_owned(), Value::String("yes".to_owned())), + ("manageScim".to_owned(), Value::Null), + ("manageResetPassword".to_owned(), json!(0)), + ("someFuturePermission".to_owned(), json!({"nested": true})), + ]); + + let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions) + .expect("unknown keys must not make a request invalid"); + assert!(parsed.manage_users); + assert!(!parsed.manage_groups); + assert!(!parsed.edit_any_collection); + } + + #[test] + fn custom_permission_change_detection_covers_collection_flags() { + let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); + 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).unwrap(); + 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) + .unwrap(), + CustomRolePermissions::default() + ); + assert_eq!( + CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap(), + 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).unwrap(); + 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/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index c95fb297..311d8865 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -214,8 +214,12 @@ pub async fn enforce_2fa_policy_for_org( ) -> EmptyResult { let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); for member in Membership::find_confirmed_by_org(org_id, conn).await { - // Don't enforce the policy for Admins and Owners. - if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { + // Don't enforce the policy for Admins and Owners, nor for the member who just enabled it -- + // see `Membership::is_policy_enforcement_target`. Every other non-compliant member is + // revoked exactly as before. + if member.is_policy_enforcement_target(act_user_id) + && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() + { if CONFIG.mail_enabled() { let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap(); mail::send_2fa_removed_from_org(&user.email, &org.name).await?; diff --git a/src/auth.rs b/src/auth.rs index 762088e5..20c6d64a 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,42 @@ 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()) + } + 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: no `can_access_reports` helper on purpose. Vaultwarden has no server-side report endpoints -- + // clients compute reports from the organization cipher list -- so `accessReports` is enforced where + // that list is served (`get_org_details`). A guard here would invite gating an endpoint on "may call + // reports" instead of "may read these ciphers". } // org_id is usually the second path param ("/organizations/"), @@ -814,6 +845,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 +877,91 @@ 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" +); +// NOTE: no `ManageUsersOrGroupsHeaders`. Reading group *details* is not a single-permission question +// -- organization-wide collection reach grants it too -- so both routes take `ManagerHeadersLoose` +// and ask `can_read_group_details`. The full *member* list is, and keeps `ManageUsersHeaders`. +generate_manage_headers!( + AccessEventLogsHeaders, + can_access_event_logs, + "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 +981,106 @@ 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*: with the limit active only Owners, Admins and +/// holders of `Delete any collection` may delete. Accepting a stored `manage` grant here would break +/// that promise and make the three collection permissions depend on each other — `Create new +/// collections` alone receives an automatic `manage` row for the collection it just created, and +/// could then delete it. A Manage grant keeps its full meaning for editing (`collection_edit_access`); +/// it just is not a delete permission. +fn collection_delete_access(membership: &Membership) -> CollectionManageAccess { + if !membership.has_status(MembershipStatus::Confirmed) { + return CollectionManageAccess::Denied; + } + + 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`, on exactly the same rules +/// as the path-based `ManagerHeaders` guard: Edit-any (or Admin/Owner) reaches every collection, +/// otherwise only those carrying a real per-collection Manage grant. Group `access_all` deliberately +/// does not qualify. +/// +/// Body-param endpoints take collection ids in the request body and so cannot use `ManagerHeaders`; +/// they run this per collection instead, so the two cannot diverge. +pub(crate) async fn can_edit_collection( + membership: &Membership, + collection_uuid: &CollectionId, + 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. +/// +/// The same rule as `CollectionReadHeaders`: Admin/Owner, Edit-any/Delete-any, or a real +/// per-collection Manage assignment. Ordinary read access and group `access_all` do not qualify. +pub(crate) async fn can_read_collection_access( + membership: &Membership, + collection_uuid: &CollectionId, + 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 +1097,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 +1124,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 +1292,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 +1680,111 @@ 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 gets no blanket collection authority from its role. Edit and read are + // answered per collection by `has_explicit_collection_manage_access`, which accepts a real manage + // grant and nothing else -- a group's `access_all` is not one. Delete has no per-collection fallback + // at all, hence Denied rather than ExplicitManage; see `collection_delete_access`. + let custom = membership(MembershipType::Custom); + assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage); + assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage); + 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/config.rs b/src/config.rs index 2502dd02..c697238f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -750,6 +750,11 @@ make_config! { /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely db_connection_retries: u32, false, def, 15; + /// Legacy User access_all migration |> What the Custom-role migration does with a plain User membership that still carries the legacy access_all flag. + /// "refuse" stops startup and prints the recovery procedure, "drop" clears the flag, "materialize" writes the reach out as explicit collection + /// assignments (confirmed memberships only) and then clears it. Only read while that migration is pending. + legacy_user_access_all_migration: String, false, def, "refuse".to_owned(); + /// Timeout when acquiring database connection database_timeout: u64, false, def, 30; @@ -972,6 +977,13 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } + if crate::db::LegacyUserAccessAllPolicy::from_config(&cfg.legacy_user_access_all_migration).is_none() { + err!(format!( + "Invalid LEGACY_USER_ACCESS_ALL_MIGRATION value `{}`, expected `refuse`, `drop` or `materialize`", + cfg.legacy_user_access_all_migration + )); + } + if cfg.password_iterations < 100_000 { err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); } diff --git a/src/db/mod.rs b/src/db/mod.rs index 2eae3f3c..bbb0a275 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -468,6 +468,608 @@ impl<'r> FromRequest<'r> for DbConn { } } +/// The single migration this feature adds. +/// +/// This section exists because some database states cannot be converted without a decision that +/// belongs to an owner. The migration file refuses them itself as a backstop, but Diesel surfaces +/// only the driver-level duplicate-key error that refusal produces; the preflight evaluates the same +/// predicates first, so the operator gets the question and the way out instead. +const CUSTOM_ROLE_PERMISSIONS_MIGRATION: &str = "20260630120000"; + +/// The nine permission columns the migration adds. +const CUSTOM_ROLE_PERMISSION_COLUMNS: [&str; 9] = [ + "manage_users", + "manage_groups", + "manage_policies", + "create_new_collections", + "edit_any_collection", + "delete_any_collection", + "access_event_logs", + "access_import_export", + "access_reports", +]; + +/// Every column `users_organizations` has once the migration has run, and nothing else. +/// +/// A fingerprint, not a schema definition: a table carrying exactly these eighteen names is the one +/// this migration produces. One column more or fewer and nothing may be inferred about it. +const EXPECTED_MEMBERSHIP_COLUMNS: [&str; 18] = [ + "uuid", + "user_uuid", + "org_uuid", + "akey", + "status", + "atype", + "reset_password_key", + "external_id", + "invited_by_email", + "manage_users", + "manage_groups", + "manage_policies", + "create_new_collections", + "edit_any_collection", + "delete_any_collection", + "access_event_logs", + "access_import_export", + "access_reports", +]; + +/// The one-line reason the Custom-role preflight refused to start, once it has. +/// +/// The refusal is deterministic -- it reads schema and ledger state no retry can change -- so +/// `create_db_pool` stops immediately instead of retrying it as a connection problem and repeating +/// the whole recovery procedure each time. It also gives the startup path a plain sentence to print: +/// `Error`'s `Display` renders the JSON API body and its `Debug` escapes newlines. +static CUSTOM_ROLE_PREFLIGHT_REFUSAL: OnceLock = OnceLock::new(); + +/// Why startup was stopped by the Custom-role preflight, if it was. `None` means the database was +/// simply not reachable (yet), which is worth retrying. +pub fn custom_role_preflight_refusal() -> Option<&'static str> { + CUSTOM_ROLE_PREFLIGHT_REFUSAL.get().map(String::as_str) +} + +/// What to do with a legacy `User + access_all` membership, from `LEGACY_USER_ACCESS_ALL_MIGRATION`. +/// +/// The bit is a state official Vaultwarden wrote: until upstream commit `0d16da44` both the invite +/// and the edit endpoint stored a client-supplied `access_all` regardless of the role requested. It +/// has no representation in the new model -- it granted dynamic read/write reach over every +/// collection, present and future, and nothing else -- so which meaning to keep is a decision about +/// that member's access, not something the upgrade can infer. +/// +/// Refusing stays the default; the other two let an owner take that decision once for the instance +/// instead of hand-writing SQL per membership. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum LegacyUserAccessAllPolicy { + /// Stop and print the recovery procedure. + #[default] + Refuse, + /// The reach is no longer wanted: clear the bit. Explicit assignments are kept. + Drop, + /// The reach has to survive: write it out as explicit assignments, then clear the bit. + Materialize, +} + +impl LegacyUserAccessAllPolicy { + pub fn from_config(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "refuse" => Some(Self::Refuse), + "drop" => Some(Self::Drop), + "materialize" => Some(Self::Materialize), + _ => None, + } + } + + /// An unparsable value cannot reach here -- `validate_config` rejects it at startup -- but + /// falling back to the refusal keeps the failure mode closed rather than silently permissive. + fn configured() -> Self { + Self::from_config(&CONFIG.legacy_user_access_all_migration()).unwrap_or_default() + } +} + +/// The migration's own last statement. +/// +/// `RecordCompletedMigration` has to run it too. That path records a migration whose schema changes +/// all committed but whose ledger entry did not, and Diesel then skips the file entirely -- so +/// nothing else would ever execute the statements after the last `ALTER TABLE`. An acknowledgement +/// surviving a repaired upgrade would let a later revert run without fresh consent, which is exactly +/// what the migration drops it to prevent. +const DROP_DOWNGRADE_ACK_SQL: &str = "DROP TABLE IF EXISTS __vw_allow_custom_role_downgrade"; + +/// Whether this backend commits a migration's schema statements one at a time, so an interrupted +/// upgrade can leave the migration half-applied. +/// +/// MySQL and MariaDB do: every `ALTER TABLE` implicitly commits. SQLite and PostgreSQL run the whole +/// migration in one transaction, so a half-applied schema there was not produced by an interruption +/// and nothing may be resumed on the assumption this migration was the only writer. +type InterruptibleSchemaChanges = bool; + +/// The migration's Manager -> Custom conversion, replayed when an interrupted upgrade is resumed. +/// +/// Character for character the `UPDATE` in +/// `migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql`. Idempotent for the same +/// reason it is safe there -- it matches only `atype = 3` and leaves none behind -- which is what +/// lets one recovery path cover *both* interruption points without telling them apart. It reads +/// `access_all`, so it must run before that column is dropped. +#[cfg(mysql)] +const CUSTOM_ROLE_MANAGER_CONVERSION_SQL: &str = "\ +UPDATE users_organizations \ +SET create_new_collections = access_all, \ + edit_any_collection = access_all \ + OR EXISTS ( \ + SELECT 1 \ + FROM groups_users AS gu \ + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid \ + WHERE gu.users_organizations_uuid = users_organizations.uuid \ + AND g.organizations_uuid = users_organizations.org_uuid \ + AND g.access_all = TRUE \ + ), \ + delete_any_collection = access_all \ + OR EXISTS ( \ + SELECT 1 \ + FROM groups_users AS gu \ + INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid \ + WHERE gu.users_organizations_uuid = users_organizations.uuid \ + AND g.organizations_uuid = users_organizations.org_uuid \ + AND g.access_all = TRUE \ + ), \ + atype = 4 \ +WHERE atype = 3"; + +/// The migration's final schema statement. +#[cfg(mysql)] +const DROP_ACCESS_ALL_SQL: &str = "ALTER TABLE users_organizations DROP COLUMN access_all"; + +/// What an interrupted upgrade still owes, in order -- exactly what the migration file does from its +/// `UPDATE` onwards. The caller records the ledger entry afterwards. Gated on MySQL, the only backend +/// a resume is reachable on. +#[cfg(mysql)] +const CUSTOM_ROLE_RESUME_STATEMENTS: [&str; 3] = + [CUSTOM_ROLE_MANAGER_CONVERSION_SQL, DROP_ACCESS_ALL_SQL, DROP_DOWNGRADE_ACK_SQL]; + +/// Relax the direct assignments of an affected membership before the bit goes away. +/// +/// `access_all` *overrode* `read_only` and `hide_passwords`: a member carrying it reached every +/// collection read/write with passwords visible even where an explicit row said otherwise. Inserting +/// only the missing rows would therefore quietly downgrade every collection the member was also +/// explicitly assigned to. `manage` is deliberately untouched -- `access_all` never conferred it, +/// and an existing grant is an independent decision. +const LEGACY_USER_ACCESS_ALL_RELAX_SQL: &str = "\ +UPDATE users_collections \ +SET read_only = FALSE, hide_passwords = FALSE \ +WHERE EXISTS ( \ + SELECT 1 \ + FROM users_organizations uo \ + INNER JOIN collections c ON c.org_uuid = uo.org_uuid \ + WHERE uo.user_uuid = users_collections.user_uuid \ + AND c.uuid = users_collections.collection_uuid \ + AND uo.atype = 2 \ + AND uo.access_all = TRUE \ + AND uo.status = 2 \ +)"; + +/// Write the reach out as explicit assignments. +/// +/// Confirmed memberships only: a `users_collections` row is not bound to the membership status the +/// way `access_all` was, so materialising an invited, accepted or revoked membership would hand it +/// durable assignments it does not have today. Those only lose the bit. +const LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL: &str = "\ +INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) \ +SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE \ +FROM users_organizations uo \ +INNER JOIN collections c ON c.org_uuid = uo.org_uuid \ +WHERE uo.atype = 2 \ + AND uo.access_all = TRUE \ + AND uo.status = 2 \ + AND NOT EXISTS ( \ + SELECT 1 FROM users_collections uc \ + WHERE uc.user_uuid = uo.user_uuid \ + AND uc.collection_uuid = c.uuid \ + )"; + +/// Clear the bit on every affected membership, whatever its status. Always the last statement: the +/// two above select on it. +const LEGACY_USER_ACCESS_ALL_CLEAR_SQL: &str = + "UPDATE users_organizations SET access_all = FALSE WHERE atype = 2 AND access_all = TRUE"; + +const LEGACY_USER_ACCESS_ALL_RECOVERY: &str = concat!( + "\n\nThe same decision applies to every affected membership on this instance, so it can also be ", + "taken once, without any SQL, by setting LEGACY_USER_ACCESS_ALL_MIGRATION before the next start:\n", + " drop clear the bit. Each member keeps the collections they are explicitly assigned\n", + " to and loses the organization-wide reach.\n", + " materialize write the reach out as explicit assignments first, then clear the bit. Confirmed\n", + " memberships only; the others are treated as 'drop'.\n", + "Both are applied before the migration touches anything, and the setting is inert afterwards.\n\n", + "To decide per membership instead, list them:\n", + "SELECT uuid, user_uuid, org_uuid, status\n", + "FROM users_organizations\n", + "WHERE atype = 2\n", + " 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", + "access_all overrode read_only and hide_passwords, so the collections the member is *already* ", + "assigned to have to be relaxed as well -- otherwise they come out of the upgrade with less access ", + "than they have now. Run both statements, in this order:\n", + "UPDATE users_collections\n", + "SET read_only = FALSE, hide_passwords = FALSE\n", + "WHERE user_uuid = (SELECT user_uuid FROM users_organizations WHERE uuid = '')\n", + " AND collection_uuid IN (\n", + " SELECT c.uuid FROM collections c\n", + " INNER JOIN users_organizations uo ON uo.org_uuid = c.org_uuid\n", + " WHERE uo.uuid = ''\n", + " );\n", + "INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage)\n", + "SELECT uo.user_uuid, c.uuid, FALSE, FALSE, FALSE\n", + "FROM users_organizations uo\n", + "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", + "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 AMBIGUOUS_PARTIAL_MIGRATION_RECOVERY: &str = concat!( + "\n\nSome of the columns this migration adds already exist, so a previous attempt changed the ", + "table -- but the result is not the schema an interrupted run leaves behind, so how far it got ", + "cannot be established and finishing it would run the conversion against a table this build does ", + "not recognise.\n\n", + "An interruption is resumed automatically, and only on MySQL and MariaDB, where each ALTER TABLE ", + "commits on its own. It requires all of:\n", + " * all nine Custom-role permission columns present and NOT NULL\n", + " * users_organizations carrying exactly the eighteen expected columns plus access_all\n", + " * a migration ledger that exists and records nothing newer than this migration\n", + " * no plain User membership still carrying access_all\n\n", + "On SQLite and PostgreSQL the whole migration runs inside one transaction, so it cannot stop ", + "half-way: this schema was produced by something else and is never resumed.\n\n", + "Restore the backup taken before the schema was changed and start the upgrade again. If the ", + "database was rolled back with tools/custom_role_rollback/, run that script to completion first -- ", + "it restores the column set and the ledger together." +); + +const MISSING_ACCESS_ALL_RECOVERY: &str = concat!( + "\n\nThe upgrade derives every Custom collection permission from that column, so it cannot run ", + "without it, and neither of the two questions above it can be answered.\n\n", + "One way to reach this state *is* recoverable and is repaired automatically: on MySQL and ", + "MariaDB every ALTER TABLE commits on its own, so a process that dies after the migration's ", + "final DROP COLUMN and before Diesel records the migration leaves a database that is already ", + "fully converted and only missing its ledger row. That is not this database -- the checks below ", + "did not all pass, so the schema is not the one the completed migration produces and nothing may ", + "be assumed about how far it got:\n", + " * all nine Custom-role permission columns present and NOT NULL\n", + " * users_organizations carrying exactly the eighteen expected columns\n", + " * no membership left on the legacy Manager role (atype = 3)\n", + " * a migration ledger that exists and records nothing newer than this migration\n\n", + "If the database was rolled back with tools/custom_role_rollback/, run that script to completion ", + "-- it restores the column and the ledger together. Otherwise restore the backup taken before the ", + "schema was changed and start again from there." +); + +/// What the preflight reads. All of it comes from the schema and the migration ledger. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +// Each field is an independent observation about the database, not a mode: they are combined by +// `custom_role_preflight_decision` and `custom_role_migration_is_complete`, which is exactly what +// the lint would have them replaced by. +#[allow(clippy::struct_excessive_bools)] +struct CustomRoleMigrationFacts { + memberships_table_exists: bool, + /// {`CUSTOM_ROLE_PERMISSIONS_MIGRATION`} is recorded, i.e. this database is already upgraded. + migration_applied: bool, + access_all_column_exists: bool, + legacy_user_access_all_count: i64, + /// The migration ledger table exists, so a missing entry means "not recorded" rather than + /// "nowhere to look". + migration_ledger_exists: bool, + /// How many of [`CUSTOM_ROLE_PERMISSION_COLUMNS`] exist, and how many of those are NOT NULL. + permission_columns_present: i64, + permission_columns_not_null: i64, + /// Total number of columns on `users_organizations`, and how many of them are names from + /// [`EXPECTED_MEMBERSHIP_COLUMNS`]. Both have to equal the expected count: the first rules out a + /// column this build knows nothing about, the second rules out a missing one. + membership_column_count: i64, + expected_membership_columns_present: i64, + /// Memberships still carrying the legacy persisted Manager role. + legacy_manager_rows: i64, + /// A migration newer than the Custom-role one is recorded. Diesel applies migrations in order, + /// so this can only mean the ledger was edited or the binary is older than the database. + newer_migration_recorded: bool, +} + +/// Whether the facts prove that the Custom-role migration ran to completion and only its ledger +/// entry is missing. +/// +/// A conjunction rather than "the legacy column is gone": dropping `access_all` is the migration's +/// *last* schema statement, so its absence alone is also what a hand-edited or half-rolled-back +/// database looks like, and recording the migration there would start the server against a schema +/// the code does not match. Each condition rules out one way of arriving here with the column gone: +/// +/// * a column dropped by hand fails the permission-column checks; +/// * a partially applied later schema change fails the exact-column-count check; +/// * a half-finished conversion still has `atype = 3` rows; +/// * a tampered ledger fails the newer-migration check. +fn custom_role_migration_is_complete(facts: CustomRoleMigrationFacts) -> bool { + let counted = |count: i64, expected: usize| usize::try_from(count).is_ok_and(|found| found == expected); + + facts.memberships_table_exists + && !facts.migration_applied + && !facts.access_all_column_exists + && facts.migration_ledger_exists + && !facts.newer_migration_recorded + && counted(facts.permission_columns_present, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.permission_columns_not_null, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.membership_column_count, EXPECTED_MEMBERSHIP_COLUMNS.len()) + && counted(facts.expected_membership_columns_present, EXPECTED_MEMBERSHIP_COLUMNS.len()) + && facts.legacy_manager_rows == 0 +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CustomRolePreflightDecision { + Proceed, + /// The migration finished but its ledger entry never committed. Record it and continue. + RecordCompletedMigration, + /// The migration got as far as adding its columns -- and possibly as far as converting the + /// legacy Managers -- but not to the end. Finish it, then record it. + ResumeInterruptedMigration, + /// Clear the legacy `User + access_all` bit, then continue. + DropLegacyUserAccessAll, + /// Write the reach of a confirmed legacy `User + access_all` membership out as explicit + /// assignments, clear the bit, then continue. + MaterializeLegacyUserAccessAll, + RefuseMissingAccessAll, + RefuseLegacyUserAccessAll, + /// Some of the migration's columns exist while it is still unrecorded, but the schema is not the + /// one an interrupted run leaves behind. Nothing may be assumed about how far it got. + RefuseAmbiguousPartialMigration, +} + +/// Whether the facts prove the migration was interrupted after it added its columns, leaving a +/// schema that can be finished rather than restored from a backup. +/// +/// An exact fingerprint of the one state an interrupted run produces, not "some of the columns are +/// there": any other shape means something other than this migration changed the table, and resuming +/// would run the conversion against a schema this build does not know. +/// +/// `legacy_manager_rows` is deliberately *not* constrained — it is non-zero at the earlier +/// interruption point and zero at the later one, and the conversion is idempotent, so one resume +/// covers both. `legacy_user_access_all_count` must be zero, which the caller establishes first. +fn custom_role_migration_is_resumable(facts: CustomRoleMigrationFacts) -> bool { + let counted = |count: i64, expected: usize| usize::try_from(count).is_ok_and(|found| found == expected); + + facts.memberships_table_exists + && !facts.migration_applied + && facts.access_all_column_exists + && facts.legacy_user_access_all_count == 0 + && facts.migration_ledger_exists + && !facts.newer_migration_recorded + && counted(facts.permission_columns_present, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.permission_columns_not_null, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + // Exactly the finished table, plus the legacy column the migration has not dropped yet. + && counted(facts.membership_column_count, EXPECTED_MEMBERSHIP_COLUMNS.len() + 1) + && counted(facts.expected_membership_columns_present, EXPECTED_MEMBERSHIP_COLUMNS.len()) +} + +/// The decision to act on once any legacy `User + access_all` rows have been resolved. +/// +/// Resolving them changes one fact, so the answer is recomputed: a database that is *both* +/// half-applied and carries such a row must still be resumed, not handed to Diesel. +fn custom_role_decision_after_legacy_resolution( + facts: CustomRoleMigrationFacts, + legacy_user_access_all: LegacyUserAccessAllPolicy, + interruptible_schema_changes: InterruptibleSchemaChanges, +) -> CustomRolePreflightDecision { + let mut resolved = facts; + resolved.legacy_user_access_all_count = 0; + custom_role_preflight_decision(resolved, legacy_user_access_all, interruptible_schema_changes) +} + +fn custom_role_preflight_decision( + facts: CustomRoleMigrationFacts, + legacy_user_access_all: LegacyUserAccessAllPolicy, + interruptible_schema_changes: InterruptibleSchemaChanges, +) -> CustomRolePreflightDecision { + // A fresh installation: Diesel creates the schema from scratch and there is nothing to convert. + if !facts.memberships_table_exists { + return CustomRolePreflightDecision::Proceed; + } + + // Already upgraded. Every question below is about the legacy schema, which no longer exists, and + // Diesel never runs a recorded migration again. + if facts.migration_applied { + return CustomRolePreflightDecision::Proceed; + } + + // The migration is pending, so the legacy column has to be there -- both questions below read it, + // and the conversion derives all three collection permissions from it. + // + // Unless the migration already ran and only its ledger entry is missing. MySQL and MariaDB + // commit every ALTER TABLE on their own, so a process killed between the migration's final + // `DROP COLUMN access_all` and Diesel's ledger insert leaves exactly that: a fully converted + // database that looks pending. Record the entry instead of sending the operator to a backup. + if !facts.access_all_column_exists { + if custom_role_migration_is_complete(facts) { + return CustomRolePreflightDecision::RecordCompletedMigration; + } + return CustomRolePreflightDecision::RefuseMissingAccessAll; + } + + // A plain User carrying membership `access_all` has no representation in the new model: unlimited + // reach over every collection, present and future, with no management authority. Materialising it as + // direct assignments turns a dynamic guarantee into a snapshot and -- since a `users_collections` row + // is not bound to the membership status -- would hand a revoked or never-confirmed member durable + // assignments. Refuse, unless the owner has already decided once (`LegacyUserAccessAllPolicy`). + if facts.legacy_user_access_all_count != 0 { + return match legacy_user_access_all { + LegacyUserAccessAllPolicy::Refuse => CustomRolePreflightDecision::RefuseLegacyUserAccessAll, + LegacyUserAccessAllPolicy::Drop => CustomRolePreflightDecision::DropLegacyUserAccessAll, + LegacyUserAccessAllPolicy::Materialize => CustomRolePreflightDecision::MaterializeLegacyUserAccessAll, + }; + } + + // Nothing left to resolve and the legacy column still there. If the migration's own columns are + // *also* present, a previous run stopped part-way: on MySQL/MariaDB each `ALTER TABLE` commits on + // its own. Handing the file back to Diesel would re-run the `ADD COLUMN` and abort with a bare + // duplicate-column error, which is what this branch replaces. + if facts.permission_columns_present != 0 { + if interruptible_schema_changes && custom_role_migration_is_resumable(facts) { + return CustomRolePreflightDecision::ResumeInterruptedMigration; + } + return CustomRolePreflightDecision::RefuseAmbiguousPartialMigration; + } + + // A legacy Manager whose organization-wide management comes from an org-local `access_all` group is + // deliberately not a question: an ordinary state that the migration maps onto `edit_any_collection` / + // `delete_any_collection`. Refusing it would block a normal upgrade; see the migration file. + CustomRolePreflightDecision::Proceed +} + +/// The full operator-facing text for a refusal: what was found, and what to do about it. +/// +/// Kept separate from the `Error` so it can be logged with `Display` (the only formatting that +/// preserves the newlines the SQL below depends on) and asserted on in tests. +fn custom_role_preflight_report(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> String { + let detail = match decision { + CustomRolePreflightDecision::RefuseMissingAccessAll => format!( + "The membership access_all column is missing while migration \ + {CUSTOM_ROLE_PERMISSIONS_MIGRATION} is still pending." + ), + 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::RefuseAmbiguousPartialMigration => format!( + "Migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION} is still pending, but {} of its {} \ + permission columns already exist on users_organizations ({} of them NOT NULL) and the \ + table currently has {} columns.", + facts.permission_columns_present, + CUSTOM_ROLE_PERMISSION_COLUMNS.len(), + facts.permission_columns_not_null, + facts.membership_column_count + ), + _ => unreachable!("only a refusal is an error"), + }; + + let recovery = match decision { + CustomRolePreflightDecision::RefuseMissingAccessAll => MISSING_ACCESS_ALL_RECOVERY, + CustomRolePreflightDecision::RefuseLegacyUserAccessAll => LEGACY_USER_ACCESS_ALL_RECOVERY, + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration => AMBIGUOUS_PARTIAL_MIGRATION_RECOVERY, + _ => "", + }; + + format!("Custom-role migration preflight stopped startup. Nothing has been changed.\n\n{detail}{recovery}") +} + +/// `'a', 'b', 'c'` — a literal list for an `IN (...)` predicate. The names are compile-time +/// constants from this file, never request data. +fn sql_name_list(names: &[&str]) -> String { + names.iter().map(|name| format!("'{name}'")).collect::>().join(", ") +} + +/// Report a refusal and produce the error that stops startup. +/// +/// Printed here through `Display`, and only here: the startup path logs a failed pool with `{e:?}`, +/// whose `Debug` formatting escapes the newlines the recovery SQL depends on, and pool creation is +/// retried. Log it once readably, flag the refusal so the retry loop stops, and let a one-line error +/// travel back. +fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> Error { + error!("{}", custom_role_preflight_report(decision, facts)); + + let detail = match decision { + CustomRolePreflightDecision::RefuseMissingAccessAll => { + "the membership access_all column is missing while the Custom-role migration is still pending" + } + CustomRolePreflightDecision::RefuseLegacyUserAccessAll => { + "a plain User membership still carries the legacy access_all bit" + } + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration => { + "the Custom-role migration is partially applied and the schema is not one it can finish" + } + _ => unreachable!("only a refusal is an error"), + }; + + let summary = format!( + "The Custom-role migration preflight refused to start: {detail}. \ + Nothing has been changed; the recovery procedure is printed above." + ); + // First refusal wins; a second would say the same thing about the same database. + drop(CUSTOM_ROLE_PREFLIGHT_REFUSAL.set(summary.clone())); + + std::io::Error::other(summary).into() +} + +/// Record a migration that finished but whose ledger entry never committed. +/// +/// `sql` is the backend's idempotent insert, so a second startup that races or repeats this is a +/// no-op rather than a duplicate-key failure. +/// The statements that resolve the legacy flag, in the order they have to run. +/// +/// The last one is always the clear, so its row count is the number of memberships resolved. +fn legacy_user_access_all_statements(decision: CustomRolePreflightDecision) -> &'static [&'static str] { + match decision { + CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => &[ + LEGACY_USER_ACCESS_ALL_RELAX_SQL, + LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL, + LEGACY_USER_ACCESS_ALL_CLEAR_SQL, + ], + CustomRolePreflightDecision::DropLegacyUserAccessAll => &[LEGACY_USER_ACCESS_ALL_CLEAR_SQL], + _ => &[], + } +} + +fn log_resolved_legacy_user_access_all(decision: CustomRolePreflightDecision, memberships: usize) { + let action = match decision { + CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + "their organization-wide reach was written out as explicit collection assignments \ + (confirmed memberships only) and the flag was cleared" + } + CustomRolePreflightDecision::DropLegacyUserAccessAll => { + "the flag was cleared; each member keeps the collections they are explicitly assigned to" + } + _ => unreachable!("no other decision resolves the legacy flag"), + }; + warn!( + "LEGACY_USER_ACCESS_ALL_MIGRATION resolved {memberships} plain User membership(s) carrying the \ + legacy access_all flag before migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION}: {action}. This ran \ + once, on the configured policy; the setting has no effect on an upgraded database." + ); +} + +fn log_recorded_completed_migration() { + warn!( + "Custom-role migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION}: the schema is fully converted but the \ + migration was not recorded. This is what an interrupted migration leaves behind on MySQL and \ + MariaDB, where every ALTER TABLE commits on its own. Every completed-schema check passed, so the \ + missing ledger entry has been recorded and startup continues; no data was changed." + ); +} + +#[cfg(mysql)] +fn log_resumed_interrupted_migration(converted: usize) { + warn!( + "Custom-role migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION}: its permission columns were already \ + present while the migration was still unrecorded, which is what an interrupted upgrade leaves \ + behind on MySQL and MariaDB, where every ALTER TABLE commits on its own. The schema matched the \ + expected fingerprint exactly, so the migration was finished: {converted} legacy Manager \ + membership(s) converted, the access_all column dropped and the migration recorded. The \ + conversion is the migration's own statement and matches only atype = 3, so a run that had \ + already converted them changed nothing here." + ); +} + // Embed the migrations from the migrations folder into the application // 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 +1079,185 @@ mod sqlite_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite"); + /// Diesel runs each SQLite migration inside a transaction, so a failure rolls the whole file + /// back and no half-applied schema can be left behind. + const INTERRUPTIBLE_SCHEMA_CHANGES: super::InterruptibleSchemaChanges = false; + + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + 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) + } + + /// Read-only, with exactly one exception: the idempotent ledger insert that records a migration + /// which provably already ran (see `custom_role_migration_is_complete`). + /// + /// `pragma_table_xinfo` rather than `table_info`: the latter omits generated columns, so one would + /// pass the exact-column-count fingerprint unseen. + fn preflight(connection: &mut diesel::sqlite::SqliteConnection) -> Result<(), super::Error> { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + if !memberships_table_exists { + return Ok(()); + } + + let migration_ledger_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_applied = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let newer_migration_recorded = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version > '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name = 'access_all'", + )? != 0; + + let permission_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let permission_columns_not_null = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({}) AND \"notnull\" = 1", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let membership_column_count = + count(connection, "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations')")?; + let expected_membership_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({})", + super::sql_name_list(&super::EXPECTED_MEMBERSHIP_COLUMNS) + ), + )?; + let legacy_manager_rows = + count(connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 3")?; + + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // 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 facts = super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_applied, + access_all_column_exists, + legacy_user_access_all_count, + migration_ledger_exists, + permission_columns_present, + permission_columns_not_null, + membership_column_count, + expected_membership_columns_present, + legacy_manager_rows, + newer_migration_recorded, + }; + + let policy = super::LegacyUserAccessAllPolicy::configured(); + let decision = super::custom_role_preflight_decision(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + match decision { + super::CustomRolePreflightDecision::Proceed => Ok(()), + super::CustomRolePreflightDecision::RecordCompletedMigration => { + // Diesel will not run the file, so the migration's own statements after its last + // schema change have to happen here. + diesel::sql_query(super::DROP_DOWNGRADE_ACK_SQL).execute(connection)?; + diesel::sql_query(format!( + "INSERT OR IGNORE INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{}', CURRENT_TIMESTAMP)", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + )) + .execute(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag changes one fact, so the answer has to be recomputed before the + // file goes back to Diesel. + // + // Data integrity (audit F-2): recomputed *before* the statements run. They commit + // immediately and cannot be undone -- `materialize` even relaxes `read_only` and + // `hide_passwords` on existing assignments -- so a database that would be refused + // afterwards anyway has to be refused now, while the refusal's "Nothing has been + // changed" is still true. + match super::custom_role_decision_after_legacy_resolution(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES) { + super::CustomRolePreflightDecision::Proceed => {} + followup => return Err(super::custom_role_preflight_error(followup, facts)), + } + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + super::log_resolved_legacy_user_access_all(decision, resolved); + Ok(()) + } + // SQLite runs the whole migration inside one transaction, so it cannot stop half-way and + // `custom_role_preflight_decision` never resumes for it. Fail closed rather than rely on + // that from a distance. + super::CustomRolePreflightDecision::ResumeInterruptedMigration => Err(super::custom_role_preflight_error( + super::CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + facts, + )), + decision => Err(super::custom_role_preflight_error(decision, facts)), + } + } + 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 +1281,225 @@ mod mysql_migrations { use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql"); + /// MySQL and MariaDB commit every `ALTER TABLE` on their own, so a process killed part-way + /// through a migration leaves it half-applied. This is the only backend an interrupted upgrade + /// can be resumed on. + const INTERRUPTIBLE_SCHEMA_CHANGES: super::InterruptibleSchemaChanges = true; + + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + 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) + } + + /// Read-only apart from the idempotent ledger insert and the resume below. This is the backend that + /// produces both states: MySQL and MariaDB commit every ALTER TABLE on their own, so a process killed + /// part-way through leaves a database that looks pending but is not. + fn preflight(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + if !memberships_table_exists { + return Ok(()); + } + + let migration_ledger_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_applied = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let newer_migration_recorded = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version > '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name = 'access_all'", + )? != 0; + + let permission_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let permission_columns_not_null = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({}) \ + AND is_nullable = 'NO'", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let membership_column_count = count( + connection, + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() AND table_name = 'users_organizations'", + )?; + let expected_membership_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({})", + super::sql_name_list(&super::EXPECTED_MEMBERSHIP_COLUMNS) + ), + )?; + let legacy_manager_rows = + count(connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 3")?; + + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // 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 facts = super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_applied, + access_all_column_exists, + legacy_user_access_all_count, + migration_ledger_exists, + permission_columns_present, + permission_columns_not_null, + membership_column_count, + expected_membership_columns_present, + legacy_manager_rows, + newer_migration_recorded, + }; + + let policy = super::LegacyUserAccessAllPolicy::configured(); + let decision = super::custom_role_preflight_decision(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + match decision { + super::CustomRolePreflightDecision::Proceed => Ok(()), + super::CustomRolePreflightDecision::RecordCompletedMigration => { + // Diesel will not run the file, so the migration's own statements after its last + // schema change have to happen here. + diesel::sql_query(super::DROP_DOWNGRADE_ACK_SQL).execute(connection)?; + record_migration(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::ResumeInterruptedMigration => resume_migration(connection), + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag changes one fact, so the answer has to be recomputed: this + // database may *also* be a half-applied upgrade, which must never be handed back to + // Diesel -- it would re-run `ALTER TABLE ... ADD COLUMN` and abort. + // + // Data integrity (audit F-2): recomputed *before* the statements run. They commit + // immediately and cannot be undone -- `materialize` even relaxes `read_only` and + // `hide_passwords` on existing assignments -- so a database that would be refused + // afterwards anyway has to be refused now, while the refusal's "Nothing has been + // changed" is still true. Resuming is not a refusal: those statements do run, and the + // interrupted migration is finished afterwards. + let followup = + super::custom_role_decision_after_legacy_resolution(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + if !matches!( + followup, + super::CustomRolePreflightDecision::Proceed + | super::CustomRolePreflightDecision::ResumeInterruptedMigration + ) { + return Err(super::custom_role_preflight_error(followup, facts)); + } + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + super::log_resolved_legacy_user_access_all(decision, resolved); + match followup { + super::CustomRolePreflightDecision::ResumeInterruptedMigration => resume_migration(connection), + _ => Ok(()), + } + } + decision => Err(super::custom_role_preflight_error(decision, facts)), + } + } + + /// Idempotent ledger insert, so a repeated or racing startup is a no-op rather than a + /// duplicate-key failure. + fn record_migration(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), diesel::result::Error> { + diesel::sql_query(format!( + "INSERT IGNORE INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{}', CURRENT_TIMESTAMP)", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + )) + .execute(connection) + .map(|_| ()) + } + + /// Finish a migration that stopped between its first `ALTER TABLE` and its last. + /// + /// Only reached once `custom_role_migration_is_resumable` has confirmed the schema, so these are the + /// statements that run has not executed -- or, for the conversion, one it may already have executed, + /// which matches nothing the second time. Diesel then finds the migration recorded and never opens + /// the file, so the `ADD COLUMN` that would abort startup is never reached. + fn resume_migration(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> { + let mut converted = 0; + for statement in super::CUSTOM_ROLE_RESUME_STATEMENTS { + let affected = diesel::sql_query(statement).execute(connection)?; + if statement == super::CUSTOM_ROLE_MANAGER_CONVERSION_SQL { + converted = affected; + } + } + record_migration(connection)?; + super::log_resumed_interrupted_migration(converted); + Ok(()) + } + pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { // 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 +1513,1538 @@ 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"); + /// Diesel runs each PostgreSQL migration inside a transaction, and PostgreSQL DDL is + /// transactional, so a failure rolls the whole file back. + const INTERRUPTIBLE_SCHEMA_CHANGES: super::InterruptibleSchemaChanges = false; + + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + 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 where new objects are created, not necessarily where an existing table is + /// found: with `search_path = decoy, real` and the tables in `real` it answers `decoy`, the lookup + /// finds nothing, `preflight` returns early on `!memberships_table_exists`, and Diesel then runs the + /// migration against `real` with both checks silently skipped. `to_regclass` walks the same path the + /// migration does, so the two cannot disagree about which table they mean. (The migration and + /// `tools/custom_role_rollback/postgresql.sql` resolve it the same way.) + fn table_exists(connection: &mut diesel::pg::PgConnection, table: &str) -> Result { + count(connection, format!("SELECT COUNT(*) AS count FROM pg_class WHERE oid = to_regclass('{table}')")) + .map(|value| value != 0) + } + + /// Read-only, with exactly one exception: the idempotent ledger insert that records a migration + /// which provably already ran (see `custom_role_migration_is_complete`). PostgreSQL has + /// transactional DDL, so it never produces that state itself -- the repair is here so a database + /// restored or copied from a MySQL-side incident is handled identically on every backend. + fn preflight(connection: &mut diesel::pg::PgConnection) -> Result<(), super::Error> { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + if !memberships_table_exists { + return Ok(()); + } + + let migration_ledger_exists = table_exists(connection, "__diesel_schema_migrations")?; + let migration_applied = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version = '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + let newer_migration_recorded = migration_ledger_exists + && count( + connection, + format!( + "SELECT COUNT(*) AS count FROM __diesel_schema_migrations \ + WHERE version > '{}'", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + ), + )? != 0; + // Columns are resolved through the same `to_regclass` lookup as [`table_exists`], so a + // `search_path` split cannot make the schema and the column check describe two different + // tables. + let access_all_column_exists = count( + connection, + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 \ + AND NOT attisdropped \ + AND attname = 'access_all'", + )? != 0; + + let permission_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped \ + AND attname IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let permission_columns_not_null = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped AND attnotnull \ + AND attname IN ({})", + super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS) + ), + )?; + let membership_column_count = count( + connection, + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped", + )?; + let expected_membership_columns_present = count( + connection, + format!( + "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped \ + AND attname IN ({})", + super::sql_name_list(&super::EXPECTED_MEMBERSHIP_COLUMNS) + ), + )?; + let legacy_manager_rows = + count(connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 3")?; + + // Status is deliberately not part of this count: an invited, accepted or revoked membership + // carrying the bit is exactly the state that must never become durable direct assignments, so + // 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 facts = super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_applied, + access_all_column_exists, + legacy_user_access_all_count, + migration_ledger_exists, + permission_columns_present, + permission_columns_not_null, + membership_column_count, + expected_membership_columns_present, + legacy_manager_rows, + newer_migration_recorded, + }; + + let policy = super::LegacyUserAccessAllPolicy::configured(); + let decision = super::custom_role_preflight_decision(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES); + match decision { + super::CustomRolePreflightDecision::Proceed => Ok(()), + super::CustomRolePreflightDecision::RecordCompletedMigration => { + // Diesel will not run the file, so the migration's own statements after its last + // schema change have to happen here. + diesel::sql_query(super::DROP_DOWNGRADE_ACK_SQL).execute(connection)?; + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{}', CURRENT_TIMESTAMP) ON CONFLICT (version) DO NOTHING", + super::CUSTOM_ROLE_PERMISSIONS_MIGRATION + )) + .execute(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag changes one fact, so the answer has to be recomputed before the + // file goes back to Diesel. + // + // Data integrity (audit F-2): recomputed *before* the statements run. They commit + // immediately and cannot be undone -- `materialize` even relaxes `read_only` and + // `hide_passwords` on existing assignments -- so a database that would be refused + // afterwards anyway has to be refused now, while the refusal's "Nothing has been + // changed" is still true. + match super::custom_role_decision_after_legacy_resolution(facts, policy, INTERRUPTIBLE_SCHEMA_CHANGES) { + super::CustomRolePreflightDecision::Proceed => {} + followup => return Err(super::custom_role_preflight_error(followup, facts)), + } + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + super::log_resolved_legacy_user_access_all(decision, resolved); + Ok(()) + } + // PostgreSQL runs the whole migration inside one transaction, so it cannot stop half-way + // and `custom_role_preflight_decision` never resumes for it. Fail closed rather than rely + // on that from a distance. + super::CustomRolePreflightDecision::ResumeInterruptedMigration => Err(super::custom_role_preflight_error( + super::CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + facts, + )), + decision => Err(super::custom_role_preflight_error(decision, facts)), + } + } + 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 file against a throwaway SQLite database. +/// +/// Everything else here tests the *decision* the preflight makes; this tests the SQL it protects. +/// The rules the migration encodes -- authority materialized from what a membership held then, and +/// only from its own organization -- are invisible to a Rust test unless the statements run. +#[cfg(all(test, sqlite))] +mod custom_role_migration_sql_tests { + use diesel::connection::SimpleConnection; + use diesel::{ + Connection, RunQueryDsl, + sql_types::{BigInt, Text}, + sqlite::SqliteConnection, + }; + + const ADD_CUSTOM_ROLE_PERMISSIONS: &str = + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql"); + + /// `users_organizations` exactly as current upstream main leaves it: membership `access_all`, the + /// retired Manager role, and none of the nine permission columns. + const LEGACY_SCHEMA: &str = " + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + access_all BOOLEAN NOT NULL, + 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) + ); + CREATE TABLE collections ( + uuid TEXT NOT NULL PRIMARY KEY, + org_uuid TEXT NOT NULL + ); + CREATE TABLE users_collections ( + user_uuid TEXT NOT NULL, + collection_uuid TEXT NOT NULL, + read_only BOOLEAN NOT NULL DEFAULT FALSE, + hide_passwords BOOLEAN NOT NULL DEFAULT FALSE, + manage BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (user_uuid, collection_uuid) + ); + "; + + /// One membership per legacy shape the conversion treats differently, in two organizations. + /// + /// `m_mgr_foreign` is the tenancy probe: an org-1 Manager carrying a `groups_users` row pointing at + /// org 2's `accessAll` group. No HTTP path creates that row, which is why the migration's + /// organization predicate has to be tested rather than assumed. + const LEGACY_MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org1', TRUE), + ('g_plain', 'org1', FALSE), + ('g2_all', 'org2', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_owner', 'u1', 'org1', TRUE, 2, 0), + ('m_admin', 'u2', 'org1', TRUE, 2, 1), + ('m_user', 'u3', 'org1', FALSE, 2, 2), + ('m_mgr_all', 'u4', 'org1', TRUE, 2, 3), + ('m_mgr_bare', 'u5', 'org1', FALSE, 2, 3), + ('m_mgr_plain_g', 'u6', 'org1', FALSE, 2, 3), + ('m_mgr_group', 'u7', 'org1', FALSE, 2, 3), + ('m_user_group', 'u8', 'org1', FALSE, 2, 2), + ('m_mgr_invited', 'u9', 'org1', FALSE, 0, 3), + ('m_mgr_revoked', 'u10', 'org1', FALSE, -1, 3), + ('m_mgr_foreign', 'u11', 'org1', FALSE, 2, 3), + ('m2_mgr_group', 'u12', 'org2', FALSE, 2, 3), + ('m2_user', 'u7', 'org2', FALSE, 2, 2); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_user_group'), + ('g_all', 'm_mgr_revoked'), + ('g_plain', 'm_mgr_plain_g'), + ('g2_all', 'm2_mgr_group'), + ('g2_all', 'm_mgr_foreign'); + "; + + /// The one state the upgrade refuses by default: a plain User still carrying `access_all`. + /// + /// `access_all` overrode `read_only` and `hide_passwords` but never conferred `manage`, so `u20`'s + /// two existing assignments must be relaxed and its `manage` grant left alone. The row on `c3` is a + /// different organization's and unbacked by any membership: nothing may touch it. `u21` and `u22` are + /// revoked and invited, so they must come out with no assignments at all. + const LEGACY_USER_ACCESS_ALL: &str = " + INSERT INTO collections (uuid, org_uuid) VALUES + ('c1', 'org1'), ('c2', 'org1'), ('c4', 'org1'), ('c3', 'org2'); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_uaa', 'u20', 'org1', TRUE, 2, 2), + ('m_uaa_rev', 'u21', 'org1', TRUE, -1, 2), + ('m_uaa_inv', 'u22', 'org1', TRUE, 0, 2); + INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) VALUES + ('u20', 'c1', TRUE, TRUE, FALSE), + ('u20', 'c2', TRUE, FALSE, TRUE), + ('u20', 'c3', TRUE, FALSE, TRUE); + "; + + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = BigInt)] + count: i64, + } + + #[derive(diesel::QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + value: String, + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + diesel::sql_query(query).get_result::(connection).map(|row| row.count).unwrap() + } + + fn rows(connection: &mut SqliteConnection, query: &str) -> Vec { + diesel::sql_query(query).load::(connection).unwrap().into_iter().map(|row| row.value).collect() + } + + fn connect(memberships: &str) -> SqliteConnection { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + connection.batch_execute("PRAGMA foreign_keys = OFF").unwrap(); + connection.batch_execute(LEGACY_SCHEMA).unwrap(); + connection.batch_execute(memberships).unwrap(); + connection + } + + /// Applies the migration the way Diesel's harness does: inside a transaction, so a refusal rolls + /// back the temporary guard tables as well and a retry starts from the same state a restart would. + fn migrate(connection: &mut SqliteConnection) -> Result<(), diesel::result::Error> { + connection.transaction(|connection| connection.batch_execute(ADD_CUSTOM_ROLE_PERMISSIONS)) + } + + /// Runs what the preflight runs for the configured policy, in the same order. + fn resolve(connection: &mut SqliteConnection, decision: super::CustomRolePreflightDecision) { + for statement in super::legacy_user_access_all_statements(decision) { + diesel::sql_query(*statement).execute(connection).unwrap(); + } + } + + /// Every collection assignment, as one line each. + fn assignments(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT user_uuid || ' ' || collection_uuid \ + || ' ro=' || read_only || ' hide=' || hide_passwords || ' manage=' || manage AS value \ + FROM users_collections ORDER BY user_uuid, collection_uuid", + ) + } + + /// Resuming replays the migration's own conversion, so it has to land on exactly what the migration + /// produces and stay there when replayed -- that idempotence is what covers both interruption points. + /// Run on SQLite because that is the backend with an in-process harness; the `DROP COLUMN` companion + /// is left out because SQLite before 3.35 cannot run it. + #[cfg(mysql)] + #[test] + fn the_resume_conversion_matches_the_migration_and_is_idempotent() { + // What the migration produces when it runs to completion. + let mut finished = connect(LEGACY_MEMBERSHIPS); + migrate(&mut finished).unwrap(); + let expected = state(&mut finished); + + // The same database, interrupted straight after `ALTER TABLE ... ADD COLUMN`: the nine + // columns exist at their defaults, nothing is converted, `access_all` is still there. + let mut interrupted = connect(LEGACY_MEMBERSHIPS); + for column in super::CUSTOM_ROLE_PERMISSION_COLUMNS { + diesel::sql_query(format!( + "ALTER TABLE users_organizations ADD COLUMN {column} BOOLEAN NOT NULL DEFAULT FALSE" + )) + .execute(&mut interrupted) + .unwrap(); + } + assert_ne!(state(&mut interrupted), expected, "the interrupted database must not already match"); + + let converted = diesel::sql_query(super::CUSTOM_ROLE_MANAGER_CONVERSION_SQL).execute(&mut interrupted).unwrap(); + assert!(converted > 0, "the first replay has legacy Managers to convert"); + assert_eq!(state(&mut interrupted), expected, "resuming must land on the migration's own result"); + + // The later interruption point: the conversion already ran, so replaying it matches nothing. + let replayed = diesel::sql_query(super::CUSTOM_ROLE_MANAGER_CONVERSION_SQL).execute(&mut interrupted).unwrap(); + assert_eq!(replayed, 0, "the conversion must match nothing the second time"); + assert_eq!(state(&mut interrupted), expected, "replaying the conversion must change nothing"); + } + + /// Every membership's role plus the six permissions the conversion can set, as one line each. + fn state(connection: &mut SqliteConnection) -> Vec { + rows( + connection, + "SELECT uuid || ' atype=' || atype \ + || ' ' || create_new_collections || edit_any_collection || delete_any_collection \ + || ' ' || manage_users || manage_groups || manage_policies \ + || access_event_logs || access_import_export || access_reports AS value \ + FROM users_organizations ORDER BY uuid", + ) + } + + 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", + ) + } + + 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 + } + + /// The whole conversion, in one comparison. Written out per membership on purpose: every line is + /// a rule, and a regression in any of them is a silent authorization change. + #[test] + fn the_conversion_maps_every_legacy_shape_exactly_once() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + + assert_eq!( + state(&mut connection), + [ + // The second organization is converted on its own terms... + "m2_mgr_group atype=4 011 000000", + // ...and the same *user* holding a plain User membership there gains nothing from + // being a Manager in the first organization. + "m2_user atype=2 000 000000", + // Admin keeps its role; the new model grants it everything implicitly, so no + // permission column is set. + "m_admin atype=1 000 000000", + // Membership access_all was the "Manage all collections" checkbox: all three. + "m_mgr_all atype=4 111 000000", + // Manager with nothing: Custom with nothing. + "m_mgr_bare atype=4 000 000000", + // A groups_users row pointing at another organization's accessAll group grants + // nothing -- the migration requires the group to belong to the membership's own org. + "m_mgr_foreign atype=4 000 000000", + // Group-derived authority: edit and delete, never create. + "m_mgr_group atype=4 011 000000", + // Invited is converted like any other membership. + "m_mgr_invited atype=4 000 000000", + // A group without accessAll conveys nothing. + "m_mgr_plain_g atype=4 000 000000", + // Revoked is converted like any other membership: status is not part of the rule. + "m_mgr_revoked atype=4 011 000000", + "m_owner atype=0 000 000000", + // A plain User is never converted, not even inside an accessAll group. + "m_user atype=2 000 000000", + "m_user_group atype=2 000 000000", + ] + ); + } + + /// The nine columns exist, `access_all` does not, and nothing else about the table changed. + #[test] + fn the_rebuilt_table_has_the_final_shape() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + + assert_eq!( + rows(&mut connection, "SELECT name AS value FROM pragma_table_xinfo('users_organizations')"), + [ + "uuid", + "user_uuid", + "org_uuid", + "akey", + "status", + "atype", + "reset_password_key", + "external_id", + "invited_by_email", + "manage_users", + "manage_groups", + "manage_policies", + "create_new_collections", + "edit_any_collection", + "delete_any_collection", + "access_event_logs", + "access_import_export", + "access_reports", + ] + ); + // The primary key and the UNIQUE pair, and nothing else: the rollback script checks for + // exactly these two and would refuse a database the rebuild had changed. + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM pragma_index_list('users_organizations')"), 2); + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM users_organizations WHERE uuid = 'm_owner' AND user_uuid = 'u1'" + ), + 1 + ); + } + + /// The one shape the upgrade refuses outright, checked in the SQL rather than only in the Rust + /// preflight: `diesel migration run` and a bare `MigrationHarness` never consult the preflight, + /// and the column that carries the reach is gone a few statements later. + #[test] + fn a_plain_user_carrying_access_all_is_refused_and_nothing_changes() { + let memberships = " + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_user_all', 'u1', 'org1', TRUE, 2); + "; + let mut connection = connect(memberships); + let before = legacy_state(&mut connection); + + assert!(migrate(&mut connection).is_err()); + + assert_eq!(legacy_state(&mut connection), before); + assert!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') WHERE name = 'access_all'" + ) == 1, + "the refusal must leave the legacy column in place" + ); + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \ + WHERE name IN ('manage_users', 'create_new_collections', 'access_reports')" + ), + 0, + "the refusal must not leave a half-applied schema behind" + ); + } + + /// The whole point of this revision: a legacy Manager whose organization-wide collection + /// management comes from an organization-local `access_all` group is an ordinary, valid + /// current-main state. It must migrate straight through -- no acknowledgement table, no failed + /// first startup -- and land on edit + delete without collection creation. + #[test] + fn group_derived_authority_migrates_without_any_acknowledgement() { + let memberships = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_mgr_group', 'u1', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm_mgr_group'); + "; + let mut connection = connect(memberships); + + migrate(&mut connection).expect("a valid current-main database must migrate on the first try"); + + assert_eq!(state(&mut connection), ["m_mgr_group atype=4 011 000000"]); + // The group and its flag are what they were: only the membership row changed. + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM \"groups\" WHERE uuid = 'g_all' AND access_all = TRUE" + ), + 1 + ); + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM groups_users WHERE groups_uuid = 'g_all'"), 1); + } + + /// Nothing about the upgrade is conditional on operator state any more, so running it twice from + /// the same legacy database has to produce the same row both times. + /// LEGACY_USER_ACCESS_ALL_MIGRATION=materialize: the reach becomes explicit assignments that + /// reproduce it exactly, and the upgrade then runs. + #[test] + fn materializing_the_legacy_flag_reproduces_the_reach_and_unblocks_the_upgrade() { + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + assert!(migrate(&mut connection).is_err(), "the guard must refuse this database untouched"); + + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + resolve(&mut connection, super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll); + + assert_eq!( + assignments(&mut connection), + [ + // relaxed: access_all overrode read_only and hide_passwords ... + "u20 c1 ro=0 hide=0 manage=0", + // ... but never conferred manage, so an explicit grant survives + "u20 c2 ro=0 hide=0 manage=1", + // a row whose collection belongs to another organization is not this membership\'s + "u20 c3 ro=1 hide=0 manage=1", + // the third collection of the organization, written out + "u20 c4 ro=0 hide=0 manage=0", + ], + "a revoked or invited membership must not receive any assignment" + ); + assert_eq!( + count(&mut connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE access_all = TRUE"), + 0 + ); + + migrate(&mut connection).expect("the upgrade runs once the flag is resolved"); + assert!(!table_exists(&mut connection, "users_organizations_new")); + } + + /// LEGACY_USER_ACCESS_ALL_MIGRATION=drop: only the flag goes; explicit assignments are kept + /// exactly as they are, including their restrictions. + #[test] + fn dropping_the_legacy_flag_keeps_every_explicit_assignment_untouched() { + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + let before = assignments(&mut connection); + resolve(&mut connection, super::CustomRolePreflightDecision::DropLegacyUserAccessAll); + + assert_eq!(assignments(&mut connection), before, "drop must not write a single assignment"); + assert_eq!( + count(&mut connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE access_all = TRUE"), + 0 + ); + + migrate(&mut connection).expect("the upgrade runs once the flag is resolved"); + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM users_organizations WHERE atype = 2"), 3); + } + + #[test] + fn the_conversion_is_deterministic() { + let first = { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + state(&mut connection) + }; + let second = { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + state(&mut connection) + }; + assert_eq!(first, second); + } + + /// A Manager whose own `access_all` bit is set /// A Manager whose own `access_all` bit is set is not part of the question: that bit is already a + /// durable membership-level grant, so converting it changes no meaning and must not stop an + /// upgrade that has nothing else to decide. + #[test] + fn a_manager_with_its_own_access_all_bit_is_not_asked_about() { + let memberships = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g_all', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m_mgr_both', 'u1', 'org1', TRUE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g_all', 'm_mgr_both'); + "; + let mut connection = connect(memberships); + + migrate(&mut connection).unwrap(); + + assert_eq!(state(&mut connection), ["m_mgr_both atype=4 111 000000"]); + } + + /// A leftover downgrade acknowledgement is cleared by the upgrade, so consent from an earlier + /// revert is never inherited by a later one. + #[test] + fn a_leftover_downgrade_acknowledgement_is_cleared() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + connection + .batch_execute("CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY)") + .unwrap(); + + migrate(&mut connection).unwrap(); + + assert!(!table_exists(&mut connection, "__vw_allow_custom_role_downgrade")); + } + + /// The upgrade must not depend on, or leave behind, any bookkeeping table of its own. + #[test] + fn the_upgrade_creates_no_bookkeeping_table() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name LIKE '__vw_%'" + ), + 0 + ); + } + + /// Every shape a valid current-main database can hold has to migrate on the first try. Only the + /// one legacy state that cannot be represented at all may abort -- and it is the last case here. + #[test] + fn every_valid_current_main_shape_migrates_on_the_first_try() { + let cases: [(&str, &str, bool); 7] = [ + ( + "Manager, nothing else", + "INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) \ + VALUES ('m', 'u', 'org1', FALSE, 3);", + true, + ), + ( + "Manager in a group without accessAll", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', FALSE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "Manager in an accessAll group", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "Manager with membership access_all as well", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', TRUE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "plain User in an accessAll group", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org1', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 2); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "Manager in another organization's accessAll group", + " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES ('g', 'org2', TRUE); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) VALUES + ('m', 'u', 'org1', FALSE, 3); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES ('g', 'm');", + true, + ), + ( + "plain User carrying membership access_all", + "INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, atype) \ + VALUES ('m', 'u', 'org1', TRUE, 2);", + false, + ), + ]; + + for (name, memberships, should_migrate) in cases { + let mut connection = connect(memberships); + assert_eq!(migrate(&mut connection).is_ok(), should_migrate, "unexpected outcome for: {name}"); + } + } +} + +/// Runs the real migration, then `tools/custom_role_rollback/sqlite.sql`} + +/// Runs the real migration, then `tools/custom_role_rollback/sqlite.sql`, then the migration again -- +/// against a throwaway SQLite database, with the real files on both legs. +/// +/// The claim the rollback tooling rests on: downgrade then upgrade again has to arrive at the same +/// permissions, or the escape hatch quietly rewrites authorization. +#[cfg(all(test, sqlite))] +mod custom_role_rollback_sql_tests { + use diesel::connection::SimpleConnection; + use diesel::{Connection, RunQueryDsl, sql_types::Text, sqlite::SqliteConnection}; + + const MIGRATION: &str = + include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql"); + const REVERT: &str = include_str!("../../migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql"); + const ROLLBACK: &str = include_str!("../../tools/custom_role_rollback/sqlite.sql"); + + const VERSION: &str = "20260630120000"; + + const DOWNGRADE_ACK: &str = + "CREATE TABLE __vw_allow_custom_role_downgrade (acknowledged INTEGER NOT NULL PRIMARY KEY)"; + const ALLOWLIST: &str = + "CREATE TABLE __vw_rollback_manager_allowlist (users_organizations_uuid TEXT NOT NULL PRIMARY KEY)"; + + /// `users_organizations` exactly as current upstream main leaves it -- the rollback script checks + /// for *precisely* eighteen columns and two indexes afterwards, so a reduced fixture would not + /// exercise the checks it exists for. + const UPSTREAM_SCHEMA: &str = " + CREATE TABLE __diesel_schema_migrations ( + version VARCHAR(50) NOT NULL PRIMARY KEY, + 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', TRUE, 2, 0), + ('m_admin', 'u2', 'org', TRUE, 2, 1), + ('m_user', 'u3', 'org', FALSE, 2, 2), + ('m_mgr_bare', 'u4', 'org', FALSE, 2, 3), + ('m_mgr_all', 'u5', 'org', TRUE, 2, 3), + ('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() + } + + fn count(connection: &mut SqliteConnection, query: &str) -> i64 { + rows(connection, &format!("SELECT ({query}) || '' AS value"))[0].parse().unwrap() + } + + /// Every membership's role plus its nine permissions, as one comparable line each. + fn permission_state(connection: &mut SqliteConnection) -> Vec { + rows( + 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 migration, recording its version the way Diesel would. + fn upgrade(connection: &mut SqliteConnection) -> Result<(), diesel::result::Error> { + connection.batch_execute(MIGRATION)?; + connection.batch_execute(&format!("INSERT INTO __diesel_schema_migrations (version) VALUES ('{VERSION}')")) + } + + /// `.bail on` is a sqlite3 shell command, not SQL. Dropping it is safe here -- a failing statement + /// fails the whole `batch_execute` anyway -- but the assertion keeps the test honest if another + /// 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 + } + + /// The whole point of the tooling: upgrade, roll back, upgrade again, and land on the same + /// permissions. The allowlist is what makes it converge -- it names exactly the memberships that + /// were Managers, which is what the second upgrade then reads. + #[test] + fn upgrade_rollback_and_upgrade_again_converge() { + let mut connection = connect(); + upgrade(&mut connection).unwrap(); + let after_first_upgrade = permission_state(&mut connection); + + connection.batch_execute(ALLOWLIST).unwrap(); + connection + .batch_execute( + "INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES \ + ('m_mgr_bare'), ('m_mgr_all'), ('m_mgr_group'), ('m_mgr_gone')", + ) + .unwrap(); + connection.batch_execute(&rollback_sql()).unwrap(); + + assert_eq!( + legacy_state(&mut connection), + [ + "m_admin atype=1 access_all=1", + "m_mgr_all atype=3 access_all=1", + "m_mgr_bare atype=3 access_all=0", + // Group-derived authority came back as 0/1/1, which is not all three, so the legacy + // "manage all collections" bit stays off -- the old binary derives the same authority + // from `groups.access_all` again anyway. + "m_mgr_gone atype=3 access_all=0", + "m_mgr_group atype=3 access_all=0", + "m_owner atype=0 access_all=1", + "m_user atype=2 access_all=0", + ] + ); + assert_eq!(count(&mut connection, "SELECT COUNT(*) FROM __diesel_schema_migrations"), 1); + + upgrade(&mut connection).unwrap(); + assert_eq!(permission_state(&mut connection), after_first_upgrade, "the round trip must converge"); + } + + /// The role mapping is a decision, not a conversion, so the script refuses to make it up. + #[test] + fn the_rollback_refuses_without_an_allowlist_and_changes_nothing() { + let mut connection = connect(); + upgrade(&mut connection).unwrap(); + let before = permission_state(&mut connection); + + assert!(connection.batch_execute(&rollback_sql()).is_err()); + + assert_eq!(permission_state(&mut connection), before); + assert_eq!(count(&mut connection, "SELECT COUNT(*) FROM __diesel_schema_migrations"), 2); + } + + /// A migration this script has never seen may have changed anything, including the table it + /// rebuilds from a fixed column list. + #[test] + fn the_rollback_refuses_a_ledger_from_the_future() { + let mut connection = connect(); + upgrade(&mut connection).unwrap(); + connection.batch_execute(ALLOWLIST).unwrap(); + connection.batch_execute("INSERT INTO __diesel_schema_migrations (version) VALUES ('20270101000000')").unwrap(); + let before = permission_state(&mut connection); + + assert!(connection.batch_execute(&rollback_sql()).is_err()); + assert_eq!(permission_state(&mut connection), before); + } + + /// The Diesel alternative the README documents, end to end. Both decisions are required, and both + /// are consumed by the revert they authorize. + #[test] + fn the_diesel_revert_runs_with_both_acknowledgements() { + let mut connection = connect(); + let before = legacy_state(&mut connection); + upgrade(&mut connection).unwrap(); + + connection.batch_execute(DOWNGRADE_ACK).unwrap(); + connection.batch_execute(ALLOWLIST).unwrap(); + connection + .batch_execute( + "INSERT INTO __vw_rollback_manager_allowlist (users_organizations_uuid) VALUES \ + ('m_mgr_bare'), ('m_mgr_all'), ('m_mgr_group'), ('m_mgr_gone')", + ) + .unwrap(); + connection.batch_execute(REVERT).unwrap(); + + // The fixture's Owner and Admin already carry the bit, which is what current main writes for + // them, so this database round-trips byte-identically. (A database where an Owner somehow had + // it cleared would come back with it set: the upgrade dropped the column precisely because + // their role already implies it, so the original value is gone.) + assert_eq!(legacy_state(&mut connection), before); + assert!( + count( + &mut connection, + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' \ + AND name IN ('__vw_allow_custom_role_downgrade', '__vw_rollback_manager_allowlist')" + ) == 0, + "both decisions authorized exactly this downgrade" + ); + } + + /// Without the acknowledgement the revert stops before its first mutation. + #[test] + fn the_revert_stops_at_the_guard_and_mutates_nothing() { + let mut connection = connect(); + upgrade(&mut connection).unwrap(); + let before = permission_state(&mut connection); + + assert!(connection.batch_execute(REVERT).is_err()); + assert_eq!(permission_state(&mut connection), before); + + // The acknowledgement alone is not enough either: the role mapping is a separate decision. + connection.batch_execute(DOWNGRADE_ACK).unwrap(); + assert!(connection.batch_execute(REVERT).is_err()); + assert_eq!(permission_state(&mut connection), before); + } +} + +#[cfg(test)] +mod custom_role_migration_preflight_tests { + use super::{ + CUSTOM_ROLE_PERMISSION_COLUMNS, CustomRoleMigrationFacts, CustomRolePreflightDecision, + EXPECTED_MEMBERSHIP_COLUMNS, LEGACY_USER_ACCESS_ALL_CLEAR_SQL, LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL, + LEGACY_USER_ACCESS_ALL_RELAX_SQL, LegacyUserAccessAllPolicy, custom_role_decision_after_legacy_resolution, + custom_role_preflight_decision, custom_role_preflight_report, legacy_user_access_all_statements, + }; + + /// The operator-facing refusal text. + fn message(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> String { + custom_role_preflight_report(decision, facts) + } + + /// Refusing is the default policy; the tests that care about the other two pass them explicitly. + /// MySQL/MariaDB is the backend an interrupted upgrade can be resumed on, so it is the default + /// here too; `decide_atomic` covers SQLite and PostgreSQL. + fn decide(facts: CustomRoleMigrationFacts) -> CustomRolePreflightDecision { + custom_role_preflight_decision(facts, LegacyUserAccessAllPolicy::Refuse, true) + } + + /// The same question on a backend that runs the whole migration in one transaction. + fn decide_atomic(facts: CustomRoleMigrationFacts) -> CustomRolePreflightDecision { + custom_role_preflight_decision(facts, LegacyUserAccessAllPolicy::Refuse, false) + } + + /// A database that has not been upgraded yet and has nothing to decide. + fn ready() -> CustomRoleMigrationFacts { + CustomRoleMigrationFacts { + memberships_table_exists: true, + migration_applied: false, + access_all_column_exists: true, + legacy_user_access_all_count: 0, + migration_ledger_exists: true, + // The legacy schema: no permission columns yet, `access_all` instead of the nine. + permission_columns_present: 0, + permission_columns_not_null: 0, + membership_column_count: 10, + expected_membership_columns_present: 9, + legacy_manager_rows: 0, + newer_migration_recorded: false, + } + } + + /// A database on which the migration ran to completion but whose ledger entry never committed -- + /// what an interrupted migration leaves behind on MySQL and MariaDB. + fn completed_but_unrecorded() -> CustomRoleMigrationFacts { + CustomRoleMigrationFacts { + memberships_table_exists: true, + migration_applied: false, + access_all_column_exists: false, + legacy_user_access_all_count: 0, + migration_ledger_exists: true, + permission_columns_present: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + permission_columns_not_null: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + membership_column_count: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + expected_membership_columns_present: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + legacy_manager_rows: 0, + newer_migration_recorded: false, + } + } + + /// What an interrupted MySQL/MariaDB upgrade leaves behind: the nine permission columns are + /// there, `access_all` has not been dropped yet, and the ledger entry never committed. + fn interrupted() -> CustomRoleMigrationFacts { + CustomRoleMigrationFacts { + permission_columns_present: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + permission_columns_not_null: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + // the finished table plus the legacy column that still has to go + membership_column_count: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len() + 1).unwrap(), + expected_membership_columns_present: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + ..ready() + } + } + + /// Audit M-2: the state that used to reach Diesel and abort with a bare duplicate-column error. + #[test] + fn an_interrupted_migration_is_resumed_instead_of_reaching_diesel() { + assert_eq!(decide(interrupted()), CustomRolePreflightDecision::ResumeInterruptedMigration); + } + + /// SQLite and PostgreSQL run the whole migration in one transaction, so a half-applied schema + /// there was not produced by an interruption and must never be finished on that assumption. + #[test] + fn a_transactional_backend_never_resumes() { + assert_eq!(decide_atomic(interrupted()), CustomRolePreflightDecision::RefuseAmbiguousPartialMigration); + } + + /// Every single deviation from the expected schema falls back to the controlled refusal. + #[test] + fn only_the_exact_interrupted_fingerprint_is_resumed() { + let mut partial_columns = interrupted(); + partial_columns.permission_columns_present = 4; + let mut nullable_column = interrupted(); + nullable_column.permission_columns_not_null -= 1; + let mut extra_column = interrupted(); + extra_column.membership_column_count += 1; + let mut renamed_column = interrupted(); + renamed_column.expected_membership_columns_present -= 1; + let mut tampered_ledger = interrupted(); + tampered_ledger.newer_migration_recorded = true; + let mut no_ledger = interrupted(); + no_ledger.migration_ledger_exists = false; + + for (what, facts) in [ + ("only some permission columns", partial_columns), + ("a nullable permission column", nullable_column), + ("an unknown extra column", extra_column), + ("a missing expected column", renamed_column), + ("a newer migration recorded", tampered_ledger), + ("no migration ledger", no_ledger), + ] { + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, "{what}"); + } + } + + /// An untouched database still has none of the columns, so it is never mistaken for an + /// interrupted one on either kind of backend. + #[test] + fn an_untouched_pending_database_is_not_mistaken_for_an_interrupted_one() { + assert_eq!(decide(ready()), CustomRolePreflightDecision::Proceed); + assert_eq!(decide_atomic(ready()), CustomRolePreflightDecision::Proceed); + } + + /// A completed-but-unrecorded database keeps its own answer: `access_all` is already gone there, + /// so it is recorded rather than resumed. + #[test] + fn a_completed_migration_is_recorded_not_resumed() { + assert_eq!(decide(completed_but_unrecorded()), CustomRolePreflightDecision::RecordCompletedMigration); + } + + /// The legacy `User + access_all` question still comes first on an interrupted database — and + /// once it is resolved the resume must still happen, rather than the file going back to Diesel. + #[test] + fn the_legacy_flag_is_answered_before_an_interrupted_migration_is_resumed() { + let mut facts = interrupted(); + facts.legacy_user_access_all_count = 2; + + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseLegacyUserAccessAll); + for (policy, expected) in [ + (LegacyUserAccessAllPolicy::Drop, CustomRolePreflightDecision::DropLegacyUserAccessAll), + (LegacyUserAccessAllPolicy::Materialize, CustomRolePreflightDecision::MaterializeLegacyUserAccessAll), + ] { + assert_eq!(custom_role_preflight_decision(facts, policy, true), expected, "{policy:?}"); + assert_eq!( + custom_role_decision_after_legacy_resolution(facts, policy, true), + CustomRolePreflightDecision::ResumeInterruptedMigration, + "{policy:?} must still finish the interrupted migration" + ); + } + } + + /// Audit F-2. Resolving the legacy flag writes -- and `materialize` also relaxes `read_only` and + /// `hide_passwords` on assignments that already exist. Those statements commit immediately, so + /// the preflight asks what the *resolved* database would answer before running any of them: a + /// schema no backend can finish has to be refused while "Nothing has been changed" is still true. + /// + /// The two cases that must not be confused: on a backend whose schema changes are interruptible + /// the exact fingerprint still resumes (the statements do run), while every other partial schema, + /// and every partial schema at all on a transactional backend, refuses without writing. + #[test] + fn a_partial_schema_is_refused_before_the_legacy_flag_is_resolved() { + let resolving = [LegacyUserAccessAllPolicy::Drop, LegacyUserAccessAllPolicy::Materialize]; + + let mut half_applied = interrupted(); + half_applied.legacy_user_access_all_count = 3; + half_applied.permission_columns_present = 4; + half_applied.permission_columns_not_null = 4; + for policy in resolving { + for interruptible in [true, false] { + assert_eq!( + custom_role_decision_after_legacy_resolution(half_applied, policy, interruptible), + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + "{policy:?} interruptible={interruptible}: a 4-of-9 schema must refuse, not write first" + ); + } + } + + let mut fingerprinted = interrupted(); + fingerprinted.legacy_user_access_all_count = 3; + for policy in resolving { + assert_eq!( + custom_role_decision_after_legacy_resolution(fingerprinted, policy, false), + CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + "{policy:?}: a transactional backend never resumes, so it must refuse before writing" + ); + assert_eq!( + custom_role_decision_after_legacy_resolution(fingerprinted, policy, true), + CustomRolePreflightDecision::ResumeInterruptedMigration, + "{policy:?}: the exact fingerprint still resumes, and the statements do run" + ); + } + + // The control: with a schema that is not partial at all, resolving is all there is to do. + let mut ordinary = ready(); + ordinary.legacy_user_access_all_count = 3; + for policy in resolving { + for interruptible in [true, false] { + assert_eq!( + custom_role_decision_after_legacy_resolution(ordinary, policy, interruptible), + CustomRolePreflightDecision::Proceed, + "{policy:?} interruptible={interruptible}" + ); + } + } + } + + /// The refusal has to say what was found and what to do about it, and must not claim the + /// database was changed. + #[test] + fn the_ambiguous_refusal_reports_the_schema_and_a_way_out() { + let mut facts = interrupted(); + facts.permission_columns_present = 4; + let text = message(CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, facts); + + assert!(text.contains("Nothing has been changed"), "{text}"); + assert!(text.contains("4 of its 9 permission columns"), "{text}"); + assert!(text.contains("Restore the backup"), "{text}"); + assert!(text.contains("SQLite and PostgreSQL"), "{text}"); + } + + #[test] + fn an_empty_database_proceeds() { + assert_eq!(decide(CustomRoleMigrationFacts::default()), CustomRolePreflightDecision::Proceed); + } + + #[test] + fn an_ordinary_upgrade_proceeds() { + assert_eq!(decide(ready()), CustomRolePreflightDecision::Proceed); + } + + /// The checks below all read the legacy schema, so an already-upgraded database must not be + /// asked about them again -- and a re-run of the question would have no data to answer it from. + #[test] + fn an_already_upgraded_database_is_not_asked_anything() { + let facts = CustomRoleMigrationFacts { + migration_applied: true, + access_all_column_exists: false, + ..ready() + }; + assert_eq!(decide(facts), CustomRolePreflightDecision::Proceed); + } + + #[test] + fn a_pending_migration_without_the_legacy_column_is_refused() { + let facts = CustomRoleMigrationFacts { + access_all_column_exists: false, + ..ready() + }; + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseMissingAccessAll); + } + + /// The state an interrupted migration leaves on MySQL/MariaDB: every ALTER TABLE committed on + /// its own, so the schema is final, but the process died before Diesel recorded the migration. + /// The database is already correct; only the ledger entry is missing. + #[test] + fn a_completed_migration_with_no_ledger_entry_is_recorded_instead_of_refused() { + assert_eq!(decide(completed_but_unrecorded()), CustomRolePreflightDecision::RecordCompletedMigration); + } + + /// Every individual condition has to hold. Each mutation below is a different way of arriving at + /// "the legacy column is gone" without the migration having finished, and each one must fall + /// back to refusing rather than recording a migration that did not happen. + #[test] + fn an_incomplete_schema_is_never_mistaken_for_a_completed_migration() { + let complete = completed_but_unrecorded(); + let permission_columns = i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(); + let membership_columns = i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(); + + let broken = [ + ( + "one permission column missing", + CustomRoleMigrationFacts { + permission_columns_present: permission_columns - 1, + permission_columns_not_null: permission_columns - 1, + membership_column_count: membership_columns - 1, + expected_membership_columns_present: membership_columns - 1, + ..complete + }, + ), + ( + "a permission column is nullable", + CustomRoleMigrationFacts { + permission_columns_not_null: permission_columns - 1, + ..complete + }, + ), + ( + "an unexpected extra column", + CustomRoleMigrationFacts { + membership_column_count: membership_columns + 1, + ..complete + }, + ), + ( + "an expected column is missing but the count matches", + CustomRoleMigrationFacts { + expected_membership_columns_present: membership_columns - 1, + ..complete + }, + ), + ( + "memberships still on the legacy Manager role", + CustomRoleMigrationFacts { + legacy_manager_rows: 1, + ..complete + }, + ), + ( + "the ledger records something newer", + CustomRoleMigrationFacts { + newer_migration_recorded: true, + ..complete + }, + ), + ( + "there is no ledger to record into", + CustomRoleMigrationFacts { + migration_ledger_exists: false, + ..complete + }, + ), + ]; + + for (label, facts) in broken { + assert_eq!( + decide(facts), + CustomRolePreflightDecision::RefuseMissingAccessAll, + "{label} must not be treated as a completed migration" + ); + } + } + + /// Audit M-2: the two earlier MySQL/MariaDB interruption points still have `access_all`, so they + /// never reach the completed-schema check. They used to fall through as an ordinary pending + /// migration, which handed the file back to Diesel and aborted startup on the duplicate column. + /// Both are now recognised and finished instead. + #[test] + fn both_earlier_interruption_points_are_resumed() { + let after_add_column = CustomRoleMigrationFacts { + permission_columns_present: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + permission_columns_not_null: i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap(), + membership_column_count: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap() + 1, + expected_membership_columns_present: i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap(), + legacy_manager_rows: 3, + ..ready() + }; + assert_eq!(decide(after_add_column), CustomRolePreflightDecision::ResumeInterruptedMigration); + + let after_update = CustomRoleMigrationFacts { + legacy_manager_rows: 0, + ..after_add_column + }; + assert_eq!(decide(after_update), CustomRolePreflightDecision::ResumeInterruptedMigration); + } + + /// An already-recorded migration is never re-examined, so the repair cannot fire twice. + #[test] + fn recording_the_migration_is_idempotent() { + let recorded = CustomRoleMigrationFacts { + migration_applied: true, + ..completed_but_unrecorded() + }; + assert_eq!(decide(recorded), CustomRolePreflightDecision::Proceed); + } + + /// The refusal text used to claim this state could not occur, and sent the operator to a backup. + #[test] + fn the_missing_column_recovery_text_no_longer_denies_the_state_can_occur() { + let message = message( + CustomRolePreflightDecision::RefuseMissingAccessAll, + CustomRoleMigrationFacts { + access_all_column_exists: false, + ..ready() + }, + ); + assert!(!message.contains("does not arise from any Vaultwarden version"), "{message}"); + assert!(message.contains("repaired automatically"), "{message}"); + } + + /// The three documented values, and nothing else. An unparsable value never reaches the + /// preflight -- `validate_config` rejects it at startup -- but it still has to fail closed. + #[test] + fn the_legacy_user_access_all_policy_parses_only_the_documented_values() { + assert_eq!(LegacyUserAccessAllPolicy::from_config("refuse"), Some(LegacyUserAccessAllPolicy::Refuse)); + assert_eq!(LegacyUserAccessAllPolicy::from_config("drop"), Some(LegacyUserAccessAllPolicy::Drop)); + assert_eq!(LegacyUserAccessAllPolicy::from_config("materialize"), Some(LegacyUserAccessAllPolicy::Materialize)); + assert_eq!( + LegacyUserAccessAllPolicy::from_config(" MATERIALIZE "), + Some(LegacyUserAccessAllPolicy::Materialize) + ); + + for value in ["", " ", "yes", "true", "1", "keep", "refuse-all", "dropall"] { + assert_eq!(LegacyUserAccessAllPolicy::from_config(value), None, "{value} must not parse"); + } + assert_eq!(LegacyUserAccessAllPolicy::default(), LegacyUserAccessAllPolicy::Refuse); + } + + /// The policy selects what happens to an affected membership, and does nothing at all when + /// there is none -- a database without the legacy flag must upgrade identically whatever it is + /// set to. + #[test] + fn the_configured_policy_only_decides_what_happens_to_an_affected_membership() { + let mut affected = ready(); + affected.legacy_user_access_all_count = 2; + + for (policy, expected) in [ + (LegacyUserAccessAllPolicy::Refuse, CustomRolePreflightDecision::RefuseLegacyUserAccessAll), + (LegacyUserAccessAllPolicy::Drop, CustomRolePreflightDecision::DropLegacyUserAccessAll), + (LegacyUserAccessAllPolicy::Materialize, CustomRolePreflightDecision::MaterializeLegacyUserAccessAll), + ] { + assert_eq!(custom_role_preflight_decision(affected, policy, true), expected, "{policy:?}"); + // nothing to resolve -> the policy is inert + assert_eq!( + custom_role_preflight_decision(ready(), policy, true), + CustomRolePreflightDecision::Proceed, + "{policy:?} must not change an unaffected database" + ); + } + } + + /// A damaged schema still outranks the flag, whatever the policy says: the resolution statements + /// read `access_all`, so they cannot run once the column is gone. + #[test] + fn the_policy_never_overrides_a_refusal_about_the_schema() { + let mut broken = ready(); + broken.access_all_column_exists = false; + broken.legacy_user_access_all_count = 3; + + for policy in + [LegacyUserAccessAllPolicy::Refuse, LegacyUserAccessAllPolicy::Drop, LegacyUserAccessAllPolicy::Materialize] + { + assert_eq!( + custom_role_preflight_decision(broken, policy, true), + CustomRolePreflightDecision::RefuseMissingAccessAll, + "{policy:?}" + ); + } + } + + /// `materialize` writes, then clears; `drop` only clears. The clear is always last, because the + /// statements before it select on the flag -- and its row count is what gets logged. + #[test] + fn the_resolution_statements_end_with_the_clear() { + let materialize = + legacy_user_access_all_statements(CustomRolePreflightDecision::MaterializeLegacyUserAccessAll); + assert_eq!(materialize.len(), 3); + assert_eq!(materialize[0], LEGACY_USER_ACCESS_ALL_RELAX_SQL); + assert_eq!(materialize[1], LEGACY_USER_ACCESS_ALL_MATERIALIZE_SQL); + assert_eq!(materialize[2], LEGACY_USER_ACCESS_ALL_CLEAR_SQL); + + let drop = legacy_user_access_all_statements(CustomRolePreflightDecision::DropLegacyUserAccessAll); + assert_eq!(drop, [LEGACY_USER_ACCESS_ALL_CLEAR_SQL]); + + for decision in [ + CustomRolePreflightDecision::Proceed, + CustomRolePreflightDecision::RecordCompletedMigration, + CustomRolePreflightDecision::RefuseMissingAccessAll, + CustomRolePreflightDecision::RefuseLegacyUserAccessAll, + ] { + assert!(legacy_user_access_all_statements(decision).is_empty(), "{decision:?}"); + } + } + + /// The refusal has to point at the setting that resolves it, and keep both manual procedures. + #[test] + fn the_recovery_text_offers_the_automatic_resolution() { + let mut facts = ready(); + facts.legacy_user_access_all_count = 1; + let text = message(CustomRolePreflightDecision::RefuseLegacyUserAccessAll, facts); + + assert!(text.contains("LEGACY_USER_ACCESS_ALL_MIGRATION")); + assert!(text.contains("materialize")); + assert!(text.contains("drop")); + // access_all overrode both flags, so the manual path has to relax existing rows too + assert!(text.contains("SET read_only = FALSE, hide_passwords = FALSE")); + assert!(text.contains("INSERT INTO users_collections")); + } + + #[test] + fn legacy_user_access_all_is_refused_with_a_recovery_path() { + let facts = CustomRoleMigrationFacts { + legacy_user_access_all_count: 2, + ..ready() + }; + let decision = decide(facts); + assert_eq!(decision, CustomRolePreflightDecision::RefuseLegacyUserAccessAll); + + let message = message(decision, facts); + assert!(message.contains("Nothing has been changed."), "{message}"); + assert!(message.contains("Found 2 membership(s)"), "{message}"); + assert!(message.contains("SET access_all = FALSE"), "{message}"); + assert!(message.contains("INSERT INTO users_collections"), "{message}"); + } + + /// The state this revision stopped refusing: a legacy Manager reaching every collection through + /// an organization-local `access_all` group. The preflight has no fact for it any more, so there + /// is nothing left that could stop an ordinary upgrade. + #[test] + fn group_derived_collection_authority_never_stops_startup() { + assert_eq!(decide(ready()), CustomRolePreflightDecision::Proceed); + } + + /// A damaged legacy schema outranks the unrepresentable-state check: its answer would be + /// unreadable, and the migration cannot run either way. + #[test] + fn a_damaged_schema_outranks_the_legacy_user_check() { + let facts = CustomRoleMigrationFacts { + access_all_column_exists: false, + legacy_user_access_all_count: 1, + ..ready() + }; + assert_eq!(decide(facts), CustomRolePreflightDecision::RefuseMissingAccessAll); + } + + /// Every refusal promises the operator that startup stopped before anything was touched. The + /// preflight only ever reads, so that promise holds by construction -- this pins the wording that + /// carries it. + #[test] + fn every_refusal_says_nothing_has_been_changed() { + for decision in [ + CustomRolePreflightDecision::RefuseMissingAccessAll, + CustomRolePreflightDecision::RefuseLegacyUserAccessAll, + ] { + let message = message(decision, ready()); + assert!(message.contains("Nothing has been changed."), "{decision:?}: {message}"); + } + } +} diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index eed5041d..d688ec9e 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,20 @@ impl Cipher { cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> Option<(bool, bool, bool)> { + // Security: central fail-closed check binding cipher -> organization -> *confirmed* membership. + // It denies access from assignment rows that outlived a revoke (or are still only + // invited/accepted) and from cross-organization assignments another path might have persisted; + // without it the queries below would keep honouring them. + // + // The sync path (cipher_sync_data is Some) is left to the caller: it is built only from + // confirmed memberships and evaluated below against that cached data. + if cipher_sync_data.is_none() + && let Some(ref org_uuid) = self.organization_uuid + && Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await.is_none() + { + 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 +680,33 @@ 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, so a row left behind by a revoke, or pointing at another + // organization's collection, grants nothing. Defense in depth. ciphers::table - .filter(ciphers::uuid.eq(&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 +719,14 @@ 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 cipher, + // collection, group and membership to share one organization. The `collections` join is what + // stops a cross-organization collection<->group assignment reaching foreign ciphers. ciphers::table - .filter(ciphers::uuid.eq(&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 +734,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 +883,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 +895,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 +924,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 +1051,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 +1082,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 +1126,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 +1135,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 +1158,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 +1204,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..bfa5006f 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,30 @@ 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. +/// +/// Belongs on what a member receives about themselves; the administrative lists echo a *stored* +/// grant and use `stored_assignment_manage` instead. +pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool { + match MembershipType::from_i32(membership_type) { + Some(MembershipType::Owner | MembershipType::Admin) => true, + Some(MembershipType::Custom) => stored_manage, + Some(MembershipType::User) | None => false, + } +} + +/// Serialize a *stored* per-collection assignment row for the admin-console access lists. +/// +/// The client writes the same value back when the dialog is saved, so reporting anything other than +/// the persisted bit would make an unrelated save silently strip it — for a plain User that also +/// revokes the cipher write access `users_collections.manage` grants. Admins and Owners manage +/// implicitly and are reported as such regardless of the stored row. +pub(super) fn stored_assignment_manage(membership_type: i32, stored_manage: bool) -> bool { + matches!(MembershipType::from_i32(membership_type), Some(MembershipType::Owner | MembershipType::Admin)) + || stored_manage +} + /// Local methods impl Collection { pub fn new(org_uuid: OrganizationId, name: String, external_id: Option) -> Self { @@ -104,41 +130,56 @@ 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 has to match what the collection guards allow, or it renders the + // wrong controls. A stored grant therefore counts even for a member who already reaches every + // collection. Reaching every collection through a group with `access_all` deliberately does + // not: the guards accept an explicit `users_collections.manage` / + // `collections_groups.manage` row only. + let assignment = cipher_sync_data + .user_collections + .get(&self.uuid) + .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 +301,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 +336,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 +427,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 +463,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 +507,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 +540,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 +588,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 +614,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 +642,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 +976,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 +1010,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..b2b1aa20 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,9 @@ impl Group { pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool { conn.run(move |conn| { + // Security: the membership linked through `groups_users` must be confirmed and belong to the + // same organization as the group, or a cross-organization row would pass as full access to + // that organization. groups::table .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) .inner_join( @@ -276,6 +280,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 +326,15 @@ impl Group { impl CollectionGroup { pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { + // Security: never persist a cross-organization link between a collection and a group -- + // attaching a foreign-tenant group to this organization's collection would grant its members + // access. Defense in depth, so no route can create one even if its own validation is wrong. + if Collection::find_by_uuid_and_org(&self.collections_uuid, org_uuid, conn).await.is_none() + || Group::find_by_uuid_and_org(&self.groups_uuid, org_uuid, conn).await.is_none() + { + 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 +509,16 @@ impl CollectionGroup { impl GroupUser { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + // Security: never persist a cross-organization link between a group and a membership -- that + // would grant a member of one organization access to another's collections through an + // access-all group. Defense in depth, so no route can create one even if its own validation is wrong. + let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else { + err!("Member not found while assigning to group") + }; + 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..00062832 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,50 @@ 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 enumerate the two values instead of comparing `atype <= Admin`: `<=` also matches every +/// value *below* `Owner`, so a corrupt or negative `atype` would satisfy the SQL check while every +/// Rust guard rejects it. Enumerating keeps both layers on the same answer. +pub(crate) const ORG_ADMIN_ATYPES: &[i32] = &[MembershipType::Owner as i32, MembershipType::Admin as i32]; + impl Ord for MembershipType { 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 +302,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 +355,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 +483,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 +554,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 +603,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 +683,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 +712,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 +744,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 +766,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 +854,190 @@ 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() + } + + /// Whether enabling an organization policy may revoke this membership as part of enforcing it. + /// + /// Two exclusions, both applying to every policy whose enforcement revokes non-compliant members + /// (Two-Factor Authentication and Single Organization): + /// + /// * Admins and Owners are never revoked. `atype < Admin` is deliberately the *ceiling* comparison + /// used everywhere else, so an unknown stored role stays sweepable. + /// * Nor is the member who made the change. Until the Custom role this was implied by the first + /// rule, since only Admins and Owners reached the policy endpoints; `managePolicies` can now be + /// held by a Custom member, who *is* sweepable and would otherwise revoke themselves mid-request. + /// Bitwarden excludes the acting user for the same reason. + /// + /// Peers are still revoked exactly as before. + pub fn is_policy_enforcement_target(&self, acting_user: &UserId) -> bool { + self.atype < MembershipType::Admin && &self.user_uuid != acting_user + } + + // The granular custom permission flags are only meaningful while the membership is of + // the Custom type. Gating them on the type here ensures that a stale flag left over from + // a type change (e.g. via the admin panel) can never grant anything. + 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. + /// + /// There is deliberately no live exception for legacy Managers whose authority came from an + /// organization-local `access_all` group: deriving one from the membership's shape ("Custom, no + /// collection permissions, member of such a group") would also match every newly created flagless + /// Custom member, so joining one to an ordinary `access_all` group would hand out organization-wide + /// edit and delete. The migration writes that authority into the visible `edit_any_collection` / + /// `delete_any_collection` columns instead, where an owner can see and revoke it. + pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool { + let membership_uuid = self.uuid.clone(); + let user_uuid = self.user_uuid.clone(); + 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 +1146,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 +1154,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 +1279,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 +1326,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 +1335,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 +1464,277 @@ 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 resolve that + /// differently on purpose — both fail-closed, and the asymmetry is easy to "tidy up" into a silent + /// authorization change. + /// + /// `MembershipType op i32` — "does the caller outrank this role?" — answers no: `gt`/`ge` are false + /// for an unknown value, so nothing is granted on the strength of one. + /// + /// `i32 op MembershipType` — "is this membership at most that role?" — answers yes: `lt`/`le` are + /// true. Every use is a *ceiling* (`atype < Admin`), so treating an unrecognized value as low-ranked + /// is the restrictive reading, and the one place that phrases a permission this way + /// (`check_reset_password_applicable_and_permissions`) guards against `Owner`, whose discriminant is + /// 0 and therefore never unknown. + #[test] + #[expect( + clippy::nonminimal_bool, + 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); + } + + /// Policy enforcement revokes non-compliant peers, never Admins/Owners, and never the member + /// who enabled the policy. Before `managePolicies` existed the last rule was implied by the + /// second one; a Custom member can now trigger a sweep it would otherwise be caught by. + #[test] + fn policy_enforcement_never_targets_admins_or_the_acting_member() { + let actor: UserId = "actor".to_owned().into(); + let other: UserId = "other".to_owned().into(); + + for role in [MembershipType::Owner, MembershipType::Admin] { + let mut member = membership(role); + member.user_uuid = other.clone(); + assert!(!member.is_policy_enforcement_target(&actor), "admins and owners are never swept"); + member.user_uuid = actor.clone(); + assert!(!member.is_policy_enforcement_target(&actor)); + } + + for role in [MembershipType::User, MembershipType::Custom] { + let mut member = membership(role); + + // A peer of that role is still a target -- enforcement itself is unchanged. + member.user_uuid = other.clone(); + assert!(member.is_policy_enforcement_target(&actor), "peers must still be revoked"); + + // The member performing the policy change is not. + member.user_uuid = actor.clone(); + assert!(!member.is_policy_enforcement_target(&actor), "the acting member must be excluded"); + } + + // An unknown stored role keeps the pre-existing fail-closed behaviour of the `< Admin` + // ceiling: it is still a target, and the actor exclusion still applies to it. + let mut corrupt = membership(MembershipType::User); + corrupt.atype = 42; + corrupt.user_uuid = other; + assert!(corrupt.is_policy_enforcement_target(&actor)); + corrupt.user_uuid = actor.clone(); + assert!(!corrupt.is_policy_enforcement_target(&actor)); + } + + #[test] + fn custom_collection_permissions_are_independent_and_type_gated() { + let mut member = membership(MembershipType::Custom); + 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/main.rs b/src/main.rs index 28645694..ea941753 100644 --- a/src/main.rs +++ b/src/main.rs @@ -553,10 +553,23 @@ fn check_web_vault() { } async fn create_db_pool() -> db::DbPool { - match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries()).await { + // A Custom-role preflight refusal is deterministic: it reads schema and ledger state that no + // amount of waiting changes. Retrying only reprinted the same answer up to + // `db_connection_retries` times, each time introduced by "Can't connect to database, retrying", + // which was never the problem. The full recovery procedure has already been logged at that + // point, so stop and report the one-line reason. + match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries(), |_| { + db::custom_role_preflight_refusal().is_none() + }) + .await + { Ok(p) => p, Err(e) => { - error!("Error creating database pool: {e:?}"); + if let Some(reason) = db::custom_role_preflight_refusal() { + error!("Not starting. {reason}"); + } else { + error!("Error creating database pool: {e:?}"); + } exit(1); } } diff --git a/src/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 @@