Browse Source

Merge experiment/custom-role-complete into feature/custom-role-permissions

Completes the Custom role by adding the three remaining Bitwarden
permissions and finishing the role-model cleanup this PR started.

New Custom permissions:

  accessEventLogs      gates the organization and per-member event log
                       endpoints (AccessEventLogsHeaders); per-cipher event
                       reads are scoped to the caller's own organization.
  accessImportExport   gates GET /organizations/<org>/export and organization
                       import. The export is scoped to the collections the
                       caller can actually reach unless they already have full
                       access, so holding the permission decides *whether* a
                       member may export, never *what* they may read.
  accessReports        opens GET /ciphers/organization-details. Vaultwarden has
                       no server-side reports - clients compute them locally
                       from that list - so this is by design a full
                       organization read permission, matching Bitwarden's
                       CanAccessAllCiphersAsync. Documented at the call site.

Role model:

- Remove the membership-level access_all flag. It is now fully expressed by
  the role model: Owners/Admins reach every collection implicitly, a Custom
  member does so via edit_any_collection. groups.access_all is a separate,
  still-supported feature and is untouched.
- Drop the legacy Manager role. Manager is no longer a variant: it is never
  persisted or emitted, and an incoming wire value of 3 is folded onto Custom
  for backward compatibility. Stored Manager rows are migrated to Custom.
  MembershipType now orders by authorization rank (User < Custom < Admin <
  Owner) and fails closed on unknown values.

Migrations (SQLite, PostgreSQL, MySQL/MariaDB):

- Legacy Managers become Custom; a Manager that held access_all keeps the
  equivalent create/edit/delete-any-collection grants.
- A Manager whose full access came from an access_all group keeps deriving it
  from that group instead of receiving a static copy, so removing the group
  still revokes the access.
- A startup preflight refuses to migrate ambiguous legacy state (a plain User
  carrying access_all) rather than guess between adding management authority
  and removing vault access.
- The down migration chain deliberately blocks an automatic downgrade: nine
  independent permissions cannot be represented by the old role/access_all
  schema without silent data loss.

Resolved the organizations.rs conflict in favour of this branch, which
supersedes the earlier merge resolution.
pull/7397/head
tom27052006 3 weeks ago
parent
commit
71349529ab
  1. 1
      migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql
  2. 13
      migrations/mysql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql
  3. 3
      migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql
  4. 67
      migrations/mysql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql
  5. 6
      migrations/mysql/2026-07-24-120000_drop_membership_access_all/down.sql
  6. 5
      migrations/mysql/2026-07-24-120000_drop_membership_access_all/up.sql
  7. 3
      migrations/mysql/2026-07-24-130000_add_custom_access_permissions/down.sql
  8. 5
      migrations/mysql/2026-07-24-130000_add_custom_access_permissions/up.sql
  9. 7
      migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/down.sql
  10. 3
      migrations/mysql/2026-07-24-140000_guard_custom_role_downgrade/up.sql
  11. 1
      migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql
  12. 13
      migrations/postgresql/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql
  13. 3
      migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql
  14. 65
      migrations/postgresql/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql
  15. 6
      migrations/postgresql/2026-07-24-120000_drop_membership_access_all/down.sql
  16. 5
      migrations/postgresql/2026-07-24-120000_drop_membership_access_all/up.sql
  17. 3
      migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/down.sql
  18. 5
      migrations/postgresql/2026-07-24-130000_add_custom_access_permissions/up.sql
  19. 7
      migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/down.sql
  20. 3
      migrations/postgresql/2026-07-24-140000_guard_custom_role_downgrade/up.sql
  21. 1
      migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/down.sql
  22. 12
      migrations/sqlite/2026-07-15-120000_mark_pending_custom_collection_migration/up.sql
  23. 3
      migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/down.sql
  24. 65
      migrations/sqlite/2026-07-23-120000_reconcile_legacy_custom_roles/up.sql
  25. 6
      migrations/sqlite/2026-07-24-120000_drop_membership_access_all/down.sql
  26. 5
      migrations/sqlite/2026-07-24-120000_drop_membership_access_all/up.sql
  27. 3
      migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/down.sql
  28. 5
      migrations/sqlite/2026-07-24-130000_add_custom_access_permissions/up.sql
  29. 7
      migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/down.sql
  30. 3
      migrations/sqlite/2026-07-24-140000_guard_custom_role_downgrade/up.sql
  31. 114
      src/api/admin.rs
  32. 336
      src/api/core/events.rs
  33. 566
      src/api/core/organizations.rs
  34. 1
      src/api/core/public.rs
  35. 81
      src/auth.rs
  36. 766
      src/db/mod.rs
  37. 36
      src/db/models/cipher.rs
  38. 171
      src/db/models/collection.rs
  39. 256
      src/db/models/organization.rs
  40. 4
      src/db/schema.rs
  41. 16
      src/static/scripts/admin_users.js
  42. 5
      src/static/templates/admin/users.hbs
  43. 5
      src/static/templates/scss/vaultwarden.scss.hbs
  44. 10
      src/util.rs

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

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

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

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

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

@ -0,0 +1,3 @@
-- This is an idempotent data repair. Reverting it must not remove permissions or recreate the
-- invalid persisted Manager type; the older-schema migration performs its own safe conversion.
SELECT 1;

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

@ -0,0 +1,67 @@
-- A normal User with the historical membership-level access_all bit cannot be mapped to the
-- Custom role without adding collection-management authority. Stop before dropping the source bit.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2 AND access_all = TRUE
LIMIT 1;
DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard;
-- The current 2026-07-16 migration copied a legacy full-access group's dynamic authority to the
-- exact direct 0/1/1 pattern. While the same organization-local source group is still present,
-- remove that deterministic copy so later group removal also revokes the authority.
UPDATE users_organizations
SET edit_any_collection = FALSE,
delete_any_collection = FALSE
WHERE atype IN (3, 4)
AND access_all = FALSE
AND create_new_collections = FALSE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE
AND EXISTS (SELECT 1 FROM __vw_custom_role_same_run_0716 WHERE marker = 1)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);
-- A remaining 0/1/1 pattern may be either an intentional direct grant or an older derived grant
-- whose source group has already been removed. Do not guess which one it is.
CREATE TEMPORARY TABLE __vw_legacy_group_access_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_group_access_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_group_access_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype IN (3, 4)
AND access_all = FALSE
AND create_new_collections = FALSE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE
LIMIT 1;
DROP TEMPORARY TABLE __vw_legacy_group_access_guard;
-- Membership access_all on a legacy Manager/Custom represented all three collection capabilities.
-- Set only TRUE values so this repair never removes independently configured permissions.
UPDATE users_organizations
SET create_new_collections = TRUE,
edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND access_all = TRUE;
-- Convert only after the legacy bit has been copied.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;
-- Clear only the marker row as transactional DML. Keeping the empty bookkeeping table avoids
-- MySQL DDL implicit commits, so the permission repair, marker clear, and Diesel ledger insert
-- either commit together or are all retried.
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1;

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

@ -0,0 +1,6 @@
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring
-- the invariant older server versions rely on: access_all == access to every collection. That is
-- exactly Owners/Admins, plus Custom members holding `edit_any_collection`.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE;

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

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

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

@ -0,0 +1,3 @@
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;

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

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

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

@ -0,0 +1,7 @@
-- Nine independent Custom-role permissions cannot be represented losslessly by the legacy
-- role/access_all schema. Always stop before any older down migration removes permission data.
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);

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

@ -0,0 +1,3 @@
-- Forward migration marker. Its down migration intentionally blocks an automatic lossy downgrade
-- before any granular permission column is removed.
SELECT 1;

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

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

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

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

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

@ -0,0 +1,3 @@
-- This is an idempotent data repair. Reverting it must not remove permissions or recreate the
-- invalid persisted Manager type; the older-schema migration performs its own safe conversion.
SELECT 1;

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

@ -0,0 +1,65 @@
-- A normal User with the historical membership-level access_all bit cannot be mapped to the
-- Custom role without adding collection-management authority. Stop before dropping the source bit.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2 AND access_all = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_user_access_all_guard;
-- The current 2026-07-16 migration copied a legacy full-access group's dynamic authority to the
-- exact direct 0/1/1 pattern. While the same organization-local source group is still present,
-- remove that deterministic copy so later group removal also revokes the authority.
UPDATE users_organizations
SET edit_any_collection = FALSE,
delete_any_collection = FALSE
WHERE atype IN (3, 4)
AND access_all = FALSE
AND create_new_collections = FALSE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE
AND EXISTS (SELECT 1 FROM __vw_custom_role_same_run_0716 WHERE marker = 1)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);
-- A remaining 0/1/1 pattern may be either an intentional direct grant or an older derived grant
-- whose source group has already been removed. Do not guess which one it is.
CREATE TEMPORARY TABLE __vw_legacy_group_access_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_group_access_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_group_access_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype IN (3, 4)
AND access_all = FALSE
AND create_new_collections = FALSE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_group_access_guard;
-- Membership access_all on a legacy Manager/Custom represented all three collection capabilities.
-- Set only TRUE values so this repair never removes independently configured permissions.
UPDATE users_organizations
SET create_new_collections = TRUE,
edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND access_all = TRUE;
-- Convert only after the legacy bit has been copied.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;
-- Clear the same-run marker only after every guard and permission update succeeds.
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1;

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

@ -0,0 +1,6 @@
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring
-- the invariant older server versions rely on: access_all == access to every collection. That is
-- exactly Owners/Admins, plus Custom members holding `edit_any_collection`.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE;

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

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

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

@ -0,0 +1,3 @@
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;

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

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

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

@ -0,0 +1,7 @@
-- Nine independent Custom-role permissions cannot be represented losslessly by the legacy
-- role/access_all schema. Always stop before any older down migration removes permission data.
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);

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

@ -0,0 +1,3 @@
-- Forward migration marker. Its down migration intentionally blocks an automatic lossy downgrade
-- before any granular permission column is removed.
SELECT 1;

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

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

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

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

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

@ -0,0 +1,3 @@
-- This is an idempotent data repair. Reverting it must not remove permissions or recreate the
-- invalid persisted Manager type; the older-schema migration performs its own safe conversion.
SELECT 1;

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

@ -0,0 +1,65 @@
-- A normal User with the historical membership-level access_all bit cannot be mapped to the
-- Custom role without adding collection-management authority. Stop before dropping the source bit.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2 AND access_all = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_user_access_all_guard;
-- The current 2026-07-16 migration copied a legacy full-access group's dynamic authority to the
-- exact direct 0/1/1 pattern. While the same organization-local source group is still present,
-- remove that deterministic copy so later group removal also revokes the authority.
UPDATE users_organizations
SET edit_any_collection = FALSE,
delete_any_collection = FALSE
WHERE atype IN (3, 4)
AND access_all = FALSE
AND create_new_collections = FALSE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE
AND EXISTS (SELECT 1 FROM __vw_custom_role_same_run_0716 WHERE marker = 1)
AND EXISTS (
SELECT 1
FROM groups_users AS gu
INNER JOIN "groups" AS g ON g.uuid = gu.groups_uuid
WHERE gu.users_organizations_uuid = users_organizations.uuid
AND g.organizations_uuid = users_organizations.org_uuid
AND g.access_all = TRUE
);
-- A remaining 0/1/1 pattern may be either an intentional direct grant or an older derived grant
-- whose source group has already been removed. Do not guess which one it is.
CREATE TEMPORARY TABLE __vw_legacy_group_access_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_group_access_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_group_access_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype IN (3, 4)
AND access_all = FALSE
AND create_new_collections = FALSE
AND edit_any_collection = TRUE
AND delete_any_collection = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_group_access_guard;
-- Membership access_all on a legacy Manager/Custom represented all three collection capabilities.
-- Set only TRUE values so this repair never removes independently configured permissions.
UPDATE users_organizations
SET create_new_collections = TRUE,
edit_any_collection = TRUE,
delete_any_collection = TRUE
WHERE atype IN (3, 4)
AND access_all = TRUE;
-- Convert only after the legacy bit has been copied.
UPDATE users_organizations SET atype = 4 WHERE atype = 3;
-- Clear the same-run marker only after every guard and permission update succeeds.
DELETE FROM __vw_custom_role_same_run_0716 WHERE marker = 1;

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

@ -0,0 +1,6 @@
-- Recreate the column and repopulate it from the role/permission model that replaced it, restoring
-- the invariant older server versions rely on: access_all == access to every collection. That is
-- exactly Owners/Admins, plus Custom members holding `edit_any_collection`.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations SET access_all = TRUE WHERE atype IN (0, 1);
UPDATE users_organizations SET access_all = TRUE WHERE atype = 4 AND edit_any_collection = TRUE;

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

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

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

@ -0,0 +1,3 @@
ALTER TABLE users_organizations DROP COLUMN access_event_logs;
ALTER TABLE users_organizations DROP COLUMN access_import_export;
ALTER TABLE users_organizations DROP COLUMN access_reports;

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

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

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

@ -0,0 +1,7 @@
-- Nine independent Custom-role permissions cannot be represented losslessly by the legacy
-- role/access_all schema. Always stop before any older down migration removes permission data.
CREATE TEMPORARY TABLE __vw_custom_role_downgrade_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);
INSERT INTO __vw_custom_role_downgrade_guard (blocked) VALUES (1);

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

@ -0,0 +1,3 @@
-- Forward migration marker. Its down migration intentionally blocks an automatic lossy downgrade
-- before any granular permission column is removed.
SELECT 1;

114
src/api/admin.rs

@ -545,38 +545,29 @@ struct MembershipTypeData {
} }
fn apply_membership_type_change(membership: &mut Membership, new_type: MembershipType) { fn apply_membership_type_change(membership: &mut Membership, new_type: MembershipType) {
let was_custom = membership.atype == MembershipType::Custom; // 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
// Leaving Custom for the legacy Manager role: `access_all` is the internal mirror of // organization member dialog. Any non-Custom role carries no custom flags at all. Only a member
// Edit any collection, but a Manager's `access_all` means the broad "manage all collections" // that is already Custom and stays Custom keeps its existing flags.
// grant (Create + Edit + Delete). Carrying an Edit-only mirror over would silently escalate the let stays_custom = new_type == MembershipType::Custom && membership.atype == MembershipType::Custom;
// member to Create/Edit/Delete. Only preserve `access_all` when the member actually held the if !stays_custom {
// full manage-all grant, mirroring the collection-permissions down-migration. Must be evaluated
// before the flags are cleared below (and while the type is still Custom).
if was_custom && new_type == MembershipType::Manager {
membership.access_all = membership.has_manage_all_collections();
}
// 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.
if new_type == MembershipType::Custom && !was_custom {
membership.clear_custom_permissions();
membership.access_all = false;
}
if new_type != MembershipType::Custom {
membership.clear_custom_permissions(); membership.clear_custom_permissions();
} }
// Prevent stale access_all from surviving a demotion to User. Admins/Owners have implicit membership.atype = new_type as i32;
// full access, while legacy Manager access_all is intentionally preserved for compatibility. }
match new_type {
MembershipType::Owner | MembershipType::Admin => membership.access_all = true, fn parse_admin_membership_type(user_type: NumberOrString) -> Option<MembershipType> {
MembershipType::User => membership.access_all = false, let raw_type = user_type.into_string();
MembershipType::Manager | MembershipType::Custom => {}
// 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;
} }
membership.atype = new_type as i32; MembershipType::from_str(&raw_type)
} }
#[post("/users/org_type", format = "application/json", data = "<data>")] #[post("/users/org_type", format = "application/json", data = "<data>")]
@ -588,7 +579,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) else { let Some(new_type) = parse_admin_membership_type(data.user_type) else {
err!("Invalid type") err!("Invalid type")
}; };
@ -936,9 +927,8 @@ mod tests {
} }
#[test] #[test]
fn admin_type_changes_clear_custom_permissions_and_stale_access() { fn admin_type_changes_clear_custom_permissions() {
let mut custom = membership(MembershipType::Custom); let mut custom = membership(MembershipType::Custom);
custom.access_all = true;
custom.manage_users = true; custom.manage_users = true;
custom.create_new_collections = true; custom.create_new_collections = true;
custom.edit_any_collection = true; custom.edit_any_collection = true;
@ -946,50 +936,48 @@ mod tests {
apply_membership_type_change(&mut custom, MembershipType::User); apply_membership_type_change(&mut custom, MembershipType::User);
assert_eq!(custom.atype, MembershipType::User as i32); assert_eq!(custom.atype, MembershipType::User as i32);
assert!(!custom.access_all);
assert!(!custom.manage_users); assert!(!custom.manage_users);
assert!(!custom.create_new_collections); assert!(!custom.create_new_collections);
assert!(!custom.edit_any_collection); assert!(!custom.edit_any_collection);
assert!(!custom.delete_any_collection); assert!(!custom.delete_any_collection);
// Entering Custom through the admin panel is fail-closed: no granular permissions are set.
let mut admin = membership(MembershipType::Admin); let mut admin = membership(MembershipType::Admin);
admin.access_all = true;
apply_membership_type_change(&mut admin, MembershipType::Custom); apply_membership_type_change(&mut admin, MembershipType::Custom);
assert_eq!(admin.atype, MembershipType::Custom as i32); assert_eq!(admin.atype, MembershipType::Custom as i32);
assert!(!admin.access_all, "entering Custom through this UI must be fail-closed");
assert!(!admin.has_manage_all_collections()); assert!(!admin.has_manage_all_collections());
assert!(!admin.edit_any_collection);
}
#[test]
fn admin_custom_to_custom_keeps_flags_but_other_transitions_clear() {
// A member kept as Custom retains its granular flags: the admin panel does not touch them;
// they are managed through the regular organization member dialog.
let mut custom = membership(MembershipType::Custom);
custom.manage_users = true;
custom.edit_any_collection = true;
apply_membership_type_change(&mut custom, MembershipType::Custom);
assert_eq!(custom.atype, MembershipType::Custom as i32);
assert!(custom.manage_users);
assert!(custom.edit_any_collection);
// Promoting to Admin/Owner drops any stale custom flags.
let mut promo = membership(MembershipType::Custom);
promo.edit_any_collection = true;
apply_membership_type_change(&mut promo, MembershipType::Admin);
assert_eq!(promo.atype, MembershipType::Admin as i32);
assert!(!promo.edit_any_collection);
} }
#[test] #[test]
fn admin_and_legacy_manager_access_all_behavior_is_preserved() { fn admin_type_parser_rejects_legacy_manager_before_normalization() {
let mut user = membership(MembershipType::User); assert!(parse_admin_membership_type(NumberOrString::Number(3)).is_none());
apply_membership_type_change(&mut user, MembershipType::Admin); assert!(parse_admin_membership_type(NumberOrString::String("3".to_owned())).is_none());
assert!(user.access_all); assert!(parse_admin_membership_type(NumberOrString::String("Manager".to_owned())).is_none());
// REGRESSION (privilege escalation, PR #7397 / finding F2): a Custom member whose assert!(parse_admin_membership_type(NumberOrString::Number(4)) == Some(MembershipType::Custom));
// `access_all` is only the Edit-any-collection mirror must NOT be turned into a legacy assert!(
// Manager with the broad "manage all collections" `access_all` grant. That would escalate parse_admin_membership_type(NumberOrString::String("Custom".to_owned())) == Some(MembershipType::Custom)
// an Edit-only member into Create + Edit + Delete. Mirrors the collection down-migration. );
let mut edit_only = membership(MembershipType::Custom);
edit_only.access_all = true;
edit_only.edit_any_collection = true;
apply_membership_type_change(&mut edit_only, MembershipType::Manager);
assert_eq!(edit_only.atype, MembershipType::Manager as i32);
assert!(!edit_only.access_all, "Edit-only Custom must not become an access_all Manager");
assert!(!edit_only.edit_any_collection);
// A Custom member who genuinely held the full manage-all grant (all three collection
// flags) keeps the equivalent legacy Manager `access_all`.
let mut manage_all = membership(MembershipType::Custom);
manage_all.access_all = true;
manage_all.create_new_collections = true;
manage_all.edit_any_collection = true;
manage_all.delete_any_collection = true;
apply_membership_type_change(&mut manage_all, MembershipType::Manager);
assert_eq!(manage_all.atype, MembershipType::Manager as i32);
assert!(manage_all.access_all, "full manage-all Custom keeps legacy Manager access_all");
assert!(!manage_all.create_new_collections);
assert!(!manage_all.edit_any_collection);
assert!(!manage_all.delete_any_collection);
} }
} }

