From 56f1a9ff6ffdda606493188d3e0e86e68a9181fe Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:42:11 +0200 Subject: [PATCH] Align custom roles with Bitwarden --- .env.template | 6 + .../down.sql | 20 + .../up.sql | 86 + .../down.sql | 20 + .../up.sql | 80 + .../down.sql | 34 + .../up.sql | 107 ++ src/api/admin.rs | 32 +- src/api/core/accounts.rs | 13 +- src/api/core/ciphers.rs | 423 ++++- src/api/core/events.rs | 255 ++- src/api/core/organizations.rs | 1423 +++++++++++++---- src/api/core/public.rs | 1 - src/api/core/two_factor/mod.rs | 8 +- src/auth.rs | 609 ++++++- src/config.rs | 12 + src/db/mod.rs | 1414 +++++++++++++++- src/db/models/cipher.rs | 510 +++++- src/db/models/collection.rs | 202 +-- src/db/models/event.rs | 57 +- src/db/models/group.rs | 37 +- src/db/models/mod.rs | 4 +- src/db/models/organization.rs | 625 ++++++-- src/db/schema.rs | 10 +- src/main.rs | 15 +- src/static/scripts/admin_users.js | 16 +- src/static/templates/admin/users.hbs | 4 +- .../templates/scss/vaultwarden.scss.hbs | 6 +- src/util.rs | 22 +- 29 files changed, 5210 insertions(+), 841 deletions(-) create mode 100644 migrations/mysql/2026-09-22-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/mysql/2026-09-22-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql diff --git a/.env.template b/.env.template index 62231776..bdee968a 100644 --- a/.env.template +++ b/.env.template @@ -65,6 +65,12 @@ ## - https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING # DATABASE_URL=postgresql://user:password@host[:port]/database_name +## Recovery policy for historical User + access_all rows during the Custom-role migration. +## The default "refuse" fails closed and stops startup. "drop" removes the historical grant; +## "materialize" first preserves confirmed members' current collection access as explicit assignments. +## Accepted values: refuse, drop, materialize. Set this upgrade/recovery option only deliberately. +# LEGACY_USER_ACCESS_ALL_MIGRATION=refuse + ## Enable WAL for the DB ## Set to false to avoid enabling WAL during startup. ## Note that if the DB already has WAL enabled, you will also need to disable WAL in the DB, diff --git a/migrations/mysql/2026-09-22-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-09-22-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..3150530e --- /dev/null +++ b/migrations/mysql/2026-09-22-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,20 @@ +-- Downgrade is lossy because the legacy role model cannot represent arbitrary Custom permissions. +-- Custom memberships are mapped to User to avoid granting additional privileges. Restore a +-- pre-upgrade database backup if exact state preservation is required. +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE users_organizations +SET access_all = CASE WHEN atype IN (0, 1) THEN TRUE ELSE FALSE END; + +UPDATE users_organizations SET atype = 2 WHERE atype = 4; + +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; diff --git a/migrations/mysql/2026-09-22-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-09-22-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..94170829 --- /dev/null +++ b/migrations/mysql/2026-09-22-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,86 @@ +-- 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. It is not +-- read or written here and keeps granting group members access dynamically. +-- +-- Only the membership bit is going away. While this file runs it 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: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * a Manager without membership `access_all` keeps all three at FALSE. In particular, +-- `groups.access_all` is not materialized into persistent membership permissions: it remains a +-- separate, dynamic group grant that ends when the group relationship or flag ends. +-- +-- 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. +-- +-- `manage_users` is not granted to restore legacy read-only member-list behavior, because it also +-- carries invite, confirm, revoke, restore and delete, which the Manager role never had. +-- +-- 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. +UPDATE users_organizations +SET create_new_collections = access_all, + edit_any_collection = access_all, + delete_any_collection = access_all, + atype = 4 +WHERE atype = 3; + +-- The membership flag is now represented by the role model: Owners/Admins hold it implicitly and a +-- Custom member that held it has all three collection permissions. `groups.access_all` stays separate. +ALTER TABLE users_organizations DROP COLUMN access_all; diff --git a/migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..3150530e --- /dev/null +++ b/migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,20 @@ +-- Downgrade is lossy because the legacy role model cannot represent arbitrary Custom permissions. +-- Custom memberships are mapped to User to avoid granting additional privileges. Restore a +-- pre-upgrade database backup if exact state preservation is required. +ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE users_organizations +SET access_all = CASE WHEN atype IN (0, 1) THEN TRUE ELSE FALSE END; + +UPDATE users_organizations SET atype = 2 WHERE atype = 4; + +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; diff --git a/migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..5774a5cf --- /dev/null +++ b/migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,80 @@ +-- 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. It is not +-- read or written here and keeps granting group members access dynamically. +-- +-- Only the membership bit is going away. While this file runs it 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: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * a Manager without membership `access_all` keeps all three at FALSE. In particular, +-- `groups.access_all` is not materialized into persistent membership permissions: it remains a +-- separate, dynamic group grant that ends when the group relationship or flag ends. +-- +-- 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. +-- +-- `manage_users` is not granted to restore legacy read-only member-list behavior, because it also +-- carries invite, confirm, revoke, restore and delete, which the Manager role never had. +-- +-- 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. +UPDATE users_organizations +SET create_new_collections = access_all, + edit_any_collection = access_all, + delete_any_collection = access_all, + atype = 4 +WHERE atype = 3; + +-- The membership flag is now represented by the role model: Owners/Admins hold it implicitly and a +-- Custom member that held it has all three collection permissions. `groups.access_all` stays separate. +ALTER TABLE users_organizations DROP COLUMN access_all; diff --git a/migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..3b535fa7 --- /dev/null +++ b/migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,34 @@ +-- Downgrade is lossy because the legacy role model cannot represent arbitrary Custom permissions. +-- Custom memberships are mapped to User to avoid granting additional privileges. Restore a +-- pre-upgrade database backup if exact state preservation is required. +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 + uuid, user_uuid, org_uuid, + CASE WHEN atype IN (0, 1) THEN TRUE ELSE FALSE END, + akey, status, + CASE WHEN atype = 4 THEN 2 ELSE atype END, + reset_password_key, external_id, invited_by_email +FROM users_organizations; + +DROP TABLE users_organizations; + +ALTER TABLE users_organizations_old RENAME TO users_organizations; diff --git a/migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..da21e42e --- /dev/null +++ b/migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,107 @@ +-- 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. It is not +-- read or written here and keeps granting group members access dynamically. +-- +-- Only the membership bit is going away. While this file runs it 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: +-- +-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three +-- collection permissions, including creating collections; +-- * a Manager without membership `access_all` keeps all three at FALSE. In particular, +-- `groups.access_all` is not materialized into persistent membership permissions: it remains a +-- separate, dynamic group grant that ends when the group relationship or flag ends. +-- +-- 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. +-- +-- `manage_users` is not granted to restore legacy read-only member-list behavior, because it also +-- carries invite, confirm, revoke, restore and delete, which the Manager role never had. +-- +-- 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. +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 THEN TRUE ELSE FALSE END, + CASE WHEN uo.atype = 3 AND uo.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; diff --git a/src/api/admin.rs b/src/api/admin.rs index 4bdf8e71..96314cad 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?; diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8cc5e55b..7f3bf795 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -31,7 +31,7 @@ use crate::{ }; use super::{ - ciphers::{CipherData, update_cipher_from_data}, + ciphers::{CipherData, CipherUpdateAuthorization, update_cipher_from_data}, sends::{SendData, update_send_from_data}, }; @@ -1000,7 +1000,16 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: // Prevent triggering cipher updates via WebSockets by settings UpdateType::None // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. // We force the users to logout after the user has been saved to try and prevent these issues. - update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; + update_cipher_from_data( + saved_cipher, + cipher_data, + &headers, + CipherUpdateAuthorization::default(), + &conn, + &nt, + UpdateType::None, + ) + .await?; } } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index a5b7e58b..d4e3ddf5 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -21,9 +21,9 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, - CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, - Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, + CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, + KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -233,21 +233,12 @@ async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult { #[get("/ciphers/")] async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { - err!("Cipher doesn't exist") - }; - - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { - err!("Cipher is not owned by user") - } - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + get_cipher_impl(cipher_id, &headers, CipherAccessScope::User, &conn).await } #[get("/ciphers//admin")] async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { - // TODO: Implement this correctly - get_cipher(cipher_id, headers, conn).await + get_cipher_impl(cipher_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await } #[get("/ciphers//details")] @@ -255,6 +246,42 @@ async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn) get_cipher(cipher_id, headers, conn).await } +async fn get_cipher_impl( + cipher_id: CipherId, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> JsonResult { + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, conn).await else { + err!("Cipher doesn't exist") + }; + + if !cipher.is_accessible_to_user(&headers.user.uuid, scope, conn).await { + err!("Cipher is not owned by user") + } + + Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) +} + +/// Serialize a cipher the caller has just been authorized for at `scope`. +/// +/// The admin routes have to report the access they were authorized with, otherwise an +/// organization-wide caller without a personal assignment is answered `edit: false` for a cipher +/// they may in fact edit. +async fn cipher_json_for_scope( + cipher: &Cipher, + headers: &Headers, + scope: CipherAccessScope, + conn: &DbConn, +) -> Result { + match scope { + CipherAccessScope::User => { + cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await + } + CipherAccessScope::OrganizationAdmin => cipher.to_json_org_admin(&headers.host, &headers.user.uuid, conn).await, + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CipherData { @@ -337,7 +364,11 @@ pub struct Attachments2Data { /// Called when an org admin clones an org cipher. #[post("/ciphers/admin", data = "")] async fn post_ciphers_admin(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - post_ciphers_create(data, headers, conn, nt).await + // Only the response differs from `/ciphers/create`: the cipher is created owned by the caller + // and then shared, so the authorization along the way is the regular one either way. Without + // this, an administrative caller without a personal assignment to the target collection is + // answered `edit: false` for the cipher they just created. + post_ciphers_create_impl(data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } /// Called when creating a new org-owned cipher, or cloning a cipher (whether @@ -349,6 +380,16 @@ async fn post_ciphers_create( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + post_ciphers_create_impl(data, headers, CipherAccessScope::User, conn, nt).await +} + +async fn post_ciphers_create_impl( + data: Json, + headers: Headers, + response_scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let mut data: ShareCipherData = data.into_inner(); @@ -373,7 +414,7 @@ async fn post_ciphers_create( // or otherwise), we can just ignore this field entirely. data.cipher.last_known_revision_date = None; - let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; + let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, response_scope, &conn, &nt, None).await; if res.is_err() { cipher.delete(&conn).await?; } @@ -403,7 +444,16 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt data.last_known_revision_date = None; let mut cipher = Cipher::new(data.r#type, data.name.clone()); - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; + update_cipher_from_data( + &mut cipher, + data, + &headers, + CipherUpdateAuthorization::default(), + &conn, + &nt, + UpdateType::SyncCipherCreate, + ) + .await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } @@ -426,11 +476,43 @@ 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, + organization_write_authorized: bool, +) -> bool { + organization_write_authorized + || shared_to_collections.is_some_and(|collections| !collections.is_empty()) + || member_has_full_access +} + +#[derive(Default)] +pub struct CipherUpdateAuthorization { + shared_to_collections: Option>, + organization_write_authorized: bool, +} + +impl CipherUpdateAuthorization { + pub fn shared_to(collections: Vec) -> Self { + Self { + shared_to_collections: Some(collections), + organization_write_authorized: false, + } + } + + pub fn organization_import(collections: Vec, organization_write_authorized: bool) -> Self { + Self { + shared_to_collections: Some(collections), + organization_write_authorized, + } + } +} + pub async fn update_cipher_from_data( cipher: &mut Cipher, data: CipherData, headers: &Headers, - shared_to_collections: Option>, + authorization: CipherUpdateAuthorization, conn: &DbConn, nt: &Notify<'_>, ut: UpdateType, @@ -449,6 +531,11 @@ pub async fn update_cipher_from_data( json_data } + let CipherUpdateAuthorization { + shared_to_collections, + organization_write_authorized, + } = authorization; + enforce_personal_ownership_policy(Some(&data), headers, conn).await?; // Check that the client isn't updating an existing cipher with stale data. @@ -486,9 +573,11 @@ pub async fn update_cipher_from_data( Some(member) => { // A non-empty list of collections implies the caller already validated the user's write // access to them, so we can move the cipher into the organization on that basis. - if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) - || member.has_full_access() - || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await + if has_prevalidated_organization_write_authority( + shared_to_collections.as_ref(), + member.has_full_access(), + organization_write_authorized, + ) || cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { cipher.organization_uuid = Some(org_id); // After some discussion in PR #1329 re-added the user_uuid = None again. @@ -665,7 +754,16 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC cipher_data.folder_id = folder_id; let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); - update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; + update_cipher_from_data( + &mut cipher, + cipher_data, + &headers, + CipherUpdateAuthorization::default(), + &conn, + &nt, + UpdateType::None, + ) + .await?; } let mut user = headers.user; @@ -684,7 +782,7 @@ async fn put_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - put_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } #[post("/ciphers//admin", data = "")] @@ -695,7 +793,7 @@ async fn post_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await } #[post("/ciphers/", data = "")] @@ -716,6 +814,17 @@ async fn put_cipher( headers: Headers, conn: DbConn, nt: Notify<'_>, +) -> JsonResult { + put_cipher_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await +} + +async fn put_cipher_impl( + cipher_id: CipherId, + data: Json, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let data: CipherData = data.into_inner(); @@ -728,13 +837,22 @@ async fn put_cipher( // cipher itself, so the user shouldn't need write access to change these. // Interestingly, upstream Bitwarden doesn't properly handle this either. - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not write accessible") } - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; + update_cipher_from_data( + &mut cipher, + data, + &headers, + CipherUpdateAuthorization::default(), + &conn, + &nt, + UpdateType::SyncCipherUpdate, + ) + .await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } #[post("/ciphers//partial", data = "")] @@ -761,7 +879,7 @@ async fn put_cipher_partial( err!("Cipher does not exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { err!("Cipher does not exist", "Cipher is not accessible for the current user") } @@ -838,7 +956,7 @@ async fn post_collections_update( err!("Cipher doesn't exist") }; - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { + if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { err!("Collection cannot be changed") } @@ -918,7 +1036,10 @@ async fn post_collections_admin( err!("Cipher doesn't exist") }; - if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { + // Upstream guards this route with `CanEditCipherAsAdminAsync`, so a member holding + // organization-wide cipher authority reaches every cipher of the organization here. + if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await + { err!("Collection cannot be changed") } @@ -992,7 +1113,7 @@ async fn post_cipher_share( ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + share_cipher_by_uuid(&cipher_id, data, &headers, CipherAccessScope::User, &conn, &nt, None).await } #[put("/ciphers//share", data = "")] @@ -1005,7 +1126,7 @@ async fn put_cipher_share( ) -> JsonResult { let data: ShareCipherData = data.into_inner(); - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + share_cipher_by_uuid(&cipher_id, data, &headers, CipherAccessScope::User, &conn, &nt, None).await } #[derive(Deserialize)] @@ -1045,7 +1166,16 @@ async fn put_cipher_share_selected( }; if let Some(id) = shared_cipher_data.cipher.id.take() { - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? + share_cipher_by_uuid( + &id, + shared_cipher_data, + &headers, + CipherAccessScope::User, + &conn, + &nt, + Some(UpdateType::None), + ) + .await? } else { err!("Request missing ids field") }; @@ -1061,12 +1191,16 @@ async fn share_cipher_by_uuid( cipher_id: &CipherId, data: ShareCipherData, headers: &Headers, + // Only the response is serialized with this. The entry check below deliberately stays + // `CipherAccessScope::User`: sharing is a regular vault operation, and `/ciphers/admin` reaches + // it with a cipher it has just created and therefore owns. + response_scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, override_ut: Option, ) -> JsonResult { let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { - if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { cipher } else { err!("Cipher is not write accessible") @@ -1110,9 +1244,18 @@ async fn share_cipher_by_uuid( UpdateType::SyncCipherCreate }; - update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; + update_cipher_from_data( + &mut cipher, + data.cipher, + headers, + CipherUpdateAuthorization::shared_to(shared_to_collections), + conn, + nt, + ut, + ) + .await?; - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) + Ok(Json(cipher_json_for_scope(&cipher, headers, response_scope, conn).await?)) } /// v2 API for downloading an attachment. This just redirects the client to @@ -1132,7 +1275,7 @@ async fn get_attachment( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await { err!("Cipher is not accessible") } @@ -1168,15 +1311,22 @@ async fn post_attachment_v2( headers: Headers, conn: DbConn, ) -> JsonResult { + let data: AttachmentRequestData = data.into_inner(); + + // Upstream's `PostAttachment` branches on `adminRequest`: it authorizes the administrative + // request with `CanEditCipherAsAdminAsync` and answers it with a `CipherMiniResponse`. The flag + // picks the predicate, not its answer -- a caller without organization-wide cipher authority is + // refused here either way. + let scope = CipherAccessScope::requested(data.admin_request); + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not write accessible") } - let data: AttachmentRequestData = data.into_inner(); let file_size = data.file_size.into_i64()?; if file_size < 0 { @@ -1188,9 +1338,11 @@ async fn post_attachment_v2( attachment.save(&conn).await.expect("Error saving attachment"); let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); - let response_key = match data.admin_request { - Some(b) if b => "cipherMiniResponse", - _ => "cipherResponse", + // Derived from the same `scope` the request was authorized with, so the response key and the + // serialization below can never disagree about which flow this is. + let response_key = match scope { + CipherAccessScope::OrganizationAdmin => "cipherMiniResponse", + CipherAccessScope::User => "cipherResponse", }; Ok(Json(json!({ // AttachmentUploadDataResponseModel @@ -1198,7 +1350,7 @@ async fn post_attachment_v2( "attachmentId": attachment_id, "url": url, "fileUploadType": FileUploadType::Direct as i32, - response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, + response_key: cipher_json_for_scope(&cipher, &headers, scope, &conn).await?, }))) } @@ -1221,6 +1373,7 @@ async fn save_attachment( cipher_id: CipherId, data: Form>, headers: &Headers, + scope: CipherAccessScope, conn: DbConn, nt: Notify<'_>, ) -> Result<(Cipher, DbConn), crate::error::Error> { @@ -1237,7 +1390,7 @@ async fn save_attachment( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await { err!("Cipher is not write accessible") } @@ -1398,11 +1551,31 @@ async fn post_attachment_v2_data( None => err!("Attachment doesn't exist"), }; - save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; + // This leg of the v2 upload carries no `adminRequest` field, so the administrative context is + // recomputed from the caller's own membership in *this cipher's* organization, exactly as + // upstream's `PostFileForExistingAttachment` does. Nothing in the request feeds into it, so the + // upload route cannot be talked into an administrative scope. + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + let scope = cipher_scope_for_member(&cipher, &headers.user.uuid, &conn).await; + + save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; Ok(()) } +/// The [`CipherAccessScope`] for a route the client cannot state one for: resolved from the +/// caller's own confirmed membership in the cipher's organization, never from the request. +async fn cipher_scope_for_member(cipher: &Cipher, user_id: &UserId, conn: &DbConn) -> CipherAccessScope { + let Some(org_id) = cipher.organization_uuid.as_ref() else { + // A personal cipher has no organization to administer. + return CipherAccessScope::User; + }; + + CipherAccessScope::for_member(Membership::find_confirmed_by_user_and_org(user_id, org_id, conn).await.as_ref()) +} + /// Legacy API for creating an attachment associated with a cipher. #[post("/ciphers//attachment", format = "multipart/form-data", data = "")] async fn post_attachment( @@ -1412,13 +1585,7 @@ async fn post_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - // Setting this as None signifies to save_attachment() that it should create - // the attachment database record as well as saving the data to disk. - let attachment = None; - - let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; - - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) + post_attachment_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await } #[post("/ciphers//attachment-admin", format = "multipart/form-data", data = "")] @@ -1429,7 +1596,24 @@ async fn post_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_attachment(cipher_id, data, headers, conn, nt).await + post_attachment_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await +} + +async fn post_attachment_impl( + cipher_id: CipherId, + data: Form>, + headers: Headers, + scope: CipherAccessScope, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + // Setting this as None signifies to save_attachment() that it should create + // the attachment database record as well as saving the data to disk. + let attachment = None; + + let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?; + + Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } #[post("/ciphers//attachment//share", format = "multipart/form-data", data = "")] @@ -1441,7 +1625,7 @@ async fn post_attachment_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; + delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await?; post_attachment(cipher_id, data, headers, conn, nt).await } @@ -1453,7 +1637,15 @@ async fn delete_attachment_post_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_attachment(cipher_id, attachment_id, headers, conn, nt).await + delete_cipher_attachment_by_id( + &cipher_id, + &attachment_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &nt, + ) + .await } #[post("/ciphers//attachment//delete")] @@ -1475,7 +1667,7 @@ async fn delete_attachment( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await + delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await } #[delete("/ciphers//attachment//admin")] @@ -1486,42 +1678,77 @@ async fn delete_attachment_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await + delete_cipher_attachment_by_id( + &cipher_id, + &attachment_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &nt, + ) + .await } #[post("/ciphers//delete")] async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) + .await // permanent delete } #[post("/ciphers//delete-admin")] async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::HardSingle, + &nt, + ) + .await // permanent delete } #[put("/ciphers//delete")] async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::SoftSingle, &nt) + .await // soft delete } #[put("/ciphers//delete-admin")] async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::SoftSingle, + &nt, + ) + .await // soft delete } #[delete("/ciphers/")] async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt) + .await // permanent delete } #[delete("/ciphers//admin")] async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { - delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await + delete_cipher_by_uuid( + &cipher_id, + &headers, + CipherAccessScope::OrganizationAdmin, + &conn, + &CipherDeleteOptions::HardSingle, + &nt, + ) + .await // permanent delete } @@ -1532,7 +1759,7 @@ async fn delete_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1543,7 +1770,7 @@ async fn delete_cipher_selected_post( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await // permanent delete } @@ -1554,7 +1781,7 @@ async fn delete_cipher_selected_put( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await + delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::SoftMulti, nt).await // soft delete } @@ -1565,7 +1792,15 @@ async fn delete_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::HardMulti, + nt, + ) + .await // permanent delete } @@ -1576,7 +1811,15 @@ async fn delete_cipher_selected_post_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::HardMulti, + nt, + ) + .await // permanent delete } @@ -1587,18 +1830,26 @@ async fn delete_cipher_selected_put_admin( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await + delete_multiple_ciphers( + data, + headers, + CipherAccessScope::OrganizationAdmin, + conn, + CipherDeleteOptions::SoftMulti, + nt, + ) + .await // soft delete } #[put("/ciphers//restore")] async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await + restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::User, &conn, &nt).await } #[put("/ciphers//restore-admin")] async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { - restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await + restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[put("/ciphers/restore-admin", data = "")] @@ -1608,7 +1859,7 @@ async fn restore_cipher_selected_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, &conn, &nt).await + restore_multiple_ciphers(data, &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await } #[put("/ciphers/restore", data = "")] @@ -1618,7 +1869,7 @@ async fn restore_cipher_selected( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - restore_multiple_ciphers(data, &headers, &conn, &nt).await + restore_multiple_ciphers(data, &headers, CipherAccessScope::User, &conn, &nt).await } #[derive(Deserialize)] @@ -1809,6 +2060,7 @@ pub enum CipherDeleteOptions { async fn delete_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, delete_options: &CipherDeleteOptions, nt: &Notify<'_>, @@ -1817,7 +2069,7 @@ async fn delete_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher can't be deleted by user") } @@ -1875,6 +2127,7 @@ struct CipherIdsData { async fn delete_multiple_ciphers( data: Json, headers: Headers, + scope: CipherAccessScope, conn: DbConn, delete_options: CipherDeleteOptions, nt: Notify<'_>, @@ -1882,7 +2135,7 @@ async fn delete_multiple_ciphers( let data = data.into_inner(); for cipher_id in data.ids { - if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { + if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, scope, &conn, &delete_options, &nt).await { return error; } } @@ -1897,6 +2150,7 @@ async fn restore_cipher_by_uuid( cipher_id: &CipherId, headers: &Headers, multi_restore: bool, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -1904,7 +2158,7 @@ async fn restore_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher can't be restored by user") } @@ -1936,12 +2190,15 @@ async fn restore_cipher_by_uuid( .await; } - Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) + // Answer with the scope the restore was authorized under, so an administrative caller without a + // personal assignment is not told `edit: false` for a cipher they just restored. + Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?)) } async fn restore_multiple_ciphers( data: Json, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -1949,7 +2206,7 @@ async fn restore_multiple_ciphers( let mut ciphers: Vec = Vec::new(); for cipher_id in data.ids { - match restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { + match restore_cipher_by_uuid(&cipher_id, headers, true, scope, conn, nt).await { Ok(json) => ciphers.push(json.into_inner()), err => return err, } @@ -1969,6 +2226,7 @@ async fn delete_cipher_attachment_by_id( cipher_id: &CipherId, attachment_id: &AttachmentId, headers: &Headers, + scope: CipherAccessScope, conn: &DbConn, nt: &Notify<'_>, ) -> JsonResult { @@ -1984,7 +2242,7 @@ async fn delete_cipher_attachment_by_id( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await { err!("Cipher cannot be deleted by user") } @@ -2012,7 +2270,8 @@ async fn delete_cipher_attachment_by_id( ) .await; } - let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; + // Same scope the deletion was authorized under; see `cipher_json_for_scope`. + let cipher_json = cipher_json_for_scope(&cipher, headers, scope, conn).await?; Ok(Json(json!({"cipher":cipher_json}))) } @@ -2027,7 +2286,7 @@ async fn archive_cipher( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { err!("Cipher is not accessible for the current user") } @@ -2059,7 +2318,7 @@ async fn unarchive_cipher( err!("Cipher doesn't exist") }; - if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await { err!("Cipher is not accessible for the current user") } diff --git a/src/api/core/events.rs b/src/api/core/events.rs index a5b5b6b1..dd26745c 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -7,12 +7,14 @@ use serde_json::Value; use crate::{ CONFIG, api::{EmptyResult, JsonResult}, - auth::{AdminHeaders, Headers}, + auth::{AccessEventLogsHeaders, Headers, may_access_event_logs}, db::{ DbConn, DbPool, - models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, + models::{ + Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId, + }, }, - util::parse_date, + util::try_parse_date, }; /// ############################################################################################################### @@ -29,9 +31,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 +68,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 +86,62 @@ 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 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 && may_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 +158,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 +219,82 @@ struct EventCollection { organization_id: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ClientEventKind { + User, + Cipher, + Organization, + OrganizationUser, +} + +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::OrganizationAutoConfirmEnabledAdmin as i32 + || event_type == EventType::OrganizationAutoConfirmDisabledAdmin as i32 + || event_type == EventType::OrganizationInviteLinkClientCopied as i32 => + { + Some(ClientEventKind::Organization) + } + // Upstream logs these through `LogOrganizationUserEventAsync`: they describe the acting + // user's own membership, not the organization as a whole. + event_type + if event_type == EventType::OrganizationUserNotificationBannerActionClicked as i32 + || event_type == EventType::OrganizationItemOrganizationAccepted as i32 + || event_type == EventType::OrganizationItemOrganizationDeclined as i32 => + { + Some(ClientEventKind::OrganizationUser) + } + _ => 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,32 +351,26 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } - // Only the vault notification banner click is accepted from clients. The rest of - // the 1500..=1599 range is written server-side and must not be forgeable by a client. - t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => { - if let Some(org_id) = &event.organization_id - && let Some(membership) = - Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await - { - log_event_impl( + ClientEventKind::OrganizationUser => { + if let Some(org_id) = &event.organization_id { + log_client_org_user_event( event.r#type, - &membership.uuid, org_id, &headers.user.uuid, headers.device.atype, - Some(event_date), + event_date, &headers.ip.ip, &conn, ) .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 && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await - && cipher.is_accessible_to_user(&headers.user.uuid, &conn).await + && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await && let Some(org_id) = cipher.organization_uuid { log_event_impl( @@ -245,6 +391,24 @@ async fn post_events_collect(data: Json>, headers: Headers, Ok(()) } +/// Logs a client event against the membership the acting user holds in `org_id`. Without such a +/// membership there is nothing to log against, so a request naming another organization writes no +/// event at all instead of one pointing at a foreign or non-existent membership. +async fn log_client_org_user_event( + event_type: i32, + org_id: &OrganizationId, + act_user_id: &UserId, + device_type: i32, + event_date: NaiveDateTime, + ip: &IpAddr, + conn: &DbConn, +) { + if let Some(membership) = Membership::find_confirmed_by_user_and_org(act_user_id, org_id, conn).await { + log_event_impl(event_type, &membership.uuid, org_id, act_user_id, device_type, Some(event_date), ip, conn) + .await; + } +} + pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; @@ -332,7 +496,14 @@ async fn log_event_impl( 1500..=1599 => { event.org_user_uuid = Some(source_uuid.to_owned().into()); } - // 1600..=1699 Are organizational events, and they do not need the source_uuid + // 1600..=1699 Are organizational events, and they do not need the source_uuid, except for + // the two item-organization events, which upstream logs against a membership. + event_type + if event_type == EventType::OrganizationItemOrganizationAccepted as i32 + || event_type == EventType::OrganizationItemOrganizationDeclined as i32 => + { + event.org_user_uuid = Some(source_uuid.to_owned().into()); + } // Policy Events 1700..=1799 => { event.policy_uuid = Some(source_uuid.to_owned().into()); diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 36297d30..429c0d6e 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -8,17 +8,22 @@ use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ - EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, - auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, + auth::{ + AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, + ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, + ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, can_read_collection_access, + can_read_collection_with_access, decode_invite, may_access_import_export, + }, db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, - Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, - UserId, + Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, + CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_role_permissions, }, }, mail, @@ -48,6 +53,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, @@ -136,8 +142,24 @@ struct FullCollectionData { external_id: Option, } +fn validate_collection_access(manage: bool, read_only: bool, hide_passwords: bool) -> EmptyResult { + if manage && (read_only || hide_passwords) { + err!( + "The Manage property is mutually exclusive and cannot be true while the ReadOnly or HidePasswords properties are also true." + ) + } + Ok(()) +} + impl FullCollectionData { pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + for group in &self.groups { + validate_collection_access(group.manage, group.read_only, group.hide_passwords)?; + } + for user in &self.users { + validate_collection_access(user.manage, user.read_only, user.hide_passwords)?; + } + let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { @@ -214,7 +236,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 +411,18 @@ 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() { - err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); - } - + let can_read_all = may_read_all_collections(&headers.membership); + let all_collections = Collection::find_by_organization(&org_id, &conn).await; + let collections = if can_read_all { + all_collections + } else { + // Same rule as `has_explicit_collection_manage_access`, resolved in one query instead of one + // per collection. + let explicitly_managed = headers.membership.explicitly_managed_collection_ids(&conn).await; + all_collections.into_iter().filter(|collection| explicitly_managed.contains(&collection.uuid)).collect() + }; 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,20 +449,9 @@ 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); - // 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) - .await - .into_iter() - .map(|member| { - json!({ - "id": member.uuid, - "readOnly": false, - "hidePasswords": false, - "manage": true, - }) - }) - .collect(); + let can_read_all_access_details = may_read_all_collections_with_access(&member); + // Get all admins, owners and managers who can manage/access all. + let manage_all_members = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn).await; let mut data = Vec::new(); for col in Collection::find_by_organization(&org_id, &conn).await { @@ -445,24 +461,31 @@ 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 { + if !can_read_all_access_details && !can_read_collection_access(&member, &col.uuid, &conn).await { continue; } - // get the users assigned directly to the given collection - let mut users: Vec = col_users + let collection_users: Vec<_> = col_users.iter().filter(|user| user.collection_uuid == col.uuid).collect(); + let stored_membership_ids: HashSet<_> = collection_users.iter().map(|user| &user.membership_uuid).collect(); + let mut users: Vec = collection_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); + users.extend(manage_all_members.iter().filter(|member| !stored_membership_ids.contains(&member.uuid)).map( + |member| { + json!({ + "id": member.uuid, + "readOnly": false, + "hidePasswords": false, + "manage": true, + }) + }, + )); - // get the group details for the given collection let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&col.uuid, &conn) .await @@ -489,8 +512,18 @@ 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::() +fn may_read_all_collections(member: &Membership) -> bool { + member.has_full_access() + || member.has_manage_groups() + || member.has_delete_any_collection() + || member.has_access_import_export() +} + +fn may_read_all_collections_with_access(member: &Membership) -> bool { + member.has_full_access() + || member.has_delete_any_collection() + || member.has_manage_users() + || member.has_manage_groups() } #[post("/organizations//collections", data = "")] @@ -503,27 +536,19 @@ async fn post_organization_collections( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } - let data: FullCollectionData = data.into_inner(); - data.validate(&org_id, &conn).await?; - if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { + // 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?; + 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; - for group in data.groups { CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage) .save(&org_id, &conn) @@ -535,10 +560,6 @@ async fn post_organization_collections( err!("User is not part of organization") }; - if member.access_all { - continue; - } - CollectionUser::save( &member.user_uuid, &collection.uuid, @@ -550,6 +571,17 @@ async fn post_organization_collections( .await?; } + 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)) } @@ -573,26 +605,64 @@ async fn post_bulk_access_collections( } let data: BulkCollectionAccessData = data.into_inner(); + for group in &data.groups { + validate_collection_access(group.manage, group.read_only, group.hide_passwords)?; + } + for user in &data.users { + validate_collection_access(user.manage, user.read_only, user.hide_passwords)?; + } + if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") } - // The collections and members are checked below, the groups only here. + // Security: authorization is per collection below, via `auth::can_modify_collection_access`, which + // mirrors upstream authorizing this route against *both* `ModifyUserAccess` and `ModifyGroupAccess`: + // the regular collection-update authorization (Owner/Admin, `Edit any collection`, or a real + // per-collection Manage grant), or `Manage users` *and* `Manage groups` together. Group `access_all` + // deliberately does not satisfy it (the previous `is_manageable_by_user` check accepted it, and + // disagreed with the single-edit endpoint). + + // Upstream loads the collections with `GetManyByManyIdsAsync()` and compares the number of rows it + // got back with the number of requested ids, so a repeated id fails the request; an empty list is + // rejected by `BulkAddCollectionAccessCommand` ("No collections were provided.") and by the bulk + // authorization handler, which fails on an empty resource set. Both are checked before anything is + // read or written, so a rejected request mutates nothing and logs no event. + if data.collection_ids.is_empty() { + err!("No collections were provided") + } + if data.collection_ids.iter().collect::>().len() != data.collection_ids.len() { + err!("One or more collections not found", "The request contains duplicate collection ids") + } + + // 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 first write happen, so a foreign-tenant group can never be linked and a later + // invalid element cannot leave earlier collections already changed. 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 { - err!("Collection not found", "The current user isn't a manager for this collection") + if !crate::auth::can_modify_collection_access(&headers.membership, &collection.uuid, &conn).await { + err!("Collection not found", "The current user isn't allowed to modify this collection's access") } + collections.push(collection); + } + + for collection in collections { // update collection modification date collection.save(&conn).await?; @@ -607,25 +677,33 @@ async fn post_bulk_access_collections( ) .await; - CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?; + // Add/update, never replace: every assignment the request does not mention is left alone. 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( + collection.uuid.clone(), + group.id.clone(), + group.read_only, + group.hide_passwords, + group.manage, + ) + .save(&org_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 { - continue; - } - - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) - .await?; + CollectionUser::save( + &member.user_uuid, + &collection.uuid, + user.read_only, + user.hide_passwords, + user.manage, + &conn, + ) + .await?; } } @@ -699,10 +777,6 @@ async fn post_organization_collection_update( err!("User is not part of organization") }; - if member.access_all { - continue; - } - CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) .await?; } @@ -713,7 +787,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 +813,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 +823,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 @@ -761,6 +835,22 @@ struct BulkCollectionIds { ids: Vec, } +/// Upstream resolves a bulk delete through `GetManyByManyIdsAsync(model.Ids)` and then compares the +/// number of loaded collections against the number of requested ids, so a repeated id resolves to one +/// entity and fails that count check. Duplicates are therefore rejected instead of deduplicated. +/// An empty request is rejected as well: upstream's bulk authorization handler fails closed on an +/// empty resource set. Both checks run before the first deletion, so nothing is authorized, deleted +/// or logged for a rejected request. +fn bulk_delete_collection_targets(ids: Vec) -> ApiResult> { + if ids.is_empty() { + err!("No collections were provided") + } + if ids.iter().collect::>().len() != ids.len() { + err!("Collection not found", "The request contains duplicate collection ids") + } + Ok(ids) +} + #[delete("/organizations//collections", data = "")] async fn bulk_delete_organization_collections( org_id: OrganizationId, @@ -773,9 +863,11 @@ async fn bulk_delete_organization_collections( } let data: BulkCollectionIds = data.into_inner(); - let collections = data.ids; + let collections = bulk_delete_collection_targets(data.ids)?; - let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; + // Full prevalidation (org scope and delete permission for every id) happens here, before the first + // deletion: one foreign or unknown collection in the request means nothing is deleted at all. + 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?; @@ -783,26 +875,31 @@ async fn bulk_delete_organization_collections( Ok(()) } +// Upstream guards this route with `BulkCollectionOperations.ReadWithAccess`, which — unlike the +// `ReadAccess` used by `/collections//users` below — also admits `Manage users`. Hence the +// route-specific `can_read_collection_with_access` instead of the general `CollectionReadHeaders` +// guard: extending that guard would have changed the `/users` endpoint along with it. #[get("/organizations//collections//details")] async fn get_org_collection_detail( org_id: OrganizationId, col_id: CollectionId, - headers: ManagerHeaders, + 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"); } - 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") - }; + // Authorize against the resolved collection, never against the request-supplied id. + if !can_read_collection_with_access(&headers.membership, &collection.uuid, &conn).await { + err!("Collection not found", "The current user isn't allowed to read this collection's access") + } let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&collection.uuid, &conn) @@ -837,7 +934,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 +951,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 +981,116 @@ 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 { + let Some(membership) = + Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await + else { + err_code!("Resource not found.", "User is not a confirmed member of the organization", Status::NotFound.code); + }; + + Ok(Json(json!({ + "data": assigned_org_ciphers_json(&membership, &headers.host, &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 assigned portion matches what the +// same member receives from `/api/sync`. +// +// On top of that, upstream's `GetAssignedOrganizationCiphers` adds the organization's *unassigned* +// ciphers for the roles allowed to reach them (`CanAccessUnassignedCiphersAsync`: Owner/Admin, or a +// Custom member holding `Edit any collection`) -- which is exactly `Membership::has_full_access`. This +// is deliberately the only place that widens the scope: the regular `/api/sync` view stays as it is. +async fn assigned_org_ciphers_json(membership: &Membership, host: &str, conn: &DbConn) -> Result { + let user_id = &membership.user_uuid; + let org_id = &membership.org_uuid; + + let ciphers = filter_ciphers_for_organization(Cipher::find_by_user_visible(user_id, conn).await, org_id); + let assigned: HashSet = ciphers.iter().map(|cipher| cipher.uuid.clone()).collect(); + + let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::User, conn).await; + let mut ciphers_json = Vec::new(); + + // Assigned ciphers keep the user's actual collection restrictions. + for cipher in ciphers { + ciphers_json.push(cipher.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::User, conn).await?); + } + + // Bitwarden exposes unassigned ciphers with full edit/password access to + // Owner/Admin and Custom members with EditAnyCollection. + if membership.has_full_access() { + for cipher in Cipher::find_unassigned_by_org(org_id, conn) + .await + .into_iter() + .filter(|cipher| !assigned.contains(&cipher.uuid)) + { + // Use Organization serialization here so the normal user-access + // assertion is deliberately skipped for this already-authorized + // special case. Add the user-specific fields below explicitly. + let mut unassigned_cipher_json = + cipher.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?; + + unassigned_cipher_json["folderId"] = json!(cipher_sync_data.cipher_folders.get(&cipher.uuid).cloned()); + unassigned_cipher_json["favorite"] = json!(cipher_sync_data.cipher_favorites.contains(&cipher.uuid)); + unassigned_cipher_json["archivedDate"] = json!( + cipher_sync_data + .cipher_archives + .get(&cipher.uuid) + .map_or(Value::Null, |date| Value::String(crate::util::format_date(date))) + ); + + unassigned_cipher_json["edit"] = json!(true); + unassigned_cipher_json["viewPassword"] = json!(true); + unassigned_cipher_json["permissions"] = json!({ + "delete": true, + "restore": true, + }); + + ciphers_json.push(unassigned_cipher_json); + } + } + + Ok(Value::Array(ciphers_json)) +} + +// The organization cipher list the clients use for the admin vault view and for computing reports +// locally. Bitwarden grants the complete organization scope to AccessReports and AccessImportExport. #[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", Status::NotFound.code); } - if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", 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::Denied => { + err_code!( + "Resource not found.", + "User does not have permission to read the organization ciphers", + 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,8 +1102,22 @@ async fn get_org_details_impl( user_id: &UserId, conn: &DbConn, ) -> Result { - let ciphers = Cipher::find_by_org(org_id, conn).await; - let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + ciphers_to_org_json(Cipher::find_by_org(org_id, conn).await, org_id, 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, + org_id: &OrganizationId, + host: &str, + user_id: &UserId, + conn: &DbConn, +) -> Result { + let mut cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; + cipher_sync_data.cipher_collections = + index_cipher_collections(Cipher::get_collections_with_cipher_by_organization(org_id, conn).await); let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { @@ -917,6 +1126,13 @@ async fn get_org_details_impl( Ok(json!(ciphers_json)) } +fn index_cipher_collections(relations: Vec<(CipherId, CollectionId)>) -> HashMap> { + relations.into_iter().fold(HashMap::new(), |mut indexed, (cipher_id, collection_id)| { + indexed.entry(cipher_id).or_default().push(collection_id); + indexed + }) +} + // Returning a Domain/Organization here allow to prefill it and prevent prompting the user // So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` @@ -947,17 +1163,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", 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 +1226,104 @@ async fn post_org_keys( }))) } +// Struct, parser, subset check and writer are all expanded from the single permission list in +// `db::models::organization`, so they cannot list different sets of permissions. +macro_rules! define_custom_role_permissions { + ($($field:ident, $json_key:literal, $accessor:ident);* $(;)?) => { + #[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 { + $( $field: bool, )* + } + + impl CustomRolePermissions { + /// Type-check and read every known permission key. See [`Self::read_known`]. + fn parse(permissions: &HashMap) -> Result { + Ok(Self { + $( $field: Self::read_known(permissions, $json_key)?, )* + }) + } + + /// The permissions a membership currently holds, as stored. + fn from_membership(membership: &Membership) -> Self { + Self { + $( $field: membership.$field, )* + } + } + + /// Whether every requested permission is one the caller holds themselves. The accessors are + /// type-gated, so a stale flag on a non-Custom caller never delegates anything. + fn is_subset_of(self, caller: &Membership) -> bool { + $( (!self.$field || caller.$accessor()) )&&* + } + + fn apply_to(self, membership: &mut Membership) { + $( membership.$field = self.$field; )* + } + } + }; +} +custom_role_permissions!(define_custom_role_permissions); + +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::parse(permissions)?; + + if member_type == MembershipType::Custom { + Ok(parsed) + } else { + Ok(Self::default()) + } + } + + /// 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::from_membership(membership) + } + None => Self::default(), + }) + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InviteData { @@ -1023,6 +1337,10 @@ struct InviteData { impl InviteData { async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + for collection in self.collections.iter().flatten() { + validate_collection_access(collection.manage, collection.read_only, collection.hide_passwords)?; + } + let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) { @@ -1043,7 +1361,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,28 +1370,31 @@ async fn send_invite( let data: InviteData = data.into_inner(); data.validate(&org_id, &conn).await?; - // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - // Membership::from_str will convert custom (4) to manager (3) - let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) { - new_type as i32 - } else { + let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; - if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can invite Managers, Admins or Owners") + if !may_provision_member_type(headers.membership_type, new_type) { + err!("You don't have permission to invite this role") } - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag - // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes - // If the box is not checked, the user will still be a manager, but not with the access_all permission - let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") - && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); + // manageAllCollections is a client-only aggregate; 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. + let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions)?; + + if !may_grant_custom_permissions(&headers.membership, new_type, Some(custom_permissions)) { + err!("Custom users can only grant the same custom permissions that they have") + } + + if headers.membership_type == MembershipType::Custom { + for group_id in &data.groups { + if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_some_and(|group| group.access_all) { + err!("Only Admins and Owners can add a member to a legacy access-all group") + } + } + } let mut user_created: bool; for email in &data.emails { @@ -1116,8 +1437,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?; @@ -1159,30 +1480,24 @@ async fn send_invite( ) .await; - // 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( - &user.uuid, - &collection.uuid, - col.read_only, - col.hide_passwords, - col.manage, - &conn, - ) - .await?; - } + 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( + &user.uuid, + &collection.uuid, + col.read_only, + col.hide_passwords, + col.manage, + &conn, + ) + .await?; } } } 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") - } let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); group_entry.save(&conn).await?; } @@ -1195,7 +1510,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 { @@ -1205,7 +1520,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:?}"), }; @@ -1230,19 +1545,23 @@ 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 } +/// Reinvite and confirm are guarded by `ManageUsersRequirement` alone upstream — neither +/// `ResendOrganizationInviteCommand` nor `ConfirmOrganizationUserCommand` consults the acting member's +/// role against the target's. Neither action can change a role, so the actor/target matrix that +/// update, remove, revoke and restore still enforce does not apply here. 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 { @@ -1268,7 +1587,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?; @@ -1361,7 +1680,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1374,7 +1693,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(), @@ -1405,7 +1736,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1418,7 +1749,7 @@ async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1433,10 +1764,6 @@ 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 member_to_confirm.status != MembershipStatus::Accepted as i32 { err!("User in invalid state") } @@ -1481,11 +1808,16 @@ async fn confirm_invite_impl( save_result } +// Organization user mini-details are available to every confirmed organization member, matching +// upstream's `MemberOrProvider` authorization for this route. That broadens metadata visibility (id, +// user id, name, email, membership type, status) compared with Vaultwarden's previous Manager-only +// behaviour, and is intentional: a broad range of client flows depends on basic member lookups. #[get("/organizations//users/mini-details", rank = 1)] async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + let mut members_json = Vec::new(); for m in Membership::find_by_org(&org_id, &conn).await { members_json.push(m.to_json_mini_details(&conn).await); @@ -1503,7 +1835,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 { @@ -1525,8 +1857,7 @@ struct EditUserData { r#type: NumberOrString, collections: Option>, groups: Option>, - #[serde(default)] - permissions: HashMap, + permissions: Option>, } #[put("/organizations//users/", data = "", rank = 1)] @@ -1534,7 +1865,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 @@ -1545,44 +1876,34 @@ 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 { err!("Organization not found", "Organization id's do not match"); } let data: EditUserData = data.into_inner(); + for collection in data.collections.iter().flatten() { + validate_collection_access(collection.manage, collection.read_only, collection.hide_passwords)?; + } - // 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") }; - if new_type != member_to_edit.atype - && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) - && headers.membership_type != MembershipType::Owner - { - err!("Only Owners can grant and remove Admin or Owner privileges") - } - - if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { - err!("Only Owners can edit Owner users") + // 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 requested_custom_permissions = data.permissions.as_ref().map(|_| custom_permissions); + if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) { + err!("You don't have permission to manage the current or requested member role") } if member_to_edit.atype == MembershipType::Owner @@ -1595,44 +1916,56 @@ async fn edit_member( } } - member_to_edit.access_all = access_all; + if !may_grant_custom_permissions(&headers.membership, new_type, requested_custom_permissions) { + err!("Custom users can only grant the same custom permissions that they have") + } + + 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?; + let mut collection_assignments: Vec<(CollectionId, bool, bool, bool)> = Vec::new(); + 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") + }; + collection_assignments.push((collection.uuid, col.read_only, col.hide_passwords, col.manage)); } - // If no accessAll, add the collections received - if !access_all { - for col in data.collections.iter().flatten() { - match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { - None => err!("Collection not found in Organization"), - Some(collection) => { - CollectionUser::save( - &member_to_edit.user_uuid, - &collection.uuid, - col.read_only, - col.hide_passwords, - col.manage, - &conn, - ) - .await?; - } + 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") + } + } + + if headers.membership_type == MembershipType::Custom { + let current_groups: HashSet = GroupUser::find_by_member(&member_to_edit.uuid, &conn) + .await + .into_iter() + .map(|group_user| group_user.groups_uuid) + .collect(); + for group_id in data.groups.iter().flatten().filter(|group_id| !current_groups.contains(*group_id)) { + if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_some_and(|group| group.access_all) { + err!("Only Admins and Owners can add a member to a legacy access-all group") } } } - GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; + for collection_user in + CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await + { + collection_user.delete(&conn).await?; + } + for (collection_id, read_only, hide_passwords, manage) in collection_assignments { + CollectionUser::save(&member_to_edit.user_uuid, &collection_id, read_only, hide_passwords, manage, &conn) + .await?; + } + GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; 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") - } let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); group_entry.save(&conn).await?; } @@ -1655,7 +1988,7 @@ async fn edit_member( async fn bulk_delete_member( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1691,7 +2024,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 { @@ -1701,7 +2034,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 { @@ -1712,8 +2045,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 @@ -1755,7 +2088,7 @@ async fn delete_member_impl( async fn bulk_public_keys( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -1791,8 +2124,7 @@ async fn bulk_public_keys( }))) } -use super::ciphers::CipherData; -use super::ciphers::update_cipher_from_data; +use super::ciphers::{CipherData, CipherUpdateAuthorization, update_cipher_from_data}; // The import endpoint only ever uses the name/id/external_id of a collection. // Bitwarden's own server ignores `groups`/`users` here too, so do not make them @@ -1822,7 +2154,7 @@ struct RelationsData { value: usize, } -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62 +// https://github.com/bitwarden/server/blob/e8afc9eb63901402fd160198e70eb865e011144a/src/Api/Tools/Controllers/ImportCiphersController.cs #[post("/ciphers/import-organization?", data = "")] async fn post_org_import( query: OrgIdData, @@ -1835,7 +2167,18 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + + // AccessImportExport authorizes the complete organization import. Other confirmed members keep + // the regular per-target Create/Update authorization. + if !headers.membership.has_status(MembershipStatus::Confirmed) { + err!("You need to be a confirmed member of this organization to import into it") + } + let organization_write_authorized = may_access_import_export(&headers.membership); + let data: ImportData = data.into_inner(); + if data.collections.is_empty() && !organization_write_authorized { + err!("Not enough privileges to import into this organization") + } // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. @@ -1843,27 +2186,65 @@ 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: index the existing collections by id so the per-collection authorization below can run + // the collection-*update* predicate `auth::can_edit_collection` on them. Upstream resolves + // `BulkCollectionOperations.ImportCiphers` through the very same `CanUpdateCollectionAsync` as a + // collection update, so importing into an existing collection needs Owner/Admin, `Edit any + // collection` or a real per-collection Manage grant. A plain write assignment + // (`readOnly = false`, `manage = false`) is deliberately *not* enough — the previous + // `is_writable_by_user` check accepted it and was more permissive than upstream. 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, unauthorized 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 can_update = crate::auth::can_edit_collection(&headers.membership, &collection.uuid, &conn).await; + if !may_import_to_collection( + &headers.membership, + OrganizationImportTarget::Existing { + can_update, + }, + ) { + 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") - } 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 }; @@ -1890,17 +2271,17 @@ async fn post_org_import( &mut cipher, cipher_data, &headers, - Some(collections.clone()), + CipherUpdateAuthorization::organization_import(collections.clone(), organization_write_authorized), &conn, &nt, UpdateType::None, ) - .await - .ok(); + .await?; 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") @@ -1957,8 +2338,13 @@ async fn post_bulk_collections(data: Json, headers: Headers for cipher_id in &data.cipher_ids { // Only act on existing cipher uuid's // Do not abort the operation just ignore it, it could be a cipher was just deleted for example + // + // Upstream authorizes this route with `CanModifyCipherCollectionsAsync`, which resolves + // through `CanEditAllCiphersAsync` -- so a member with organization-wide cipher authority + // reaches every cipher of the organization here, exactly as the collection half above + // already does. if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await - && cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await + && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await { // When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection // In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. @@ -1977,13 +2363,17 @@ async fn post_bulk_collections(data: Json, headers: Headers Ok(()) } +// `ManagePoliciesRequirement` upstream, exactly as the single-policy route below: a member without the +// permission is refused rather than served an empty list. Policy *enforcement* is unaffected — holding +// `managePolicies` does not make a Custom member exempt from any policy. #[get("/organizations//policies")] -async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn list_policies(org_id: OrganizationId, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { 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(); + + let policies_json: Vec = + OrgPolicy::find_by_org(&org_id, &conn).await.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ "data": policies_json, @@ -2044,7 +2434,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"); } @@ -2081,7 +2471,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 { @@ -2140,10 +2530,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 @@ -2201,7 +2592,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 @@ -2278,7 +2669,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 @@ -2288,7 +2679,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 { @@ -2327,7 +2718,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 { @@ -2338,8 +2729,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 @@ -2371,7 +2762,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. @@ -2383,7 +2774,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 @@ -2393,7 +2784,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 { @@ -2427,19 +2818,20 @@ 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 { err!("Organization not found", "Organization id's do not match"); } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { - Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { + // Revoking stores `status - 128`, so every revoked value is accepted, not only -1. + Some(mut member) if member.status <= MembershipStatus::Revoked as i32 => { 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(); @@ -2465,32 +2857,21 @@ 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"); - } +fn may_read_basic_directory(membership: &Membership) -> bool { + membership.has_full_access() + || membership.has_manage_users() + || membership.has_manage_groups() + || membership.can_create_new_collections() + || membership.has_access_reports() +} - // 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); - let allowed = if details { - has_full_access - } else { - has_full_access - || Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await - }; - if !allowed { - err_code!("Resource not found.", "User does not have access", Status::NotFound.code); - } +async fn can_read_basic_directory(org_id: &OrganizationId, membership: &Membership, conn: &DbConn) -> bool { + may_read_basic_directory(membership) + || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(org_id, &membership.uuid, conn).await) + || Collection::has_manageable_collection_by_user(org_id, &membership.user_uuid, conn).await +} +async fn get_groups_data(details: bool, org_id: OrganizationId, conn: DbConn) -> JsonResult { let groups: Vec = if CONFIG.org_groups_enabled() { let groups = Group::find_by_organization(&org_id, &conn).await; let mut groups_json = Vec::with_capacity(groups.len()); @@ -2518,14 +2899,30 @@ async fn get_groups_data( }))) } +// The plain group list (id, name, externalId) exposes no access mappings. Upstream guards it with +// `OrganizationCollectionManagementAccessRequirement`, so it stays readable for members who have a +// reason to see it — the web vault needs it to render group names. #[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"); + } + if !can_read_basic_directory(&org_id, &headers.membership, &conn).await { + err_code!("Resource not found.", "User does not have access", Status::NotFound.code); + } + get_groups_data(false, org_id, conn).await } +// Group *details* expose accessAll, external IDs and collection mappings. Upstream guards the details +// *list* with `ManageUsersOrGroupsRequirement` and the *single* group below with the narrower +// `ManageGroupsRequirement`, so the two are authorized separately. Neither accepts organization-wide +// collection reach or a legacy `groups.access_all` membership as a substitute. #[get("/organizations//groups/details", rank = 1)] -async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { - get_groups_data(true, org_id, headers, conn).await +async fn get_groups_details(org_id: OrganizationId, headers: ManageUsersOrGroupsHeaders, conn: DbConn) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + get_groups_data(true, org_id, conn).await } #[derive(Deserialize)] @@ -2555,6 +2952,10 @@ impl GroupRequest { /// Validate if all the collections and members belong to the provided organization pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + for collection in &self.collections { + validate_collection_access(collection.manage, collection.read_only, collection.hide_passwords)?; + } + let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { @@ -2591,7 +2992,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 @@ -2600,7 +3001,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 { @@ -2613,6 +3014,9 @@ async fn post_groups( let group_request = data.into_inner(); group_request.validate(&org_id, &conn).await?; + if group_request.access_all && headers.membership_type == MembershipType::Custom { + err!("Only Admins and Owners can create a legacy access-all group") + } let group = group_request.to_group(&org_id); @@ -2635,7 +3039,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 { @@ -2651,15 +3055,34 @@ async fn put_group( let group_request = data.into_inner(); group_request.validate(&org_id, &conn).await?; + if group_request.access_all && !group.access_all && headers.membership_type == MembershipType::Custom { + err!("Only Admins and Owners can enable legacy access-all group access") + } + if group_request.access_all && headers.membership_type == MembershipType::Custom { + let current_members: HashSet = GroupUser::find_by_group(&group_id, &org_id, &conn) + .await + .into_iter() + .map(|group_user| group_user.users_organizations_uuid) + .collect(); + if group_request.users.iter().any(|member_id| !current_members.contains(member_id)) { + err!("Only Admins and Owners can add a member to a legacy access-all group") + } + } 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?; + let response = add_update_group( + updated_group, + group_request.collections, + group_request.users, + org_id.clone(), + &headers, + &conn, + ) + .await?; log_event( EventType::GroupUpdated, - &updated_group.uuid, + &group_id, &org_id, &headers.user.uuid, headers.device.atype, @@ -2668,7 +3091,121 @@ async fn put_group( ) .await; - add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &conn).await + Ok(response) +} + +fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool { + MembershipType::from_i32(current_atype).is_some_and(|current_type| { + may_manage_member_type(caller_type, current_type) && may_manage_member_type(caller_type, new_type) + }) +} + +/// 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` may manage Users and other Custom members. +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 => matches!(target_type, MembershipType::User | MembershipType::Custom), + 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 create or remove a membership of `target_type`. +/// +/// Currently the same rule as [`may_manage_member_type`]; kept separate because upstream treats +/// provisioning and managing as distinct operations, and this is where they would diverge. +fn may_provision_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { + may_manage_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 and restoring 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. +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), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OrganizationImportTarget { + Existing { + /// The outcome of `auth::can_edit_collection` for this collection — upstream resolves + /// `BulkCollectionOperations.ImportCiphers` through exactly the same `CanUpdateCollectionAsync` + /// it uses for a collection update, so this is the collection-update authorization, not a + /// write/edit assignment. A `readOnly = false, manage = false` assignment does not qualify. + can_update: bool, + }, + New, +} + +fn may_import_to_collection(caller: &Membership, target: OrganizationImportTarget) -> bool { + if !caller.has_status(MembershipStatus::Confirmed) { + return false; + } + if may_access_import_export(caller) { + return true; + } + + match target { + OrganizationImportTarget::Existing { + can_update, + } => can_update, + OrganizationImportTarget::New => caller.can_create_new_collections(), + } +} + +fn may_grant_custom_permissions( + caller: &Membership, + target_type: MembershipType, + requested: Option, +) -> bool { + !caller.has_type(MembershipType::Custom) + || target_type != MembershipType::Custom + || requested.is_none_or(|permissions| permissions.is_subset_of(caller)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OrganizationReportScope { + Complete, + Denied, +} + +fn organization_report_scope(caller: &Membership) -> OrganizationReportScope { + if caller.has_status(MembershipStatus::Confirmed) + && (caller.has_full_access() || caller.has_access_import_export() || caller.has_access_reports()) + { + OrganizationReportScope::Complete + } else { + OrganizationReportScope::Denied + } } async fn add_update_group( @@ -2676,16 +3213,17 @@ async fn add_update_group( collections: Vec, members: Vec, org_id: OrganizationId, - headers: &AdminHeaders, + headers: &ManageGroupsHeaders, conn: &DbConn, ) -> JsonResult { group.save(conn).await?; + CollectionGroup::delete_all_by_group(&group.uuid, &org_id, conn).await?; for col_selection in collections { - let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); - collection_group.save(&org_id, conn).await?; + col_selection.to_collection_group(group.uuid.clone()).save(&org_id, conn).await?; } + 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?; @@ -2712,11 +3250,13 @@ async fn add_update_group( }))) } +// Upstream guards this with `ManageGroupsRequirement` — deliberately narrower than the details *list* +// above, which also admits `Manage users`. #[get("/organizations//groups//details")] async fn get_group_details( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2737,21 +3277,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 { @@ -2761,10 +3306,27 @@ async fn delete_group_impl( err!("Group support is disabled"); } + let group = find_group_in_organization(group_id, org_id, conn).await?; + delete_authorized_group(&group, org_id, headers, conn).await +} + +async fn find_group_in_organization( + group_id: &GroupId, + org_id: &OrganizationId, + 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") }; + Ok(group) +} +async fn delete_authorized_group( + group: &Group, + org_id: &OrganizationId, + headers: &ManageGroupsHeaders, + conn: &DbConn, +) -> EmptyResult { log_event( EventType::GroupDeleted, &group.uuid, @@ -2783,7 +3345,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 { @@ -2795,14 +3357,30 @@ async fn bulk_delete_groups( let data: BulkGroupIds = data.into_inner(); + // Resolve the complete request before the first event or deletion so a foreign id cannot leave a + // valid prefix already deleted. + 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(find_group_in_organization(&group_id, &org_id, &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"); } @@ -2821,7 +3399,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 { @@ -2848,7 +3426,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 { @@ -2859,9 +3437,9 @@ 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") - } + }; let assigned_members = data.into_inner(); @@ -2871,6 +3449,17 @@ async fn put_group_members( err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) } + if group.access_all && headers.membership_type == MembershipType::Custom { + let current_members: HashSet = GroupUser::find_by_group(&group_id, &org_id, &conn) + .await + .into_iter() + .map(|group_user| group_user.users_organizations_uuid) + .collect(); + if assigned_members.iter().any(|member_id| !current_members.contains(member_id)) { + err!("Only Admins and Owners can add a member to a legacy access-all group") + } + } + GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; for assigned_member in assigned_members { let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone()); @@ -2896,7 +3485,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 { @@ -3214,18 +3803,21 @@ 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! -// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 +// https://github.com/bitwarden/server/blob/e8afc9eb63901402fd160198e70eb865e011144a/src/Api/Tools/Controllers/OrganizationExportController.cs #[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"); } + let collections = Collection::find_by_organization(&org_id, &conn).await; + let ciphers = Cipher::find_by_org(&org_id, &conn).await; + + 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, &org_id, &headers.host, &headers.user.uuid, &conn).await?), }))) } @@ -3285,3 +3877,230 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::{Value, json}; + + use super::{ + CustomRolePermissions as Perms, may_change_member_type, may_delete_stored_member_type, + may_grant_custom_permissions, may_manage_member_type, may_revoke_stored_member_type, + }; + use crate::db::models::{Membership, MembershipStatus, MembershipType}; + use MembershipType::{Admin, Custom, Owner, User}; + + const UNKNOWN_ATYPE: i32 = Membership::UNKNOWN_ATYPE; + + /// Every permission, with the way to set it on a request and on a stored membership. + type SetRequested = fn(&mut Perms); + type SetStored = fn(&mut Membership); + const PERMISSIONS: [(&str, SetRequested, SetStored); 9] = [ + ("manageUsers", |p| p.manage_users = true, |m| m.manage_users = true), + ("manageGroups", |p| p.manage_groups = true, |m| m.manage_groups = true), + ("managePolicies", |p| p.manage_policies = true, |m| m.manage_policies = true), + ("createNewCollections", |p| p.create_new_collections = true, |m| m.create_new_collections = true), + ("editAnyCollection", |p| p.edit_any_collection = true, |m| m.edit_any_collection = true), + ("deleteAnyCollection", |p| p.delete_any_collection = true, |m| m.delete_any_collection = true), + ("accessEventLogs", |p| p.access_event_logs = true, |m| m.access_event_logs = true), + ("accessImportExport", |p| p.access_import_export = true, |m| m.access_import_export = true), + ("accessReports", |p| p.access_reports = true, |m| m.access_reports = true), + ]; + + fn member(atype: MembershipType, set: impl FnOnce(&mut Membership)) -> Membership { + Membership::for_test(atype as i32, MembershipStatus::Confirmed, set) + } + + fn requested(set: SetRequested) -> Perms { + let mut permissions = Perms::default(); + set(&mut permissions); + permissions + } + + fn object(pairs: &[(&str, Value)]) -> HashMap { + pairs.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect() + } + + /// Who may act on whose membership. + /// + /// The hierarchy is what keeps `manageUsers` from being a way up: it lets a Custom member run the + /// member dialog, but only over the half of the organization below Admin. + #[test] + fn member_type_authority_matrix() { + // (caller, may manage [Owner, Admin, Custom, User]) + let cases = [ + ("Owner", Owner, [true, true, true, true]), + // An Admin manages everything below itself, never another Owner. + ("Admin", Admin, [false, true, true, true]), + // A Custom member holding manageUsers stays inside its own half of the hierarchy. + ("Custom", Custom, [false, false, true, true]), + ("User", User, [false, false, false, false]), + ]; + + for (name, caller, expected) in cases { + for (target, allowed) in [Owner, Admin, Custom, User].into_iter().zip(expected) { + assert_eq!(may_manage_member_type(caller, target), allowed, "{name} acting on role {}", target as i32); + } + } + + // A role change needs authority over the role the member *has* and the one it would *get*, so + // neither end can be used to step outside the caller's half of the hierarchy. + assert!(may_change_member_type(Admin, Custom as i32, User)); + assert!(!may_change_member_type(Admin, Custom as i32, Owner), "an Admin must not promote anyone to Owner"); + assert!(!may_change_member_type(Custom, Admin as i32, User), "a Custom member must not demote an Admin"); + assert!(!may_change_member_type(Custom, User as i32, Admin), "a Custom member must not promote to Admin"); + + // A stored role this build cannot interpret holds no authority, but somebody still has to be + // able to get rid of the row. Only an Owner may, and only with the two actions that reduce what + // the row can become: editing or restoring it would preserve a role the server cannot reason + // about. + for caller in [Owner, Admin, Custom, User] { + let owner_only = caller == Owner; + for (action, allowed) in [ + ("deleting", may_delete_stored_member_type(caller, UNKNOWN_ATYPE)), + ("revoking", may_revoke_stored_member_type(caller, UNKNOWN_ATYPE)), + ] { + assert_eq!(allowed, owner_only, "{action} an unknown stored role as role {}", caller as i32); + } + assert!( + !may_change_member_type(caller, UNKNOWN_ATYPE, User), + "an unknown stored role must never be edited into a known one" + ); + } + + // For a role this build does know, delete and revoke follow the same hierarchy. + assert!(may_delete_stored_member_type(Admin, Custom as i32)); + assert!(!may_delete_stored_member_type(Admin, Owner as i32)); + assert!(!may_revoke_stored_member_type(Custom, Admin as i32)); + } + + /// A Custom member running the member dialog may hand on only what they hold themselves. + /// + /// Without this, `manageUsers` would be a one-step path to every other permission: grant yourself + /// nothing, create a Custom member with `managePolicies`, and act through them. + #[test] + fn custom_permissions_cannot_be_escalated() { + // Each permission is its own gate: holding one lets a caller pass on that one and no other. + for (granted, _, hold) in PERMISSIONS { + let caller = member(Custom, hold); + for (asked_for, set, _) in PERMISSIONS { + let allowed = requested(set).is_subset_of(&caller); + assert_eq!(allowed, asked_for == granted, "a caller holding {granted} granting {asked_for}"); + } + assert!(Perms::default().is_subset_of(&caller), "{granted}: an empty request is always within the set"); + } + + // The same permissions on a stale, non-Custom membership are inert, so their holder can pass on + // nothing at all. + let stale = member(User, |m| { + for (_, _, hold) in PERMISSIONS { + hold(m); + } + }); + for (asked_for, set, _) in PERMISSIONS { + assert!(!requested(set).is_subset_of(&stale), "a stale {asked_for} flag must not be delegatable"); + } + + // The endpoint guard, which contains only what a *Custom* delegator may pass on. + let delegator = member(Custom, |m| { + m.manage_users = true; + m.edit_any_collection = true; + }); + let admin = member(Admin, |_| {}); + let policies = || Some(requested(|p| p.manage_policies = true)); + + // (case, caller, target role, requested permissions, allowed) + let grants = [ + ( + "a permission the delegator holds", + &delegator, + Custom, + Some(requested(|p| p.edit_any_collection = true)), + true, + ), + ("a permission the delegator lacks", &delegator, Custom, policies(), false), + // An Admin or Owner holds every permission by role and is not constrained by this guard. + ("an Admin granting anything", &admin, Custom, policies(), true), + // A target that is not Custom cannot carry permissions, so there is nothing to contain. + ("a non-Custom target", &delegator, User, policies(), true), + // No permissions object means no grant to check. Whether the caller may reach the endpoint + // at all is `ManageUsersHeaders`, not this guard. + ("no permissions object", &delegator, Custom, None, true), + ]; + for (case, caller, target, permissions, allowed) in grants { + assert_eq!(may_grant_custom_permissions(caller, target, permissions), allowed, "{case}"); + } + } + + /// How a permissions object is read off the wire. + /// + /// The strictness matters because this is a *replace*: whatever comes out of the parser becomes the + /// member's complete set. Reading a malformed value as "not true" once turned a bad request into a + /// silent permission removal that still answered 200. + #[test] + fn custom_permissions_are_parsed_strictly() { + // A present key must be a JSON boolean; an absent one is simply false. + let booleans = object(&[("editAnyCollection", json!(true)), ("manageUsers", json!(false))]); + assert_eq!( + Perms::from_request(Custom, &booleans).expect("booleans parse"), + requested(|p| p.edit_any_collection = true) + ); + + for bad in [json!(null), json!("true"), json!(1), json!([true]), json!({"value": true})] { + let malformed = object(&[("editAnyCollection", bad.clone())]); + assert!( + Perms::from_request(Custom, &malformed).is_err(), + "{bad} must be rejected rather than read as a permission removal" + ); + // The check runs before the role is considered, so the same request fails the same way + // whatever role it names. + assert!(Perms::from_request(User, &malformed).is_err(), "{bad} must be rejected for a non-Custom role too"); + } + + // Bitwarden sends permissions Vaultwarden does not implement; rejecting them would break the + // official clients. And only a Custom member carries permissions at all. + let unknown_keys = object(&[("manageSso", json!(true)), ("manageScim", json!("x")), ("manageReset", json!(1))]); + let known_key = object(&[("managePolicies", json!(true))]); + for (case, member_type, permissions) in + [("unknown keys", Custom, &unknown_keys), ("a non-Custom role", Admin, &known_key)] + { + assert_eq!( + Perms::from_request(member_type, permissions).expect("valid object"), + Perms::default(), + "{case}" + ); + } + + // Editing an existing member. An *omitted* object is not an instruction to clear every grant, + // because older clients send the legacy role value without one; an explicitly empty object is. + let held = member(Custom, |m| m.access_reports = true); + let stale = member(User, |m| m.access_reports = true); + assert_eq!( + Perms::from_edit_request(Custom, None, &held).expect("omitted object"), + requested(|p| p.access_reports = true) + ); + // (case, requested role, permissions object, stored membership) + let cleared = [ + ("an explicitly empty object", Custom, Some(&object(&[])), &held), + ("a role change away from Custom", User, None, &held), + ("a stale set on a non-Custom membership", Custom, None, &stale), + ]; + for (case, member_type, permissions, membership) in cleared { + assert_eq!( + Perms::from_edit_request(member_type, permissions, membership).expect("valid request"), + Perms::default(), + "{case}" + ); + } + + // Applying has to reach all nine columns; a forgotten one would drop a granted permission. + let mut everything = Perms::default(); + for (_, set, _) in PERMISSIONS { + set(&mut everything); + } + let mut target = member(Custom, |_| {}); + everything.apply_to(&mut target); + assert_eq!(Perms::from_membership(&target), everything, "apply_to must write every permission it parsed"); + } +} 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 0eb6563e..084aff20 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -215,8 +215,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 07373389..93a0619f 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,64 @@ 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 + } +} + +/// Upstream's `BasePermissionRequirement`: a confirmed Owner or Admin, or a Custom member holding the +/// permission itself. An unparsable stored role satisfies neither comparison and so fails closed. +/// +/// The single definition of that rule. Everything which asks "may this member do X" goes through one +/// of the `may_*` predicates below, so the rule cannot drift between call sites. +pub(crate) fn has_org_permission(membership: &Membership, permission: impl FnOnce(&Membership) -> bool) -> bool { + membership.has_status(MembershipStatus::Confirmed) + && (membership.atype >= MembershipType::Admin || permission(membership)) +} + +/// Upstream's `ManageUsersRequirement`. +fn may_manage_users(membership: &Membership) -> bool { + has_org_permission(membership, Membership::has_manage_users) +} + +/// Upstream's `ManageGroupsRequirement`, which guards `GET /organizations//groups//details` +/// as well as creating, updating and deleting groups. +fn may_manage_groups(membership: &Membership) -> bool { + has_org_permission(membership, Membership::has_manage_groups) +} + +/// Upstream's `ManageUsersOrGroupsRequirement`, which guards the group *details list* only. +fn may_manage_users_or_groups(membership: &Membership) -> bool { + has_org_permission(membership, |m| m.has_manage_users() || m.has_manage_groups()) +} + +/// Upstream's `ManagePoliciesRequirement`. Note that holding it does not make a member exempt from any +/// policy; only Owners and Admins are excluded from policy enforcement. +fn may_manage_policies(membership: &Membership) -> bool { + has_org_permission(membership, Membership::has_manage_policies) +} + +/// Upstream's `AccessEventLogsRequirement`. Also used by the cipher event endpoints in `api::core::events`. +pub(crate) fn may_access_event_logs(membership: &Membership) -> bool { + has_org_permission(membership, Membership::has_access_event_logs) +} + +/// Upstream's `AccessImportExportRequirement`. Also used by the organization import endpoint. +/// +/// NOTE: there is deliberately no `may_access_reports` guard. 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". +pub(crate) fn may_access_import_export(membership: &Membership) -> bool { + has_org_permission(membership, Membership::has_access_import_export) } // org_id is usually the second path param ("/organizations/"), @@ -814,6 +867,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, @@ -849,6 +905,96 @@ 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 $check(&headers.membership) { + 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, + may_manage_users, + "You need the 'Manage Users' permission, or to be an Admin or Owner, to call this endpoint" +); +generate_manage_headers!( + ManageGroupsHeaders, + may_manage_groups, + "You need the 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint" +); +generate_manage_headers!( + ManagePoliciesHeaders, + may_manage_policies, + "You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint" +); +// Upstream's `ManageUsersOrGroupsRequirement`, which guards only the group *details list* +// (`GET /organizations//groups/details`). The single-group view is narrower +// (`ManageGroupsRequirement`) and therefore keeps `ManageGroupsHeaders`. +generate_manage_headers!( + ManageUsersOrGroupsHeaders, + may_manage_users_or_groups, + "You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint" +); +generate_manage_headers!( + AccessEventLogsHeaders, + may_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, + may_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 on `may_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. @@ -868,62 +1014,273 @@ 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. -pub struct ManagerHeaders { - pub host: String, - pub device: Device, - pub user: User, - pub ip: ClientIp, - pub org_id: OrganizationId, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CollectionManageAccess { + Any, + ExplicitManage, + Denied, } -#[rocket::async_trait] -impl<'r> FromRequest<'r> for ManagerHeaders { - type Error = &'static str; +fn collection_access_by_role(membership: &Membership, custom_has_any_access: bool) -> CollectionManageAccess { + if !membership.has_status(MembershipStatus::Confirmed) { + return CollectionManageAccess::Denied; + } - async fn from_request(request: &'r Request<'_>) -> Outcome { - 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") - }; + match MembershipType::from_i32(membership.atype) { + Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any, + Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any, + // A 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 | MembershipType::User) => CollectionManageAccess::ExplicitManage, + None => CollectionManageAccess::Denied, + } +} - 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") - } - } else { - err_handler!("Error getting the collection id") - } +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(), + ) +} + +/// Upstream's `BulkCollectionOperations.ReadWithAccess`, which guards the *single* collection +/// `/details` endpoint: Owner/Admin, `Edit any collection`, `Delete any collection` and `Manage users` +/// reach every collection, everyone else needs a real per-collection Manage grant. +/// +/// Deliberately not the same question as [`collection_read_access`], which models upstream's +/// `ReadAccess` (`GET /collections//users`) and does *not* accept `Manage users`. The two +/// upstream operations differ, so these two predicates differ as well — widening +/// `CollectionReadHeaders` instead would have silently changed the `/users` endpoint too. +/// +/// `Manage groups` is absent on purpose: upstream grants it `ReadAllWithAccess` (the collection +/// *list*, see `may_read_all_collections_with_access`) but not `ReadWithAccess`. +fn collection_read_with_access(membership: &Membership) -> CollectionManageAccess { + collection_access_by_role( + membership, + membership.has_edit_any_collection() || membership.has_delete_any_collection() || membership.has_manage_users(), + ) +} + +/// Upstream authorizes `POST /collections/bulk-access` against **both** +/// `BulkCollectionOperations.ModifyUserAccess` and `BulkCollectionOperations.ModifyGroupAccess`, and +/// its authorization service only succeeds when every requirement passes. With Vaultwarden's +/// effective `allowAdminAccessToAllCollectionItems = true`, upstream resolves them as +/// +/// * `ModifyUserAccess` = `Manage users` OR the regular collection-update authorization +/// * `ModifyGroupAccess` = `Manage groups` OR the regular collection-update authorization +/// +/// Requiring both therefore reduces to: a caller who may update the collection anyway (Owner/Admin, +/// `Edit any collection`, or a per-collection Manage grant), or one holding *both* org-wide +/// permissions. Only `Manage users` or only `Manage groups` is not enough, because the other +/// requirement then still falls back to the update check — which is the point of an endpoint that +/// rewrites a collection's user *and* group assignments in the same request. +fn collection_modify_access(membership: &Membership) -> CollectionManageAccess { + if membership.has_status(MembershipStatus::Confirmed) + && membership.has_manage_users() + && membership.has_manage_groups() + { + return CollectionManageAccess::Any; + } - Outcome::Success(Self { - host: headers.host, - device: headers.device, - user: headers.user, - ip: headers.ip, - org_id: headers.membership.org_uuid, - }) - } else { - err_handler!("You need to be a Manager, Admin or Owner to call this endpoint") - } + collection_edit_access(membership) +} + +/// 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 a per-collection Manage ACL double as a collection-deletion permission. +/// A Manage grant keeps its full meaning for editing (`collection_edit_access`). +fn collection_delete_access(membership: &Membership) -> CollectionManageAccess { + // Blanket authority or nothing -- `ExplicitManage` is never returned here. `can_delete_any_collection` + // is the same rule the rest of the tree asks, and fails closed on an uninterpretable stored role. + if membership.can_delete_any_collection() { + CollectionManageAccess::Any + } else { + CollectionManageAccess::Denied } } -impl From for Headers { - fn from(h: ManagerHeaders) -> Headers { - Headers { - host: h.host, - device: h.device, - user: h.user, - ip: h.ip, +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, } } -/// The ManagerHeadersLoose is used when you at least need to be a Manager, -/// but there is no collection_id sent with the request (either in the path or as form data). +/// 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 cannot use `ManagerHeaders`, so they run this per collection instead. +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 +} + +/// Whether `membership` may read `collection_uuid` *together with* its user/group assignments — +/// upstream's `ReadWithAccess`, which guards `GET /organizations//collections//details`. +/// See [`collection_read_with_access`] for why this is not [`can_read_collection_access`]. +pub(crate) async fn can_read_collection_with_access( + membership: &Membership, + collection_uuid: &CollectionId, + conn: &DbConn, +) -> bool { + can_manage_collection(collection_read_with_access(membership), membership, collection_uuid, conn).await +} + +/// Whether `membership` may rewrite both the user *and* the group assignments of `collection_uuid`, +/// as `POST /organizations//collections/bulk-access` does. See [`collection_modify_access`]. +pub(crate) async fn can_modify_collection_access( + membership: &Membership, + collection_uuid: &CollectionId, + conn: &DbConn, +) -> bool { + can_manage_collection(collection_modify_access(membership), membership, collection_uuid, conn).await +} + +// Collection-scoped request guards. All three resolve the same way -- a confirmed membership, a +// collection id on the route, and one of the `collection_*_access` predicates -- and differ only in +// which predicate they ask and what they say when it refuses. +// +// `Denied` is answered without taking a database connection. That is not a behaviour change over the +// hand-written guards this replaced: `collection_access_by_role` only answers `Denied` for an +// unconfirmed membership or an `atype` this build cannot interpret, and neither reaches this point -- +// the status is checked above, and `OrgHeaders` refuses an unknown role outright ("Unknown user type in +// the database"). So for `ManagerHeaders` and `CollectionReadHeaders` the arm is unreachable, and +// `collection_delete_access` never answers `ExplicitManage`, so the delete guard consulted no +// assignment before either. Answering it here keeps all three on one path and cannot grant anything: a +// stored assignment is only ever consulted for `ExplicitManage`. +macro_rules! generate_collection_headers { + ( + $(#[$doc:meta])* + $name:ident, $confirmed:ident, $access:ident, $confirm_err:literal, $denied_err:literal + ) => { + $(#[$doc])* + pub struct $name { + pub host: String, + pub device: Device, + pub user: User, + 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.$confirmed() { + err_handler!($confirm_err) + } + + let Some(col_id) = get_col_id(request) else { + err_handler!("Error getting the collection id") + }; + + match $access(&headers.membership) { + CollectionManageAccess::Any => {} + CollectionManageAccess::Denied => err_handler!($denied_err), + access @ CollectionManageAccess::ExplicitManage => { + 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!($denied_err) + } + } + } + + Outcome::Success(Self { + host: headers.host, + device: headers.device, + user: headers.user, + ip: headers.ip, + org_id: headers.membership.org_uuid, + }) + } + } + + impl From<$name> for Headers { + fn from(h: $name) -> Headers { + Headers { + host: h.host, + device: h.device, + user: h.user, + ip: h.ip, + } + } + } + }; +} + +generate_collection_headers!( + /// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can + /// update every collection; otherwise the caller must hold a per-collection Manage permission. + /// Read and delete use separate guards so Edit cannot accidentally imply Delete. + ManagerHeaders, + is_confirmed, + collection_edit_access, + "You need to be a Manager, Admin or Owner to call this endpoint", + "The current user isn't a manager for this collection" +); + +generate_collection_headers!( + /// Read access to a collection's access mappings -- upstream's `BulkCollectionOperations.ReadAccess`. + /// Delete any collection needs this visibility to render the standard collection view, but it does not + /// grant edit or cipher access, and -- unlike `ReadWithAccess`, see [`collection_read_with_access`] -- + /// `Manage users` alone does not open it. + CollectionReadHeaders, + is_confirmed, + collection_read_access, + "You need collection read permission to call this endpoint", + "The current user isn't a manager for this collection" +); + +generate_collection_headers!( + /// 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. That predicate never answers `ExplicitManage`, so a stored + /// assignment is never consulted here. + CollectionDeleteHeaders, + is_confirmed_and_manager, + collection_delete_access, + "You need collection delete permission to call this endpoint", + "You need the 'Delete any collection' permission to call this endpoint" +); + +/// The ManagerHeadersLoose is used for organization endpoints whose exact permission depends on +/// request data or whose response is filtered by the caller's collection-management authority. pub struct ManagerHeadersLoose { pub host: String, pub device: Device, @@ -938,7 +1295,7 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose { async fn from_request(request: &'r Request<'_>) -> Outcome { let headers = try_outcome!(OrgHeaders::from_request(request).await); - if headers.is_confirmed_and_manager() { + if headers.membership.has_status(MembershipStatus::Confirmed) { Outcome::Success(Self { host: headers.host, device: headers.device, @@ -947,7 +1304,7 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose { ip: headers.ip, }) } else { - err_handler!("You need to be a Manager, Admin or Owner to call this endpoint") + err_handler!("You need to be a confirmed organization member to call this endpoint") } } } @@ -963,22 +1320,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, @@ -1345,3 +1708,145 @@ pub async fn refresh_tokens( Ok((device, auth_tokens)) } + +#[cfg(test)] +mod tests { + use super::{ + CollectionManageAccess, collection_delete_access, collection_edit_access, collection_modify_access, + collection_read_access, collection_read_with_access, may_manage_groups, may_manage_policies, may_manage_users, + may_manage_users_or_groups, + }; + use crate::db::models::{Membership, MembershipStatus as Status, MembershipType}; + + const OWNER: i32 = MembershipType::Owner as i32; + const ADMIN: i32 = MembershipType::Admin as i32; + const USER: i32 = MembershipType::User as i32; + const CUSTOM: i32 = MembershipType::Custom as i32; + const UNKNOWN: i32 = Membership::UNKNOWN_ATYPE; + + fn confirmed(atype: i32, set: impl FnOnce(&mut Membership)) -> Membership { + Membership::for_test(atype, Status::Confirmed, set) + } + + fn nothing(_: &mut Membership) {} + + /// Every permission this file's guards read, so a row can show that none of them help. + fn all_permissions(m: &mut Membership) { + m.edit_any_collection = true; + m.delete_any_collection = true; + m.manage_users = true; + m.manage_groups = true; + m.manage_policies = true; + } + + /// Who may edit, read, read-with-access, rewrite the access of, and delete a collection. + /// + /// These five predicates model five *different* upstream operations and are deliberately not the + /// same rule; the differences between the columns are the point of this table. A change that makes + /// any two of them agree where they must not is what this test exists to catch. + /// + /// `Any` reaches every collection of the organization, `ExplicitManage` only those carrying a real + /// `users_collections.manage` / `collections_groups.manage` grant, `Denied` none at all. + #[test] + fn collection_operation_access_matrix() { + use CollectionManageAccess::{Any, Denied, ExplicitManage as Explicit}; + + let owner = confirmed(OWNER, nothing); + let admin = confirmed(ADMIN, nothing); + let edit_any = confirmed(CUSTOM, |m| m.edit_any_collection = true); + let delete_any = confirmed(CUSTOM, |m| m.delete_any_collection = true); + let manage_users = confirmed(CUSTOM, |m| m.manage_users = true); + let manage_groups = confirmed(CUSTOM, |m| m.manage_groups = true); + let manage_both = confirmed(CUSTOM, |m| { + m.manage_users = true; + m.manage_groups = true; + }); + let bare_custom = confirmed(CUSTOM, nothing); + let user = confirmed(USER, nothing); + let stale_user = confirmed(USER, all_permissions); + let revoked = Membership::for_test(CUSTOM, Status::Revoked, all_permissions); + let unknown = Membership::for_test(UNKNOWN, Status::Confirmed, all_permissions); + + // (case, membership, edit, read, read-with-access, modify access, delete) + let cases = [ + ("Owner", &owner, Any, Any, Any, Any, Any), + ("Admin", &admin, Any, Any, Any, Any, Any), + // Edit-any reaches every collection for editing and may read the access lists, but + // deletion never follows from it: Vaultwarden always serializes + // `limitCollectionDeletion = true`. + ("Custom + editAnyCollection", &edit_any, Any, Any, Any, Any, Denied), + // Delete-any is the mirror image: it deletes and reads, but does not edit. + ("Custom + deleteAnyCollection", &delete_any, Explicit, Any, Any, Explicit, Any), + // Manage-users reaches the single collection *details* view (upstream's `ReadWithAccess`) + // but not the `/users` access list (`ReadAccess`), which is a narrower operation. + ("Custom + manageUsers", &manage_users, Explicit, Explicit, Any, Explicit, Denied), + // Manage-groups reaches neither: upstream grants it the collection *list*, not the single + // collection with its access. + ("Custom + manageGroups", &manage_groups, Explicit, Explicit, Explicit, Explicit, Denied), + // `bulk-access` rewrites user *and* group assignments in one request, so upstream requires + // both permissions. Either alone still falls back to the regular update authorization. + ("Custom + manageUsers + manageGroups", &manage_both, Explicit, Explicit, Any, Any, Denied), + // Without an org-wide permission a Custom member is exactly a User: only real + // per-collection Manage grants count, and deleting is out of reach entirely. + ("Custom without permissions", &bare_custom, Explicit, Explicit, Explicit, Explicit, Denied), + ("User", &user, Explicit, Explicit, Explicit, Explicit, Denied), + // Flags a role change left behind, an unconfirmed membership and a role this build cannot + // interpret all fail closed. + ("User with stale permission flags", &stale_user, Explicit, Explicit, Explicit, Explicit, Denied), + ("revoked Custom holding everything", &revoked, Denied, Denied, Denied, Denied, Denied), + ("unknown role holding everything", &unknown, Denied, Denied, Denied, Denied, Denied), + ]; + + for (case, m, edit, read, read_with_access, modify, delete) in cases { + assert_eq!(collection_edit_access(m), edit, "{case}: edit"); + assert_eq!(collection_read_access(m), read, "{case}: read access lists"); + assert_eq!(collection_read_with_access(m), read_with_access, "{case}: read with access"); + assert_eq!(collection_modify_access(m), modify, "{case}: modify user and group access"); + assert_eq!(collection_delete_access(m), delete, "{case}: delete"); + } + } + + /// The organization-wide permission guards behind `ManageUsersHeaders` and friends: a confirmed + /// Owner/Admin, or a Custom member holding *that* permission. + /// + /// Catches a guard wired to the wrong flag, a lost status gate, and an unknown stored role + /// slipping through any of them. + #[test] + fn org_permission_guards_require_a_confirmed_role_or_the_matching_flag() { + let owner = confirmed(OWNER, nothing); + let admin = confirmed(ADMIN, nothing); + let invited_owner = Membership::for_test(OWNER, Status::Invited, nothing); + let users = confirmed(CUSTOM, |m| m.manage_users = true); + let groups = confirmed(CUSTOM, |m| m.manage_groups = true); + let policies = confirmed(CUSTOM, |m| m.manage_policies = true); + let bare_custom = confirmed(CUSTOM, nothing); + let user = confirmed(USER, nothing); + let stale_user = confirmed(USER, all_permissions); + let revoked = Membership::for_test(CUSTOM, Status::Revoked, all_permissions); + let unknown = Membership::for_test(UNKNOWN, Status::Confirmed, all_permissions); + + // (case, membership, manage users, manage groups, either, manage policies) + let cases = [ + ("Owner", &owner, true, true, true, true), + ("Admin", &admin, true, true, true, true), + // Admins and Owners hold every permission by role, but only once confirmed. + ("invited Owner", &invited_owner, false, false, false, false), + ("Custom + manageUsers", &users, true, false, true, false), + ("Custom + manageGroups", &groups, false, true, true, false), + ("Custom + managePolicies", &policies, false, false, false, true), + ("Custom without permissions", &bare_custom, false, false, false, false), + ("User", &user, false, false, false, false), + // Stale flags, an unconfirmed membership and an unknown role all fail closed. + ("User with stale permission flags", &stale_user, false, false, false, false), + ("revoked Custom holding everything", &revoked, false, false, false, false), + ("unknown role holding everything", &unknown, false, false, false, false), + ]; + + for (case, m, users, groups, users_or_groups, policies) in cases { + assert_eq!(may_manage_users(m), users, "{case}: manage users"); + assert_eq!(may_manage_groups(m), groups, "{case}: manage groups"); + assert_eq!(may_manage_users_or_groups(m), users_or_groups, "{case}: manage users or groups"); + assert_eq!(may_manage_policies(m), policies, "{case}: manage policies"); + } + } +} diff --git a/src/config.rs b/src/config.rs index 9f0ae2e1..2c7b4d02 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..4a0611c7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -468,20 +468,810 @@ impl<'r> FromRequest<'r> for DbConn { } } +/// The single migration this feature adds. +/// +/// 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 produces; the preflight evaluates the same predicates first and offers the way out. +const CUSTOM_ROLE_PERMISSIONS_MIGRATION: &str = "20260922120000"; +// Upgrade compatibility covers official Vaultwarden database states. Intermediate, unreleased +// revisions of the Custom-role PR are development artifacts and must be reset or restored from a +// pre-PR backup instead of growing another migration-reconciliation state machine here. + +/// 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. It also gives the +/// startup path a plain sentence: `Error`'s `Display` renders the JSON body, 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 -- dynamic read/write reach over every collection 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 decide once for the instance. +#[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() + } +} + +/// 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. +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-09-22-120000_add_custom_role_permissions/up.sql`. Idempotent for the same +/// reason it is safe there -- it matches only `atype = 3` -- which is what lets one recovery path cover +/// *both* interruption points. 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, \ + delete_any_collection = access_all, \ + 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; 2] = [CUSTOM_ROLE_MANAGER_CONVERSION_SQL, DROP_ACCESS_ALL_SQL]; + +/// Relax the direct assignments of an affected membership before the bit goes away. +/// +/// `access_all` *overrode* `read_only` and `hide_passwords`, so inserting only the missing rows would +/// 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 its own 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) \ +SELECT uo.user_uuid, c.uuid, 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)\n", + "SELECT uo.user_uuid, c.uuid, 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." +); + +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", + "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, +} + +/// The stable part of the migration's final schema fingerprint. Additional columns may be added by +/// later migrations, but the removed legacy column must stay gone and all permission columns must be +/// present and non-nullable. +fn custom_role_schema_matches_applied_migration(facts: CustomRoleMigrationFacts) -> bool { + let counted = |count: i64, expected: usize| usize::try_from(count).is_ok_and(|found| found == expected); + + facts.memberships_table_exists + && !facts.access_all_column_exists + && counted(facts.permission_columns_present, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) + && counted(facts.permission_columns_not_null, CUSTOM_ROLE_PERMISSION_COLUMNS.len()) +} + +/// Whether the facts prove that the Custom-role migration ran to completion and only its ledger entry +/// is missing. This repair requires the exact table produced by this migration, no legacy Manager rows, +/// and a ledger into which the missing entry can safely be inserted. +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); + + custom_role_schema_matches_applied_migration(facts) + && !facts.migration_applied + && facts.migration_ledger_exists + && !facts.newer_migration_recorded + && 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, + /// The migration ledger says the migration ran, but the expected final schema is not present. + RefuseMigrationHistorySchemaMismatch, + /// 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. Only +/// `legacy_manager_rows` is unconstrained -- it differs between the two interruption points 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 recorded migration is trusted only when the table has the required final column fingerprint. + // Diesel will never run it again, so a mismatch must stop here instead of failing later at runtime. + if facts.migration_applied { + return if custom_role_schema_matches_applied_migration(facts) { + CustomRolePreflightDecision::Proceed + } else { + CustomRolePreflightDecision::RefuseMigrationHistorySchemaMismatch + }; + } + + // A fresh installation: Diesel creates the schema from scratch and there is nothing to convert. + if !facts.memberships_table_exists { + return CustomRolePreflightDecision::Proceed; + } + + // The migration is pending, so the legacy column has to be there -- both questions below read 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 final `DROP COLUMN access_all` + // and Diesel's ledger insert leaves 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; + } + + 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`, which is the only formatting +/// that preserves the newlines the SQL below depends on. +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::RefuseMigrationHistorySchemaMismatch => format!( + "Migration {CUSTOM_ROLE_PERMISSIONS_MIGRATION} is recorded as applied, but the database \ + does not have the expected final users_organizations schema: access_all present={}, \ + permission columns={}/{} ({} NOT NULL), table columns={}, expected columns present={}.", + facts.access_all_column_exists, + facts.permission_columns_present, + CUSTOM_ROLE_PERMISSION_COLUMNS.len(), + facts.permission_columns_not_null, + facts.membership_column_count, + facts.expected_membership_columns_present + ), + 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, + CustomRolePreflightDecision::RefuseMigrationHistorySchemaMismatch => concat!( + "\n\nMigration history and database schema disagree; restore a backup or repair the schema ", + "before starting Vaultwarden. No automatic repair was attempted." + ), + _ => "", + }; + + 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` 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::RefuseMigrationHistorySchemaMismatch => { + "migration history and the database schema disagree" + } + 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() +} + +/// 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 +/// Generates the schema preflight for one database backend. +/// +/// The three backends ask the same eleven questions in the same order and act on the answer with the +/// same `CustomRolePreflightDecision` match; only *how* a question is asked differs, because each +/// backend exposes its catalog differently. Those statements are the macro's parameters, so the shared +/// part exists once and a backend contributes its dialect and nothing else. +/// +/// Each expanding module also provides `fn resume_interrupted(&mut Connection, CustomRoleMigrationFacts) +/// -> Result<(), Error>`. `ResumeInterruptedMigration` can only be decided when the backend declares +/// `INTERRUPTIBLE_SCHEMA_CHANGES = true` (see `custom_role_preflight_decision`), so for the other two it +/// is unreachable -- they still state their refusal explicitly rather than rely on that from a distance. +macro_rules! generate_custom_role_preflight { + ( + connection: $conn:ty, + table_exists: $table_exists:literal, + column_exists: $column_exists:literal, + columns_present: $columns_present:literal, + columns_not_null: $columns_not_null:literal, + column_count: $column_count:literal, + record_migration: $record_migration:literal, + ) => { + #[derive(diesel::QueryableByName)] + struct Count { + #[diesel(sql_type = diesel::sql_types::BigInt)] + count: i64, + } + + fn count(connection: &mut $conn, query: impl Into) -> Result { + diesel::sql_query(query).get_result::(connection).map(|row| row.count) + } + + fn table_exists(connection: &mut $conn, table: &str) -> Result { + count(connection, format!($table_exists, table = table)).map(|value| value != 0) + } + + /// Idempotent ledger insert, so a repeated or racing startup is a no-op rather than a + /// duplicate-key failure. + fn record_migration(connection: &mut $conn) -> Result<(), diesel::result::Error> { + diesel::sql_query(format!($record_migration, version = super::CUSTOM_ROLE_PERMISSIONS_MIGRATION)) + .execute(connection) + .map(|_| ()) + } + + /// Read-only, with exactly one exception: the idempotent ledger insert that records a migration + /// which provably already ran (see `custom_role_migration_is_complete`). + fn preflight(connection: &mut $conn) -> Result<(), super::Error> { + let memberships_table_exists = table_exists(connection, "users_organizations")?; + 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; + if !memberships_table_exists { + let facts = super::CustomRoleMigrationFacts { + memberships_table_exists, + migration_applied, + migration_ledger_exists, + ..Default::default() + }; + return match super::custom_role_preflight_decision( + facts, + super::LegacyUserAccessAllPolicy::configured(), + INTERRUPTIBLE_SCHEMA_CHANGES, + ) { + super::CustomRolePreflightDecision::Proceed => Ok(()), + decision => Err(super::custom_role_preflight_error(decision, facts)), + }; + } + + 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, format!($column_exists, column = "access_all"))? != 0; + + let permission_columns_present = count( + connection, + format!($columns_present, columns = super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS)), + )?; + let permission_columns_not_null = count( + connection, + format!($columns_not_null, columns = super::sql_name_list(&super::CUSTOM_ROLE_PERMISSION_COLUMNS)), + )?; + let membership_column_count = count(connection, $column_count)?; + let expected_membership_columns_present = count( + connection, + format!($columns_present, columns = 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 => { + record_migration(connection)?; + super::log_recorded_completed_migration(); + Ok(()) + } + super::CustomRolePreflightDecision::ResumeInterruptedMigration => resume_interrupted(connection, facts), + super::CustomRolePreflightDecision::DropLegacyUserAccessAll + | super::CustomRolePreflightDecision::MaterializeLegacyUserAccessAll => { + // Resolving the flag mutates authorization data, so all refusal conditions are + // evaluated before entering the resolution transaction. A valid interruption (only + // possible on a backend with interruptible schema changes) is resumed 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 resolved = connection.transaction::(|connection| { + let mut resolved = 0; + for statement in super::legacy_user_access_all_statements(decision) { + resolved = diesel::sql_query(*statement).execute(connection)?; + } + Ok(resolved) + })?; + super::log_resolved_legacy_user_access_all(decision, resolved); + match followup { + super::CustomRolePreflightDecision::ResumeInterruptedMigration => { + resume_interrupted(connection, facts) + } + _ => Ok(()), + } + } + decision => Err(super::custom_role_preflight_error(decision, facts)), + } + } + }; +} + #[cfg(sqlite)] mod sqlite_migrations { use diesel::{Connection, RunQueryDsl}; 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; + + // `pragma_table_xinfo` rather than `table_info`: the latter omits generated columns, so one would + // pass the exact-column-count fingerprint unseen. + generate_custom_role_preflight! { + connection: diesel::sqlite::SqliteConnection, + table_exists: "SELECT COUNT(*) AS count FROM sqlite_master \ + WHERE type = 'table' AND name = '{table}'", + column_exists: "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name = '{column}'", + columns_present: "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({columns})", + columns_not_null: "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name IN ({columns}) AND \"notnull\" = 1", + column_count: "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations')", + record_migration: "INSERT OR IGNORE INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{version}', CURRENT_TIMESTAMP)", + } + + /// 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. + fn resume_interrupted( + _connection: &mut diesel::sqlite::SqliteConnection, + facts: super::CustomRoleMigrationFacts, + ) -> Result<(), super::Error> { + Err(super::custom_role_preflight_error( + super::CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + 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 +1295,66 @@ 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; + + // This is the backend that produces both repairable 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. + generate_custom_role_preflight! { + connection: diesel::mysql::MysqlConnection, + table_exists: "SELECT COUNT(*) AS count FROM information_schema.tables \ + WHERE table_schema = DATABASE() AND table_name = '{table}'", + column_exists: "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name = '{column}'", + columns_present: "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({columns})", + columns_not_null: "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() \ + AND table_name = 'users_organizations' \ + AND column_name IN ({columns}) \ + AND is_nullable = 'NO'", + column_count: "SELECT COUNT(*) AS count FROM information_schema.columns \ + WHERE table_schema = DATABASE() AND table_name = 'users_organizations'", + record_migration: "INSERT IGNORE INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{version}', CURRENT_TIMESTAMP)", + } + + /// MySQL and MariaDB commit every `ALTER TABLE` on its own, so a killed process can leave the + /// migration half-applied. `custom_role_migration_is_resumable` has already established that the + /// remaining work is exactly the two statements below. + fn resume_interrupted( + connection: &mut diesel::mysql::MysqlConnection, + _facts: super::CustomRoleMigrationFacts, + ) -> Result<(), super::Error> { + resume_migration(connection) + } + + 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 +1368,581 @@ 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; + + // Columns are resolved through the same `to_regclass` lookup as the table check, so a + // `search_path` split cannot make the schema and the column check describe two different tables. + // PostgreSQL has transactional DDL and never produces the "completed but unrecorded" state itself -- + // the repair is here so a database restored or copied from a MySQL-side incident is handled + // identically on every backend. + generate_custom_role_preflight! { + connection: diesel::pg::PgConnection, + table_exists: "SELECT COUNT(*) AS count FROM pg_class WHERE oid = to_regclass('{table}')", + column_exists: "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 \ + AND NOT attisdropped \ + AND attname = '{column}'", + columns_present: "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped \ + AND attname IN ({columns})", + columns_not_null: "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped AND attnotnull \ + AND attname IN ({columns})", + column_count: "SELECT COUNT(*) AS count FROM pg_attribute \ + WHERE attrelid = to_regclass('users_organizations') \ + AND attnum > 0 AND NOT attisdropped", + record_migration: "INSERT INTO __diesel_schema_migrations (version, run_on) \ + VALUES ('{version}', CURRENT_TIMESTAMP) ON CONFLICT (version) DO NOTHING", + } + + /// Diesel runs each PostgreSQL migration inside a 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. + fn resume_interrupted( + _connection: &mut diesel::pg::PgConnection, + facts: super::CustomRoleMigrationFacts, + ) -> Result<(), super::Error> { + Err(super::custom_role_preflight_error( + super::CustomRolePreflightDecision::RefuseAmbiguousPartialMigration, + 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(()) } } + +/// A throwaway SQLite database for tests that have to run a real query. +#[cfg(all(test, sqlite))] +pub(crate) mod test_db { + use std::{ + future::Future, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::*; + + pub struct TestDb { + path: PathBuf, + // An `Option` so `Drop` can close every connection before deleting the file. + pool: Option>, + } + + impl TestDb { + /// `schema` is the DDL (and any seed data) the test needs; only the tables under test have to + /// be declared. + pub fn new(schema: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "vaultwarden-test-{}-{}.sqlite3", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + // An existing file makes `DbConnType::from_url` take the bare-path SQLite branch. + drop(std::fs::remove_file(&path)); + std::fs::File::create(&path).expect("Error creating test database file"); + + let pool = Pool::builder() + .max_size(4) + .build(DbConnManager::new(path.to_str().expect("Test database path is not UTF-8"))) + .expect("Error creating test database pool"); + pool.get().expect("Error opening test database").batch_execute(schema).expect("Error applying test schema"); + + Self { + path, + pool: Some(pool), + } + } + + pub fn conn(&self) -> DbConn { + let pool = self.pool.as_ref().expect("Test pool is closed"); + DbConn { + conn: Arc::new(Mutex::new(Some(pool.get().expect("Error getting test connection")))), + permit: None, + } + } + } + + impl Drop for TestDb { + fn drop(&mut self) { + drop(self.pool.take()); + drop(std::fs::remove_file(&self.path)); + } + } + + /// `DbConn::run` uses `block_in_place`, which needs a multi-threaded runtime. + pub fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Error building test runtime") + .block_on(future) + } +} + +/// What the Custom-role migration does to the memberships it finds, run as SQL against SQLite. +/// +/// The conversion lives in the migration file, not in Rust, so nothing but executing it can show what +/// an upgraded database looks like. +#[cfg(all(test, sqlite))] +mod custom_role_migration_sql_tests { + use diesel::{ + Connection, RunQueryDsl, + connection::SimpleConnection, + sql_types::{BigInt, Text}, + sqlite::SqliteConnection, + }; + + const ADD_CUSTOM_ROLE_PERMISSIONS: &str = + include_str!("../../migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql"); + + /// `users_organizations` exactly as 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. + /// + /// The `g_all` group carries the still-supported *group*-level `access_all`, which is a different + /// column from the membership bit this migration replaces and must survive untouched. + const LEGACY_MEMBERSHIPS: &str = " + INSERT INTO groups (uuid, organizations_uuid, access_all) VALUES + ('g_all', 'org1', TRUE), + ('g_plain', 'org1', FALSE); + 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); + INSERT INTO groups_users (groups_uuid, users_organizations_uuid) VALUES + ('g_all', 'm_mgr_group'), + ('g_all', 'm_user_group'), + ('g_plain', 'm_mgr_plain_g'); + "; + + /// The one state the upgrade refuses: a plain User still carrying membership `access_all`. + const LEGACY_USER_ACCESS_ALL: &str = " + INSERT INTO collections (uuid, org_uuid) VALUES ('c1', 'org1'); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, access_all, status, atype) VALUES + ('m_owner', 'u1', 'org1', TRUE, 2, 0), + ('m_uaa', 'u20', 'org1', TRUE, 2, 2); + INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) VALUES + ('u20', 'c1', TRUE, TRUE, FALSE); + "; + + #[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 state a restart would see. + fn migrate(connection: &mut SqliteConnection) -> Result<(), diesel::result::Error> { + connection.transaction(|connection| connection.batch_execute(ADD_CUSTOM_ROLE_PERMISSIONS)) + } + + /// One line per membership: `uuid atype=N `. + 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", + ) + } + + /// The legacy Manager conversion, and everything it deliberately leaves alone. + #[test] + fn legacy_manager_conversion() { + let mut connection = connect(LEGACY_MEMBERSHIPS); + migrate(&mut connection).unwrap(); + + assert_eq!( + state(&mut connection), + [ + // 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 + // collection permissions, and none of the six management/access ones -- nothing they + // unlock was ever a Manager capability. + "m_mgr_all atype=4 111 000000", + // A Manager with nothing becomes a Custom member with nothing. + "m_mgr_bare atype=4 000 000000", + // DELIBERATE: `groups.access_all` is a separate, still-supported feature. It keeps + // granting collection access dynamically and is never materialized into permanent + // membership permissions -- not editAnyCollection, not deleteAnyCollection, not + // createNewCollections, and not any management permission. + "m_mgr_group atype=4 000 000000", + // Status is not part of the rule: an invited or revoked Manager converts like any + // other, since none holds authority in that state. + "m_mgr_invited atype=4 000 000000", + // A group without access_all conveys nothing either. + "m_mgr_plain_g atype=4 000 000000", + "m_mgr_revoked atype=4 000 000000", + // Owner keeps its role and gains no explicit permissions. + "m_owner atype=0 000 000000", + // A plain User is never converted... + "m_user atype=2 000 000000", + // ...not even inside an access_all group. + "m_user_group atype=2 000 000000", + ] + ); + + // The group feature itself is untouched: same flags, same memberships. + assert_eq!( + rows(&mut connection, "SELECT uuid || ' access_all=' || access_all AS value FROM groups ORDER BY uuid"), + ["g_all access_all=1", "g_plain access_all=0"] + ); + assert_eq!( + count(&mut connection, "SELECT COUNT(*) AS count FROM groups_users"), + 3, + "the migration must not touch group membership" + ); + + // The conversion rebuilds the table, so a forgotten column would silently drop data. It also has + // to be exactly the table the preflight recognizes as finished. + assert_eq!( + rows(&mut connection, "SELECT name AS value FROM pragma_table_xinfo('users_organizations')"), + super::EXPECTED_MEMBERSHIP_COLUMNS + ); + // The primary key and the UNIQUE (user_uuid, org_uuid) pair survive the rebuild; losing the + // latter would allow duplicate memberships. + assert_eq!(count(&mut connection, "SELECT COUNT(*) AS count FROM pragma_index_list('users_organizations')"), 2); + } + + /// A plain User carrying membership `access_all` has no representation in the new model: unlimited + /// reach over every collection with no management authority. Converting it either way would change + /// that member's access, so the migration refuses instead of guessing. + #[test] + fn plain_user_access_all_blocks_migration() { + let mut connection = connect(LEGACY_USER_ACCESS_ALL); + + assert!(migrate(&mut connection).is_err(), "a plain User carrying access_all must abort the migration"); + + // Nothing was mutated: the legacy schema is still in place, no permission column exists, and + // the affected membership keeps exactly the access it had. + assert_eq!( + rows( + &mut connection, + "SELECT uuid || ' atype=' || atype || ' access_all=' || access_all AS value \ + FROM users_organizations ORDER BY uuid" + ), + ["m_owner atype=0 access_all=1", "m_uaa atype=2 access_all=1"] + ); + assert_eq!( + count( + &mut connection, + "SELECT COUNT(*) AS count FROM pragma_table_xinfo('users_organizations') \ + WHERE name = 'edit_any_collection'" + ), + 0, + "no permission column may exist after a refused migration" + ); + assert_eq!( + rows( + &mut 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" + ), + ["u20 c1 ro=1 hide=1 manage=0"], + "a refused migration must not relax or add a single assignment" + ); + } +} + +/// The startup preflight that decides whether this database may be handed to Diesel. +#[cfg(test)] +mod custom_role_migration_preflight_tests { + use super::{ + CUSTOM_ROLE_PERMISSION_COLUMNS, CustomRoleMigrationFacts as Facts, CustomRolePreflightDecision as Decision, + EXPECTED_MEMBERSHIP_COLUMNS, LegacyUserAccessAllPolicy as Policy, custom_role_preflight_decision, + }; + + /// MySQL and MariaDB commit each `ALTER TABLE` on its own, so an upgrade can be interrupted + /// half-way there; SQLite and PostgreSQL run the whole migration in one transaction. + const INTERRUPTIBLE: bool = true; + const ATOMIC: bool = false; + + /// The nine permission columns the migration adds. + fn permission_columns() -> i64 { + i64::try_from(CUSTOM_ROLE_PERMISSION_COLUMNS.len()).unwrap() + } + + /// The eighteen columns the finished table has. + fn membership_columns() -> i64 { + i64::try_from(EXPECTED_MEMBERSHIP_COLUMNS.len()).unwrap() + } + + /// A database that has not been upgraded yet and has nothing to decide. + fn pending() -> Facts { + Facts { + 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, + } + } + + /// The migration ran to completion but its ledger entry never committed. + fn completed_but_unrecorded() -> Facts { + Facts { + access_all_column_exists: false, + permission_columns_present: permission_columns(), + permission_columns_not_null: permission_columns(), + membership_column_count: membership_columns(), + expected_membership_columns_present: membership_columns(), + ..pending() + } + } + + fn applied() -> Facts { + Facts { + migration_applied: true, + ..completed_but_unrecorded() + } + } + + /// 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() -> Facts { + Facts { + permission_columns_present: permission_columns(), + permission_columns_not_null: permission_columns(), + // the finished table plus the legacy column that still has to go + membership_column_count: membership_columns() + 1, + expected_membership_columns_present: membership_columns(), + ..pending() + } + } + + /// `interrupted()` with one fact changed, for the states that only look like an interruption. + fn interrupted_but(change: impl FnOnce(&mut Facts)) -> Facts { + let mut facts = interrupted(); + change(&mut facts); + facts + } + + /// Migrate, resume or refuse -- the whole decision, state by state. + /// + /// Every refusal is a state where continuing could change or lose a member's access, so the only + /// safe answer is to stop before the first mutation. + #[test] + fn custom_role_preflight_decision_table() { + // (case, facts, backend commits each schema step on its own, decision) + let cases = [ + // Nothing to do: run the migration. + ("fresh installation", Facts::default(), INTERRUPTIBLE, Decision::Proceed), + ("untouched legacy database", pending(), INTERRUPTIBLE, Decision::Proceed), + ("untouched legacy database, atomic backend", pending(), ATOMIC, Decision::Proceed), + ("recorded and upgraded", applied(), INTERRUPTIBLE, Decision::Proceed), + // Diesel never runs a recorded migration again, so a schema that disagrees with the ledger + // has to stop startup rather than fail at runtime. + ( + "recorded but schema missing", + Facts { + access_all_column_exists: true, + ..applied() + }, + INTERRUPTIBLE, + Decision::RefuseMigrationHistorySchemaMismatch, + ), + // The migration finished and only the ledger insert was lost: record it, do not migrate. + ("finished but unrecorded", completed_but_unrecorded(), INTERRUPTIBLE, Decision::RecordCompletedMigration), + // Interrupted after the columns were added: finish it, but only where an interruption can + // actually produce this state. + ("interrupted upgrade", interrupted(), INTERRUPTIBLE, Decision::ResumeInterruptedMigration), + ("same schema on an atomic backend", interrupted(), ATOMIC, Decision::RefuseAmbiguousPartialMigration), + // Anything that is not exactly the fingerprint an interruption leaves behind: something + // other than this migration changed the table, so nothing may be assumed about it. + ( + "only some permission columns", + interrupted_but(|f| f.permission_columns_present = 4), + INTERRUPTIBLE, + Decision::RefuseAmbiguousPartialMigration, + ), + ( + "a nullable permission column", + interrupted_but(|f| f.permission_columns_not_null -= 1), + INTERRUPTIBLE, + Decision::RefuseAmbiguousPartialMigration, + ), + ( + "an unknown extra column", + interrupted_but(|f| f.membership_column_count += 1), + INTERRUPTIBLE, + Decision::RefuseAmbiguousPartialMigration, + ), + ( + "a newer migration recorded", + interrupted_but(|f| f.newer_migration_recorded = true), + INTERRUPTIBLE, + Decision::RefuseAmbiguousPartialMigration, + ), + // Pending, but the column the conversion reads is gone. + ( + "pending without access_all", + Facts { + access_all_column_exists: false, + ..pending() + }, + INTERRUPTIBLE, + Decision::RefuseMissingAccessAll, + ), + ]; + + for (case, facts, interruptible, expected) in cases { + assert_eq!(custom_role_preflight_decision(facts, Policy::Refuse, interruptible), expected, "{case}"); + } + + // A legacy `User + access_all` membership is answered before anything else may happen to the + // database, on a database that *also* needs a resume. The configured policy decides only that + // question. + let affected = interrupted_but(|f| f.legacy_user_access_all_count = 3); + let resolved = interrupted(); + let broken = interrupted_but(|f| { + f.legacy_user_access_all_count = 3; + f.access_all_column_exists = false; + }); + + for (policy, expected) in [ + (Policy::Refuse, Decision::RefuseLegacyUserAccessAll), + (Policy::Drop, Decision::DropLegacyUserAccessAll), + (Policy::Materialize, Decision::MaterializeLegacyUserAccessAll), + ] { + assert_eq!( + custom_role_preflight_decision(affected, policy, INTERRUPTIBLE), + expected, + "{policy:?}: the legacy rows come first" + ); + // Once they are resolved the resume still happens, rather than the file going back to + // Diesel, which would abort on a duplicate column. + assert_eq!( + custom_role_preflight_decision(resolved, policy, INTERRUPTIBLE), + Decision::ResumeInterruptedMigration, + "{policy:?}: after resolution the interrupted upgrade is still finished" + ); + // And the policy is not a way to talk the preflight past a broken schema. + assert_eq!( + custom_role_preflight_decision(broken, policy, INTERRUPTIBLE), + Decision::RefuseMissingAccessAll, + "{policy:?} must not override a schema refusal" + ); + } + } + + /// The operator-supplied policy decides what happens to a membership nobody else can classify, so + /// an unrecognised value must never be read as the permissive one. + #[test] + fn legacy_user_access_all_policy_parsing() { + assert_eq!(Policy::from_config("refuse"), Some(Policy::Refuse)); + assert_eq!(Policy::from_config("drop"), Some(Policy::Drop)); + assert_eq!(Policy::from_config("materialize"), Some(Policy::Materialize)); + // Case and surrounding whitespace are tolerated, because a .env value carries both. + assert_eq!(Policy::from_config(" Materialize \n"), Some(Policy::Materialize)); + + for rejected in ["", " ", "Materialise", "materialize!", "yes", "true", "0", "drop;refuse"] { + assert_eq!( + Policy::from_config(rejected), + None, + "{rejected:?} must not parse; `validate_config` rejects it at startup" + ); + } + + // Refusing is the default, so a value that somehow got past validation still stops startup + // rather than silently changing a member's access. + assert_eq!(Policy::default(), Policy::Refuse); + } +} diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 721d9790..1df857b5 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -26,6 +26,7 @@ use macros::UuidFromParam; use super::{ Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, MembershipStatus, MembershipType, OrganizationId, User, UserId, + organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection}, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -68,6 +69,89 @@ pub enum RepromptType { Password = 1, } +/// Whether `membership` holds organization-wide authority over its organization's ciphers. +/// +/// Upstream gates every administrative cipher route on `CanEditCipherAsAdminAsync`, +/// `CanDeleteOrRestoreCipherAsAdminAsync` or `CanEditAllCiphersAsync`. All three first require +/// Owner, Admin or `Edit any collection`, and then resolve through `CanEditAllCiphersAsync` -- +/// which is that very same set, because Vaultwarden always serializes +/// `allowAdminAccessToAllCollectionItems = true`. So all three reduce to this one predicate, and +/// the per-cipher fallbacks they contain for restricted admins are unreachable here. +/// +/// Deliberately narrower than upstream's `ViewAllCollections` (which guards +/// `GET /ciphers//admin` and additionally admits `Delete any collection`): honouring that would +/// hand cipher *contents* to a permission that upstream's own `CanAccessAllCiphersAsync` -- and +/// therefore `GET /ciphers/organization-details` here -- deliberately keeps away from them. Where +/// the two upstream answers disagree, this takes the stricter one. +pub fn may_administer_org_ciphers(membership: &Membership) -> bool { + // `has_full_access()` is exactly "confirmed, and Owner/Admin or Custom holding + // `Edit any collection`". + membership.has_full_access() +} + +/// Which authorization a cipher operation runs under. +/// +/// Vaultwarden serves the organization's administrative cipher routes (`/ciphers//admin` and +/// friends) from the same handlers as the regular vault routes, so the handlers state the scope +/// explicitly and every authorization call site shows which one it uses. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CipherAccessScope { + /// The regular vault routes. Only ownership and the caller's per-collection/group assignments + /// count. `Edit any collection` deliberately does not widen `/sync`, `GET /ciphers` or the + /// non-admin `GET|PUT /ciphers/`, so a Custom member holding it still sees exactly the + /// ciphers they are assigned to. + User, + /// The organization's administrative cipher routes, where a member with organization-wide + /// cipher authority reaches every cipher of that organization. + OrganizationAdmin, +} + +impl CipherAccessScope { + /// Whether `membership` reaches every cipher of its organization in this scope. + /// + /// Owner and Admin qualify in both scopes, which is the behaviour Vaultwarden has always had. + /// `Edit any collection` is administrative authority only, so it qualifies in + /// [`Self::OrganizationAdmin`] alone. + fn grants_org_wide_cipher_access(self, membership: &Membership) -> bool { + match self { + Self::User => membership.atype >= MembershipType::Admin, + Self::OrganizationAdmin => may_administer_org_ciphers(membership), + } + } + + /// The scope a request asks for, for the one route that states it: the v2 attachment create. + /// + /// Upstream's `PostAttachment` branches on the request's `adminRequest` flag -- `true` + /// authorizes with `CanEditCipherAsAdminAsync` and answers with a `CipherMiniResponse`, + /// anything else authorizes and answers as the regular vault route. + /// + /// The flag only selects *which* predicate is evaluated, never what it answers: + /// [`Self::OrganizationAdmin`] still requires the caller to hold organization-wide cipher + /// authority, so a member without it gains nothing by setting the flag. + pub fn requested(admin_request: Option) -> Self { + if admin_request == Some(true) { + Self::OrganizationAdmin + } else { + Self::User + } + } + + /// The scope for a route that cannot be told which one to use, resolved from the caller's own + /// membership in the cipher's organization. + /// + /// The second leg of the v2 attachment upload is a bare file POST with no `adminRequest` field, + /// so upstream's `PostFileForExistingAttachment` recomputes the administrative context from the + /// caller instead (`orgAdmin = CanEditCipherAsAdminAsync(...)`). Nothing from the request feeds + /// into this, so that route cannot be talked into an administrative scope. + pub fn for_member(membership: Option<&Membership>) -> Self { + if membership.is_some_and(may_administer_org_ciphers) { + Self::OrganizationAdmin + } else { + Self::User + } + } +} + /// Local methods impl Cipher { pub fn new(atype: i32, name: String) -> Self { @@ -151,6 +235,35 @@ impl Cipher { cipher_sync_data: Option<&CipherSyncData>, sync_type: CipherSyncType, conn: &DbConn, + ) -> Result { + self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await + } + + /// [`Cipher::to_json`] for one of the organization's administrative cipher routes. + /// + /// Same response shape those handlers have always produced; only the access flags differ. They + /// are resolved with [`CipherAccessScope::OrganizationAdmin`], so a member acting with + /// organization-wide cipher authority is reported as able to edit the cipher -- which is + /// exactly what the route authorized. Resolving them as `User` would answer a successfully + /// authorized admin request with `edit: false` and log an ownership assertion failure. + pub async fn to_json_org_admin( + &self, + host: &str, + user_uuid: &UserId, + conn: &DbConn, + ) -> Result { + self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn) + .await + } + + async fn to_json_scoped( + &self, + host: &str, + user_uuid: &UserId, + cipher_sync_data: Option<&CipherSyncData>, + sync_type: CipherSyncType, + scope: CipherAccessScope, + conn: &DbConn, ) -> Result { use crate::util::{format_date, validate_and_format_date}; @@ -179,7 +292,7 @@ impl Cipher { // We don't need these values at all for Organizational syncs // Skip any other database calls if this is the case and just return false. let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { - if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { + if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await { (ro, hp, mn) } else { error!("Cipher ownership assertion failure"); @@ -543,20 +656,22 @@ impl Cipher { self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid } - /// Returns whether this cipher is owned by an org in which the user has full access. + /// Returns whether this cipher is owned by an org in which the user reaches every cipher by + /// role, for the given [`CipherAccessScope`]. async fn is_in_full_access_org( &self, user_uuid: &UserId, + scope: CipherAccessScope, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn, ) -> bool { if let Some(ref org_uuid) = self.organization_uuid { if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { - return cached_member.has_full_access(); + return scope.grants_org_wide_cipher_access(cached_member); } } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { - return member.has_full_access(); + return scope.grants_org_wide_cipher_access(&member); } } false @@ -589,14 +704,27 @@ impl Cipher { pub async fn get_access_restrictions( &self, user_uuid: &UserId, + scope: CipherAccessScope, 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. + // 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. if self.is_owned_by_user(user_uuid) - || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await + || self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await { return Some((false, false, true)); @@ -657,16 +785,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) @@ -679,9 +824,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 @@ -689,13 +839,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) @@ -704,8 +862,13 @@ impl Cipher { .await } - pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { + pub async fn is_write_accessible_to_user( + &self, + user_uuid: &UserId, + scope: CipherAccessScope, + conn: &DbConn, + ) -> bool { + match self.get_access_restrictions(user_uuid, scope, None, conn).await { Some((read_only, _hide_passwords, manage)) => !read_only || manage, None => false, } @@ -713,15 +876,20 @@ impl Cipher { // used for checking if collection can be edited (only if user has access to a collection they // can write to and also passwords are not hidden to prevent privilege escalation) - pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - match self.get_access_restrictions(user_uuid, None, conn).await { + pub async fn is_in_editable_collection_by_user( + &self, + user_uuid: &UserId, + scope: CipherAccessScope, + conn: &DbConn, + ) -> bool { + match self.get_access_restrictions(user_uuid, scope, None, conn).await { Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, None => false, } } - pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { - self.get_access_restrictions(user_uuid, None, conn).await.is_some() + pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool { + self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some() } // Returns whether this cipher is a favorite of the specified user. @@ -830,15 +998,16 @@ 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 .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 .into_boxed(); if !visible_only { + // Administrative organization scope, separate from the normal user vault. query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + custom_membership_with_edit_any_collection() + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), ); } @@ -867,13 +1036,14 @@ 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 .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .into_boxed(); if !visible_only { + // Administrative organization scope, separate from the normal user vault. query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + custom_membership_with_edit_any_collection() + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), ); } @@ -990,8 +1160,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))) @@ -1021,8 +1191,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))), @@ -1065,8 +1235,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))) @@ -1074,7 +1244,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) @@ -1097,12 +1267,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) @@ -1143,8 +1313,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) @@ -1154,6 +1324,37 @@ impl Cipher { }) .await } + + pub async fn get_collections_with_cipher_by_organization( + org_uuid: &OrganizationId, + conn: &DbConn, + ) -> Vec<(CipherId, CollectionId)> { + conn.run(move |conn| { + ciphers_collections::table + .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) + .filter(collections::org_uuid.eq(org_uuid)) + .select(ciphers_collections::all_columns) + .load::<(CipherId, CollectionId)>(conn) + .unwrap_or_default() + }) + .await + } + + /// The organization's ciphers that have no collection assignment — upstream's + /// `GetUnassignedOrganizationCiphers`. + pub async fn find_unassigned_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec { + conn.run(move |conn| { + ciphers::table + .left_join(ciphers_collections::table.on(ciphers_collections::cipher_uuid.eq(ciphers::uuid))) + .filter(ciphers::organization_uuid.eq(org_uuid)) + .filter(ciphers::user_uuid.is_null()) + .filter(ciphers_collections::cipher_uuid.is_null()) + .select(ciphers::all_columns) + .load::(conn) + .unwrap_or_default() + }) + .await + } } #[derive( @@ -1173,3 +1374,250 @@ impl Cipher { UuidFromParam, )] pub struct CipherId(String); + +#[cfg(test)] +mod tests { + use super::CipherAccessScope; + use crate::db::models::{Membership, MembershipStatus as Status, MembershipType}; + + const OWNER: i32 = MembershipType::Owner as i32; + const ADMIN: i32 = MembershipType::Admin as i32; + const USER: i32 = MembershipType::User as i32; + const CUSTOM: i32 = MembershipType::Custom as i32; + const UNKNOWN: i32 = Membership::UNKNOWN_ATYPE; + + /// Who reaches *every* cipher of an organization, in each of the two scopes, and which scope the + /// v2 attachment upload resolves for the member. + /// + /// The regular vault scope answers by role alone; the administrative scope additionally admits a + /// confirmed Custom member holding `Edit any collection` -- upstream's `CanEditAllCiphersAsync`, + /// which every `/ciphers/.../admin` route resolves through. The upload leg carries no + /// `adminRequest`, so its scope comes from the membership alone and is exactly that + /// administrative answer: nothing in the request can talk that route into it. + #[test] + fn cipher_access_scope_matrix() { + // (case, atype, status, edit_any_collection, regular vault scope, administrative scope) + let cases = [ + ("Owner", OWNER, Status::Confirmed, false, true, true), + ("Admin", ADMIN, Status::Confirmed, false, true, true), + // The role this change adds: administrative authority, and nothing beyond it. The two + // scopes disagreeing here *is* the fix: `/sync`, `GET /ciphers` and the non-admin + // `GET|PUT /ciphers/` keep answering from the member's own collection assignments. + ("Custom + EditAny", CUSTOM, Status::Confirmed, true, false, true), + ("Custom without EditAny", CUSTOM, Status::Confirmed, false, false, false), + ("User", USER, Status::Confirmed, false, false, false), + // A permission flag is only meaningful on a Custom membership, so one left behind by a + // role change grants nothing. + ("User with a stale EditAny flag", USER, Status::Confirmed, true, false, false), + // The permission only activates once the membership is confirmed. + ("Custom + EditAny, invited", CUSTOM, Status::Invited, true, false, false), + ("Custom + EditAny, accepted", CUSTOM, Status::Accepted, true, false, false), + ("Custom + EditAny, revoked", CUSTOM, Status::Revoked, true, false, false), + // A role this build cannot interpret holds nothing, in either scope. + ("unknown role", UNKNOWN, Status::Confirmed, true, false, false), + ]; + + for (case, atype, status, edit_any_collection, user_scope, admin_scope) in cases { + let member = Membership::for_test(atype, status, |m| m.edit_any_collection = edit_any_collection); + + assert_eq!( + CipherAccessScope::User.grants_org_wide_cipher_access(&member), + user_scope, + "{case}: regular vault scope" + ); + assert_eq!( + CipherAccessScope::OrganizationAdmin.grants_org_wide_cipher_access(&member), + admin_scope, + "{case}: administrative scope" + ); + // A route picks which scope it runs under, so the administrative one must never be the + // narrower of the two -- the worst case of picking it is the regular answer. + assert!(!user_scope || admin_scope, "{case}: this row expects the administrative scope to narrow"); + + let upload = if admin_scope { + CipherAccessScope::OrganizationAdmin + } else { + CipherAccessScope::User + }; + assert_eq!(CipherAccessScope::for_member(Some(&member)), upload, "{case}: upload scope"); + } + // No membership at all, e.g. a personal cipher. + assert_eq!(CipherAccessScope::for_member(None), CipherAccessScope::User); + } + + /// The v2 attachment *create* is the one route upstream lets the request pick the flow for + /// (`adminRequest`). The flag only selects which predicate runs; the matrix above shows the + /// administrative one still asks whether the member holds the authority, so it is no way up. + #[test] + fn admin_request_scope_selection() { + assert_eq!(CipherAccessScope::requested(Some(true)), CipherAccessScope::OrganizationAdmin); + assert_eq!(CipherAccessScope::requested(Some(false)), CipherAccessScope::User); + assert_eq!(CipherAccessScope::requested(None), CipherAccessScope::User); + } +} + +/// The scope decision as the database actually answers it. +/// +/// The tests above pin the predicate; this one pins the query path that consumes it. It is the only +/// place that shows a Custom member holding `Edit any collection` reaching a cipher they have no +/// assignment for -- and not reaching it in the regular vault scope. +#[cfg(all(test, sqlite))] +mod db_scope_tests { + use super::{Cipher, CipherAccessScope}; + use crate::db::models::UserId; + use crate::db::test_db::{TestDb, block_on}; + + /// Two organizations, and one cipher in each. + /// + /// `u_custom` is a confirmed Custom member of org 1 holding `Edit any collection`, with **no** + /// `users_collections` row and no group -- exactly the member whose administrative reach is not + /// backed by an assignment. `u_assigned` is the plain User the collection is actually assigned + /// to, so the fixture proves the cipher is reachable at all. `u_revoked` holds the same Custom + /// permission with a revoked membership. + const FIXTURE: &str = " + INSERT INTO collections (uuid, org_uuid, name) VALUES + ('col1', 'org1', 'c'), + ('col2', 'org2', 'c'); + INSERT INTO ciphers (uuid, created_at, updated_at, organization_uuid, atype, name, data) VALUES + ('cipher1', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'org1', 1, 'n', '{}'), + ('cipher2', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'org2', 1, 'n', '{}'); + INSERT INTO ciphers_collections (cipher_uuid, collection_uuid) VALUES + ('cipher1', 'col1'), + ('cipher2', 'col2'); + INSERT INTO users_organizations (uuid, user_uuid, org_uuid, akey, status, atype, edit_any_collection) VALUES + ('m_custom', 'u_custom', 'org1', '', 2, 4, TRUE), + ('m_revoked', 'u_revoked', 'org1', '', -1, 4, TRUE), + ('m_assigned', 'u_assigned', 'org1', '', 2, 2, FALSE); + INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) VALUES + ('u_assigned', 'col1', FALSE, FALSE, FALSE); + "; + + /// Only the tables the cipher access queries join. + const SCHEMA: &str = " + CREATE TABLE collections ( + uuid TEXT NOT NULL PRIMARY KEY, + org_uuid TEXT NOT NULL, + name TEXT NOT NULL, + external_id TEXT + ); + CREATE TABLE ciphers ( + uuid TEXT NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + user_uuid TEXT, + organization_uuid TEXT, + key TEXT, + atype INTEGER NOT NULL, + name TEXT NOT NULL, + notes TEXT, + fields TEXT, + data TEXT NOT NULL, + password_history TEXT, + deleted_at DATETIME, + reprompt INTEGER + ); + CREATE TABLE ciphers_collections ( + cipher_uuid TEXT NOT NULL, + collection_uuid TEXT NOT NULL, + PRIMARY KEY (cipher_uuid, collection_uuid) + ); + CREATE TABLE users_collections ( + user_uuid TEXT NOT NULL, + collection_uuid TEXT NOT NULL, + read_only BOOLEAN NOT NULL DEFAULT FALSE, + hide_passwords BOOLEAN NOT NULL DEFAULT FALSE, + manage BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (user_uuid, collection_uuid) + ); + CREATE TABLE users_organizations ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + invited_by_email TEXT, + akey TEXT NOT NULL, + status INTEGER NOT NULL, + atype INTEGER NOT NULL, + reset_password_key TEXT, + external_id TEXT, + 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 + ); + "; + + /// A cipher in `org_uuid`, matching the seeded row of the same id. + fn cipher(uuid: &str, org_uuid: &str) -> Cipher { + let mut cipher = Cipher::new(1, String::from("n")); + cipher.uuid = uuid.to_owned().into(); + cipher.organization_uuid = Some(org_uuid.to_owned().into()); + cipher.data = String::from("{}"); + cipher + } + + #[test] + fn cipher_scope_is_enforced_against_the_database() { + let db = TestDb::new(&format!("{SCHEMA}{FIXTURE}")); + + let own_org = cipher("cipher1", "org1"); + let other_org = cipher("cipher2", "org2"); + let custom: UserId = String::from("u_custom").into(); + let revoked: UserId = String::from("u_revoked").into(); + let assigned: UserId = String::from("u_assigned").into(); + + // `DbConn` has to be created *and* dropped inside the runtime: its `Drop` uses + // `spawn_blocking` to return the connection to the pool. + block_on(async { + let conn = db.conn(); + // The fixture is meaningful: the cipher is reachable through a real assignment. + assert!( + own_org.is_accessible_to_user(&assigned, CipherAccessScope::User, &conn).await, + "the assigned member must reach the cipher, otherwise this fixture proves nothing" + ); + + // Custom + `Edit any collection`, with no assignment of its own: administrative reach... + assert!( + own_org.is_accessible_to_user(&custom, CipherAccessScope::OrganizationAdmin, &conn).await, + "Custom + EditAny must reach an unassigned org cipher on the administrative routes" + ); + assert!( + own_org.is_write_accessible_to_user(&custom, CipherAccessScope::OrganizationAdmin, &conn).await, + "administrative reach includes writing" + ); + + // ...and nothing at all in the regular vault, which is what `/sync` and `GET /ciphers` + // answer from. A query that resolved the scope from the membership instead of from the + // route would make this true and hand the member the whole organization vault. + assert!( + !own_org.is_accessible_to_user(&custom, CipherAccessScope::User, &conn).await, + "Custom + EditAny must not reach an unassigned org cipher in the regular vault scope" + ); + assert!( + !own_org.is_write_accessible_to_user(&custom, CipherAccessScope::User, &conn).await, + "Custom + EditAny must not write an unassigned org cipher in the regular vault scope" + ); + + // A revoked membership holds the same flag and reaches nothing, in either scope. + for scope in [CipherAccessScope::User, CipherAccessScope::OrganizationAdmin] { + assert!( + !own_org.is_accessible_to_user(&revoked, scope, &conn).await, + "a revoked membership must not reach org ciphers in any scope" + ); + } + + // Another organization's cipher stays out of reach even in the administrative scope: + // the authority is bound to the organization the membership belongs to. + for scope in [CipherAccessScope::User, CipherAccessScope::OrganizationAdmin] { + assert!( + !other_org.is_accessible_to_user(&custom, scope, &conn).await, + "a cipher of another organization must never be reachable" + ); + } + }); + } +} diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index be108f13..f1f237d4 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,29 @@ 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 | MembershipType::User) => stored_manage, + 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. +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 +129,49 @@ 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 it through a group with `access_all` does not. + 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 => { - (false, false, true) - } - Some(m) => { - let is_manager = m.atype == MembershipType::Manager; + // 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.has_explicit_collection_manage_access(&self.uuid, conn).await => (false, false, true), + 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), } @@ -252,8 +285,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 @@ -285,10 +320,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) @@ -372,9 +411,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( @@ -408,9 +447,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) @@ -452,8 +491,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))) @@ -485,8 +524,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))), @@ -533,9 +572,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( @@ -559,71 +598,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, @@ -650,6 +626,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_any([MembershipType::User as i32, 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)), @@ -958,11 +936,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), }) } } diff --git a/src/db/models/event.rs b/src/db/models/event.rs index cc0eb504..4158141f 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, @@ -126,6 +141,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, @@ -330,20 +350,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 { diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 32e9333f..35b47dc3 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)] @@ -84,9 +84,6 @@ impl Group { } pub async fn to_json_details(&self, conn: &DbConn) -> Value { - // If both read_only and hide_passwords are false, then manage should be true - // You can't have an entry with read_only and manage, or hide_passwords and manage - // Or an entry with everything to false let collections_groups: Vec = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn) .await .iter() @@ -138,15 +135,13 @@ impl CollectionGroup { } pub fn to_json_details_for_group(&self) -> Value { - // If both read_only and hide_passwords are false, then manage should be true - // You can't have an entry with read_only and manage, or hide_passwords and manage - // Or an entry with everything to false - // For backwards compatibility and migration proposes we keep checking read_only and hide_password + // `manage` is a stored permission of its own and is reported exactly as stored: read/write + // access is not management. json!({ "id": self.groups_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, - "manage": self.manage || (!self.read_only && !self.hide_passwords), + "manage": self.manage, }) } } @@ -249,6 +244,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() @@ -260,6 +256,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( @@ -268,6 +267,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) @@ -313,6 +313,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; @@ -464,6 +473,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; let values = ( diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..561d433e 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -21,7 +21,7 @@ mod user; pub use self::archive::Archive; pub use self::attachment::{Attachment, AttachmentId}; pub use self::auth_request::{AuthRequest, AuthRequestId}; -pub use self::cipher::{Cipher, CipherId, RepromptType}; +pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; @@ -30,6 +30,8 @@ pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; +/// The single list of Custom-role permissions, see `organization::custom_role_permissions`. +pub(crate) use self::organization::custom_role_permissions; pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index d615a3fc..b1b840c0 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,7 +1,4 @@ -use std::{ - cmp::Ordering, - collections::{HashMap, HashSet}, -}; +use std::{cmp::Ordering, collections::HashSet}; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; @@ -15,8 +12,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, @@ -24,8 +21,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, - OrgPolicyType, TwoFactor, User, UserId, + Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, + TwoFactor, User, UserId, collection::stored_assignment_manage, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -44,6 +41,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 +49,86 @@ 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, +} + +/// The nine Custom-role permissions in one place: struct field, Bitwarden JSON key, accessor name. +/// +/// Everything that needs the complete set -- the `Membership` accessors, the permissions object the +/// clients receive, and the request parser in `api::core::organizations` -- expands this list instead +/// of repeating it, so the set cannot drift apart between them. +macro_rules! custom_role_permissions { + ($consumer:path) => { + $consumer! { + manage_users, "manageUsers", has_manage_users; + manage_groups, "manageGroups", has_manage_groups; + manage_policies, "managePolicies", has_manage_policies; + create_new_collections, "createNewCollections", has_create_new_collections; + edit_any_collection, "editAnyCollection", has_edit_any_collection; + delete_any_collection, "deleteAnyCollection", has_delete_any_collection; + access_event_logs, "accessEventLogs", has_access_event_logs; + access_import_export, "accessImportExport", has_access_import_export; + access_reports, "accessReports", has_access_reports; + } + }; +} +pub(crate) use custom_role_permissions; + +macro_rules! impl_membership_custom_permissions { + ($($field:ident, $json_key:literal, $accessor:ident);* $(;)?) => { + impl Membership { + // 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 $accessor(&self) -> bool { + self.has_type(MembershipType::Custom) && self.$field + } + )* + + pub fn clear_custom_permissions(&mut self) { + $( self.$field = false; )* + } + + /// The permissions object the Bitwarden clients receive. + /// + /// Type-gated through the accessors above, so a flag left behind on a non-Custom + /// membership is reported as `false` rather than as a grant. + pub fn custom_permissions_json(&self) -> Value { + json!({ + $( $json_key: self.$accessor(), )* + "manageSso": false, // Not supported + "manageResetPassword": false, + "manageScim": false // Not supported (Not AGPLv3 Licensed) + }) + } + } + }; +} +custom_role_permissions!(impl_membership_custom_permissions); + +/// 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 +169,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 +353,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 +406,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 { @@ -441,28 +525,14 @@ 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 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, - "manageSso": false, // Not supported - "manageUsers": false, - "manageResetPassword": false, - "manageScim": false // Not supported (Not AGPLv3 Licensed) - }); + let membership_type = self.atype; + + let permissions = self.custom_permissions_json(); + + // 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!({ @@ -513,8 +583,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, @@ -557,85 +626,29 @@ impl Membership { Vec::new() }; - // Check if a user is in a group which has access to all collections - // If that is the case, we should not return individual collections! - let full_access_group = - 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) - .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) + let collections: Vec = if include_collections { + CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &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, - })) + .map(|collection_user| { + json!({ + "id": collection_user.collection_uuid, + "readOnly": collection_user.read_only, + "hidePasswords": collection_user.hide_passwords, + "manage": stored_assignment_manage(self.atype, collection_user.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(); - - // 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 { - 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, - "manageSso": false, // Not supported - "manageUsers": false, - "manageResetPassword": false, - "manageScim": false // Not supported (Not AGPLv3 Licensed) - }) + let membership_type = self.atype; + + // 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 { + self.custom_permissions_json() } else { json!(null) }; @@ -652,7 +665,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(), @@ -679,7 +694,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 = @@ -711,7 +726,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", @@ -732,7 +748,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, @@ -812,7 +828,145 @@ 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`. The + /// successor of the removed `access_all` flag: it backs the `accessAll` field the Bitwarden clients + /// still read, and intentionally does not gate on status, matching the old column. 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; + /// `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 + } + + /// Check for an explicit per-collection Manage grant without treating any `access_all` value as such + /// a grant. 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. That legacy management authority is intentionally not + /// materialized into Custom membership permissions during migration. The group continues to grant + /// collection access dynamically, while any desired Custom collection-management permissions must + /// be assigned explicitly after the upgrade. + pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool { + !self.explicit_collection_manage_grants(Some(collection_uuid.clone()), conn).await.is_empty() + } + + /// Every collection of this organization carrying a real per-collection Manage grant for this + /// membership, for callers that would otherwise ask the single-collection question once per + /// collection. + pub async fn explicitly_managed_collection_ids(&self, conn: &DbConn) -> HashSet { + self.explicit_collection_manage_grants(None, conn).await.into_iter().collect() + } + + /// The single definition of "holds a real per-collection Manage grant" -- for one collection or for + /// all of them, in one statement either way. + /// + /// Both grant paths are resolved in the same query: a direct `users_collections.manage` row, or a + /// `collections_groups.manage` row reached through a group of *this* organization. The + /// `collections_groups` join hangs off `groups::uuid` rather than off `groups_users::groups_uuid`, + /// so a `groups_users` row pointing at another organization's group contributes nothing: the group + /// fails the organization check, `groups::uuid` is then NULL and the join cannot match. Every + /// collection considered is joined on the membership's own `org_uuid`, so no grant crosses + /// organizations. + /// + /// `groups.access_all` is never read here. It grants collection *access* dynamically and is not a + /// management grant; counting it would make an access-all group double as one. + async fn explicit_collection_manage_grants( + &self, + collection_uuid: Option, + conn: &DbConn, + ) -> Vec { + let membership_uuid = self.uuid.clone(); + let user_uuid = self.user_uuid.clone(); + let org_uuid = self.org_uuid.clone(); + + conn.run(move |conn| { + let grants = users_organizations::table + .inner_join(collections::table.on(collections::org_uuid.eq(users_organizations::org_uuid))) + .left_join( + users_collections::table.on(users_collections::collection_uuid + .eq(collections::uuid) + .and(users_collections::user_uuid.eq(users_organizations::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 + .nullable() + .eq(groups::uuid.nullable()) + .and(collections_groups::collections_uuid.eq(collections::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_any([MembershipType::User as i32, MembershipType::Custom as i32])) + .filter(users_collections::manage.eq(true).or(collections_groups::manage.eq(true))) + .select(collections::uuid) + .distinct(); + + match collection_uuid { + Some(collection_uuid) => grants.filter(collections::uuid.eq(collection_uuid)).load(conn), + None => grants.load(conn), + } + .unwrap_or_default() + }) + .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 async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { @@ -936,7 +1090,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 @@ -944,10 +1098,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() @@ -1071,10 +1223,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) @@ -1117,27 +1270,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, @@ -1147,10 +1279,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") @@ -1262,16 +1395,160 @@ pub struct MembershipId(String); #[derive(Clone, Debug, DieselNewType, Display, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct OrgApiKeyId(String); +/// Fixtures for the tests that exercise the Custom role, here and in the modules using it. +#[cfg(test)] +impl Membership { + /// An `atype` this build cannot interpret: a future build, a partial rollback or a hand-edited row. + pub const UNKNOWN_ATYPE: i32 = 99; + + /// `atype` is a raw `i32` and `set` runs regardless of the role on purpose, so the tests can cover + /// a role this build does not know and a permission flag left behind by a role change. + pub fn for_test(atype: i32, status: MembershipStatus, set: impl FnOnce(&mut Self)) -> Self { + let mut membership = + Self::new(UserId::from(String::from("test-user")), OrganizationId::from(String::from("test-org")), None); + membership.atype = atype; + membership.status = status as i32; + set(&mut membership); + membership + } +} + #[cfg(test)] mod tests { use super::*; + const UNKNOWN_ATYPE: i32 = Membership::UNKNOWN_ATYPE; + + fn membership(atype: i32) -> Membership { + Membership::for_test(atype, MembershipStatus::Confirmed, |_| {}) + } + + /// How roles rank against each other, and how a stored `atype` is read. + /// + /// Every authorization guard in the tree asks `atype >= MembershipType::X` or + /// `atype < MembershipType::X` against a value straight from the database, so those two answers -- + /// including the answers for a value this build does not know -- are the security semantics here. #[test] - #[allow(non_snake_case)] - fn partial_cmp_MembershipType() { + fn membership_type_ordering_and_parsing() { + // Roles rank by authority, not by the stored discriminant: Custom is stored as 4 but sits + // between User and Admin. 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); + + // (stored atype, reaches Admin authority, is below Admin) + let stored = [ + (MembershipType::Owner as i32, true, false), + (MembershipType::Admin as i32, true, false), + (MembershipType::Custom as i32, false, true), + (MembershipType::User as i32, false, true), + // An unknown role answers "no" to authority *and* "yes" to being below Admin. Both are + // deliberate: it never reaches administrative authority, and it stays sweepable by policy + // enforcement instead of becoming a row nothing can act on. + (UNKNOWN_ATYPE, false, true), + (-1, false, true), + ]; + for (atype, reaches_admin, below_admin) in stored { + assert_eq!(atype >= MembershipType::Admin, reaches_admin, "atype {atype} >= Admin"); + assert_eq!(atype < MembershipType::Admin, below_admin, "atype {atype} < Admin"); + } + + // Wire values. Modern clients no longer offer the Manager role, but an old client or a stored + // request may still send 3; Custom supersedes it, so it is accepted and folded on. + let accepted = [ + ("0", MembershipType::Owner), + ("Owner", MembershipType::Owner), + ("1", MembershipType::Admin), + ("Admin", MembershipType::Admin), + ("2", MembershipType::User), + ("User", MembershipType::User), + ("3", MembershipType::Custom), + ("Manager", MembershipType::Custom), + ("4", MembershipType::Custom), + ("Custom", MembershipType::Custom), + ]; + for (wire, expected) in accepted { + assert!( + MembershipType::from_str(wire) == Some(expected), + "{wire:?} must parse as the role stored as {}", + expected as i32 + ); + } + for rejected in ["", " ", "3 ", "5", "-1", "manager", "custom", "Manager\n"] { + assert!(MembershipType::from_str(rejected).is_none(), "{rejected:?} must not parse"); + } + } + + /// The nine granular permissions are stored as plain columns, so they outlive a role change. Every + /// reader gates them on the Custom type for that reason: a flag left behind on a User, an Admin or + /// a role this build cannot read must grant nothing. + #[test] + fn custom_permission_flags_are_type_gated() { + type Reader = fn(&Membership) -> bool; + type Setter = fn(&mut Membership, bool); + + let permissions: [(&str, Reader, Setter); 9] = [ + ("manageUsers", Membership::has_manage_users, |m, v| m.manage_users = v), + ("manageGroups", Membership::has_manage_groups, |m, v| m.manage_groups = v), + ("managePolicies", Membership::has_manage_policies, |m, v| m.manage_policies = v), + ("createNewCollections", Membership::has_create_new_collections, |m, v| m.create_new_collections = v), + ("editAnyCollection", Membership::has_edit_any_collection, |m, v| m.edit_any_collection = v), + ("deleteAnyCollection", Membership::has_delete_any_collection, |m, v| m.delete_any_collection = v), + ("accessEventLogs", Membership::has_access_event_logs, |m, v| m.access_event_logs = v), + ("accessImportExport", Membership::has_access_import_export, |m, v| m.access_import_export = v), + ("accessReports", Membership::has_access_reports, |m, v| m.access_reports = v), + ]; + + for (name, read, set) in permissions { + for atype in [ + MembershipType::Owner as i32, + MembershipType::Admin as i32, + MembershipType::User as i32, + MembershipType::Custom as i32, + UNKNOWN_ATYPE, + ] { + let mut member = membership(atype); + assert!(!read(&member), "{name} must be off while its column is false (atype {atype})"); + + set(&mut member, true); + assert_eq!( + read(&member), + atype == MembershipType::Custom as i32, + "{name} is only meaningful on a Custom membership (atype {atype})" + ); + } + } + + // Clearing has to reach every one of the nine; a forgotten field would leave authority behind + // on a member that was just moved off the Custom role. + let mut member = membership(MembershipType::Custom as i32); + for (_, _, set) in permissions { + set(&mut member, true); + } + member.clear_custom_permissions(); + for (name, read, _) in permissions { + assert!(!read(&member), "{name} survived clear_custom_permissions"); + } + + // `manageAllCollections` is a client-side aggregate: selected exactly when all three child + // permissions are. + let all_collections = |atype, create, edit, delete| { + let mut member = membership(atype); + member.create_new_collections = create; + member.edit_any_collection = edit; + member.delete_any_collection = delete; + member + }; + let custom = MembershipType::Custom as i32; + assert!(all_collections(custom, true, true, true).has_manage_all_collections()); + for (missing, member) in [ + ("createNewCollections", all_collections(custom, false, true, true)), + ("editAnyCollection", all_collections(custom, true, false, true)), + ("deleteAnyCollection", all_collections(custom, true, true, false)), + // And, like every other reader, the aggregate is gated on the type. + ("the Custom role", all_collections(MembershipType::User as i32, true, true, true)), + ] { + assert!(!member.has_manage_all_collections(), "{missing} missing must clear the aggregate"); + } } } diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..410f44f9 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -237,12 +237,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 437354af..ae9c2de7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -551,10 +551,21 @@ 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, and retrying only reprinted the same answer under a misleading "Can't connect + // to database". The full recovery procedure is already logged, 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 63ee2d7b..a9fb5ba7 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -174,20 +174,21 @@ const ORG_TYPES = { "bg": "blue" }, "4": { - "name": "Manager", - "bg": "green" + "name": "Custom", + "bg": "teal" }, }; 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; @@ -195,7 +196,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. @@ -222,7 +225,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 b1dfb17d..44ac5c1d 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -130,10 +130,10 @@