336
src/api/core/events.rs

@ -7,12 +7,15 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{EmptyResult, JsonResult}, api::{EmptyResult, JsonResult},
auth::{AdminHeaders, Headers}, auth::{AccessEventLogsHeaders, Headers},
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId}, models::{
Cipher, CipherId, Event, EventType, Membership, MembershipId, MembershipStatus, MembershipType,
OrganizationId, UserId,
},
}, },
util::parse_date, util::try_parse_date,
}; };
/// ############################################################################################################### /// ###############################################################################################################
@ -29,9 +32,36 @@ struct EventRange {
continuation_token: Option<String>, continuation_token: Option<String>,
} }
fn parse_event_date(date: &str, field: &str) -> Result<NaiveDateTime, crate::Error> {
try_parse_date(date)
.map_err(|error| crate::Error::new("Invalid event date", format!("Invalid RFC 3339 {field}: {error}")))
}
fn parse_event_range(data: &EventRange) -> Result<(NaiveDateTime, NaiveDateTime), crate::Error> {
let start_date = parse_event_date(&data.start, "start date")?;
let end_date = if let Some(continuation_token) = &data.continuation_token {
try_parse_date(continuation_token).map_err(|error| {
crate::Error::new(
"Invalid continuation token",
format!("Continuation token is not a valid RFC 3339 date: {error}"),
)
})?
} else {
parse_event_date(&data.end, "end date")?
};
Ok((start_date, end_date))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")] #[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { async fn get_org_events(
org_id: OrganizationId,
data: EventRange,
headers: AccessEventLogsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
@ -39,12 +69,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// Return an empty vec when we org events are disabled. // Return an empty vec when we org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start); let (start_date, end_date) = parse_event_range(&data)?;
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await .await
@ -62,21 +87,70 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
}))) })))
} }
#[derive(Debug, Eq, PartialEq)]
enum CipherEventScope {
Organization(OrganizationId),
Personal,
}
impl CipherEventScope {
fn includes(&self, event: &Event) -> bool {
match self {
Self::Organization(org_id) => event.org_uuid.as_ref() == Some(org_id),
Self::Personal => event.org_uuid.is_none(),
}
}
}
fn membership_can_access_event_logs(membership: &Membership) -> bool {
membership.has_status(MembershipStatus::Confirmed)
&& (membership.atype >= MembershipType::Admin || membership.has_access_event_logs())
}
fn cipher_event_scope(cipher: &Cipher, user_id: &UserId, membership: Option<&Membership>) -> Option<CipherEventScope> {
match &cipher.organization_uuid {
Some(org_id)
if membership.is_some_and(|membership| {
membership.user_uuid == *user_id
&& membership.org_uuid == *org_id
&& membership_can_access_event_logs(membership)
}) =>
{
Some(CipherEventScope::Organization(org_id.clone()))
}
None if cipher.is_owned_by_user(user_id) => Some(CipherEventScope::Personal),
_ => None,
}
}
#[get("/ciphers/<cipher_id>/events?<data..>")] #[get("/ciphers/<cipher_id>/events?<data..>")]
async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult { async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult {
// Return an empty vec when org events are disabled. // Return an empty vec when org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await let (start_date, end_date) = parse_event_range(&data)?;
{
let start_date = parse_date(&data.start); let scope = if let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await {
let end_date = if let Some(before_date) = &data.continuation_token { let membership = if let Some(org_id) = &cipher.organization_uuid {
parse_date(before_date) 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 { } 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, &start_date, &end_date, &conn)
.await
.iter()
.filter(|event| scope.includes(event))
.map(Event::to_json)
.collect()
} else {
Vec::new()
}
} else { } else {
Vec::new() Vec::new()
}; };
@ -93,21 +167,17 @@ async fn get_user_events(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
data: EventRange, data: EventRange,
headers: AdminHeaders, headers: AccessEventLogsHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
// Return an empty vec when we org events are disabled. // Return an empty vec when we org events are disabled.
// This prevents client errors // This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() { let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start); let (start_date, end_date) = parse_event_range(&data)?;
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn) Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await .await
@ -158,6 +228,48 @@ struct EventCollection {
organization_id: Option<OrganizationId>, organization_id: Option<OrganizationId>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ClientEventKind {
User,
Cipher,
Organization,
}
const MAX_CLIENT_EVENT_BATCH_SIZE: usize = 1_000;
fn validate_client_event_batch_size(event_count: usize) -> Result<(), crate::Error> {
if event_count > MAX_CLIENT_EVENT_BATCH_SIZE {
return Err(crate::Error::new(
"Event batch is too large",
format!("At most {MAX_CLIENT_EVENT_BATCH_SIZE} events are accepted per request"),
));
}
Ok(())
}
fn client_event_kind(event_type: i32) -> Option<ClientEventKind> {
match event_type {
event_type if event_type == EventType::UserClientExportedVault as i32 => Some(ClientEventKind::User),
event_type
if event_type == EventType::CipherClientViewed as i32
|| event_type == EventType::CipherClientToggledPasswordVisible as i32
|| event_type == EventType::CipherClientToggledHiddenFieldVisible as i32
|| event_type == EventType::CipherClientToggledCardCodeVisible as i32
|| event_type == EventType::CipherClientCopiedPassword as i32
|| event_type == EventType::CipherClientCopiedHiddenField as i32
|| event_type == EventType::CipherClientCopiedCardCode as i32
|| event_type == EventType::CipherClientAutofilled as i32
|| event_type == EventType::CipherClientToggledCardNumberVisible as i32 =>
{
Some(ClientEventKind::Cipher)
}
event_type if event_type == EventType::OrganizationClientExportedVault as i32 => {
Some(ClientEventKind::Organization)
}
_ => None,
}
}
// Upstream: // Upstream:
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Events/Controllers/CollectController.cs // 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 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Services/Implementations/EventService.cs
@ -167,10 +279,25 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
return Ok(()); 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() { for event in data.iter() {
let event_date = parse_date(&event.date); if let Some(kind) = client_event_kind(event.r#type) {
match event.r#type { accepted_events.push((event, kind, parse_event_date(&event.date, "event date")?));
1000..=1099 => { }
}
for (event, kind, event_date) in accepted_events {
match kind {
ClientEventKind::User => {
log_user_event_impl( log_user_event_impl(
event.r#type, event.r#type,
&headers.user.uuid, &headers.user.uuid,
@ -181,7 +308,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
) )
.await; .await;
} }
1600..=1699 => { ClientEventKind::Organization => {
// Only allow logging events for an organization the user is actually a member of. // Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some() && Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
@ -199,7 +326,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
} }
_ => { ClientEventKind::Cipher => {
// The cipher determines the organization the event is logged to, so make sure the // 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. // user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id
@ -341,3 +468,152 @@ pub async fn event_cleanup_job(pool: DbPool) {
error!("Failed to get DB connection while trying to cleanup the events table"); error!("Failed to get DB connection while trying to cleanup the events table");
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn membership(member_type: MembershipType, status: MembershipStatus) -> Membership {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = member_type as i32;
membership.status = status as i32;
membership
}
#[test]
fn cipher_event_access_requires_confirmed_admin_or_access_event_logs() {
for member_type in [MembershipType::Owner, MembershipType::Admin] {
assert!(membership_can_access_event_logs(&membership(member_type, MembershipStatus::Confirmed)));
assert!(!membership_can_access_event_logs(&membership(member_type, MembershipStatus::Invited)));
assert!(!membership_can_access_event_logs(&membership(member_type, MembershipStatus::Accepted)));
assert!(!membership_can_access_event_logs(&membership(member_type, MembershipStatus::Revoked)));
}
let mut custom = membership(MembershipType::Custom, MembershipStatus::Confirmed);
assert!(!membership_can_access_event_logs(&custom));
custom.access_event_logs = true;
assert!(membership_can_access_event_logs(&custom));
custom.status = MembershipStatus::Revoked as i32;
assert!(!membership_can_access_event_logs(&custom));
assert!(!membership_can_access_event_logs(&membership(MembershipType::User, MembershipStatus::Confirmed)));
}
#[test]
fn cipher_event_scope_is_bound_to_cipher_org_or_personal_owner() {
let user_id: UserId = "test-user".to_owned().into();
let org_id: OrganizationId = "test-org".to_owned().into();
let mut cipher = Cipher::new(1, "test-cipher".to_owned());
cipher.organization_uuid = Some(org_id.clone());
let admin = membership(MembershipType::Admin, MembershipStatus::Confirmed);
assert_eq!(cipher_event_scope(&cipher, &user_id, Some(&admin)), Some(CipherEventScope::Organization(org_id)));
let accepted_admin = membership(MembershipType::Admin, MembershipStatus::Accepted);
assert_eq!(cipher_event_scope(&cipher, &user_id, Some(&accepted_admin)), None);
let mut foreign_membership = membership(MembershipType::Admin, MembershipStatus::Confirmed);
foreign_membership.org_uuid = "other-org".to_owned().into();
assert_eq!(cipher_event_scope(&cipher, &user_id, Some(&foreign_membership)), None);
cipher.organization_uuid = None;
cipher.user_uuid = Some(user_id.clone());
assert_eq!(cipher_event_scope(&cipher, &user_id, None), Some(CipherEventScope::Personal));
assert_eq!(cipher_event_scope(&cipher, &"other-user".to_owned().into(), None), None);
}
#[test]
fn cipher_event_rows_must_match_the_authorized_scope() {
let org_id: OrganizationId = "test-org".to_owned().into();
let mut event = Event::new(EventType::CipherClientViewed as i32, None);
assert!(CipherEventScope::Personal.includes(&event));
event.org_uuid = Some(org_id.clone());
assert!(!CipherEventScope::Personal.includes(&event));
assert!(CipherEventScope::Organization(org_id).includes(&event));
assert!(!CipherEventScope::Organization("other-org".to_owned().into()).includes(&event));
}
#[test]
fn event_range_rejects_invalid_dates_and_continuation_tokens() {
let valid = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "2026-07-25T11:00:00Z".to_owned(),
continuation_token: None,
};
assert!(parse_event_range(&valid).is_ok());
let invalid_start = EventRange {
start: "not-a-date".to_owned(),
..valid
};
assert!(parse_event_range(&invalid_start).is_err());
let invalid_end = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "not-a-date".to_owned(),
continuation_token: None,
};
assert!(parse_event_range(&invalid_end).is_err());
let invalid_token = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "2026-07-25T11:00:00Z".to_owned(),
continuation_token: Some("not-a-date".to_owned()),
};
assert!(parse_event_range(&invalid_token).is_err());
let token_supersedes_end = EventRange {
start: "2026-07-25T10:00:00Z".to_owned(),
end: "legacy-client-value-that-is-not-used".to_owned(),
continuation_token: Some("2026-07-25T10:30:00Z".to_owned()),
};
assert!(parse_event_range(&token_supersedes_end).is_ok());
}
#[test]
fn collect_accepts_only_official_client_generated_event_types() {
assert_eq!(client_event_kind(EventType::UserClientExportedVault as i32), Some(ClientEventKind::User));
for event_type in [
EventType::CipherClientViewed,
EventType::CipherClientToggledPasswordVisible,
EventType::CipherClientToggledHiddenFieldVisible,
EventType::CipherClientToggledCardCodeVisible,
EventType::CipherClientCopiedPassword,
EventType::CipherClientCopiedHiddenField,
EventType::CipherClientCopiedCardCode,
EventType::CipherClientAutofilled,
EventType::CipherClientToggledCardNumberVisible,
] {
assert_eq!(client_event_kind(event_type as i32), Some(ClientEventKind::Cipher));
}
assert_eq!(
client_event_kind(EventType::OrganizationClientExportedVault as i32),
Some(ClientEventKind::Organization)
);
for event_type in [
EventType::UserLoggedIn,
EventType::UserChangedPassword,
EventType::CipherCreated,
EventType::CipherUpdated,
EventType::CipherDeleted,
EventType::OrganizationUpdated,
EventType::OrganizationPurgedVault,
EventType::PolicyUpdated,
] {
assert_eq!(client_event_kind(event_type as i32), None);
}
assert_eq!(client_event_kind(1099), None);
assert_eq!(client_event_kind(1199), None);
assert_eq!(client_event_kind(1699), None);
}
#[test]
fn collect_batch_limit_preserves_normal_batches_and_rejects_excess() {
assert!(validate_client_event_batch_size(0).is_ok());
assert!(validate_client_event_batch_size(100).is_ok());
assert!(validate_client_event_batch_size(MAX_CLIENT_EVENT_BATCH_SIZE).is_ok());
assert!(validate_client_event_batch_size(MAX_CLIENT_EVENT_BATCH_SIZE + 1).is_err());
}
}

566
src/api/core/organizations.rs

@ -12,9 +12,9 @@ use crate::{
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{ auth::{
AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManageGroupsHeaders, AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers,
ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, ManagerHeadersLoose, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders,
OrgMemberHeaders, OwnerHeaders, decode_invite, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite,
}, },
db::{ db::{
DbConn, DbConn,
@ -219,7 +219,6 @@ async fn create_organization(headers: Headers, data: Json<OrgData>, conn: DbConn
let collection = Collection::new(org.uuid.clone(), data.collection_name, None); let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
member.akey = data.key; member.akey = data.key;
member.access_all = true;
member.atype = MembershipType::Owner as i32; member.atype = MembershipType::Owner as i32;
member.status = MembershipStatus::Confirmed as i32; member.status = MembershipStatus::Confirmed as i32;
@ -539,8 +538,8 @@ async fn post_organization_collections(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
// Create is independent from Edit/Delete. In particular, Edit any collection's internal // Create is independent from Edit/Delete. In particular, Edit any collection (full access to
// access_all representation must not implicitly grant this endpoint. // every collection) must not implicitly grant this endpoint.
if !headers.membership.can_create_new_collections() { if !headers.membership.can_create_new_collections() {
err!("You don't have permission to create collections") err!("You don't have permission to create collections")
} }
@ -565,27 +564,25 @@ async fn post_organization_collections(
let collection = Collection::new(org_id.clone(), data.name, data.external_id); let collection = Collection::new(org_id.clone(), data.name, data.external_id);
collection.save(&conn).await?; collection.save(&conn).await?;
log_event(
EventType::CollectionCreated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
// Security (F-3): a `manage` grant carries collection *delete*/administer authority // Security (F-3): a `manage` grant carries collection *delete*/administer authority
// (`has_explicit_collection_manage_access` -> CollectionDeleteHeaders/ManagerHeaders), so only a // (`has_explicit_collection_manage_access` -> CollectionDeleteHeaders/ManagerHeaders), so only a
// caller who could delete this collection may confer it — the same rule the collection-update and // caller who could delete this collection may confer it — the same rule the collection-update and
// bulk-access endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom // bulk-access endpoints apply. Create is deliberately independent from Edit/Delete, so a Custom
// member holding only `create_new_collections` must not be able to hand a manage row to another // member holding only `create_new_collections` must not be able to hand a manage row to another
// member or to a group (nor to itself) while creating the collection. For such callers the // member or to a group while creating the collection. For such callers the requested `manage`
// requested `manage` is forced to false; Admin/Owner, Custom-with-`delete_any_collection` and the // is forced to false; Admin/Owner and Custom-with-`delete_any_collection` keep it. The creator's
// legacy access_all Manager keep it. Evaluated after the collection exists so the per-collection // own object-scoped ownership is added separately below. Evaluated after the collection exists
// lookup sees it. // so the per-collection lookup sees it.
let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await; let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await;
let creator_needs_assignment = !headers.membership.has_full_access();
// Persist the creator's object-scoped ownership before secondary assignments. If a later
// assignment write fails, the otherwise non-transactional create path still leaves the new
// collection recoverably manageable by its creator. An explicit self-assignment below is
// skipped so it cannot weaken this grant.
if creator_needs_assignment {
CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, true, &conn).await?;
}
for group in data.groups { for group in data.groups {
CollectionGroup::new( CollectionGroup::new(
@ -604,7 +601,10 @@ async fn post_organization_collections(
err!("User is not part of organization") err!("User is not part of organization")
}; };
if member.access_all { if member.grants_access_to_all_collections() {
continue;
}
if member.user_uuid == headers.membership.user_uuid && creator_needs_assignment {
continue; continue;
} }
@ -619,6 +619,19 @@ async fn post_organization_collections(
.await?; .await?;
} }
// Emit the success event only after all requested assignments and the creator's object-scoped
// manage grant have been persisted. A later write failure must not leave a false audit record.
log_event(
EventType::CollectionCreated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
} }
@ -648,13 +661,12 @@ async fn post_bulk_access_collections(
// Security (F-1): authorization is enforced per collection below via `auth::can_edit_collection`, // Security (F-1): authorization is enforced per collection below via `auth::can_edit_collection`,
// the exact same Custom-aware check the single-collection edit endpoint (`ManagerHeaders`) uses. // the exact same Custom-aware check the single-collection edit endpoint (`ManagerHeaders`) uses.
// Edit any collection (or Admin/Owner) may bulk-edit every collection; a legacy Manager keeps its // Edit any collection (or Admin/Owner) may bulk-edit every collection; any other Custom member
// broad per-collection helper; any other Custom member must hold a real per-collection Manage // must hold a real per-collection Manage grant. In particular a Custom member's group
// grant. In particular a Custom member's membership/group `access_all` does NOT satisfy this here // `access_all` does NOT satisfy this here (it did under the previous `is_manageable_by_user`
// (it did under the previous `is_manageable_by_user` check, which diverged from the single-edit // check, which diverged from the single-edit endpoint). A custom user with only manage_users /
// endpoint). A custom user with only manage_users / manage_groups / manage_policies holds no such // manage_groups / manage_policies holds no such grant and is rejected, while a member who manages
// grant and is rejected, while a member who manages some collections keeps the ability to // some collections keeps the ability to bulk-edit exactly those.
// bulk-edit exactly those.
// Security (audit H-3) and atomicity (audit M-2): validate the whole request against this // Security (audit H-3) and atomicity (audit M-2): validate the whole request against this
// organization *before* mutating anything. Every collection must exist in the org and be // organization *before* mutating anything. Every collection must exist in the org and be
@ -662,10 +674,10 @@ async fn post_bulk_access_collections(
// once the entire request is known-valid do we begin the destructive delete/replace of // once the entire request is known-valid do we begin the destructive delete/replace of
// assignments, so a foreign-tenant group can never be linked and a later invalid element can no // assignments, so a foreign-tenant group can never be linked and a later invalid element can no
// longer leave earlier collections with their assignments already wiped. // longer leave earlier collections with their assignments already wiped.
for group in &data.groups { let org_groups = Group::find_by_organization(&org_id, &conn).await;
if Group::find_by_uuid_and_org(&group.id, &org_id, &conn).await.is_none() { let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
err!("Group not found in this organization") 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 user in &data.users { for user in &data.users {
if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() { if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() {
@ -726,7 +738,7 @@ async fn post_bulk_access_collections(
err!("User is not part of organization") err!("User is not part of organization")
}; };
if member.access_all { if member.grants_access_to_all_collections() {
continue; continue;
} }
@ -826,7 +838,7 @@ async fn post_organization_collection_update(
err!("User is not part of organization") err!("User is not part of organization")
}; };
if member.access_all { if member.grants_access_to_all_collections() {
continue; continue;
} }
@ -1057,14 +1069,38 @@ async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbCon
}))) })))
} }
// Returns every cipher in the organization, serialized with `CipherSyncType::Organization` — which
// deliberately skips the per-cipher access restrictions, so `readOnly`/`hidePasswords` are not
// applied and collection assignments are ignored. Whoever passes the check below reads the whole
// organization vault.
//
// `accessReports` is therefore, by design, a full organization *read* permission and not merely
// "may open the reports screen". Vaultwarden implements no server-side reports: the clients fetch
// this list and compute Exposed/Reused/Weak Passwords, Unsecured Websites, Inactive 2FA etc.
// locally, so the permission cannot be satisfied with less data.
//
// This matches Bitwarden upstream, which grants the same endpoint to Owner/Admin and to Custom
// members holding AccessImportExport, EditAnyCollection *or* AccessReports:
// https://github.com/bitwarden/server/blob/main/src/Api/Vault/Controllers/CiphersController.cs
// (`CanAccessAllCiphersAsync`)
//
// We are intentionally *stricter* than Bitwarden for `accessImportExport`: it does not open this
// endpoint, and `get_org_export` scopes its output to the caller's own collections, so
// "may export" never widens what a member can read. Granting `accessReports` does widen it — that
// is the documented trade-off of staying Bitwarden-compatible, and administrators must treat
// `accessReports` as equivalent to read access to every collection in the organization.
#[get("/ciphers/organization-details?<data..>")] #[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid { if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
} }
if !headers.membership.has_full_access() { if !headers.membership.has_full_access() && !headers.membership.has_access_reports() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); err_code!(
"Resource not found.",
"User does not have permission to access all organization ciphers",
rocket::http::Status::NotFound.code
);
} }
Ok(Json(json!({ Ok(Json(json!({
@ -1080,7 +1116,18 @@ async fn get_org_details_impl(
user_id: &UserId, user_id: &UserId,
conn: &DbConn, conn: &DbConn,
) -> Result<Value, crate::Error> { ) -> Result<Value, crate::Error> {
let ciphers = Cipher::find_by_org(org_id, conn).await; ciphers_to_org_json(Cipher::find_by_org(org_id, conn).await, host, user_id, conn).await
}
// Serialize an already-authorized set of organization ciphers. The caller decides which ciphers go
// in: `CipherSyncType::Organization` skips the per-cipher access restrictions, so this must never be
// handed a cipher the user is not allowed to see.
async fn ciphers_to_org_json(
ciphers: Vec<Cipher>,
host: &str,
user_id: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
let mut ciphers_json = Vec::with_capacity(ciphers.len()); let mut ciphers_json = Vec::with_capacity(ciphers.len());
@ -1193,6 +1240,9 @@ struct CustomRolePermissions {
create_new_collections: bool, create_new_collections: bool,
edit_any_collection: bool, edit_any_collection: bool,
delete_any_collection: bool, delete_any_collection: bool,
access_event_logs: bool,
access_import_export: bool,
access_reports: bool,
} }
impl CustomRolePermissions { impl CustomRolePermissions {
@ -1209,16 +1259,47 @@ impl CustomRolePermissions {
create_new_collections: enabled("createNewCollections"), create_new_collections: enabled("createNewCollections"),
edit_any_collection: enabled("editAnyCollection"), edit_any_collection: enabled("editAnyCollection"),
delete_any_collection: enabled("deleteAnyCollection"), delete_any_collection: enabled("deleteAnyCollection"),
access_event_logs: enabled("accessEventLogs"),
access_import_export: enabled("accessImportExport"),
access_reports: enabled("accessReports"),
} }
} }
/// Bitwarden grants a Custom member with Edit any collection full read/edit/manage access to /// Whether the requested role/permissions give this member access to *every* collection in the
/// organization ciphers. Vaultwarden's existing access_all flag is the internal data-plane /// org: Admins/Owners implicitly, and a Custom member holding Edit any collection. Such members
/// representation of that capability. Create and Delete remain completely independent. /// do not need (and must not be given) individual per-collection assignments. Create and Delete
fn access_all_for(self, member_type: MembershipType) -> bool { /// remain completely independent of this.
fn grants_full_collection_access(self, member_type: MembershipType) -> bool {
member_type >= MembershipType::Admin || (member_type == MembershipType::Custom && self.edit_any_collection) member_type >= MembershipType::Admin || (member_type == MembershipType::Custom && self.edit_any_collection)
} }
/// Parse permissions for an existing member without treating an omitted permissions object as
/// an instruction to clear every Custom-role grant. Older clients send legacy role value `3`
/// without the modern object; that value is normalized to Custom for compatibility.
fn from_edit_request(
member_type: MembershipType,
permissions: Option<&HashMap<String, Value>>,
membership: &Membership,
) -> Self {
match permissions {
Some(permissions) => Self::from_request(member_type, permissions),
None if member_type == MembershipType::Custom && membership.atype == MembershipType::Custom as i32 => {
Self {
manage_users: membership.manage_users,
manage_groups: membership.manage_groups,
manage_policies: membership.manage_policies,
create_new_collections: membership.create_new_collections,
edit_any_collection: membership.edit_any_collection,
delete_any_collection: membership.delete_any_collection,
access_event_logs: membership.access_event_logs,
access_import_export: membership.access_import_export,
access_reports: membership.access_reports,
}
}
None => Self::default(),
}
}
fn differs_from(self, membership: &Membership) -> bool { fn differs_from(self, membership: &Membership) -> bool {
self.manage_users != membership.manage_users self.manage_users != membership.manage_users
|| self.manage_groups != membership.manage_groups || self.manage_groups != membership.manage_groups
@ -1226,6 +1307,9 @@ impl CustomRolePermissions {
|| self.create_new_collections != membership.create_new_collections || self.create_new_collections != membership.create_new_collections
|| self.edit_any_collection != membership.edit_any_collection || self.edit_any_collection != membership.edit_any_collection
|| self.delete_any_collection != membership.delete_any_collection || self.delete_any_collection != membership.delete_any_collection
|| self.access_event_logs != membership.access_event_logs
|| self.access_import_export != membership.access_import_export
|| self.access_reports != membership.access_reports
} }
fn apply_to(self, membership: &mut Membership) { fn apply_to(self, membership: &mut Membership) {
@ -1235,6 +1319,9 @@ impl CustomRolePermissions {
membership.create_new_collections = self.create_new_collections; membership.create_new_collections = self.create_new_collections;
membership.edit_any_collection = self.edit_any_collection; membership.edit_any_collection = self.edit_any_collection;
membership.delete_any_collection = self.delete_any_collection; membership.delete_any_collection = self.delete_any_collection;
membership.access_event_logs = self.access_event_logs;
membership.access_import_export = self.access_import_export;
membership.access_reports = self.access_reports;
} }
} }
@ -1285,14 +1372,15 @@ async fn send_invite(
err!("Invalid type") err!("Invalid type")
}; };
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { if !may_manage_member_type(headers.membership_type, new_type) {
err!("Only Owners can invite Managers, Admins or Owners") err!("You don't have permission to invite this role")
} }
// manageAllCollections is a client-only aggregate. Persist its three children independently; // manageAllCollections is a client-only aggregate. Persist its three children independently.
// only Edit any collection maps to the existing all-cipher access representation. // Whether the member reaches every collection (Admin/Owner, or Custom + Edit any collection)
// decides whether we skip creating individual per-collection assignments below.
let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions); let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions);
let access_all = custom_permissions.access_all_for(new_type); let grants_full_access = custom_permissions.grants_full_collection_access(new_type);
let mut user_created: bool = false; let mut user_created: bool = false;
for email in &data.emails { for email in &data.emails {
@ -1334,7 +1422,6 @@ async fn send_invite(
}; };
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); 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 as i32; new_member.atype = new_type as i32;
custom_permissions.apply_to(&mut new_member); custom_permissions.apply_to(&mut new_member);
new_member.status = member_status; new_member.status = member_status;
@ -1387,8 +1474,8 @@ async fn send_invite(
None => false, None => false,
}; };
// If no accessAll, add the collections received // If the member does not already reach every collection, add the collections received
if !access_all && caller_can_manage_collections { if !grants_full_access && caller_can_manage_collections {
// Security (F-1): a per-collection `manage` grant carries delete authority, so the // Security (F-1): a per-collection `manage` grant carries delete authority, so the
// caller may only confer it on collections they could delete themselves. Otherwise a // caller may only confer it on collections they could delete themselves. Otherwise a
// caller acting via Edit-any-collection could invite an account they control with a // caller acting via Edit-any-collection could invite an account they control with a
@ -1421,6 +1508,8 @@ async fn send_invite(
// Security: assigning groups can indirectly grant collection access via the groups' // Security: assigning groups can indirectly grant collection access via the groups'
// collections. Only callers who may manage groups (Admins/Owners or users with // collections. Only callers who may manage groups (Admins/Owners or users with
// manage_groups) are allowed to assign groups when inviting. // manage_groups) are allowed to assign groups when inviting.
// NOTE: every requested group was already validated against this organization in
// `InviteData::validate` above, before any record was created.
let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin
|| match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
Some(m) => m.has_manage_groups(), Some(m) => m.has_manage_groups(),
@ -1428,14 +1517,6 @@ async fn send_invite(
}; };
if caller_can_manage_groups { if caller_can_manage_groups {
// Preserve main's same-organization validation before evaluating whether a group may
// confer collection access. This also produces a clear error for foreign group IDs.
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 this organization")
}
}
for group_id in &data.groups { for group_id in &data.groups {
// Security: a caller who cannot manage collections must not grant collection // Security: a caller who cannot manage collections must not grant collection
// access to the invitee by placing them into a collection-bearing group. // access to the invitee by placing them into a collection-bearing group.
@ -1468,7 +1549,7 @@ async fn bulk_reinvite_members(
let mut bulk_response = Vec::new(); let mut bulk_response = Vec::new();
for member_id in data.ids { 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(), Ok(()) => String::new(),
Err(e) => format!("{e:?}"), Err(e) => format!("{e:?}"),
}; };
@ -1499,19 +1580,23 @@ async fn reinvite_member(
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await reinvite_member_impl(&org_id, &member_id, &headers, &conn).await
} }
async fn reinvite_member_impl( async fn reinvite_member_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
invited_by_email: &str, headers: &ManageUsersHeaders,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
err!("The user hasn't been invited to the organization.") err!("The user hasn't been invited to the organization.")
}; };
if !may_manage_stored_member_type(headers.membership_type, member.atype) {
err!("You don't have permission to reinvite this user")
}
if member.status != MembershipStatus::Invited as i32 { if member.status != MembershipStatus::Invited as i32 {
err!("The user is already accepted or confirmed to the organization") err!("The user is already accepted or confirmed to the organization")
} }
@ -1531,7 +1616,7 @@ async fn reinvite_member_impl(
}; };
if CONFIG.mail_enabled() { 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() { } else if user.password_hash.is_empty() {
let invitation = Invitation::new(&user.email); let invitation = Invitation::new(&user.email);
invitation.save(conn).await?; invitation.save(conn).await?;
@ -1696,8 +1781,8 @@ async fn confirm_invite_impl(
err!("The specified user isn't a member of the organization") err!("The specified user isn't a member of the organization")
}; };
if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { if !may_manage_stored_member_type(headers.membership_type, member_to_confirm.atype) {
err!("Only Owners can confirm Managers, Admins or Owners") err!("You don't have permission to confirm this user")
} }
if member_to_confirm.status != MembershipStatus::Accepted as i32 { if member_to_confirm.status != MembershipStatus::Accepted as i32 {
@ -1788,8 +1873,7 @@ struct EditUserData {
r#type: NumberOrString, r#type: NumberOrString,
collections: Option<Vec<CollectionData>>, collections: Option<Vec<CollectionData>>,
groups: Option<Vec<GroupId>>, groups: Option<Vec<GroupId>>,
#[serde(default)] permissions: Option<HashMap<String, Value>>,
permissions: HashMap<String, Value>,
} }
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)] #[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
@ -1821,13 +1905,14 @@ async fn edit_member(
err!("Invalid type") err!("Invalid type")
}; };
let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions);
let access_all = custom_permissions.access_all_for(new_type);
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { 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") err!("The specified user isn't member of the organization")
}; };
let custom_permissions =
CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit);
let grants_full_access = custom_permissions.grants_full_collection_access(new_type);
if new_type != member_to_edit.atype if new_type != member_to_edit.atype
&& (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin)
&& headers.membership_type != MembershipType::Owner && headers.membership_type != MembershipType::Owner
@ -1836,11 +1921,10 @@ async fn edit_member(
} }
// Security: only Admins and Owners may change a member's role type at all. A Custom member // Security: only Admins and Owners may change a member's role type at all. A Custom member
// with manage_users must not change roles: raising a member to Manager/Custom grants // with manage_users must not change roles: raising a member to Custom can activate existing
// collection-"manage" on every collection they can already write (see the `atype >= Manager` // explicit collection-Manage assignments and other Custom-only authorization paths, while
// branch in `Collection`/`Membership` json), and lowering it revokes that access — both are // lowering it revokes them. Those authority changes are outside Manage Users even though
// collection-access changes this caller is not entitled to make, even though the custom // granular permission changes are independently gated below. Requests that leave the role
// permission flags and access_all are already gated below. Requests that leave the role
// unchanged are allowed, so such members can still use the regular edit dialog. The // unchanged are allowed, so such members can still use the regular edit dialog. The
// Admin/Owner guard above still governs Admin/Owner transitions for Owners. // Admin/Owner guard above still governs Admin/Owner transitions for Owners.
if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) { if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) {
@ -1880,14 +1964,10 @@ async fn edit_member(
None => false, None => false,
}; };
// Security: `access_all` grants full access to every collection, so only callers who may // Edit any collection (the successor of the removed access_all flag) grants full access to
// manage collections are allowed to change it. Otherwise a custom user with only manage_users // every collection. It is part of the granular custom permissions applied here, and the
// could enable Edit any collection on any member (including themselves) to grant full // differs_from guard above already prevents a non-Admin caller from changing it — so a Custom
// collection access — a privilege escalation. For everyone else we keep the member's existing // member with only manage_users can never grant themselves or others full collection access.
// access_all grant untouched (neither granted nor revoked).
if caller_can_manage_collections {
member_to_edit.access_all = access_all;
}
custom_permissions.apply_to(&mut member_to_edit); custom_permissions.apply_to(&mut member_to_edit);
member_to_edit.atype = new_type as i32; member_to_edit.atype = new_type as i32;
@ -1906,8 +1986,8 @@ async fn edit_member(
// Edit-any-collection thus cannot hand another member a manage/delete grant it lacks. // Edit-any-collection thus cannot hand another member a manage/delete grant it lacks.
let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await;
// If no accessAll, add the collections received // If the member does not already reach every collection, add the collections received
if !access_all { if !grants_full_access {
for col in data.collections.iter().flatten() { for col in data.collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
@ -2066,8 +2146,8 @@ async fn delete_member_impl(
err!("User to delete isn't member of the organization") err!("User to delete isn't member of the organization")
}; };
if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { if !may_manage_stored_member_type(headers.membership_type, member_to_delete.atype) {
err!("Only Owners can delete Admins or Owners") 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 if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32
@ -2178,6 +2258,20 @@ async fn post_org_import(
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
// accessImportExport: importing into the organization requires the permission (or Admin/Owner),
// mirroring the export endpoint and the Bitwarden permission model. The web-vault only offers org
// import to members holding this permission; enforcing it server-side keeps the two consistent.
// NOTE: this tightens the previous member-level behaviour (any confirmed member could import into
// collections they could write) — see the branch notes.
if !(headers.membership.has_status(MembershipStatus::Confirmed)
&& (headers.membership.atype >= MembershipType::Admin || headers.membership.has_access_import_export()))
{
err!(
"You need the 'Access Import/Export' permission, or to be an Admin or Owner, to import into this organization"
)
}
let data: ImportData = data.into_inner(); let data: ImportData = data.into_inner();
// Validate the import before continuing // Validate the import before continuing
@ -2202,6 +2296,9 @@ async fn post_org_import(
} }
} }
// Security (audit F8/upstream): index the existing collections by id so the per-collection
// authorization below can use the *write* predicate `is_writable_by_user`. A read-only
// assignment must not let an importer plant ciphers into a shared collection.
let existing_collections: HashMap<CollectionId, Collection> = let existing_collections: HashMap<CollectionId, Collection> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
@ -2218,7 +2315,7 @@ async fn post_org_import(
} else { } else {
// Collection creation through an organization import is governed by the same // Collection creation through an organization import is governed by the same
// independent permission as the regular create endpoint. In particular, // independent permission as the regular create endpoint. In particular,
// Edit any collection's access_all mirror must not satisfy this check. // Edit any collection (full access to every collection) must not satisfy this check.
if !headers.membership.can_create_new_collections() { if !headers.membership.can_create_new_collections() {
err!(Compact, "The current user isn't allowed to create new collections") err!(Compact, "The current user isn't allowed to create new collections")
} }
@ -2273,10 +2370,7 @@ async fn post_org_import(
// any future drift fails closed with an error instead of panicking. // any future drift fails closed with an error instead of panicking.
for (cipher_index, col_index) in relations { for (cipher_index, col_index) in relations {
let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else { let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
err!( err!(Compact, "Invalid collection relationship")
"Invalid collection relationship",
"A collection relationship references a non-existent cipher or collection"
)
}; };
CollectionCipher::save(cipher_id, col_id, &conn).await?; CollectionCipher::save(cipher_id, col_id, &conn).await?;
} }
@ -2722,15 +2816,9 @@ async fn revoke_member_impl(
if member.user_uuid == headers.user.uuid { if member.user_uuid == headers.user.uuid {
err!("You cannot revoke yourself") err!("You cannot revoke yourself")
} }
// Security: a Custom user with manage_users must not be able to revoke Admins or if !may_manage_stored_member_type(headers.membership_type, member.atype) {
// Owners. Mirrors the restriction in delete_member_impl; the Owner-specific check
// below still guards Admin-vs-Owner actions.
if member.atype != MembershipType::User && headers.membership_type < MembershipType::Admin {
err!("You don't have permission to revoke this user") err!("You don't have permission to revoke this user")
} }
if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can revoke other owners")
}
if member.atype == MembershipType::Owner if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1
{ {
@ -2828,15 +2916,9 @@ async fn restore_member_impl(
if member.user_uuid == headers.user.uuid { if member.user_uuid == headers.user.uuid {
err!("You cannot restore yourself") err!("You cannot restore yourself")
} }
// Security: a Custom user with manage_users must not be able to restore Admins or if !may_manage_stored_member_type(headers.membership_type, member.atype) {
// Owners. Mirrors the restriction in delete_member_impl; the Owner-specific check
// below still guards Admin-vs-Owner actions.
if member.atype != MembershipType::User && headers.membership_type < MembershipType::Admin {
err!("You don't have permission to restore this user") err!("You don't have permission to restore this user")
} }
if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can restore other owners")
}
member.restore(); member.restore();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
@ -2861,7 +2943,28 @@ async fn restore_member_impl(
Ok(()) Ok(())
} }
async fn get_groups_data(details: bool, org_id: OrganizationId, conn: DbConn) -> JsonResult { async fn get_groups_data(details: bool, org_id: OrganizationId, membership: &Membership, conn: DbConn) -> JsonResult {
// The details view (group→collection/user mappings) needs full org access; the plain list only
// needs manage access to a collection, so a manager of a collection (directly or via a group)
// can load it to assign groups.
// Custom roles: the 'Manage Users'/'Manage Groups' permissions are the authority for reading the
// group mappings (they are what the route guards enforce for the details view), so they satisfy
// this check as well even when the member reaches no collection of their own.
let has_full_access = membership.has_full_access()
|| (CONFIG.org_groups_enabled()
&& GroupUser::has_full_access_by_member(&org_id, &membership.uuid, &conn).await);
let can_manage_users_or_groups = membership.has_manage_users() || membership.has_manage_groups();
let allowed = if details {
has_full_access || can_manage_users_or_groups
} else {
has_full_access
|| can_manage_users_or_groups
|| Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
let groups = Group::find_by_organization(&org_id, &conn).await; let groups = Group::find_by_organization(&org_id, &conn).await;
let mut groups_json = Vec::with_capacity(groups.len()); let mut groups_json = Vec::with_capacity(groups.len());
@ -2889,30 +2992,15 @@ async fn get_groups_data(details: bool, org_id: OrganizationId, conn: DbConn) ->
}))) })))
} }
// The plain group list exposes no access mappings, but the web vault needs it to resolve group // The plain group list (id, name, externalId) exposes no access mappings, so it stays readable for
// names while managing users, groups or collections. Preserve main's restriction for legacy // members who have a reason to see it — the web vault needs it to render group names. The exact
// Managers while admitting Custom members whose explicit permission requires this metadata. // condition is enforced in `get_groups_data`.
#[get("/organizations/<org_id>/groups")] #[get("/organizations/<org_id>/groups")]
async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
get_groups_data(false, org_id, &headers.membership, conn).await
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 custom_permission_needs_group_metadata = headers.membership.has_manage_users()
|| headers.membership.has_manage_groups()
|| headers.membership.has_create_new_collections()
|| headers.membership.has_delete_any_collection();
let allowed = has_full_access
|| custom_permission_needs_group_metadata
|| 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", rocket::http::Status::NotFound.code);
}
get_groups_data(false, org_id, conn).await
} }
// Security (audit M-1): group *details* expose accessAll, external IDs and collection mappings, so // Security (audit M-1): group *details* expose accessAll, external IDs and collection mappings, so
@ -2923,7 +3011,7 @@ async fn get_groups_details(org_id: OrganizationId, headers: ManageUsersOrGroups
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
get_groups_data(true, org_id, conn).await get_groups_data(true, org_id, &headers.membership, conn).await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -3144,17 +3232,32 @@ fn may_change_group_membership(caller_can_manage_collections: bool, group_confer
/// Whether a caller of `edit_member` may change a member's role type. /// Whether a caller of `edit_member` may change a member's role type.
/// ///
/// Only Admins and Owners may change a member's role at all. A Custom member with `manage_users` /// Only Admins and Owners may change a member's role at all. A Custom member with `manage_users`
/// must not, because the role type has collection-access side effects: a member of type /// must not, because the role type changes organization-wide collection reach and which granular
/// `Manager`/`Custom` gains collection-"manage" on every collection they can write (the /// permissions are effective. `manage_users` covers the user lifecycle, not the data plane, so
/// `atype >= Manager` branches in `Collection`/`Membership`), so promoting grants that access and /// role changes are reserved for Admins/Owners. Leaving the role unchanged is always allowed so
/// demoting revokes it. `manage_users` covers the user lifecycle, not the data plane, so role
/// changes are reserved for Admins/Owners. Leaving the role unchanged is always allowed so
/// `manage_users` members can still use the regular edit dialog. Admin/Owner transitions are /// `manage_users` members can still use the regular edit dialog. Admin/Owner transitions are
/// additionally governed by the dedicated Owner-only guard in `edit_member`. /// additionally governed by the dedicated Owner-only guard in `edit_member`.
fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool { fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool {
caller_type >= MembershipType::Admin || new_type == current_atype caller_type >= MembershipType::Admin || new_type == current_atype
} }
/// Whether a caller with user-management access may perform lifecycle actions on a target role.
///
/// Owners may manage every role. Admins may manage Admin, Custom, and User memberships, but never
/// Owners. Custom members holding `manage_users` are limited to ordinary Users.
fn may_manage_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool {
match caller_type {
MembershipType::Owner => true,
MembershipType::Admin => target_type != MembershipType::Owner,
MembershipType::Custom => target_type == MembershipType::User,
MembershipType::User => false,
}
}
fn may_manage_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool {
MembershipType::from_i32(target_atype).is_some_and(|target_type| may_manage_member_type(caller_type, target_type))
}
/// Returns true if being a member of `group_id` confers collection access — either because the /// Returns true if being a member of `group_id` confers collection access — either because the
/// group has `access_all` set, or because it has collections assigned. /// group has `access_all` set, or because it has collections assigned.
async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool {
@ -3169,37 +3272,42 @@ async fn group_confers_collection_access(group_id: &GroupId, org_id: &Organizati
/// ///
/// Security (F-1, edit-any -> delete-any escalation): a `manage` grant carries collection *delete* /// Security (F-1, edit-any -> delete-any escalation): a `manage` grant carries collection *delete*
/// authority — `CollectionDeleteHeaders` accepts it via `has_explicit_collection_manage_access`. /// authority — `CollectionDeleteHeaders` accepts it via `has_explicit_collection_manage_access`.
/// Without this gate a Custom member holding only `edit_any_collection` (whose `access_all` mirror /// Without this gate a Custom member holding only `edit_any_collection` (which grants full access to
/// makes every collection "manageable") could, through the collection-access / group endpoints, /// every collection) could, through the collection-access / group endpoints, hand a `manage` row to
/// hand a `manage` row to a group they belong to (or to a manager-level member) and thereby gain /// a group they belong to (or to a manager-level member) and thereby gain deletion — a capability
/// deletion — a capability `edit_any_collection` must never imply. /// `edit_any_collection` must never imply.
/// ///
/// We therefore allow granting `manage` on a collection only to a caller who could delete that same /// We therefore allow granting `manage` on a collection only to a caller who could delete that same
/// collection themselves, mirroring `collection_delete_access` exactly so it can never hand out a /// collection themselves, mirroring `collection_delete_access` exactly so it can never hand out a
/// right the caller lacks: Admin/Owner and Custom-with-`delete_any_collection` always qualify; an /// right the caller lacks: Admin/Owner and Custom-with-`delete_any_collection` always qualify; any
/// exact legacy Manager uses its per-collection manage helper; any other Custom member must hold a /// other Custom member must hold a real explicit manage grant. This is strictly subtractive — it can
/// real explicit manage grant. This is strictly subtractive — it can only ever downgrade a requested /// only ever downgrade a requested `manage` to `false`, never grant it — so it opens no new access,
/// `manage` to `false`, never grant it — so it opens no new access, and delete-capable members /// and delete-capable members (including all Admins/Owners) are unaffected.
/// (including all Admins/Owners) are unaffected.
async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool {
match caller_manage_grant_role_check(caller) { match caller_manage_grant_role_check(caller) {
// Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no). // Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no).
Some(decision) => decision, Some(decision) => decision,
// Manager/Custom: the answer is per-collection and must reflect a *real* manage grant. // Custom without delete_any: the answer is per-collection and must reflect a *real* manage
// grant. A Custom member must prove a real users_collections.manage /
// collections_groups.manage grant; Edit any collection deliberately does not count here.
None => match MembershipType::from_i32(caller.atype) { None => match MembershipType::from_i32(caller.atype) {
// The exact legacy Manager keeps its broad per-collection manage helper (which also
// honors membership/group access_all), matching its pre-existing delete authorization.
Some(MembershipType::Manager) => {
Collection::is_coll_manageable_by_user(col_id, &caller.user_uuid, conn).await
}
// A Custom member must prove a real users_collections.manage / collections_groups.manage
// grant; edit_any_collection's access_all mirror deliberately does not count here.
Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await, Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await,
_ => false, _ => false,
}, },
} }
} }
/// Whether `caller` may export the *entire* organization instead of only their own assignments.
///
/// Security (audit F1): the `AccessImportExportHeaders` guard on `get_org_export` decides whether a
/// member may export at all; it must not decide *what* they get. Only members who already reach
/// every collection — Admins/Owners, and Custom members holding `edit_any_collection` — may receive
/// the full organization dump. For anyone else the export is built from their own assigned
/// collections/ciphers, so 'Access Import/Export' can never turn into a full vault read.
fn may_export_entire_organization(caller: &Membership) -> bool {
caller.has_full_access()
}
/// Pure, collection-independent part of `caller_may_grant_collection_manage`. /// Pure, collection-independent part of `caller_may_grant_collection_manage`.
/// ///
/// `Some(true)` -> the caller may grant `manage` on *any* collection (Admin/Owner, or a Custom /// `Some(true)` -> the caller may grant `manage` on *any* collection (Admin/Owner, or a Custom
@ -3217,7 +3325,7 @@ fn caller_manage_grant_role_check(caller: &Membership) -> Option<bool> {
return Some(false); return Some(false);
} }
match MembershipType::from_i32(caller.atype) { match MembershipType::from_i32(caller.atype) {
Some(MembershipType::Manager | MembershipType::Custom) => None, Some(MembershipType::Custom) => None,
_ => Some(false), _ => Some(false),
} }
} }
@ -3829,18 +3937,34 @@ async fn put_reset_password_enrollment(
// NOTE: It seems clients can't handle uppercase-first keys!! // 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. // 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. // Else the export will be just an empty JSON file.
// We currently only support exports by members of the Admin or Owner status. // Members with full access to the organization (Admin/Owner, or a Custom member with
// Vaultwarden does not yet support exporting only managed collections! // 'Edit any collection') export the whole organization; everyone else exports only what they can
// actually reach, like Bitwarden's export controller does.
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
#[get("/organizations/<org_id>/export")] #[get("/organizations/<org_id>/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 { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
// Security (audit F1): 'Access Import/Export' decides *whether* a member may export, it must not
// widen *what* they may read. Without this scoping a Custom member holding only this permission
// — assigned to no collection at all — would receive every cipher in the organization, because
// the organization sync type deliberately skips the per-cipher access restrictions.
let (collections, ciphers) = if may_export_entire_organization(&headers.membership) {
(Collection::find_by_organization(&org_id, &conn).await, Cipher::find_by_org(&org_id, &conn).await)
} else {
(
Collection::find_by_organization_and_user_uuid(&org_id, &headers.user.uuid, &conn).await,
filter_ciphers_for_organization(Cipher::find_by_user_visible(&headers.user.uuid, &conn).await, &org_id),
)
};
let collections_json: Value = collections.iter().map(Collection::to_json).collect();
Ok(Json(json!({ Ok(Json(json!({
"collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await), "collections": convert_json_key_lcase_first(collections_json),
"ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?), "ciphers": convert_json_key_lcase_first(ciphers_to_org_json(ciphers, &headers.host, &headers.user.uuid, &conn).await?),
}))) })))
} }
@ -3909,7 +4033,8 @@ mod tests {
use super::{ use super::{
CustomRolePermissions, caller_manage_grant_role_check, filter_ciphers_for_organization, CustomRolePermissions, caller_manage_grant_role_check, filter_ciphers_for_organization,
may_change_group_membership, may_change_member_type, may_change_group_membership, may_change_member_type, may_export_entire_organization, may_manage_member_type,
may_manage_stored_member_type,
}; };
use crate::db::models::{Cipher, Membership, MembershipStatus, MembershipType, OrganizationId}; use crate::db::models::{Cipher, Membership, MembershipStatus, MembershipType, OrganizationId};
@ -3937,12 +4062,11 @@ mod tests {
// manufacturing. This is what stops edit-any from escalating into delete-any. // manufacturing. This is what stops edit-any from escalating into delete-any.
let mut edit_any = confirmed_member(MembershipType::Custom); let mut edit_any = confirmed_member(MembershipType::Custom);
edit_any.edit_any_collection = true; edit_any.edit_any_collection = true;
edit_any.access_all = true; // the internal mirror of edit_any must not shortcut to yes
assert_eq!(caller_manage_grant_role_check(&edit_any), None); assert_eq!(caller_manage_grant_role_check(&edit_any), None);
// A flagless Custom / exact Manager also defer to the per-collection DB check. // A flagless Custom member (this is what a migrated legacy Manager becomes) also defers to
// the per-collection DB check.
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Custom)), None); assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Custom)), None);
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Manager)), None);
// Plain User never qualifies. // Plain User never qualifies.
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::User)), Some(false)); assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::User)), Some(false));
@ -3954,6 +4078,32 @@ mod tests {
assert_eq!(caller_manage_grant_role_check(&unconfirmed), Some(false)); assert_eq!(caller_manage_grant_role_check(&unconfirmed), Some(false));
} }
#[test]
fn access_import_export_alone_does_not_widen_the_export() {
// REGRESSION (audit F1): 'Access Import/Export' opens the export endpoint, but a Custom
// member holding only that permission reaches no collection of their own, so the export
// must be built from their assignments — never from the whole organization.
let mut import_export_only = confirmed_member(MembershipType::Custom);
import_export_only.access_import_export = true;
assert!(!may_export_entire_organization(&import_export_only));
// Custom members who already reach every collection keep the full dump.
let mut edit_any = confirmed_member(MembershipType::Custom);
edit_any.edit_any_collection = true;
edit_any.access_import_export = true;
assert!(may_export_entire_organization(&edit_any));
// Admins and Owners are unaffected.
assert!(may_export_entire_organization(&confirmed_member(MembershipType::Admin)));
assert!(may_export_entire_organization(&confirmed_member(MembershipType::Owner)));
// An unconfirmed membership never qualifies, whatever its flags say.
let mut unconfirmed = confirmed_member(MembershipType::Custom);
unconfirmed.edit_any_collection = true;
unconfirmed.status = MembershipStatus::Accepted as i32;
assert!(!may_export_entire_organization(&unconfirmed));
}
#[test] #[test]
fn assigned_cipher_response_is_scoped_to_requested_organization() { fn assigned_cipher_response_is_scoped_to_requested_organization() {
let requested_org: OrganizationId = "requested-org".to_owned().into(); let requested_org: OrganizationId = "requested-org".to_owned().into();
@ -3979,27 +4129,49 @@ mod tests {
#[test] #[test]
fn manage_users_caller_cannot_change_member_role() { fn manage_users_caller_cannot_change_member_role() {
let user = MembershipType::User as i32; let user = MembershipType::User as i32;
let manager = MembershipType::Manager as i32;
let custom = MembershipType::Custom as i32; let custom = MembershipType::Custom as i32;
// Admins and Owners may change a member's role. // Admins and Owners may change a member's role.
assert!(may_change_member_type(MembershipType::Owner, user, MembershipType::Manager)); assert!(may_change_member_type(MembershipType::Owner, user, MembershipType::Custom));
assert!(may_change_member_type(MembershipType::Admin, user, MembershipType::Custom)); assert!(may_change_member_type(MembershipType::Admin, user, MembershipType::Custom));
// A below-Admin caller (Manager / Custom-with-manage_users) may only submit an unchanged // A below-Admin caller (Custom-with-manage_users) may only submit an unchanged role, so the
// role, so the regular edit dialog keeps working. // regular edit dialog keeps working.
assert!(may_change_member_type(MembershipType::Custom, user, MembershipType::User)); assert!(may_change_member_type(MembershipType::Custom, user, MembershipType::User));
assert!(may_change_member_type(MembershipType::Custom, custom, MembershipType::Custom)); assert!(may_change_member_type(MembershipType::Custom, custom, MembershipType::Custom));
assert!(may_change_member_type(MembershipType::Manager, manager, MembershipType::Manager));
// REGRESSION (privilege escalation, PR #7397 / finding F1): a caller below Admin must NOT // REGRESSION (privilege escalation, PR #7397 / finding F1): a caller below Admin must NOT
// be able to change a member's role. Promoting User -> Manager/Custom grants that member // be able to change a member's role. Promoting User -> Custom can activate explicit
// collection-"manage" on their writable collections (atype >= Manager), and demoting // collection-Manage assignments and Custom-only authorization paths; demoting revokes
// revokes it — collection-access changes a manage_users caller is not entitled to make. // them. A manage_users caller is not entitled to either authority change.
assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Manager));
assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Custom)); assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Custom));
assert!(!may_change_member_type(MembershipType::Custom, manager, MembershipType::User)); assert!(!may_change_member_type(MembershipType::Custom, custom, MembershipType::User));
assert!(!may_change_member_type(MembershipType::Manager, custom, MembershipType::User)); }
#[test]
fn member_lifecycle_permissions_follow_the_role_hierarchy() {
let roles = [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User];
for target in roles {
assert!(may_manage_member_type(MembershipType::Owner, target));
}
assert!(!may_manage_member_type(MembershipType::Admin, MembershipType::Owner));
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin));
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Custom));
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::User));
assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Owner));
assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Admin));
assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Custom));
assert!(may_manage_member_type(MembershipType::Custom, MembershipType::User));
for target in roles {
assert!(!may_manage_member_type(MembershipType::User, target));
}
assert!(may_manage_stored_member_type(MembershipType::Admin, MembershipType::Custom as i32));
assert!(!may_manage_stored_member_type(MembershipType::Owner, i32::MAX));
} }
#[test] #[test]
@ -4036,8 +4208,8 @@ mod tests {
assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}"); assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}");
assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}"); assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}");
assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}"); assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}");
// Only Edit any collection maps to all-cipher access. Create/Delete must never do so. // Only Edit any collection maps to all-collection access. Create/Delete must never do so.
assert_eq!(parsed.access_all_for(MembershipType::Custom), edit, "mask={mask:03b}"); assert_eq!(parsed.grants_full_collection_access(MembershipType::Custom), edit, "mask={mask:03b}");
} }
} }
@ -4050,6 +4222,9 @@ mod tests {
("createNewCollections".to_owned(), json!(true)), ("createNewCollections".to_owned(), json!(true)),
("editAnyCollection".to_owned(), json!(true)), ("editAnyCollection".to_owned(), json!(true)),
("deleteAnyCollection".to_owned(), json!(true)), ("deleteAnyCollection".to_owned(), json!(true)),
("accessEventLogs".to_owned(), json!(true)),
("accessImportExport".to_owned(), json!(true)),
("accessReports".to_owned(), json!(true)),
]); ]);
let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions); let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions);
@ -4059,14 +4234,17 @@ mod tests {
assert!(custom.create_new_collections); assert!(custom.create_new_collections);
assert!(custom.edit_any_collection); assert!(custom.edit_any_collection);
assert!(custom.delete_any_collection); assert!(custom.delete_any_collection);
assert!(custom.access_event_logs);
assert!(custom.access_import_export);
assert!(custom.access_reports);
let user = CustomRolePermissions::from_request(MembershipType::User, &permissions); let user = CustomRolePermissions::from_request(MembershipType::User, &permissions);
assert_eq!(user, CustomRolePermissions::default()); assert_eq!(user, CustomRolePermissions::default());
assert!(!user.access_all_for(MembershipType::User)); assert!(!user.grants_full_collection_access(MembershipType::User));
let admin = CustomRolePermissions::from_request(MembershipType::Admin, &permissions); let admin = CustomRolePermissions::from_request(MembershipType::Admin, &permissions);
assert_eq!(admin, CustomRolePermissions::default()); assert_eq!(admin, CustomRolePermissions::default());
assert!(admin.access_all_for(MembershipType::Admin)); assert!(admin.grants_full_collection_access(MembershipType::Admin));
} }
#[test] #[test]
@ -4079,6 +4257,9 @@ mod tests {
create_new_collections: true, create_new_collections: true,
edit_any_collection: true, edit_any_collection: true,
delete_any_collection: true, delete_any_collection: true,
access_event_logs: true,
access_import_export: true,
access_reports: true,
..CustomRolePermissions::default() ..CustomRolePermissions::default()
}; };
@ -4088,5 +4269,44 @@ mod tests {
assert!(membership.create_new_collections); assert!(membership.create_new_collections);
assert!(membership.edit_any_collection); assert!(membership.edit_any_collection);
assert!(membership.delete_any_collection); assert!(membership.delete_any_collection);
assert!(membership.access_event_logs);
assert!(membership.access_import_export);
assert!(membership.access_reports);
}
#[test]
fn omitted_edit_permissions_preserve_supported_custom_grants() {
let mut membership = confirmed_member(MembershipType::Custom);
membership.manage_users = true;
membership.manage_groups = true;
membership.manage_policies = true;
membership.create_new_collections = true;
membership.edit_any_collection = true;
membership.delete_any_collection = true;
membership.access_event_logs = true;
membership.access_import_export = true;
membership.access_reports = true;
let preserved = CustomRolePermissions::from_edit_request(MembershipType::Custom, None, &membership);
assert!(preserved.manage_users);
assert!(preserved.manage_groups);
assert!(preserved.manage_policies);
assert!(preserved.create_new_collections);
assert!(preserved.edit_any_collection);
assert!(preserved.delete_any_collection);
assert!(preserved.access_event_logs);
assert!(preserved.access_import_export);
assert!(preserved.access_reports);
assert!(!preserved.differs_from(&membership));
let explicit_reset = HashMap::new();
assert_eq!(
CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&explicit_reset), &membership),
CustomRolePermissions::default()
);
assert_eq!(
CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership),
CustomRolePermissions::default()
);
} }
} }

1
src/api/core/public.rs

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

81
src/auth.rs

@ -725,8 +725,10 @@ impl OrgHeaders {
fn is_confirmed_and_admin(&self) -> bool { fn is_confirmed_and_admin(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin 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 { 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 { fn is_confirmed_and_owner(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner
@ -756,6 +758,19 @@ impl OrgHeaders {
|| self.membership.has_manage_users() || self.membership.has_manage_users()
|| self.membership.has_manage_groups()) || self.membership.has_manage_groups())
} }
fn can_access_event_logs(&self) -> bool {
self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin || self.membership.has_access_event_logs())
}
fn can_access_import_export(&self) -> bool {
self.is_confirmed()
&& (self.membership_type >= MembershipType::Admin || self.membership.has_access_import_export())
}
// NOTE: there is deliberately no `can_access_reports` guard helper. Vaultwarden has no
// server-side report endpoints — the clients compute every report locally from the
// organization cipher list — so `accessReports` is enforced inline where that list is served
// (`get_org_details`), not through a request guard. A guard here would be dead code that
// invites gating an endpoint on "may call reports" instead of "may read these ciphers".
} }
// org_id is usually the second path param ("/organizations/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
@ -840,6 +855,9 @@ impl<'r> FromRequest<'r> for OrgHeaders {
} }
pub struct AdminHeaders { 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 host: String,
pub device: Device, pub device: Device,
pub user: User, pub user: User,
@ -880,6 +898,10 @@ macro_rules! generate_manage_headers {
pub device: Device, pub device: Device,
pub user: User, pub user: User,
pub membership_type: MembershipType, 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 ip: ClientIp,
pub org_id: OrganizationId, pub org_id: OrganizationId,
} }
@ -897,7 +919,8 @@ macro_rules! generate_manage_headers {
user: headers.user, user: headers.user,
membership_type: headers.membership_type, membership_type: headers.membership_type,
ip: headers.ip, ip: headers.ip,
org_id: headers.membership.org_uuid, org_id: headers.membership.org_uuid.clone(),
membership: headers.membership,
}) })
} else { } else {
err_handler!($err) err_handler!($err)
@ -938,6 +961,18 @@ generate_manage_headers!(
can_manage_users_or_groups, can_manage_users_or_groups,
"You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint" "You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
); );
generate_manage_headers!(
AccessEventLogsHeaders,
can_access_event_logs,
"You need the 'Access Event Logs' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
AccessImportExportHeaders,
can_access_import_export,
"You need the 'Access Import/Export' permission, or to be an Admin or Owner, to call this endpoint"
);
// NOTE: no `AccessReportsHeaders`. See the note next to `can_access_import_export` above:
// `accessReports` guards data (the organization cipher list), not a dedicated endpoint.
// col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"), // col_id is usually the fourth path param ("/organizations/<org_id>/collections/<col_id>"),
// but there could be cases where it is a query value. // but there could be cases where it is a query value.
@ -961,7 +996,6 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CollectionManageAccess { enum CollectionManageAccess {
Any, Any,
LegacyManager,
ExplicitManage, ExplicitManage,
Denied, Denied,
} }
@ -973,9 +1007,6 @@ fn collection_access_by_role(membership: &Membership, custom_has_any_access: boo
match MembershipType::from_i32(membership.atype) { match MembershipType::from_i32(membership.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any, Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any,
// Keep the pre-Custom role's broad behavior isolated to an exact legacy Manager. Its
// existing helper intentionally accepts membership/group access_all.
Some(MembershipType::Manager) => CollectionManageAccess::LegacyManager,
Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any, Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any,
// A Custom member must prove an actual users_collections.manage or // A Custom member must prove an actual users_collections.manage or
// collections_groups.manage assignment. In particular, groups.access_all is not Manage. // collections_groups.manage assignment. In particular, groups.access_all is not Manage.
@ -1007,9 +1038,6 @@ async fn can_manage_collection(
) -> bool { ) -> bool {
match access { match access {
CollectionManageAccess::Any => true, CollectionManageAccess::Any => true,
CollectionManageAccess::LegacyManager => {
Collection::is_coll_manageable_by_user(collection_uuid, &membership.user_uuid, conn).await
}
CollectionManageAccess::ExplicitManage => { CollectionManageAccess::ExplicitManage => {
membership.has_explicit_collection_manage_access(collection_uuid, conn).await membership.has_explicit_collection_manage_access(collection_uuid, conn).await
} }
@ -1036,7 +1064,7 @@ pub(crate) async fn can_edit_collection(
} }
/// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can /// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can
/// update every collection; otherwise the caller must be at least a legacy Manager and have the /// update every collection; otherwise the caller must be a Custom member (or above) holding the
/// per-collection Manage permission. Read and delete use separate guards so Edit cannot /// per-collection Manage permission. Read and delete use separate guards so Edit cannot
/// accidentally imply Delete. /// accidentally imply Delete.
pub struct ManagerHeaders { pub struct ManagerHeaders {
@ -1145,8 +1173,8 @@ impl From<CollectionReadHeaders> for Headers {
/// limitCollectionDeletion=true, so deleting *any* collection requires the explicit Delete any /// limitCollectionDeletion=true, so deleting *any* collection requires the explicit Delete any
/// collection permission (or Admin/Owner). Deleting an individual collection is additionally /// collection permission (or Admin/Owner). Deleting an individual collection is additionally
/// allowed for members holding the per-collection Manage grant on it. Custom members use the /// allowed for members holding the per-collection Manage grant on it. Custom members use the
/// explicit assignment only; unlike the exact legacy Manager path, membership/group access_all /// explicit assignment only; a group `access_all` grant never counts as their per-collection Manage
/// never counts as their per-collection Manage grant. /// grant.
pub struct CollectionDeleteHeaders { pub struct CollectionDeleteHeaders {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
@ -1172,11 +1200,11 @@ impl<'r> FromRequest<'r> for CollectionDeleteHeaders {
match collection_delete_access(&headers.membership) { match collection_delete_access(&headers.membership) {
CollectionManageAccess::Any => {} CollectionManageAccess::Any => {}
CollectionManageAccess::Denied => { CollectionManageAccess::Denied => {
// Custom is a distinct, fail-closed role. Edit any collection and access_all alone // Custom is a distinct, fail-closed role. Edit any collection alone must not satisfy
// must not satisfy a Delete request without either Delete any or explicit Manage. // a Delete request without either Delete any or an explicit per-collection Manage.
err_handler!("You need the 'Delete any collection' permission to call this endpoint") err_handler!("You need the 'Delete any collection' permission to call this endpoint")
} }
access @ (CollectionManageAccess::LegacyManager | CollectionManageAccess::ExplicitManage) => { access @ CollectionManageAccess::ExplicitManage => {
let Outcome::Success(conn) = DbConn::from_request(request).await else { let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB") err_handler!("Error getting DB")
}; };
@ -1667,26 +1695,20 @@ mod tests {
#[test] #[test]
fn flagless_custom_requires_explicit_manage_for_edit_read_and_delete() { fn flagless_custom_requires_explicit_manage_for_edit_read_and_delete() {
// A flagless Custom member (this is what a migrated legacy Manager becomes) must prove a
// real per-collection Manage grant for every collection operation. ExplicitManage invokes
// the database helper that only accepts users_collections.manage / collections_groups.manage
// — an external groups.access_all grant deliberately does not switch to a broad helper.
let custom = membership(MembershipType::Custom); let custom = membership(MembershipType::Custom);
assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage); assert_eq!(collection_edit_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage); assert_eq!(collection_read_access(&custom), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&custom), CollectionManageAccess::ExplicitManage); assert_eq!(collection_delete_access(&custom), CollectionManageAccess::ExplicitManage);
// Neither a stale membership access_all value nor an external groups.access_all grant may
// switch a Custom member to the legacy broad helper. ExplicitManage invokes the database
// helper that only accepts users_collections.manage / collections_groups.manage.
let mut access_all = membership(MembershipType::Custom);
access_all.access_all = true;
assert_eq!(collection_edit_access(&access_all), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_read_access(&access_all), CollectionManageAccess::ExplicitManage);
assert_eq!(collection_delete_access(&access_all), CollectionManageAccess::ExplicitManage);
} }
#[test] #[test]
fn custom_any_permissions_remain_independent() { fn custom_any_permissions_remain_independent() {
let mut edit_any = membership(MembershipType::Custom); let mut edit_any = membership(MembershipType::Custom);
edit_any.edit_any_collection = true; edit_any.edit_any_collection = true;
edit_any.access_all = true;
assert_eq!(collection_edit_access(&edit_any), CollectionManageAccess::Any); assert_eq!(collection_edit_access(&edit_any), CollectionManageAccess::Any);
assert_eq!(collection_read_access(&edit_any), CollectionManageAccess::Any); assert_eq!(collection_read_access(&edit_any), CollectionManageAccess::Any);
// Edit-any alone is not blanket Delete. It still permits deletion of an explicitly managed // Edit-any alone is not blanket Delete. It still permits deletion of an explicitly managed
@ -1701,18 +1723,15 @@ mod tests {
} }
#[test] #[test]
fn exact_legacy_manager_keeps_broad_helper() { fn admin_and_user_collection_access_roles() {
let manager = membership(MembershipType::Manager);
assert_eq!(collection_edit_access(&manager), CollectionManageAccess::LegacyManager);
assert_eq!(collection_read_access(&manager), CollectionManageAccess::LegacyManager);
assert_eq!(collection_delete_access(&manager), CollectionManageAccess::LegacyManager);
let admin = membership(MembershipType::Admin); let admin = membership(MembershipType::Admin);
assert_eq!(collection_edit_access(&admin), CollectionManageAccess::Any); assert_eq!(collection_edit_access(&admin), CollectionManageAccess::Any);
assert_eq!(collection_read_access(&admin), CollectionManageAccess::Any);
assert_eq!(collection_delete_access(&admin), CollectionManageAccess::Any); assert_eq!(collection_delete_access(&admin), CollectionManageAccess::Any);
let user = membership(MembershipType::User); let user = membership(MembershipType::User);
assert_eq!(collection_edit_access(&user), CollectionManageAccess::Denied); assert_eq!(collection_edit_access(&user), CollectionManageAccess::Denied);
assert_eq!(collection_read_access(&user), CollectionManageAccess::Denied);
assert_eq!(collection_delete_access(&user), CollectionManageAccess::Denied); assert_eq!(collection_delete_access(&user), CollectionManageAccess::Denied);
} }

766
src/db/mod.rs

@ -468,6 +468,168 @@ impl<'r> FromRequest<'r> for DbConn {
} }
} }
const CUSTOM_ROLE_REPAIR_MIGRATION: &str = "20260723120000";
const CUSTOM_COLLECTION_PERMISSIONS_MIGRATION: &str = "20260716120000";
const DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION: &str = "20260724120000";
const CUSTOM_ROLE_SAME_RUN_MARKER_TABLE: &str = "__vw_custom_role_same_run_0716";
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[expect(
clippy::struct_excessive_bools,
reason = "These are independent facts read from a historical database schema and migration ledger"
)]
struct CustomRoleMigrationFacts {
memberships_table_exists: bool,
migration_table_exists: bool,
access_all_column_exists: bool,
collection_permission_columns: i64,
collection_permissions_migration_applied: bool,
repair_migration_applied: bool,
access_all_drop_migration_applied: bool,
legacy_user_access_all_count: i64,
ambiguous_direct_permission_count: i64,
same_run_0716_marker: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CustomRolePreflightDecision {
Proceed,
CompleteMysqlCollectionMigration,
RefuseAlreadyDropped,
RefuseMissingAccessAll,
RefuseMissingMigrationLedger,
RefuseLegacyUserAccessAll,
RefuseAmbiguousDirectPermissions,
RefusePartialCollectionSchema,
RefuseCollectionLedgerMismatch,
}
fn custom_role_preflight_decision(
facts: CustomRoleMigrationFacts,
can_complete_mysql_partial_migration: bool,
) -> CustomRolePreflightDecision {
if !facts.memberships_table_exists || facts.repair_migration_applied {
return CustomRolePreflightDecision::Proceed;
}
if !facts.migration_table_exists {
return CustomRolePreflightDecision::RefuseMissingMigrationLedger;
}
// Once access_all has been dropped, its former value and the provenance of 0/1/1
// collection permissions can no longer be reconstructed. Never guess at either.
if facts.access_all_drop_migration_applied {
return CustomRolePreflightDecision::RefuseAlreadyDropped;
}
if !facts.access_all_column_exists {
return CustomRolePreflightDecision::RefuseMissingAccessAll;
}
if facts.legacy_user_access_all_count != 0 {
return CustomRolePreflightDecision::RefuseLegacyUserAccessAll;
}
if facts.ambiguous_direct_permission_count != 0 && !facts.same_run_0716_marker {
return CustomRolePreflightDecision::RefuseAmbiguousDirectPermissions;
}
match (facts.collection_permission_columns, facts.collection_permissions_migration_applied) {
(0, false) | (3, true) => CustomRolePreflightDecision::Proceed,
(3, false) if can_complete_mysql_partial_migration => {
CustomRolePreflightDecision::CompleteMysqlCollectionMigration
}
(_, true) => CustomRolePreflightDecision::RefuseCollectionLedgerMismatch,
_ => CustomRolePreflightDecision::RefusePartialCollectionSchema,
}
}
fn custom_role_preflight_error(decision: CustomRolePreflightDecision, facts: CustomRoleMigrationFacts) -> Error {
let detail = match decision {
CustomRolePreflightDecision::RefuseAlreadyDropped => format!(
"The membership access_all column was already dropped by migration \
{DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION}, but the required repair migration \
{CUSTOM_ROLE_REPAIR_MIGRATION} is not recorded. The former permission values cannot \
be reconstructed safely."
),
CustomRolePreflightDecision::RefuseMissingAccessAll => format!(
"The membership access_all column is missing before repair migration \
{CUSTOM_ROLE_REPAIR_MIGRATION}; refusing to infer deleted permissions."
),
CustomRolePreflightDecision::RefuseMissingMigrationLedger => {
"The users_organizations table exists, but the Diesel migration ledger does not. \
Refusing to guess which schema and data migrations were previously applied."
.to_owned()
}
CustomRolePreflightDecision::RefuseLegacyUserAccessAll => format!(
"{} legacy User membership(s) still have membership access_all=true. Mapping these \
records to Custom/EditAny would add management authority, while clearing the bit \
would remove existing vault access.",
facts.legacy_user_access_all_count
),
CustomRolePreflightDecision::RefuseAmbiguousDirectPermissions => format!(
"Found {} membership(s) with an ambiguous 0/1/1 collection-permission pattern. It is \
not possible to distinguish an older group-derived backfill from an intentional \
direct Edit+Delete assignment.",
facts.ambiguous_direct_permission_count
),
CustomRolePreflightDecision::RefusePartialCollectionSchema => format!(
"Found {} of the three custom collection-permission columns without a completed \
{CUSTOM_COLLECTION_PERMISSIONS_MIGRATION} migration. This is not an automatically \
recoverable state for this database backend.",
facts.collection_permission_columns
),
CustomRolePreflightDecision::RefuseCollectionLedgerMismatch => format!(
"Migration {CUSTOM_COLLECTION_PERMISSIONS_MIGRATION} is recorded, but only {} of its \
three collection-permission columns exist.",
facts.collection_permission_columns
),
CustomRolePreflightDecision::Proceed | CustomRolePreflightDecision::CompleteMysqlCollectionMigration => {
unreachable!("successful preflight decisions do not produce errors")
}
};
std::io::Error::other(format!(
"Custom-role migration preflight stopped startup: {detail} Back up the database and resolve \
the legacy membership state manually before restarting."
))
.into()
}
#[cfg(any(mysql, test))]
fn mysql_partial_unexpected_values_query(allow_same_run_group_derived: bool) -> String {
let same_run_group_derived = if allow_same_run_group_derived {
" OR \
(atype = 4 \
AND access_all = FALSE \
AND create_new_collections = FALSE \
AND edit_any_collection = TRUE \
AND delete_any_collection = TRUE \
AND EXISTS ( \
SELECT 1 \
FROM groups_users AS gu \
INNER JOIN `groups` AS g ON g.uuid = gu.groups_uuid \
WHERE gu.users_organizations_uuid = users_organizations.uuid \
AND g.organizations_uuid = users_organizations.org_uuid \
AND g.access_all = TRUE \
))"
} else {
""
};
format!(
"SELECT COUNT(*) AS count FROM users_organizations \
WHERE NOT ( \
(create_new_collections = FALSE \
AND edit_any_collection = FALSE \
AND delete_any_collection = FALSE) \
OR \
(atype = 4 \
AND create_new_collections = access_all \
AND edit_any_collection = access_all \
AND delete_any_collection = access_all) \
{same_run_group_derived} \
)"
)
}
// Embed the migrations from the migrations folder into the application // Embed the migrations from the migrations folder into the application
// This way, the program automatically migrates the database to the latest version // This way, the program automatically migrates the database to the latest version
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html // https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
@ -477,11 +639,130 @@ mod sqlite_migrations {
use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/sqlite");
#[derive(diesel::QueryableByName)]
struct Count {
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
fn count(
connection: &mut diesel::sqlite::SqliteConnection,
query: impl Into<String>,
) -> Result<i64, diesel::result::Error> {
diesel::sql_query(query).get_result::<Count>(connection).map(|row| row.count)
}
fn table_exists(
connection: &mut diesel::sqlite::SqliteConnection,
table: &str,
) -> Result<bool, diesel::result::Error> {
count(
connection,
format!(
"SELECT COUNT(*) AS count FROM sqlite_master \
WHERE type = 'table' AND name = '{table}'"
),
)
.map(|value| value != 0)
}
fn migration_applied(
connection: &mut diesel::sqlite::SqliteConnection,
version: &str,
) -> Result<bool, diesel::result::Error> {
count(
connection,
format!(
"SELECT COUNT(*) AS count FROM __diesel_schema_migrations \
WHERE version = '{version}'"
),
)
.map(|value| value != 0)
}
fn preflight(connection: &mut diesel::sqlite::SqliteConnection) -> Result<(), super::Error> {
let memberships_table_exists = table_exists(connection, "users_organizations")?;
if !memberships_table_exists {
return Ok(());
}
let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?;
let access_all_column_exists = count(
connection,
"SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \
WHERE name = 'access_all'",
)? != 0;
let collection_permission_columns = count(
connection,
"SELECT COUNT(*) AS count FROM pragma_table_info('users_organizations') \
WHERE name IN ('create_new_collections', 'edit_any_collection', 'delete_any_collection')",
)?;
let collection_permissions_migration_applied =
migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?;
let repair_migration_applied =
migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?;
let access_all_drop_migration_applied =
migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?;
let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?;
let same_run_0716_marker = same_run_marker_table_exists
&& count(
connection,
format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE),
)? != 0;
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 ambiguous_direct_permission_count = if access_all_column_exists && collection_permission_columns == 3 {
count(
connection,
"SELECT COUNT(*) AS count FROM users_organizations \
WHERE atype IN (3, 4) \
AND access_all = FALSE \
AND create_new_collections = FALSE \
AND edit_any_collection = TRUE \
AND delete_any_collection = TRUE",
)?
} else {
0
};
let facts = super::CustomRoleMigrationFacts {
memberships_table_exists,
migration_table_exists,
access_all_column_exists,
collection_permission_columns,
collection_permissions_migration_applied,
repair_migration_applied,
access_all_drop_migration_applied,
legacy_user_access_all_count,
ambiguous_direct_permission_count,
same_run_0716_marker,
};
let decision = super::custom_role_preflight_decision(facts, false);
if decision == super::CustomRolePreflightDecision::Proceed {
Ok(())
} else {
Err(super::custom_role_preflight_error(decision, facts))
}
}
pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { 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 // 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). // not exist, and exit if there is an error).
let mut connection = diesel::sqlite::SqliteConnection::establish(db_url)?; let mut connection = diesel::sqlite::SqliteConnection::establish(db_url)?;
preflight(&mut connection)?;
// Run the migrations after successfully establishing a connection // Run the migrations after successfully establishing a connection
// Disable Foreign Key Checks during migration // Disable Foreign Key Checks during migration
// Scoped to a connection. // Scoped to a connection.
@ -505,10 +786,193 @@ mod mysql_migrations {
use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/mysql");
#[derive(diesel::QueryableByName)]
struct Count {
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
fn count(
connection: &mut diesel::mysql::MysqlConnection,
query: impl Into<String>,
) -> Result<i64, diesel::result::Error> {
diesel::sql_query(query).get_result::<Count>(connection).map(|row| row.count)
}
fn table_exists(
connection: &mut diesel::mysql::MysqlConnection,
table: &str,
) -> Result<bool, diesel::result::Error> {
count(
connection,
format!(
"SELECT COUNT(*) AS count FROM information_schema.tables \
WHERE table_schema = DATABASE() AND table_name = '{table}'"
),
)
.map(|value| value != 0)
}
fn migration_applied(
connection: &mut diesel::mysql::MysqlConnection,
version: &str,
) -> Result<bool, diesel::result::Error> {
count(
connection,
format!(
"SELECT COUNT(*) AS count FROM __diesel_schema_migrations \
WHERE version = '{version}'"
),
)
.map(|value| value != 0)
}
fn complete_partial_collection_migration(
connection: &mut diesel::mysql::MysqlConnection,
allow_same_run_group_derived: bool,
) -> Result<(), super::Error> {
// MySQL implicitly committed the three historical ALTER TABLE statements before the
// unquoted `groups` identifier made the migration fail. Complete that exact, known state
// without dropping columns or inventing values.
let matching_column_definitions = count(
connection,
"SELECT COUNT(*) AS count FROM information_schema.columns \
WHERE table_schema = DATABASE() \
AND table_name = 'users_organizations' \
AND column_name IN \
('create_new_collections', 'edit_any_collection', 'delete_any_collection') \
AND data_type = 'tinyint' \
AND is_nullable = 'NO' \
AND LOWER(COALESCE(CAST(column_default AS CHAR), '')) IN ('0', 'false')",
)?;
let unexpected_values =
count(connection, super::mysql_partial_unexpected_values_query(allow_same_run_group_derived))?;
if matching_column_definitions != 3 || unexpected_values != 0 {
return Err(std::io::Error::other(format!(
"Custom-role migration preflight found the historical MySQL partial \
{version} schema, but its column definitions or data were modified \
(matching columns: {matching_column_definitions}/3, unexpected rows: \
{unexpected_values}). Refusing automatic recovery. Back up the database and \
resolve the partial migration manually before restarting.",
version = super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION,
))
.into());
}
connection.transaction::<(), diesel::result::Error, _>(|connection| {
// This is the first data statement from the canonical migration. It also resets an
// exact, same-run group-derived 0/1/1 row to 0/0/0; that authority remains dynamically
// derived from the group, and the separate 07-23 repair then reconciles the role.
diesel::sql_query(
"UPDATE users_organizations \
SET create_new_collections = access_all, \
edit_any_collection = access_all, \
delete_any_collection = access_all \
WHERE atype = 4",
)
.execute(connection)?;
diesel::sql_query(format!(
"INSERT INTO __diesel_schema_migrations (version) \
VALUES ('{}')",
super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION
))
.execute(connection)?;
Ok(())
})?;
Ok(())
}
fn preflight(connection: &mut diesel::mysql::MysqlConnection) -> Result<(), super::Error> {
let memberships_table_exists = table_exists(connection, "users_organizations")?;
if !memberships_table_exists {
return Ok(());
}
let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?;
let access_all_column_exists = count(
connection,
"SELECT COUNT(*) AS count FROM information_schema.columns \
WHERE table_schema = DATABASE() \
AND table_name = 'users_organizations' \
AND column_name = 'access_all'",
)? != 0;
let collection_permission_columns = count(
connection,
"SELECT COUNT(*) AS count FROM information_schema.columns \
WHERE table_schema = DATABASE() \
AND table_name = 'users_organizations' \
AND column_name IN \
('create_new_collections', 'edit_any_collection', 'delete_any_collection')",
)?;
let collection_permissions_migration_applied =
migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?;
let repair_migration_applied =
migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?;
let access_all_drop_migration_applied =
migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?;
let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?;
let same_run_0716_marker = same_run_marker_table_exists
&& count(
connection,
format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE),
)? != 0;
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 ambiguous_direct_permission_count = if access_all_column_exists && collection_permission_columns == 3 {
count(
connection,
"SELECT COUNT(*) AS count FROM users_organizations \
WHERE atype IN (3, 4) \
AND access_all = FALSE \
AND create_new_collections = FALSE \
AND edit_any_collection = TRUE \
AND delete_any_collection = TRUE",
)?
} else {
0
};
let facts = super::CustomRoleMigrationFacts {
memberships_table_exists,
migration_table_exists,
access_all_column_exists,
collection_permission_columns,
collection_permissions_migration_applied,
repair_migration_applied,
access_all_drop_migration_applied,
legacy_user_access_all_count,
ambiguous_direct_permission_count,
same_run_0716_marker,
};
match super::custom_role_preflight_decision(facts, true) {
super::CustomRolePreflightDecision::Proceed => Ok(()),
super::CustomRolePreflightDecision::CompleteMysqlCollectionMigration => {
complete_partial_collection_migration(connection, same_run_0716_marker)
}
decision => Err(super::custom_role_preflight_error(decision, facts)),
}
}
pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { 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) // 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)?; let mut connection = diesel::mysql::MysqlConnection::establish(db_url)?;
preflight(&mut connection)?;
// Disable Foreign Key Checks during migration // Disable Foreign Key Checks during migration
// Scoped to a connection/session. // Scoped to a connection/session.
diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0") diesel::sql_query("SET FOREIGN_KEY_CHECKS = 0")
@ -522,15 +986,315 @@ mod mysql_migrations {
#[cfg(postgresql)] #[cfg(postgresql)]
mod postgresql_migrations { mod postgresql_migrations {
use diesel::Connection; use diesel::{Connection, RunQueryDsl};
use diesel_migrations::{EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations/postgresql");
#[derive(diesel::QueryableByName)]
struct Count {
#[diesel(sql_type = diesel::sql_types::BigInt)]
count: i64,
}
fn count(
connection: &mut diesel::pg::PgConnection,
query: impl Into<String>,
) -> Result<i64, diesel::result::Error> {
diesel::sql_query(query).get_result::<Count>(connection).map(|row| row.count)
}
fn table_exists(connection: &mut diesel::pg::PgConnection, table: &str) -> Result<bool, diesel::result::Error> {
count(
connection,
format!(
"SELECT COUNT(*) AS count FROM information_schema.tables \
WHERE table_schema = current_schema() AND table_name = '{table}'"
),
)
.map(|value| value != 0)
}
fn migration_applied(
connection: &mut diesel::pg::PgConnection,
version: &str,
) -> Result<bool, diesel::result::Error> {
count(
connection,
format!(
"SELECT COUNT(*) AS count FROM __diesel_schema_migrations \
WHERE version = '{version}'"
),
)
.map(|value| value != 0)
}
fn preflight(connection: &mut diesel::pg::PgConnection) -> Result<(), super::Error> {
let memberships_table_exists = table_exists(connection, "users_organizations")?;
if !memberships_table_exists {
return Ok(());
}
let migration_table_exists = table_exists(connection, "__diesel_schema_migrations")?;
let access_all_column_exists = count(
connection,
"SELECT COUNT(*) AS count FROM information_schema.columns \
WHERE table_schema = current_schema() \
AND table_name = 'users_organizations' \
AND column_name = 'access_all'",
)? != 0;
let collection_permission_columns = count(
connection,
"SELECT COUNT(*) AS count FROM information_schema.columns \
WHERE table_schema = current_schema() \
AND table_name = 'users_organizations' \
AND column_name IN \
('create_new_collections', 'edit_any_collection', 'delete_any_collection')",
)?;
let collection_permissions_migration_applied =
migration_table_exists && migration_applied(connection, super::CUSTOM_COLLECTION_PERMISSIONS_MIGRATION)?;
let repair_migration_applied =
migration_table_exists && migration_applied(connection, super::CUSTOM_ROLE_REPAIR_MIGRATION)?;
let access_all_drop_migration_applied =
migration_table_exists && migration_applied(connection, super::DROP_MEMBERSHIP_ACCESS_ALL_MIGRATION)?;
let same_run_marker_table_exists = table_exists(connection, super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE)?;
let same_run_0716_marker = same_run_marker_table_exists
&& count(
connection,
format!("SELECT COUNT(*) AS count FROM {} WHERE marker = 1", super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE),
)? != 0;
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 ambiguous_direct_permission_count = if access_all_column_exists && collection_permission_columns == 3 {
count(
connection,
"SELECT COUNT(*) AS count FROM users_organizations \
WHERE atype IN (3, 4) \
AND access_all = FALSE \
AND create_new_collections = FALSE \
AND edit_any_collection = TRUE \
AND delete_any_collection = TRUE",
)?
} else {
0
};
let facts = super::CustomRoleMigrationFacts {
memberships_table_exists,
migration_table_exists,
access_all_column_exists,
collection_permission_columns,
collection_permissions_migration_applied,
repair_migration_applied,
access_all_drop_migration_applied,
legacy_user_access_all_count,
ambiguous_direct_permission_count,
same_run_0716_marker,
};
let decision = super::custom_role_preflight_decision(facts, false);
if decision == super::CustomRolePreflightDecision::Proceed {
Ok(())
} else {
Err(super::custom_role_preflight_error(decision, facts))
}
}
pub fn run_migrations(db_url: &str) -> Result<(), super::Error> { 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) // 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)?; let mut connection = diesel::pg::PgConnection::establish(db_url)?;
preflight(&mut connection)?;
connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations"); connection.run_pending_migrations(MIGRATIONS).expect("Error running migrations");
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod custom_role_migration_preflight_tests {
use super::{
CustomRoleMigrationFacts as Facts, CustomRolePreflightDecision as Decision, custom_role_preflight_decision,
mysql_partial_unexpected_values_query,
};
fn pending_repair() -> Facts {
Facts {
memberships_table_exists: true,
migration_table_exists: true,
access_all_column_exists: true,
..Facts::default()
}
}
#[test]
fn empty_database_can_run_normal_migrations() {
assert_eq!(custom_role_preflight_decision(Facts::default(), false), Decision::Proceed);
}
#[test]
fn existing_schema_without_a_ledger_is_not_guessed() {
assert_eq!(
custom_role_preflight_decision(
Facts {
memberships_table_exists: true,
access_all_column_exists: true,
..Facts::default()
},
false,
),
Decision::RefuseMissingMigrationLedger
);
}
#[test]
fn repair_marker_makes_completed_state_idempotent() {
assert_eq!(
custom_role_preflight_decision(
Facts {
memberships_table_exists: true,
migration_table_exists: true,
repair_migration_applied: true,
access_all_drop_migration_applied: true,
collection_permission_columns: 3,
..Facts::default()
},
false,
),
Decision::Proceed
);
}
#[test]
fn a_historical_drop_without_the_repair_is_refused() {
assert_eq!(
custom_role_preflight_decision(
Facts {
access_all_drop_migration_applied: true,
access_all_column_exists: false,
..pending_repair()
},
false,
),
Decision::RefuseAlreadyDropped
);
}
#[test]
fn legacy_user_access_all_requires_an_operator_decision() {
assert_eq!(
custom_role_preflight_decision(
Facts {
legacy_user_access_all_count: 1,
..pending_repair()
},
false,
),
Decision::RefuseLegacyUserAccessAll
);
}
#[test]
fn group_derived_zero_permissions_are_safe_but_ambiguous_direct_permissions_are_refused() {
assert_eq!(custom_role_preflight_decision(pending_repair(), false), Decision::Proceed);
assert_eq!(
custom_role_preflight_decision(
Facts {
collection_permission_columns: 3,
collection_permissions_migration_applied: true,
..pending_repair()
},
false,
),
Decision::Proceed
);
assert_eq!(
custom_role_preflight_decision(
Facts {
collection_permission_columns: 3,
collection_permissions_migration_applied: true,
ambiguous_direct_permission_count: 1,
..pending_repair()
},
false,
),
Decision::RefuseAmbiguousDirectPermissions
);
assert_eq!(
custom_role_preflight_decision(
Facts {
collection_permission_columns: 3,
collection_permissions_migration_applied: true,
ambiguous_direct_permission_count: 1,
same_run_0716_marker: true,
..pending_repair()
},
false,
),
Decision::Proceed
);
}
#[test]
fn exact_mysql_partial_schema_uses_only_the_mysql_completion_path() {
let facts = Facts {
collection_permission_columns: 3,
collection_permissions_migration_applied: false,
..pending_repair()
};
assert_eq!(custom_role_preflight_decision(facts, true), Decision::CompleteMysqlCollectionMigration);
assert_eq!(custom_role_preflight_decision(facts, false), Decision::RefusePartialCollectionSchema);
}
#[test]
fn historical_mysql_partial_query_does_not_require_the_new_marker_table() {
let query = mysql_partial_unexpected_values_query(false);
assert!(!query.contains(super::CUSTOM_ROLE_SAME_RUN_MARKER_TABLE));
assert!(!query.contains("groups_users"));
}
#[test]
fn same_run_mysql_partial_query_requires_the_current_group_source() {
let query = mysql_partial_unexpected_values_query(true);
assert!(query.contains("access_all = FALSE"));
assert!(query.contains("edit_any_collection = TRUE"));
assert!(query.contains("delete_any_collection = TRUE"));
assert!(query.contains("INNER JOIN `groups` AS g"));
assert!(query.contains("g.organizations_uuid = users_organizations.org_uuid"));
assert!(query.contains("g.access_all = TRUE"));
}
#[test]
fn incomplete_columns_and_ledger_mismatch_are_refused() {
assert_eq!(
custom_role_preflight_decision(
Facts {
collection_permission_columns: 2,
..pending_repair()
},
true,
),
Decision::RefusePartialCollectionSchema
);
assert_eq!(
custom_role_preflight_decision(
Facts {
collection_permission_columns: 2,
collection_permissions_migration_applied: true,
..pending_repair()
},
true,
),
Decision::RefuseCollectionLedgerMismatch
);
}
}

36
src/db/models/cipher.rs

@ -889,7 +889,12 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))), .and(collections_groups::groups_uuid.eq(groups::uuid))),
) )
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org // Edit any collection (Custom) or org admin/owner — the successor of access_all
.or_filter(
users_organizations::edit_any_collection
.eq(true)
.or(users_organizations::atype.le(MembershipType::Admin as i32)),
)
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.or_filter(groups::access_all.eq(true)) // Access via groups .or_filter(groups::access_all.eq(true)) // Access via groups
.or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups .or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups
@ -926,7 +931,12 @@ impl Cipher {
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))), .and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
) )
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org // Edit any collection (Custom) or org admin/owner — the successor of access_all
.or_filter(
users_organizations::edit_any_collection
.eq(true)
.or(users_organizations::atype.le(MembershipType::Admin as i32)),
)
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.into_boxed(); .into_boxed();
@ -1049,8 +1059,9 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))), .and(collections_groups::groups_uuid.eq(groups::uuid))),
) )
.filter( .filter(
users_organizations::access_all users_organizations::edit_any_collection
.eq(true) // User has access all .eq(true) // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
@ -1080,8 +1091,9 @@ impl Cipher {
.and(users_collections::user_uuid.eq(user_uuid.clone()))), .and(users_collections::user_uuid.eq(user_uuid.clone()))),
) )
.filter( .filter(
users_organizations::access_all users_organizations::edit_any_collection
.eq(true) // User has access all .eq(true) // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))), .and(users_collections::read_only.eq(false))),
@ -1124,8 +1136,9 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))), .and(collections_groups::groups_uuid.eq(groups::uuid))),
) )
.filter( .filter(
users_organizations::access_all users_organizations::edit_any_collection
.eq(true) // User has access all .eq(true) // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
@ -1156,8 +1169,9 @@ impl Cipher {
.and(users_collections::user_uuid.eq(user_uuid.clone()))), .and(users_collections::user_uuid.eq(user_uuid.clone()))),
) )
.filter( .filter(
users_organizations::access_all users_organizations::edit_any_collection
.eq(true) // User has access all .eq(true) // Custom "Edit any collection" (successor of access_all)
.or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
@ -1202,7 +1216,7 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))), .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_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::edit_any_collection.eq(true)) // Custom "Edit any collection" (successor of access_all)
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group .or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group

171
src/db/models/collection.rs

@ -1,5 +1,6 @@
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
use num_traits::FromPrimitive;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
@ -52,6 +53,16 @@ pub struct CollectionCipher {
pub collection_uuid: CollectionId, 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.
pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool {
match MembershipType::from_i32(membership_type) {
Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Custom) => stored_manage,
Some(MembershipType::User) | None => false,
}
}
/// Local methods /// Local methods
impl Collection { impl Collection {
pub fn new(org_uuid: OrganizationId, name: String, external_id: Option<String>) -> Self { pub fn new(org_uuid: OrganizationId, name: String, external_id: Option<String>) -> Self {
@ -104,25 +115,14 @@ impl Collection {
) -> Value { ) -> Value {
let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data { let (read_only, hide_passwords, manage) = if let Some(cipher_sync_data) = cipher_sync_data {
match cipher_sync_data.members.get(&self.org_uuid) { match cipher_sync_data.members.get(&self.org_uuid) {
// Only for Manager types Bitwarden returns true for the manage option // Full collection visibility is not collection-management authority. Admins and
// Owners and Admins always have true. Users are not able to have full access // Owners manage implicitly; Custom members still need an explicit stored grant.
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) if m.has_full_access() => (false, false, assignment_manage_for_member(m.atype, false)),
Some(m) => { Some(m) => {
// Only let a manager-level member (Manager or Custom) manage collections
// when they have full read/write access
let is_manager = m.atype >= MembershipType::Manager;
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
( (cu.read_only, cu.hide_passwords, assignment_manage_for_member(m.atype, cu.manage))
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) { } else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
( (cg.read_only, cg.hide_passwords, assignment_manage_for_member(m.atype, cg.manage))
cg.read_only,
cg.hide_passwords,
is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)),
)
} else { } else {
(false, false, false) (false, false, false)
} }
@ -131,15 +131,17 @@ impl Collection {
} }
} else { } else {
match Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await { 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.has_full_access() => (false, false, assignment_manage_for_member(m.atype, false)),
Some(m) if m.atype >= MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => { Some(m)
if m.atype >= MembershipType::Custom
&& m.has_explicit_collection_manage_access(&self.uuid, conn).await =>
{
(false, false, true) (false, false, true)
} }
Some(m) => { Some(_) => {
let is_manager = m.atype >= MembershipType::Manager;
let read_only = !self.is_writable_by_user(user_uuid, conn).await; let read_only = !self.is_writable_by_user(user_uuid, conn).await;
let hide_passwords = self.hide_passwords_for_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), _ => (true, true, false),
} }
@ -261,8 +263,11 @@ impl Collection {
users_collections::user_uuid users_collections::user_uuid
.eq(user_uuid) .eq(user_uuid)
.or( .or(
// Directly accessed collection // Full-access member: Custom "Edit any collection" or org admin/owner
users_organizations::access_all.eq(true), // access_all in Organization // (successor of the removed membership access_all)
users_organizations::edit_any_collection
.eq(true)
.or(users_organizations::atype.le(MembershipType::Admin as i32)),
) )
.or( .or(
groups::access_all.eq(true), // access_all in groups groups::access_all.eq(true), // access_all in groups
@ -294,10 +299,15 @@ impl Collection {
.and(users_organizations::user_uuid.eq(user_uuid.clone()))), .and(users_organizations::user_uuid.eq(user_uuid.clone()))),
) )
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_collections::user_uuid.eq(user_uuid).or( .filter(
// Directly accessed collection users_collections::user_uuid.eq(user_uuid).or(
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)
users_organizations::edit_any_collection
.eq(true)
.or(users_organizations::atype.le(MembershipType::Admin as i32)),
),
)
.select(collections::all_columns) .select(collections::all_columns)
.distinct() .distinct()
.load::<Self>(conn) .load::<Self>(conn)
@ -381,8 +391,8 @@ impl Collection {
.eq(uuid) .eq(uuid)
.or( .or(
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::edit_any_collection.eq(true).or(
// access_all in Organization // Custom "Edit any collection" or org admin/owner (successor of access_all)
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
), ),
) )
@ -417,8 +427,8 @@ impl Collection {
.filter(collections::uuid.eq(uuid)) .filter(collections::uuid.eq(uuid))
.filter(users_collections::collection_uuid.eq(uuid).or( .filter(users_collections::collection_uuid.eq(uuid).or(
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::edit_any_collection.eq(true).or(
// access_all in Organization // Custom "Edit any collection" or org admin/owner (successor of access_all)
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
), ),
)) ))
@ -462,7 +472,7 @@ impl Collection {
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .le(MembershipType::Admin as i32) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::edit_any_collection.eq(true)) // Custom "Edit any collection" (successor of access_all)
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
@ -495,7 +505,7 @@ impl Collection {
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .le(MembershipType::Admin as i32) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_organizations::edit_any_collection.eq(true)) // Custom "Edit any collection" (successor of access_all)
.or(users_collections::collection_uuid .or(users_collections::collection_uuid
.eq(&self.uuid) // write access given to collection .eq(&self.uuid) // write access given to collection
.and(users_collections::read_only.eq(false))), .and(users_collections::read_only.eq(false))),
@ -542,8 +552,8 @@ impl Collection {
.and(users_collections::hide_passwords.eq(true)) .and(users_collections::hide_passwords.eq(true))
.or( .or(
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( users_organizations::edit_any_collection.eq(true).or(
// access_all in Organization // Custom "Edit any collection" or org admin/owner (successor of access_all)
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
), ),
) )
@ -568,71 +578,8 @@ impl Collection {
.await .await
} }
pub async fn is_coll_manageable_by_user(uuid: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool {
let uuid = uuid.to_string();
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
collections::table
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
.left_join(
groups::table.on(groups::uuid
.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.left_join(
collections_groups::table.on(collections_groups::groups_uuid
.eq(groups_users::groups_uuid)
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&uuid))
.filter(
users_collections::collection_uuid
.eq(&uuid)
.and(users_collections::manage.eq(true))
.or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
// access_all in Organization
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner
),
)
.or(
groups::access_all.eq(true), // access_all in groups
)
.or(
// access via groups
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
collections_groups::collections_uuid
.is_not_null()
.and(collections_groups::manage.eq(true)),
),
),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
}
// Whether the user has manage access to at least one collection in the org, directly or via a // 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( pub async fn has_manageable_collection_by_user(
org_uuid: &OrganizationId, org_uuid: &OrganizationId,
user_uuid: &UserId, user_uuid: &UserId,
@ -659,6 +606,8 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))), .and(collections_groups::collections_uuid.eq(collections::uuid))),
) )
.filter(collections::org_uuid.eq(&org_uuid)) .filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq(MembershipType::Custom as i32))
.filter( .filter(
// Manage permission on a collection assigned directly or via a group. // Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)), users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
@ -991,11 +940,7 @@ impl CollectionMembership {
"id": self.membership_uuid, "id": self.membership_uuid,
"readOnly": self.read_only, "readOnly": self.read_only,
"hidePasswords": self.hide_passwords, "hidePasswords": self.hide_passwords,
"manage": membership_type >= MembershipType::Admin "manage": assignment_manage_for_member(membership_type, self.manage),
|| self.manage
|| (membership_type >= MembershipType::Manager
&& !self.read_only
&& !self.hide_passwords),
}) })
} }
} }
@ -1029,3 +974,21 @@ impl From<CollectionUser> for CollectionMembership {
UuidFromParam, UuidFromParam,
)] )]
pub struct CollectionId(String); pub struct CollectionId(String);
#[cfg(test)]
mod tests {
use super::assignment_manage_for_member;
use crate::db::models::MembershipType;
#[test]
fn assignment_manage_matches_collection_guard_role_boundaries() {
for role in [MembershipType::Owner, MembershipType::Admin] {
assert!(assignment_manage_for_member(role as i32, false));
}
assert!(assignment_manage_for_member(MembershipType::Custom as i32, true));
assert!(!assignment_manage_for_member(MembershipType::Custom as i32, false));
assert!(!assignment_manage_for_member(MembershipType::User as i32, true));
assert!(!assignment_manage_for_member(i32::MAX, true));
}
}

256
src/db/models/organization.rs

@ -25,7 +25,7 @@ use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
OrgPolicyType, TwoFactor, User, UserId, OrgPolicyType, TwoFactor, User, UserId, collection::assignment_manage_for_member as assignment_manage,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -52,7 +52,6 @@ pub struct Membership {
pub invited_by_email: Option<String>, pub invited_by_email: Option<String>,
pub access_all: bool,
pub akey: String, pub akey: String,
pub status: i32, pub status: i32,
pub atype: i32, pub atype: i32,
@ -64,6 +63,9 @@ pub struct Membership {
pub create_new_collections: bool, pub create_new_collections: bool,
pub edit_any_collection: bool, pub edit_any_collection: bool,
pub delete_any_collection: bool, pub delete_any_collection: bool,
pub access_event_logs: bool,
pub access_import_export: bool,
pub access_reports: bool,
} }
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -104,7 +106,10 @@ pub enum MembershipType {
Owner = 0, Owner = 0,
Admin = 1, Admin = 1,
User = 2, 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, Custom = 4,
} }
@ -114,8 +119,10 @@ impl MembershipType {
"0" | "Owner" => Some(MembershipType::Owner), "0" | "Owner" => Some(MembershipType::Owner),
"1" | "Admin" => Some(MembershipType::Admin), "1" | "Admin" => Some(MembershipType::Admin),
"2" | "User" => Some(MembershipType::User), "2" | "User" => Some(MembershipType::User),
"3" | "Manager" => Some(MembershipType::Manager), // "3"/"Manager" is the legacy Manager role. Modern clients no longer offer it, but an old
"4" | "Custom" => Some(MembershipType::Custom), // 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, _ => None,
} }
} }
@ -123,7 +130,7 @@ impl MembershipType {
const fn access_rank(self) -> u8 { const fn access_rank(self) -> u8 {
match self { match self {
Self::User => 0, Self::User => 0,
Self::Manager | Self::Custom => 1, Self::Custom => 1,
Self::Admin => 2, Self::Admin => 2,
Self::Owner => 3, Self::Owner => 3,
} }
@ -132,11 +139,9 @@ impl MembershipType {
impl Ord for MembershipType { impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering { fn cmp(&self, other: &MembershipType) -> Ordering {
// Manager and Custom intentionally share the same authorization rank. A total ordering // Roles are ordered by their authorization rank, not by their raw discriminant (Custom's
// still has to distinguish unequal enum variants, otherwise `Ord` would disagree with // discriminant is 4 but it ranks between User and Admin). The discriminant is kept as a
// `Eq` and ordered maps/sets could collapse one role into the other. The discriminant is a // stable tie-breaker so `Ord` never disagrees with `Eq`.
// stable tie-breaker and places Custom after Manager, preserving `Custom >= Manager` while
// keeping both roles below Admin.
self.access_rank().cmp(&other.access_rank()).then_with(|| (*self as i32).cmp(&(*other as i32))) self.access_rank().cmp(&other.access_rank()).then_with(|| (*self as i32).cmp(&(*other as i32)))
} }
} }
@ -278,7 +283,6 @@ impl Membership {
org_uuid, org_uuid,
invited_by_email, invited_by_email,
access_all: false,
akey: String::new(), akey: String::new(),
status: MembershipStatus::Accepted as i32, status: MembershipStatus::Accepted as i32,
atype: MembershipType::User as i32, atype: MembershipType::User as i32,
@ -290,6 +294,9 @@ impl Membership {
create_new_collections: false, create_new_collections: false,
edit_any_collection: false, edit_any_collection: false,
delete_any_collection: false, delete_any_collection: false,
access_event_logs: false,
access_import_export: false,
access_reports: false,
} }
} }
@ -460,9 +467,9 @@ impl Membership {
let membership_type = self.atype; let membership_type = self.atype;
let permissions = json!({ let permissions = json!({
"accessEventLogs": false, "accessEventLogs": membership_type == MembershipType::Custom as i32 && self.access_event_logs,
"accessImportExport": false, "accessImportExport": membership_type == MembershipType::Custom as i32 && self.access_import_export,
"accessReports": false, "accessReports": membership_type == MembershipType::Custom as i32 && self.access_reports,
"createNewCollections": membership_type == MembershipType::Custom as i32 && self.create_new_collections, "createNewCollections": membership_type == MembershipType::Custom as i32 && self.create_new_collections,
"editAnyCollection": membership_type == MembershipType::Custom as i32 && self.edit_any_collection, "editAnyCollection": membership_type == MembershipType::Custom as i32 && self.edit_any_collection,
"deleteAnyCollection": membership_type == MembershipType::Custom as i32 && self.delete_any_collection, "deleteAnyCollection": membership_type == MembershipType::Custom as i32 && self.delete_any_collection,
@ -474,10 +481,9 @@ impl Membership {
"manageScim": false // Not supported (Not AGPLv3 Licensed) "manageScim": false // Not supported (Not AGPLv3 Licensed)
}); });
// edit_any_collection is internally mirrored to access_all to provide Bitwarden-compatible // Edit any collection grants full read/edit access to every collection, but it must not
// cipher access, but it must not accidentally grant collection creation. The client treats // accidentally grant collection creation. The client treats limitCollectionCreation=false as
// limitCollectionCreation=false as an independent create grant, so compute it from the // an independent create grant, so compute it from the actual role/permission.
// actual role/permission rather than access_all for Custom members.
let limit_collection_creation = self.limit_collection_creation(); let limit_collection_creation = self.limit_collection_creation();
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
@ -578,53 +584,50 @@ impl Membership {
CONFIG.org_groups_enabled() && Group::is_in_full_access_group(&self.user_uuid, &self.org_uuid, conn).await; 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 // If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) { let collections: Vec<Value> =
// Get all collections for the user here already to prevent more queries if include_collections && !(full_access_group || self.grants_access_to_all_collections()) {
let cu: HashMap<CollectionId, CollectionUser> = // Get all collections for the user here already to prevent more queries
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) let cu: HashMap<CollectionId, CollectionUser> =
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await
.into_iter()
.map(|cu| (cu.collection_uuid.clone(), cu))
.collect();
// Get all collection groups for this user to prevent there inclusion
let cg: HashSet<CollectionId> = CollectionGroup::find_by_user(&self.user_uuid, conn)
.await .await
.into_iter() .into_iter()
.map(|cu| (cu.collection_uuid.clone(), cu)) .map(|cg| cg.collections_uuid)
.collect(); .collect();
// Get all collection groups for this user to prevent there inclusion Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
let cg: HashSet<CollectionId> = CollectionGroup::find_by_user(&self.user_uuid, conn) .await
.await .into_iter()
.into_iter() .filter_map(|c| {
.map(|cg| cg.collections_uuid) let (read_only, hide_passwords, manage) = if self.has_full_access() {
.collect(); (false, false, assignment_manage(self.atype, false))
} else if let Some(cu) = cu.get(&c.uuid) {
Collection::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) (cu.read_only, cu.hide_passwords, assignment_manage(self.atype, cu.manage))
.await // If previous checks failed it might be that this user has access via a group, but we should not return those elements here
.into_iter() // Those are returned via a special group endpoint
.filter_map(|c| { } else if cg.contains(&c.uuid) {
let (read_only, hide_passwords, manage) = if self.has_full_access() { return None;
(false, false, self.atype >= MembershipType::Manager) } else {
} else if let Some(cu) = cu.get(&c.uuid) { (true, true, false)
( };
cu.read_only,
cu.hide_passwords, Some(json!({
cu.manage || (self.atype >= MembershipType::Manager && !cu.read_only && !cu.hide_passwords), "id": c.uuid,
) "readOnly": read_only,
// If previous checks failed it might be that this user has access via a group, but we should not return those elements here "hidePasswords": hide_passwords,
// Those are returned via a special group endpoint "manage": manage,
} else if cg.contains(&c.uuid) { }))
return None; })
} else { .collect()
(true, true, false) } else {
}; Vec::new()
};
Some(json!({
"id": c.uuid,
"readOnly": read_only,
"hidePasswords": hide_passwords,
"manage": manage,
}))
})
.collect()
} else {
Vec::new()
};
let membership_type = self.atype; let membership_type = self.atype;
@ -632,9 +635,9 @@ impl Membership {
// all-false defaults and the role itself supplies any elevated capabilities. // all-false defaults and the role itself supplies any elevated capabilities.
let permissions = if membership_type == MembershipType::Custom as i32 { let permissions = if membership_type == MembershipType::Custom as i32 {
json!({ json!({
"accessEventLogs": false, "accessEventLogs": self.access_event_logs,
"accessImportExport": false, "accessImportExport": self.access_import_export,
"accessReports": false, "accessReports": self.access_reports,
"createNewCollections": self.create_new_collections, "createNewCollections": self.create_new_collections,
"editAnyCollection": self.edit_any_collection, "editAnyCollection": self.edit_any_collection,
"deleteAnyCollection": self.delete_any_collection, "deleteAnyCollection": self.delete_any_collection,
@ -661,7 +664,9 @@ impl Membership {
"status": status, "status": status,
"type": membership_type, "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, "twoFactorEnabled": twofactor_enabled,
"resetPasswordEnrolled": self.reset_password_key.is_some(), "resetPasswordEnrolled": self.reset_password_key.is_some(),
"hasMasterPassword": !user.password_hash.is_empty(), "hasMasterPassword": !user.password_hash.is_empty(),
@ -688,7 +693,7 @@ impl Membership {
} }
pub async fn to_json_details(&self, conn: &DbConn) -> Value { 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 vec![] // If we have complete access, no need to fill the array
} else { } else {
let collections = let collections =
@ -720,7 +725,8 @@ impl Membership {
"status": status, "status": status,
"type": self.atype, "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, "collections": coll_uuids,
"object": "organizationUserDetails", "object": "organizationUserDetails",
@ -829,10 +835,19 @@ impl Membership {
} }
pub fn has_full_access(&self) -> bool { pub fn has_full_access(&self) -> bool {
(self.access_all || self.has_edit_any_collection() || self.atype >= MembershipType::Admin) (self.has_edit_any_collection() || self.atype >= MembershipType::Admin)
&& self.has_status(MembershipStatus::Confirmed) && self.has_status(MembershipStatus::Confirmed)
} }
/// Whether this membership reaches every collection in the org regardless of per-collection
/// assignments — Admins/Owners implicitly, and Custom members holding `edit_any_collection`.
/// This is the successor of the removed `access_all` flag: it backs the `accessAll` field the
/// Bitwarden clients still read, and it intentionally does not gate on status, matching the old
/// column's semantics. Authorization decisions use the status-aware `has_full_access` instead.
pub fn grants_access_to_all_collections(&self) -> bool {
self.atype >= MembershipType::Admin || self.has_edit_any_collection()
}
// The granular custom permission flags are only meaningful while the membership is of // The granular custom permission flags are only meaningful while the membership is of
// the Custom type. Gating them on the type here ensures that a stale flag left over from // the Custom type. Gating them on the type here ensures that a stale flag left over from
// a type change (e.g. via the admin panel) can never grant anything. // a type change (e.g. via the admin panel) can never grant anything.
@ -860,6 +875,18 @@ impl Membership {
self.has_type(MembershipType::Custom) && self.delete_any_collection self.has_type(MembershipType::Custom) && self.delete_any_collection
} }
pub fn has_access_event_logs(&self) -> bool {
self.has_type(MembershipType::Custom) && self.access_event_logs
}
pub fn has_access_import_export(&self) -> bool {
self.has_type(MembershipType::Custom) && self.access_import_export
}
pub fn has_access_reports(&self) -> bool {
self.has_type(MembershipType::Custom) && self.access_reports
}
/// Check for an explicit per-collection Manage grant without treating any `access_all` value /// Check for an explicit per-collection Manage grant without treating any `access_all` value
/// as such a grant. Custom-role collection guards use this instead of the legacy broad helper, /// as such a grant. Custom-role collection guards use this instead of the legacy broad helper,
/// because membership/group `access_all` must not manufacture a per-collection Manage grant. /// because membership/group `access_all` must not manufacture a per-collection Manage grant.
@ -883,6 +910,7 @@ impl Membership {
.filter(users_organizations::user_uuid.eq(user_uuid.clone())) .filter(users_organizations::user_uuid.eq(user_uuid.clone()))
.filter(users_organizations::org_uuid.eq(org_uuid.clone())) .filter(users_organizations::org_uuid.eq(org_uuid.clone()))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq(MembershipType::Custom as i32))
.filter(collections::uuid.eq(collection_uuid.clone())) .filter(collections::uuid.eq(collection_uuid.clone()))
.filter(users_collections::manage.eq(true)) .filter(users_collections::manage.eq(true))
.count() .count()
@ -913,6 +941,7 @@ impl Membership {
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq(MembershipType::Custom as i32))
.filter(collections::uuid.eq(collection_uuid)) .filter(collections::uuid.eq(collection_uuid))
.filter(collections_groups::manage.eq(true)) .filter(collections_groups::manage.eq(true))
.count() .count()
@ -929,9 +958,8 @@ impl Membership {
self.has_create_new_collections() && self.has_edit_any_collection() && self.has_delete_any_collection() 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 new Custom /// Match Vaultwarden's existing collection-creation policy while keeping the Custom
/// permission independent from edit/delete. Legacy Manager memberships retain their former /// permission independent from edit/delete.
/// access_all-based behavior.
pub fn can_create_new_collections(&self) -> bool { pub fn can_create_new_collections(&self) -> bool {
if !self.has_status(MembershipStatus::Confirmed) { if !self.has_status(MembershipStatus::Confirmed) {
return false; return false;
@ -939,7 +967,6 @@ impl Membership {
match MembershipType::from_i32(self.atype) { match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => true, Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Manager) => self.access_all,
Some(MembershipType::Custom) => self.create_new_collections, Some(MembershipType::Custom) => self.create_new_collections,
Some(MembershipType::User) | None => false, Some(MembershipType::User) | None => false,
} }
@ -948,7 +975,6 @@ impl Membership {
pub fn limit_collection_creation(&self) -> bool { pub fn limit_collection_creation(&self) -> bool {
match MembershipType::from_i32(self.atype) { match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => false, Some(MembershipType::Owner | MembershipType::Admin) => false,
Some(MembershipType::Manager) => !self.access_all,
Some(MembershipType::Custom) => !self.create_new_collections, Some(MembershipType::Custom) => !self.create_new_collections,
Some(MembershipType::User) | None => true, Some(MembershipType::User) | None => true,
} }
@ -966,6 +992,9 @@ impl Membership {
self.create_new_collections = false; self.create_new_collections = false;
self.edit_any_collection = false; self.edit_any_collection = false;
self.delete_any_collection = false; self.delete_any_collection = false;
self.access_event_logs = false;
self.access_import_export = false;
self.access_reports = false;
} }
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
@ -1074,7 +1103,7 @@ impl Membership {
.await .await
} }
// Get all users which are either owner or admin, or a manager/custom member which can manage/access all // Get all users which are either owner or admin, or a Custom member which can access all collections
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> { pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
@ -1084,8 +1113,8 @@ impl Membership {
users_organizations::atype users_organizations::atype
.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]) .eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32])
.or(users_organizations::atype .or(users_organizations::atype
.eq_any(vec![MembershipType::Manager as i32, MembershipType::Custom as i32]) .eq(MembershipType::Custom as i32)
.and(users_organizations::access_all.eq(true))), .and(users_organizations::edit_any_collection.eq(true))),
) )
.load::<Self>(conn) .load::<Self>(conn)
.unwrap_or_default() .unwrap_or_default()
@ -1209,10 +1238,12 @@ impl Membership {
.eq(users_collections::collection_uuid) .eq(users_collections::collection_uuid)
.and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))), .and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))),
) )
.filter(users_organizations::access_all.eq(true).or( .filter(
// AccessAll.. users_organizations::edit_any_collection
ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection with cipher .eq(true) // Custom "Edit any collection" (successor of access_all)
)) .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner
.or(ciphers_collections::cipher_uuid.eq(&cipher_uuid)), // ..or access to collection with cipher
)
.select(users_organizations::all_columns) .select(users_organizations::all_columns)
.distinct() .distinct()
.load::<Self>(conn) .load::<Self>(conn)
@ -1285,10 +1316,12 @@ impl Membership {
users_organizations::table users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid))
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
.filter(users_organizations::access_all.eq(true).or( .filter(
// AccessAll.. users_organizations::edit_any_collection
users_collections::collection_uuid.eq(&collection_uuid), // ..or access to collection with cipher .eq(true) // Custom "Edit any collection" (successor of access_all)
)) .or(users_organizations::atype.le(MembershipType::Admin as i32)) // or org admin/owner
.or(users_collections::collection_uuid.eq(&collection_uuid)), // ..or access to collection
)
.select(users_organizations::all_columns) .select(users_organizations::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading user organizations") .expect("Error loading user organizations")
@ -1424,23 +1457,17 @@ mod tests {
fn membership_type_order_preserves_access_rank_and_ord_contract() { fn membership_type_order_preserves_access_rank_and_ord_contract() {
assert!(MembershipType::Owner > MembershipType::Admin); assert!(MembershipType::Owner > MembershipType::Admin);
assert!(MembershipType::Admin > MembershipType::Custom); assert!(MembershipType::Admin > MembershipType::Custom);
assert!(MembershipType::Custom > MembershipType::Manager); assert!(MembershipType::Custom > MembershipType::User);
assert!(MembershipType::Manager > MembershipType::User);
assert!(MembershipType::Custom == MembershipType::from_str("4").unwrap()); assert!(MembershipType::Custom == MembershipType::from_str("4").unwrap());
// The legacy Manager wire value (3) is accepted and folded onto Custom.
assert!(MembershipType::Custom == MembershipType::from_str("3").unwrap());
// Permission comparisons continue to treat Custom as manager-level and below Admin. // Permission comparisons continue to treat Custom as manager-level and below Admin.
assert!(MembershipType::Custom >= MembershipType::Manager);
let custom = MembershipType::Custom as i32; let custom = MembershipType::Custom as i32;
assert!(custom >= MembershipType::Manager); assert!(custom >= MembershipType::Custom);
assert!(custom < MembershipType::Admin); assert!(custom < MembershipType::Admin);
let types = [ let types = [MembershipType::Owner, MembershipType::Admin, MembershipType::User, MembershipType::Custom];
MembershipType::Owner,
MembershipType::Admin,
MembershipType::User,
MembershipType::Manager,
MembershipType::Custom,
];
for lhs in types { for lhs in types {
for rhs in types { for rhs in types {
assert_eq!(lhs.cmp(&rhs) == Ordering::Equal, lhs == rhs); assert_eq!(lhs.cmp(&rhs) == Ordering::Equal, lhs == rhs);
@ -1486,23 +1513,20 @@ mod tests {
fn edit_any_collection_does_not_imply_create_or_delete() { fn edit_any_collection_does_not_imply_create_or_delete() {
let mut custom = membership(MembershipType::Custom); let mut custom = membership(MembershipType::Custom);
custom.edit_any_collection = true; custom.edit_any_collection = true;
// The persisted access_all mirror is intentionally tested too: client-facing create and
// delete decisions must still use their dedicated permissions.
custom.access_all = true;
// Edit any collection grants full (read/edit) access to every collection, but client-facing
// create and delete decisions must still use their own dedicated permissions.
assert!(custom.has_full_access()); assert!(custom.has_full_access());
assert!(custom.grants_access_to_all_collections());
assert!(!custom.can_create_new_collections()); assert!(!custom.can_create_new_collections());
assert!(custom.limit_collection_creation()); assert!(custom.limit_collection_creation());
assert!(!custom.can_delete_any_collection()); assert!(!custom.can_delete_any_collection());
let mut manager = membership(MembershipType::Manager);
manager.access_all = true;
assert!(manager.can_create_new_collections());
let admin = membership(MembershipType::Admin); let admin = membership(MembershipType::Admin);
assert!(admin.can_create_new_collections()); assert!(admin.can_create_new_collections());
assert!(!admin.limit_collection_creation()); assert!(!admin.limit_collection_creation());
assert!(admin.can_delete_any_collection()); assert!(admin.can_delete_any_collection());
assert!(admin.grants_access_to_all_collections());
} }
#[test] #[test]
@ -1527,6 +1551,9 @@ mod tests {
member.create_new_collections = true; member.create_new_collections = true;
member.edit_any_collection = true; member.edit_any_collection = true;
member.delete_any_collection = true; member.delete_any_collection = true;
member.access_event_logs = true;
member.access_import_export = true;
member.access_reports = true;
member.clear_custom_permissions(); member.clear_custom_permissions();
@ -1536,5 +1563,28 @@ mod tests {
assert!(!member.create_new_collections); assert!(!member.create_new_collections);
assert!(!member.edit_any_collection); assert!(!member.edit_any_collection);
assert!(!member.delete_any_collection); assert!(!member.delete_any_collection);
assert!(!member.access_event_logs);
assert!(!member.access_import_export);
assert!(!member.access_reports);
}
#[test]
fn custom_access_permissions_are_independent_and_type_gated() {
let mut member = membership(MembershipType::Custom);
member.access_event_logs = true;
assert!(member.has_access_event_logs());
assert!(!member.has_access_import_export());
member.access_import_export = true;
member.access_reports = true;
assert!(member.has_access_import_export());
// None of them imply collection or management capabilities.
assert!(!member.has_full_access());
assert!(!member.has_manage_users());
// Stale flags on a non-Custom role grant nothing.
member.atype = MembershipType::User as i32;
assert!(!member.has_access_event_logs());
assert!(!member.has_access_import_export());
} }
} }

4
src/db/schema.rs

@ -236,7 +236,6 @@ table! {
user_uuid -> Text, user_uuid -> Text,
org_uuid -> Text, org_uuid -> Text,
invited_by_email -> Nullable<Text>, invited_by_email -> Nullable<Text>,
access_all -> Bool,
akey -> Text, akey -> Text,
status -> Integer, status -> Integer,
atype -> Integer, atype -> Integer,
@ -248,6 +247,9 @@ table! {
create_new_collections -> Bool, create_new_collections -> Bool,
edit_any_collection -> Bool, edit_any_collection -> Bool,
delete_any_collection -> Bool, delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
} }
} }

16
src/static/scripts/admin_users.js

@ -174,10 +174,6 @@ const ORG_TYPES = {
"name": "User", "name": "User",
"bg": "blue" "bg": "blue"
}, },
"3": {
"name": "Manager",
"bg": "green"
},
"4": { "4": {
"name": "Custom", "name": "Custom",
"bg": "teal" "bg": "teal"
@ -215,12 +211,13 @@ jQuery.extend(jQuery.fn.dataTableExt.oSort, {
const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); const userOrgTypeDialog = document.getElementById("userOrgTypeDialog");
// Fill the form and title // Fill the form and title
userOrgTypeDialog.addEventListener("show.bs.modal", function(event) { userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
document.getElementById("userOrgTypeForm").reset();
// Get shared values // Get shared values
const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail; const userEmail = event.relatedTarget.parentNode.dataset.vwUserEmail;
const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid;
// Get org specific values // Get org specific values
const userOrgType = event.relatedTarget.dataset.vwOrgType; const userOrgType = event.relatedTarget.dataset.vwOrgType;
const userOrgTypeName = ORG_TYPES[userOrgType]["name"];
const orgName = event.relatedTarget.dataset.vwOrgName; const orgName = event.relatedTarget.dataset.vwOrgName;
const orgUuid = event.relatedTarget.dataset.vwOrgUuid; const orgUuid = event.relatedTarget.dataset.vwOrgUuid;
@ -228,7 +225,9 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function(event) {
document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail;
document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeUserUuid").value = userUuid;
document.getElementById("userOrgTypeOrgUuid").value = orgUuid; 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); }, false);
// Prevent accidental submission of the form with valid elements after the modal has been hidden. // Prevent accidental submission of the form with valid elements after the modal has been hidden.
@ -255,7 +254,10 @@ function updateUserOrgType(event) {
function initUserTable() { function initUserTable() {
// Color all the org buttons per type // Color all the org buttons per type
document.querySelectorAll("button[data-vw-org-type]").forEach(function(e) { 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; e.style.backgroundColor = orgType.bg;
if (orgType.font !== undefined) { if (orgType.font !== undefined) {
e.style.color = orgType.font; e.style.color = orgType.font;

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

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

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

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

10
src/util.rs

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

Loading…
Cancel
Save