Browse Source

Merge ca424cd2c9 into 061694d0cb

pull/7397/merge
Tom 11 hours ago
committed by GitHub
parent
commit
c84a0cb008
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      .env.template
  2. 20
      migrations/mysql/2026-09-22-120000_add_custom_role_permissions/down.sql
  3. 86
      migrations/mysql/2026-09-22-120000_add_custom_role_permissions/up.sql
  4. 20
      migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/down.sql
  5. 80
      migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/up.sql
  6. 34
      migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/down.sql
  7. 107
      migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql
  8. 32
      src/api/admin.rs
  9. 13
      src/api/core/accounts.rs
  10. 423
      src/api/core/ciphers.rs
  11. 255
      src/api/core/events.rs
  12. 1388
      src/api/core/organizations.rs
  13. 1
      src/api/core/public.rs
  14. 8
      src/api/core/two_factor/mod.rs
  15. 561
      src/auth.rs
  16. 12
      src/config.rs
  17. 1414
      src/db/mod.rs
  18. 517
      src/db/models/cipher.rs
  19. 202
      src/db/models/collection.rs
  20. 45
      src/db/models/event.rs
  21. 35
      src/db/models/group.rs
  22. 4
      src/db/models/mod.rs
  23. 584
      src/db/models/organization.rs
  24. 10
      src/db/schema.rs
  25. 13
      src/main.rs
  26. 16
      src/static/scripts/admin_users.js
  27. 4
      src/static/templates/admin/users.hbs
  28. 6
      src/static/templates/scss/vaultwarden.scss.hbs
  29. 22
      src/util.rs

6
.env.template

@ -65,6 +65,12 @@
## - https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING ## - https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
# DATABASE_URL=postgresql://user:password@host[:port]/database_name # DATABASE_URL=postgresql://user:password@host[:port]/database_name
## Recovery policy for historical User + access_all rows during the Custom-role migration.
## The default "refuse" fails closed and stops startup. "drop" removes the historical grant;
## "materialize" first preserves confirmed members' current collection access as explicit assignments.
## Accepted values: refuse, drop, materialize. Set this upgrade/recovery option only deliberately.
# LEGACY_USER_ACCESS_ALL_MIGRATION=refuse
## Enable WAL for the DB ## Enable WAL for the DB
## Set to false to avoid enabling WAL during startup. ## Set to false to avoid enabling WAL during startup.
## Note that if the DB already has WAL enabled, you will also need to disable WAL in the DB, ## Note that if the DB already has WAL enabled, you will also need to disable WAL in the DB,

20
migrations/mysql/2026-09-22-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,20 @@
-- Downgrade is lossy because the legacy role model cannot represent arbitrary Custom permissions.
-- Custom memberships are mapped to User to avoid granting additional privileges. Restore a
-- pre-upgrade database backup if exact state preservation is required.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations
SET access_all = CASE WHEN atype IN (0, 1) THEN TRUE ELSE FALSE END;
UPDATE users_organizations SET atype = 2 WHERE atype = 4;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;

86
migrations/mysql/2026-09-22-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,86 @@
-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine
-- granular permissions.
--
-- Two different columns are called `access_all`, and everything below depends on keeping them apart:
--
-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped
-- at the end of this file.
-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. It is not
-- read or written here and keeps granting group members access dynamically.
--
-- Only the membership bit is going away. While this file runs it still exists and `atype = 3` still
-- unambiguously means "legacy Manager".
--
-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates
-- the same condition at startup and prints the recovery text, because Diesel would surface the abort
-- below as nothing but a driver-level duplicate-key error.
--
-- A temporary table on purpose: on MySQL/MariaDB it is the only DDL that does not commit implicitly,
-- so a refusal cannot leave a half-applied migration behind.
-- A plain User carrying membership `access_all`, reachable only on databases written before the web
-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and
-- future, with no management authority, and the new model has no permission for that:
-- `edit_any_collection` would add management authority, dropping the bit would take the reach away.
-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when
-- such a membership exists.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2
AND access_all = TRUE
LIMIT 1;
DROP TEMPORARY TABLE __vw_legacy_user_access_all_guard;
-- One ALTER TABLE for all nine columns: MySQL/MariaDB commit every DDL statement implicitly, so nine
-- separate statements would leave nine points at which a crash produces a partially migrated schema.
-- A single ALTER is one such point, and on MySQL 8 it is atomic.
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives
-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard
-- above), so only a Manager becomes Custom:
--
-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three
-- collection permissions, including creating collections;
-- * a Manager without membership `access_all` keeps all three at FALSE. In particular,
-- `groups.access_all` is not materialized into persistent membership permissions: it remains a
-- separate, dynamic group grant that ends when the group relationship or flag ends.
--
-- The management (manage_users / manage_groups / manage_policies) and access (event logs /
-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager
-- capability -- every member mutation, every policy write, the organization export and both
-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege.
--
-- `manage_users` is not granted to restore legacy read-only member-list behavior, because it also
-- carries invite, confirm, revoke, restore and delete, which the Manager role never had.
--
-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means
-- Manager everywhere it is read.
--
-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is
-- converted like a confirmed one, since none holds authority in that state and the permissions are
-- what it would come back with -- the same thing `access_all` would have done.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all,
atype = 4
WHERE atype = 3;
-- The membership flag is now represented by the role model: Owners/Admins hold it implicitly and a
-- Custom member that held it has all three collection permissions. `groups.access_all` stays separate.
ALTER TABLE users_organizations DROP COLUMN access_all;

20
migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,20 @@
-- Downgrade is lossy because the legacy role model cannot represent arbitrary Custom permissions.
-- Custom memberships are mapped to User to avoid granting additional privileges. Restore a
-- pre-upgrade database backup if exact state preservation is required.
ALTER TABLE users_organizations ADD COLUMN access_all BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users_organizations
SET access_all = CASE WHEN atype IN (0, 1) THEN TRUE ELSE FALSE END;
UPDATE users_organizations SET atype = 2 WHERE atype = 4;
ALTER TABLE users_organizations
DROP COLUMN manage_users,
DROP COLUMN manage_groups,
DROP COLUMN manage_policies,
DROP COLUMN create_new_collections,
DROP COLUMN edit_any_collection,
DROP COLUMN delete_any_collection,
DROP COLUMN access_event_logs,
DROP COLUMN access_import_export,
DROP COLUMN access_reports;

80
migrations/postgresql/2026-09-22-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,80 @@
-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine
-- granular permissions.
--
-- Two different columns are called `access_all`, and everything below depends on keeping them apart:
--
-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped
-- at the end of this file.
-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. It is not
-- read or written here and keeps granting group members access dynamically.
--
-- Only the membership bit is going away. While this file runs it still exists and `atype = 3` still
-- unambiguously means "legacy Manager".
--
-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates
-- the same condition at startup and prints the recovery text, because Diesel would surface the abort
-- below as nothing but a driver-level duplicate-key error.
-- A plain User carrying membership `access_all`, reachable only on databases written before the web
-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and
-- future, with no management authority, and the new model has no permission for that:
-- `edit_any_collection` would add management authority, dropping the bit would take the reach away.
-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when
-- such a membership exists.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2
AND access_all = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_user_access_all_guard;
ALTER TABLE users_organizations
ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE;
-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives
-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard
-- above), so only a Manager becomes Custom:
--
-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three
-- collection permissions, including creating collections;
-- * a Manager without membership `access_all` keeps all three at FALSE. In particular,
-- `groups.access_all` is not materialized into persistent membership permissions: it remains a
-- separate, dynamic group grant that ends when the group relationship or flag ends.
--
-- The management (manage_users / manage_groups / manage_policies) and access (event logs /
-- import-export / reports) permissions keep their FALSE default. Nothing they unlock was a Manager
-- capability -- every member mutation, every policy write, the organization export and both
-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege.
--
-- `manage_users` is not granted to restore legacy read-only member-list behavior, because it also
-- carries invite, confirm, revoke, restore and delete, which the Manager role never had.
--
-- Role conversion and permission values are one statement, so `atype = 3` unambiguously still means
-- Manager everywhere it is read.
--
-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is
-- converted like a confirmed one, since none holds authority in that state and the permissions are
-- what it would come back with -- the same thing `access_all` would have done.
UPDATE users_organizations
SET create_new_collections = access_all,
edit_any_collection = access_all,
delete_any_collection = access_all,
atype = 4
WHERE atype = 3;
-- The membership flag is now represented by the role model: Owners/Admins hold it implicitly and a
-- Custom member that held it has all three collection permissions. `groups.access_all` stays separate.
ALTER TABLE users_organizations DROP COLUMN access_all;

34
migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/down.sql

@ -0,0 +1,34 @@
-- Downgrade is lossy because the legacy role model cannot represent arbitrary Custom permissions.
-- Custom memberships are mapped to User to avoid granting additional privileges. Restore a
-- pre-upgrade database backup if exact state preservation is required.
CREATE TABLE users_organizations_old (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users (uuid),
org_uuid TEXT NOT NULL REFERENCES organizations (uuid),
access_all BOOLEAN NOT NULL,
akey TEXT NOT NULL,
status INTEGER NOT NULL,
atype INTEGER NOT NULL,
reset_password_key TEXT,
external_id TEXT,
invited_by_email TEXT DEFAULT NULL,
UNIQUE (user_uuid, org_uuid)
);
INSERT INTO users_organizations_old (
uuid, user_uuid, org_uuid, access_all, akey, status, atype,
reset_password_key, external_id, invited_by_email
)
SELECT
uuid, user_uuid, org_uuid,
CASE WHEN atype IN (0, 1) THEN TRUE ELSE FALSE END,
akey, status,
CASE WHEN atype = 4 THEN 2 ELSE atype END,
reset_password_key, external_id, invited_by_email
FROM users_organizations;
DROP TABLE users_organizations;
ALTER TABLE users_organizations_old RENAME TO users_organizations;

107
migrations/sqlite/2026-09-22-120000_add_custom_role_permissions/up.sql

@ -0,0 +1,107 @@
-- Replace the membership-level `access_all` flag with the persisted Custom role and its nine
-- granular permissions.
--
-- Two different columns are called `access_all`, and everything below depends on keeping them apart:
--
-- * `users_organizations.access_all` -- the MEMBERSHIP-level bit this migration replaces. Dropped
-- at the end of this file.
-- * `groups.access_all` -- the GROUP-level flag, a separate and still-supported feature. It is not
-- read or written here and keeps granting group members access dynamically.
--
-- Only the membership bit is going away. While this file runs it still exists and `atype = 3` still
-- unambiguously means "legacy Manager".
--
-- One state cannot be converted and is refused before the first mutation; `src/db/mod.rs` evaluates
-- the same condition at startup and prints the recovery text, because Diesel would surface the abort
-- below as nothing but a driver-level duplicate-key error.
-- A plain User carrying membership `access_all`, reachable only on databases written before the web
-- vault stopped sending the flag. The bit gave read/write reach over every collection, present and
-- future, with no management authority, and the new model has no permission for that:
-- `edit_any_collection` would add management authority, dropping the bit would take the reach away.
-- Refuse and let an owner choose. The duplicate key aborts the migration, and is only inserted when
-- such a membership exists.
CREATE TEMPORARY TABLE __vw_legacy_user_access_all_guard (
blocked INTEGER NOT NULL PRIMARY KEY
);
INSERT INTO __vw_legacy_user_access_all_guard (blocked) VALUES (1);
INSERT INTO __vw_legacy_user_access_all_guard (blocked)
SELECT 1
FROM users_organizations
WHERE atype = 2
AND access_all = TRUE
LIMIT 1;
DROP TABLE __vw_legacy_user_access_all_guard;
-- Schema and data change in one table rebuild, which also keeps the conversion unambiguous:
-- `atype = 3` still means Manager while the permission values are computed from it.
--
-- `ALTER TABLE ... DROP COLUMN` is deliberately not used -- it needs SQLite 3.35.0, while a
-- `sqlite_system` build links whatever the host provides and libsqlite3-sys accepts 3.34.1. The
-- rebuild follows the existing 2022-03-02-210038_update_devices_primary_key pattern; Vaultwarden runs
-- SQLite migrations with `PRAGMA foreign_keys = OFF`, so the drop does not cascade into groups_users.
CREATE TABLE users_organizations_new (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL REFERENCES users (uuid),
org_uuid TEXT NOT NULL REFERENCES organizations (uuid),
akey TEXT NOT NULL,
status INTEGER NOT NULL,
atype INTEGER NOT NULL,
reset_password_key TEXT,
external_id TEXT,
invited_by_email TEXT DEFAULT NULL,
manage_users BOOLEAN NOT NULL DEFAULT FALSE,
manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
access_reports BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (user_uuid, org_uuid)
);
-- Owners and Admins are not touched: they carried `access_all` implicitly and the new model gives
-- them every permission by role. A plain User cannot reach this point carrying the bit (the guard
-- above), so only a Manager becomes Custom:
--
-- * membership `access_all` -- the "Manage all collections" checkbox -- covered all three
-- collection permissions, including creating collections;
-- * a Manager without membership `access_all` keeps all three at FALSE. In particular,
-- `groups.access_all` is not materialized into persistent membership permissions: it remains a
-- separate, dynamic group grant that ends when the group relationship or flag ends.
--
-- The management (manage_users / manage_groups / manage_policies) and access (event logs /
-- import-export / reports) permissions start out FALSE for everyone. Nothing they unlock was a Manager
-- capability -- every member mutation, every policy write, the organization export and both
-- event-log routes were gated on Admin/Owner -- so granting one here would be a new privilege.
--
-- `manage_users` is not granted to restore legacy read-only member-list behavior, because it also
-- carries invite, confirm, revoke, restore and delete, which the Manager role never had.
--
-- Status is deliberately not part of the predicate: an invited, accepted or revoked membership is
-- converted like a confirmed one, since none holds authority in that state and the permissions are
-- what it would come back with -- the same thing `access_all` would have done.
INSERT INTO users_organizations_new (
uuid, user_uuid, org_uuid, akey, status, atype, reset_password_key, external_id,
invited_by_email, manage_users, manage_groups, manage_policies,
create_new_collections, edit_any_collection, delete_any_collection,
access_event_logs, access_import_export, access_reports
)
SELECT
uo.uuid, uo.user_uuid, uo.org_uuid, uo.akey, uo.status,
CASE WHEN uo.atype = 3 THEN 4 ELSE uo.atype END,
uo.reset_password_key, uo.external_id, uo.invited_by_email,
FALSE, FALSE, FALSE,
CASE WHEN uo.atype = 3 AND uo.access_all = TRUE THEN TRUE ELSE FALSE END,
CASE WHEN uo.atype = 3 AND uo.access_all = TRUE THEN TRUE ELSE FALSE END,
CASE WHEN uo.atype = 3 AND uo.access_all = TRUE THEN TRUE ELSE FALSE END,
FALSE, FALSE, FALSE
FROM users_organizations AS uo;
DROP TABLE users_organizations;
ALTER TABLE users_organizations_new RENAME TO users_organizations;

32
src/api/admin.rs

@ -544,6 +544,32 @@ struct MembershipTypeData {
org_uuid: OrganizationId, org_uuid: OrganizationId,
} }
fn apply_membership_type_change(membership: &mut Membership, new_type: MembershipType) {
// Entering Custom through the Vaultwarden admin panel is deliberately fail-closed because that
// UI cannot select granular permissions; they can be granted later through the regular
// organization member dialog. Any non-Custom role carries no custom flags at all. Only a member
// that is already Custom and stays Custom keeps its existing flags.
let stays_custom = new_type == MembershipType::Custom && membership.atype == MembershipType::Custom;
if !stays_custom {
membership.clear_custom_permissions();
}
membership.atype = new_type as i32;
}
fn parse_admin_membership_type(user_type: NumberOrString) -> Option<MembershipType> {
let raw_type = user_type.into_string();
// The public API still accepts the legacy Manager representation for compatibility and folds
// it into Custom. The admin panel must not do that: treating an apparent Manager demotion as a
// Custom-to-Custom update would preserve the member's existing granular permissions.
if matches!(raw_type.as_str(), "3" | "Manager") {
return None;
}
MembershipType::from_str(&raw_type)
}
#[post("/users/org_type", format = "application/json", data = "<data>")] #[post("/users/org_type", format = "application/json", data = "<data>")]
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, conn: DbConn) -> EmptyResult { async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, conn: DbConn) -> EmptyResult {
let data: MembershipTypeData = data.into_inner(); let data: MembershipTypeData = data.into_inner();
@ -553,9 +579,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization") err!("The specified user isn't member of the organization")
}; };
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { let Some(new_type) = parse_admin_membership_type(data.user_type) else {
new_type as i32
} else {
err!("Invalid type") err!("Invalid type")
}; };
@ -566,7 +590,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
} }
} }
member_to_edit.atype = new_type; apply_membership_type_change(&mut member_to_edit, new_type);
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;

13
src/api/core/accounts.rs

@ -31,7 +31,7 @@ use crate::{
}; };
use super::{ use super::{
ciphers::{CipherData, update_cipher_from_data}, ciphers::{CipherData, CipherUpdateAuthorization, update_cipher_from_data},
sends::{SendData, update_send_from_data}, sends::{SendData, update_send_from_data},
}; };
@ -1000,7 +1000,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// Prevent triggering cipher updates via WebSockets by settings UpdateType::None // Prevent triggering cipher updates via WebSockets by settings UpdateType::None
// The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues. // The user sessions are invalidated because all the ciphers were re-encrypted and thus triggering an update could cause issues.
// We force the users to logout after the user has been saved to try and prevent these issues. // We force the users to logout after the user has been saved to try and prevent these issues.
update_cipher_from_data(saved_cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; update_cipher_from_data(
saved_cipher,
cipher_data,
&headers,
CipherUpdateAuthorization::default(),
&conn,
&nt,
UpdateType::None,
)
.await?;
} }
} }

423
src/api/core/ciphers.rs

@ -21,9 +21,9 @@ use crate::{
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
}, },
}, },
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@ -233,21 +233,12 @@ async fn get_ciphers(headers: Headers, conn: DbConn) -> JsonResult {
#[get("/ciphers/<cipher_id>")] #[get("/ciphers/<cipher_id>")]
async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { async fn get_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { get_cipher_impl(cipher_id, &headers, CipherAccessScope::User, &conn).await
err!("Cipher doesn't exist")
};
if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await {
err!("Cipher is not owned by user")
}
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
} }
#[get("/ciphers/<cipher_id>/admin")] #[get("/ciphers/<cipher_id>/admin")]
async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult { async fn get_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn) -> JsonResult {
// TODO: Implement this correctly get_cipher_impl(cipher_id, &headers, CipherAccessScope::OrganizationAdmin, &conn).await
get_cipher(cipher_id, headers, conn).await
} }
#[get("/ciphers/<cipher_id>/details")] #[get("/ciphers/<cipher_id>/details")]
@ -255,6 +246,42 @@ async fn get_cipher_details(cipher_id: CipherId, headers: Headers, conn: DbConn)
get_cipher(cipher_id, headers, conn).await get_cipher(cipher_id, headers, conn).await
} }
async fn get_cipher_impl(
cipher_id: CipherId,
headers: &Headers,
scope: CipherAccessScope,
conn: &DbConn,
) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, conn).await else {
err!("Cipher doesn't exist")
};
if !cipher.is_accessible_to_user(&headers.user.uuid, scope, conn).await {
err!("Cipher is not owned by user")
}
Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?))
}
/// Serialize a cipher the caller has just been authorized for at `scope`.
///
/// The admin routes have to report the access they were authorized with, otherwise an
/// organization-wide caller without a personal assignment is answered `edit: false` for a cipher
/// they may in fact edit.
async fn cipher_json_for_scope(
cipher: &Cipher,
headers: &Headers,
scope: CipherAccessScope,
conn: &DbConn,
) -> Result<Value, crate::Error> {
match scope {
CipherAccessScope::User => {
cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await
}
CipherAccessScope::OrganizationAdmin => cipher.to_json_org_admin(&headers.host, &headers.user.uuid, conn).await,
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CipherData { pub struct CipherData {
@ -337,7 +364,11 @@ pub struct Attachments2Data {
/// Called when an org admin clones an org cipher. /// Called when an org admin clones an org cipher.
#[post("/ciphers/admin", data = "<data>")] #[post("/ciphers/admin", data = "<data>")]
async fn post_ciphers_admin(data: Json<ShareCipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_ciphers_admin(data: Json<ShareCipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
post_ciphers_create(data, headers, conn, nt).await // Only the response differs from `/ciphers/create`: the cipher is created owned by the caller
// and then shared, so the authorization along the way is the regular one either way. Without
// this, an administrative caller without a personal assignment to the target collection is
// answered `edit: false` for the cipher they just created.
post_ciphers_create_impl(data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await
} }
/// Called when creating a new org-owned cipher, or cloning a cipher (whether /// Called when creating a new org-owned cipher, or cloning a cipher (whether
@ -349,6 +380,16 @@ async fn post_ciphers_create(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult {
post_ciphers_create_impl(data, headers, CipherAccessScope::User, conn, nt).await
}
async fn post_ciphers_create_impl(
data: Json<ShareCipherData>,
headers: Headers,
response_scope: CipherAccessScope,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
let mut data: ShareCipherData = data.into_inner(); let mut data: ShareCipherData = data.into_inner();
@ -373,7 +414,7 @@ async fn post_ciphers_create(
// or otherwise), we can just ignore this field entirely. // or otherwise), we can just ignore this field entirely.
data.cipher.last_known_revision_date = None; data.cipher.last_known_revision_date = None;
let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, &conn, &nt, None).await; let res = share_cipher_by_uuid(&cipher.uuid, data, &headers, response_scope, &conn, &nt, None).await;
if res.is_err() { if res.is_err() {
cipher.delete(&conn).await?; cipher.delete(&conn).await?;
} }
@ -403,7 +444,16 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
data.last_known_revision_date = None; data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, data.name.clone()); let mut cipher = Cipher::new(data.r#type, data.name.clone());
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?; update_cipher_from_data(
&mut cipher,
data,
&headers,
CipherUpdateAuthorization::default(),
&conn,
&nt,
UpdateType::SyncCipherCreate,
)
.await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
} }
@ -426,11 +476,43 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
Ok(()) Ok(())
} }
fn has_prevalidated_organization_write_authority(
shared_to_collections: Option<&Vec<CollectionId>>,
member_has_full_access: bool,
organization_write_authorized: bool,
) -> bool {
organization_write_authorized
|| shared_to_collections.is_some_and(|collections| !collections.is_empty())
|| member_has_full_access
}
#[derive(Default)]
pub struct CipherUpdateAuthorization {
shared_to_collections: Option<Vec<CollectionId>>,
organization_write_authorized: bool,
}
impl CipherUpdateAuthorization {
pub fn shared_to(collections: Vec<CollectionId>) -> Self {
Self {
shared_to_collections: Some(collections),
organization_write_authorized: false,
}
}
pub fn organization_import(collections: Vec<CollectionId>, organization_write_authorized: bool) -> Self {
Self {
shared_to_collections: Some(collections),
organization_write_authorized,
}
}
}
pub async fn update_cipher_from_data( pub async fn update_cipher_from_data(
cipher: &mut Cipher, cipher: &mut Cipher,
data: CipherData, data: CipherData,
headers: &Headers, headers: &Headers,
shared_to_collections: Option<Vec<CollectionId>>, authorization: CipherUpdateAuthorization,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
ut: UpdateType, ut: UpdateType,
@ -449,6 +531,11 @@ pub async fn update_cipher_from_data(
json_data json_data
} }
let CipherUpdateAuthorization {
shared_to_collections,
organization_write_authorized,
} = authorization;
enforce_personal_ownership_policy(Some(&data), headers, conn).await?; enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
// Check that the client isn't updating an existing cipher with stale data. // Check that the client isn't updating an existing cipher with stale data.
@ -486,9 +573,11 @@ pub async fn update_cipher_from_data(
Some(member) => { Some(member) => {
// A non-empty list of collections implies the caller already validated the user's write // A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis. // access to them, so we can move the cipher into the organization on that basis.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) if has_prevalidated_organization_write_authority(
|| member.has_full_access() shared_to_collections.as_ref(),
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await member.has_full_access(),
organization_write_authorized,
) || cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await
{ {
cipher.organization_uuid = Some(org_id); cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again. // After some discussion in PR #1329 re-added the user_uuid = None again.
@ -665,7 +754,16 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
cipher_data.folder_id = folder_id; cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; update_cipher_from_data(
&mut cipher,
cipher_data,
&headers,
CipherUpdateAuthorization::default(),
&conn,
&nt,
UpdateType::None,
)
.await?;
} }
let mut user = headers.user; let mut user = headers.user;
@ -684,7 +782,7 @@ async fn put_cipher_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
put_cipher(cipher_id, data, headers, conn, nt).await put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await
} }
#[post("/ciphers/<cipher_id>/admin", data = "<data>")] #[post("/ciphers/<cipher_id>/admin", data = "<data>")]
@ -695,7 +793,7 @@ async fn post_cipher_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
post_cipher(cipher_id, data, headers, conn, nt).await put_cipher_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await
} }
#[post("/ciphers/<cipher_id>", data = "<data>")] #[post("/ciphers/<cipher_id>", data = "<data>")]
@ -716,6 +814,17 @@ async fn put_cipher(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult {
put_cipher_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await
}
async fn put_cipher_impl(
cipher_id: CipherId,
data: Json<CipherData>,
headers: Headers,
scope: CipherAccessScope,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
let data: CipherData = data.into_inner(); let data: CipherData = data.into_inner();
@ -728,13 +837,22 @@ async fn put_cipher(
// cipher itself, so the user shouldn't need write access to change these. // cipher itself, so the user shouldn't need write access to change these.
// Interestingly, upstream Bitwarden doesn't properly handle this either. // Interestingly, upstream Bitwarden doesn't properly handle this either.
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await {
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
} }
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; update_cipher_from_data(
&mut cipher,
data,
&headers,
CipherUpdateAuthorization::default(),
&conn,
&nt,
UpdateType::SyncCipherUpdate,
)
.await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?))
} }
#[post("/ciphers/<cipher_id>/partial", data = "<data>")] #[post("/ciphers/<cipher_id>/partial", data = "<data>")]
@ -761,7 +879,7 @@ async fn put_cipher_partial(
err!("Cipher does not exist") err!("Cipher does not exist")
}; };
if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await {
err!("Cipher does not exist", "Cipher is not accessible for the current user") err!("Cipher does not exist", "Cipher is not accessible for the current user")
} }
@ -838,7 +956,7 @@ async fn post_collections_update(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, CipherAccessScope::User, &conn).await {
err!("Collection cannot be changed") err!("Collection cannot be changed")
} }
@ -918,7 +1036,10 @@ async fn post_collections_admin(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, &conn).await { // Upstream guards this route with `CanEditCipherAsAdminAsync`, so a member holding
// organization-wide cipher authority reaches every cipher of the organization here.
if !cipher.is_in_editable_collection_by_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await
{
err!("Collection cannot be changed") err!("Collection cannot be changed")
} }
@ -992,7 +1113,7 @@ async fn post_cipher_share(
) -> JsonResult { ) -> JsonResult {
let data: ShareCipherData = data.into_inner(); let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await share_cipher_by_uuid(&cipher_id, data, &headers, CipherAccessScope::User, &conn, &nt, None).await
} }
#[put("/ciphers/<cipher_id>/share", data = "<data>")] #[put("/ciphers/<cipher_id>/share", data = "<data>")]
@ -1005,7 +1126,7 @@ async fn put_cipher_share(
) -> JsonResult { ) -> JsonResult {
let data: ShareCipherData = data.into_inner(); let data: ShareCipherData = data.into_inner();
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await share_cipher_by_uuid(&cipher_id, data, &headers, CipherAccessScope::User, &conn, &nt, None).await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -1045,7 +1166,16 @@ async fn put_cipher_share_selected(
}; };
if let Some(id) = shared_cipher_data.cipher.id.take() { if let Some(id) = shared_cipher_data.cipher.id.take() {
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? share_cipher_by_uuid(
&id,
shared_cipher_data,
&headers,
CipherAccessScope::User,
&conn,
&nt,
Some(UpdateType::None),
)
.await?
} else { } else {
err!("Request missing ids field") err!("Request missing ids field")
}; };
@ -1061,12 +1191,16 @@ async fn share_cipher_by_uuid(
cipher_id: &CipherId, cipher_id: &CipherId,
data: ShareCipherData, data: ShareCipherData,
headers: &Headers, headers: &Headers,
// Only the response is serialized with this. The entry check below deliberately stays
// `CipherAccessScope::User`: sharing is a regular vault operation, and `/ciphers/admin` reaches
// it with a cipher it has just created and therefore owns.
response_scope: CipherAccessScope,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
override_ut: Option<UpdateType>, override_ut: Option<UpdateType>,
) -> JsonResult { ) -> JsonResult {
let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await { let mut cipher = if let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await {
if cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { if cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await {
cipher cipher
} else { } else {
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
@ -1110,9 +1244,18 @@ async fn share_cipher_by_uuid(
UpdateType::SyncCipherCreate UpdateType::SyncCipherCreate
}; };
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?; update_cipher_from_data(
&mut cipher,
data.cipher,
headers,
CipherUpdateAuthorization::shared_to(shared_to_collections),
conn,
nt,
ut,
)
.await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) Ok(Json(cipher_json_for_scope(&cipher, headers, response_scope, conn).await?))
} }
/// v2 API for downloading an attachment. This just redirects the client to /// v2 API for downloading an attachment. This just redirects the client to
@ -1132,7 +1275,7 @@ async fn get_attachment(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_accessible_to_user(&headers.user.uuid, &conn).await { if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await {
err!("Cipher is not accessible") err!("Cipher is not accessible")
} }
@ -1168,15 +1311,22 @@ async fn post_attachment_v2(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
let data: AttachmentRequestData = data.into_inner();
// Upstream's `PostAttachment` branches on `adminRequest`: it authorizes the administrative
// request with `CanEditCipherAsAdminAsync` and answers it with a `CipherMiniResponse`. The flag
// picks the predicate, not its answer -- a caller without organization-wide cipher authority is
// refused here either way.
let scope = CipherAccessScope::requested(data.admin_request);
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await {
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
} }
let data: AttachmentRequestData = data.into_inner();
let file_size = data.file_size.into_i64()?; let file_size = data.file_size.into_i64()?;
if file_size < 0 { if file_size < 0 {
@ -1188,9 +1338,11 @@ async fn post_attachment_v2(
attachment.save(&conn).await.expect("Error saving attachment"); attachment.save(&conn).await.expect("Error saving attachment");
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
let response_key = match data.admin_request { // Derived from the same `scope` the request was authorized with, so the response key and the
Some(b) if b => "cipherMiniResponse", // serialization below can never disagree about which flow this is.
_ => "cipherResponse", let response_key = match scope {
CipherAccessScope::OrganizationAdmin => "cipherMiniResponse",
CipherAccessScope::User => "cipherResponse",
}; };
Ok(Json(json!({ // AttachmentUploadDataResponseModel Ok(Json(json!({ // AttachmentUploadDataResponseModel
@ -1198,7 +1350,7 @@ async fn post_attachment_v2(
"attachmentId": attachment_id, "attachmentId": attachment_id,
"url": url, "url": url,
"fileUploadType": FileUploadType::Direct as i32, "fileUploadType": FileUploadType::Direct as i32,
response_key: cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?, response_key: cipher_json_for_scope(&cipher, &headers, scope, &conn).await?,
}))) })))
} }
@ -1221,6 +1373,7 @@ async fn save_attachment(
cipher_id: CipherId, cipher_id: CipherId,
data: Form<UploadData<'_>>, data: Form<UploadData<'_>>,
headers: &Headers, headers: &Headers,
scope: CipherAccessScope,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> Result<(Cipher, DbConn), crate::error::Error> { ) -> Result<(Cipher, DbConn), crate::error::Error> {
@ -1237,7 +1390,7 @@ async fn save_attachment(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await { if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, &conn).await {
err!("Cipher is not write accessible") err!("Cipher is not write accessible")
} }
@ -1398,11 +1551,31 @@ async fn post_attachment_v2_data(
None => err!("Attachment doesn't exist"), None => err!("Attachment doesn't exist"),
}; };
save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?; // This leg of the v2 upload carries no `adminRequest` field, so the administrative context is
// recomputed from the caller's own membership in *this cipher's* organization, exactly as
// upstream's `PostFileForExistingAttachment` does. Nothing in the request feeds into it, so the
// upload route cannot be talked into an administrative scope.
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
err!("Cipher doesn't exist")
};
let scope = cipher_scope_for_member(&cipher, &headers.user.uuid, &conn).await;
save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?;
Ok(()) Ok(())
} }
/// The [`CipherAccessScope`] for a route the client cannot state one for: resolved from the
/// caller's own confirmed membership in the cipher's organization, never from the request.
async fn cipher_scope_for_member(cipher: &Cipher, user_id: &UserId, conn: &DbConn) -> CipherAccessScope {
let Some(org_id) = cipher.organization_uuid.as_ref() else {
// A personal cipher has no organization to administer.
return CipherAccessScope::User;
};
CipherAccessScope::for_member(Membership::find_confirmed_by_user_and_org(user_id, org_id, conn).await.as_ref())
}
/// Legacy API for creating an attachment associated with a cipher. /// Legacy API for creating an attachment associated with a cipher.
#[post("/ciphers/<cipher_id>/attachment", format = "multipart/form-data", data = "<data>")] #[post("/ciphers/<cipher_id>/attachment", format = "multipart/form-data", data = "<data>")]
async fn post_attachment( async fn post_attachment(
@ -1412,13 +1585,7 @@ async fn post_attachment(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
// Setting this as None signifies to save_attachment() that it should create post_attachment_impl(cipher_id, data, headers, CipherAccessScope::User, conn, nt).await
// the attachment database record as well as saving the data to disk.
let attachment = None;
let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, conn, nt).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
} }
#[post("/ciphers/<cipher_id>/attachment-admin", format = "multipart/form-data", data = "<data>")] #[post("/ciphers/<cipher_id>/attachment-admin", format = "multipart/form-data", data = "<data>")]
@ -1429,7 +1596,24 @@ async fn post_attachment_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
post_attachment(cipher_id, data, headers, conn, nt).await post_attachment_impl(cipher_id, data, headers, CipherAccessScope::OrganizationAdmin, conn, nt).await
}
async fn post_attachment_impl(
cipher_id: CipherId,
data: Form<UploadData<'_>>,
headers: Headers,
scope: CipherAccessScope,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
// Setting this as None signifies to save_attachment() that it should create
// the attachment database record as well as saving the data to disk.
let attachment = None;
let (cipher, conn) = save_attachment(attachment, cipher_id, data, &headers, scope, conn, nt).await?;
Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?))
} }
#[post("/ciphers/<cipher_id>/attachment/<attachment_id>/share", format = "multipart/form-data", data = "<data>")] #[post("/ciphers/<cipher_id>/attachment/<attachment_id>/share", format = "multipart/form-data", data = "<data>")]
@ -1441,7 +1625,7 @@ async fn post_attachment_share(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await?; delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await?;
post_attachment(cipher_id, data, headers, conn, nt).await post_attachment(cipher_id, data, headers, conn, nt).await
} }
@ -1453,7 +1637,15 @@ async fn delete_attachment_post_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
delete_attachment(cipher_id, attachment_id, headers, conn, nt).await delete_cipher_attachment_by_id(
&cipher_id,
&attachment_id,
&headers,
CipherAccessScope::OrganizationAdmin,
&conn,
&nt,
)
.await
} }
#[post("/ciphers/<cipher_id>/attachment/<attachment_id>/delete")] #[post("/ciphers/<cipher_id>/attachment/<attachment_id>/delete")]
@ -1475,7 +1667,7 @@ async fn delete_attachment(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, CipherAccessScope::User, &conn, &nt).await
} }
#[delete("/ciphers/<cipher_id>/attachment/<attachment_id>/admin")] #[delete("/ciphers/<cipher_id>/attachment/<attachment_id>/admin")]
@ -1486,42 +1678,77 @@ async fn delete_attachment_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
delete_cipher_attachment_by_id(&cipher_id, &attachment_id, &headers, &conn, &nt).await delete_cipher_attachment_by_id(
&cipher_id,
&attachment_id,
&headers,
CipherAccessScope::OrganizationAdmin,
&conn,
&nt,
)
.await
} }
#[post("/ciphers/<cipher_id>/delete")] #[post("/ciphers/<cipher_id>/delete")]
async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn delete_cipher_post(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt)
.await
// permanent delete // permanent delete
} }
#[post("/ciphers/<cipher_id>/delete-admin")] #[post("/ciphers/<cipher_id>/delete-admin")]
async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn delete_cipher_post_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await delete_cipher_by_uuid(
&cipher_id,
&headers,
CipherAccessScope::OrganizationAdmin,
&conn,
&CipherDeleteOptions::HardSingle,
&nt,
)
.await
// permanent delete // permanent delete
} }
#[put("/ciphers/<cipher_id>/delete")] #[put("/ciphers/<cipher_id>/delete")]
async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn delete_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::SoftSingle, &nt)
.await
// soft delete // soft delete
} }
#[put("/ciphers/<cipher_id>/delete-admin")] #[put("/ciphers/<cipher_id>/delete-admin")]
async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn delete_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::SoftSingle, &nt).await delete_cipher_by_uuid(
&cipher_id,
&headers,
CipherAccessScope::OrganizationAdmin,
&conn,
&CipherDeleteOptions::SoftSingle,
&nt,
)
.await
// soft delete // soft delete
} }
#[delete("/ciphers/<cipher_id>")] #[delete("/ciphers/<cipher_id>")]
async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn delete_cipher(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await delete_cipher_by_uuid(&cipher_id, &headers, CipherAccessScope::User, &conn, &CipherDeleteOptions::HardSingle, &nt)
.await
// permanent delete // permanent delete
} }
#[delete("/ciphers/<cipher_id>/admin")] #[delete("/ciphers/<cipher_id>/admin")]
async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn delete_cipher_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
delete_cipher_by_uuid(&cipher_id, &headers, &conn, &CipherDeleteOptions::HardSingle, &nt).await delete_cipher_by_uuid(
&cipher_id,
&headers,
CipherAccessScope::OrganizationAdmin,
&conn,
&CipherDeleteOptions::HardSingle,
&nt,
)
.await
// permanent delete // permanent delete
} }
@ -1532,7 +1759,7 @@ async fn delete_cipher_selected(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await
// permanent delete // permanent delete
} }
@ -1543,7 +1770,7 @@ async fn delete_cipher_selected_post(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::HardMulti, nt).await
// permanent delete // permanent delete
} }
@ -1554,7 +1781,7 @@ async fn delete_cipher_selected_put(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await delete_multiple_ciphers(data, headers, CipherAccessScope::User, conn, CipherDeleteOptions::SoftMulti, nt).await
// soft delete // soft delete
} }
@ -1565,7 +1792,15 @@ async fn delete_cipher_selected_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await delete_multiple_ciphers(
data,
headers,
CipherAccessScope::OrganizationAdmin,
conn,
CipherDeleteOptions::HardMulti,
nt,
)
.await
// permanent delete // permanent delete
} }
@ -1576,7 +1811,15 @@ async fn delete_cipher_selected_post_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::HardMulti, nt).await delete_multiple_ciphers(
data,
headers,
CipherAccessScope::OrganizationAdmin,
conn,
CipherDeleteOptions::HardMulti,
nt,
)
.await
// permanent delete // permanent delete
} }
@ -1587,18 +1830,26 @@ async fn delete_cipher_selected_put_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
delete_multiple_ciphers(data, headers, conn, CipherDeleteOptions::SoftMulti, nt).await delete_multiple_ciphers(
data,
headers,
CipherAccessScope::OrganizationAdmin,
conn,
CipherDeleteOptions::SoftMulti,
nt,
)
.await
// soft delete // soft delete
} }
#[put("/ciphers/<cipher_id>/restore")] #[put("/ciphers/<cipher_id>/restore")]
async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn restore_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::User, &conn, &nt).await
} }
#[put("/ciphers/<cipher_id>/restore-admin")] #[put("/ciphers/<cipher_id>/restore-admin")]
async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn restore_cipher_put_admin(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
restore_cipher_by_uuid(&cipher_id, &headers, false, &conn, &nt).await restore_cipher_by_uuid(&cipher_id, &headers, false, CipherAccessScope::OrganizationAdmin, &conn, &nt).await
} }
#[put("/ciphers/restore-admin", data = "<data>")] #[put("/ciphers/restore-admin", data = "<data>")]
@ -1608,7 +1859,7 @@ async fn restore_cipher_selected_admin(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
restore_multiple_ciphers(data, &headers, &conn, &nt).await restore_multiple_ciphers(data, &headers, CipherAccessScope::OrganizationAdmin, &conn, &nt).await
} }
#[put("/ciphers/restore", data = "<data>")] #[put("/ciphers/restore", data = "<data>")]
@ -1618,7 +1869,7 @@ async fn restore_cipher_selected(
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
restore_multiple_ciphers(data, &headers, &conn, &nt).await restore_multiple_ciphers(data, &headers, CipherAccessScope::User, &conn, &nt).await
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -1809,6 +2060,7 @@ pub enum CipherDeleteOptions {
async fn delete_cipher_by_uuid( async fn delete_cipher_by_uuid(
cipher_id: &CipherId, cipher_id: &CipherId,
headers: &Headers, headers: &Headers,
scope: CipherAccessScope,
conn: &DbConn, conn: &DbConn,
delete_options: &CipherDeleteOptions, delete_options: &CipherDeleteOptions,
nt: &Notify<'_>, nt: &Notify<'_>,
@ -1817,7 +2069,7 @@ async fn delete_cipher_by_uuid(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await {
err!("Cipher can't be deleted by user") err!("Cipher can't be deleted by user")
} }
@ -1875,6 +2127,7 @@ struct CipherIdsData {
async fn delete_multiple_ciphers( async fn delete_multiple_ciphers(
data: Json<CipherIdsData>, data: Json<CipherIdsData>,
headers: Headers, headers: Headers,
scope: CipherAccessScope,
conn: DbConn, conn: DbConn,
delete_options: CipherDeleteOptions, delete_options: CipherDeleteOptions,
nt: Notify<'_>, nt: Notify<'_>,
@ -1882,7 +2135,7 @@ async fn delete_multiple_ciphers(
let data = data.into_inner(); let data = data.into_inner();
for cipher_id in data.ids { for cipher_id in data.ids {
if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, &conn, &delete_options, &nt).await { if let error @ Err(_) = delete_cipher_by_uuid(&cipher_id, &headers, scope, &conn, &delete_options, &nt).await {
return error; return error;
} }
} }
@ -1897,6 +2150,7 @@ async fn restore_cipher_by_uuid(
cipher_id: &CipherId, cipher_id: &CipherId,
headers: &Headers, headers: &Headers,
multi_restore: bool, multi_restore: bool,
scope: CipherAccessScope,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> JsonResult { ) -> JsonResult {
@ -1904,7 +2158,7 @@ async fn restore_cipher_by_uuid(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await {
err!("Cipher can't be restored by user") err!("Cipher can't be restored by user")
} }
@ -1936,12 +2190,15 @@ async fn restore_cipher_by_uuid(
.await; .await;
} }
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?)) // Answer with the scope the restore was authorized under, so an administrative caller without a
// personal assignment is not told `edit: false` for a cipher they just restored.
Ok(Json(cipher_json_for_scope(&cipher, headers, scope, conn).await?))
} }
async fn restore_multiple_ciphers( async fn restore_multiple_ciphers(
data: Json<CipherIdsData>, data: Json<CipherIdsData>,
headers: &Headers, headers: &Headers,
scope: CipherAccessScope,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> JsonResult { ) -> JsonResult {
@ -1949,7 +2206,7 @@ async fn restore_multiple_ciphers(
let mut ciphers: Vec<Value> = Vec::new(); let mut ciphers: Vec<Value> = Vec::new();
for cipher_id in data.ids { for cipher_id in data.ids {
match restore_cipher_by_uuid(&cipher_id, headers, true, conn, nt).await { match restore_cipher_by_uuid(&cipher_id, headers, true, scope, conn, nt).await {
Ok(json) => ciphers.push(json.into_inner()), Ok(json) => ciphers.push(json.into_inner()),
err => return err, err => return err,
} }
@ -1969,6 +2226,7 @@ async fn delete_cipher_attachment_by_id(
cipher_id: &CipherId, cipher_id: &CipherId,
attachment_id: &AttachmentId, attachment_id: &AttachmentId,
headers: &Headers, headers: &Headers,
scope: CipherAccessScope,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> JsonResult { ) -> JsonResult {
@ -1984,7 +2242,7 @@ async fn delete_cipher_attachment_by_id(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { if !cipher.is_write_accessible_to_user(&headers.user.uuid, scope, conn).await {
err!("Cipher cannot be deleted by user") err!("Cipher cannot be deleted by user")
} }
@ -2012,7 +2270,8 @@ async fn delete_cipher_attachment_by_id(
) )
.await; .await;
} }
let cipher_json = cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?; // Same scope the deletion was authorized under; see `cipher_json_for_scope`.
let cipher_json = cipher_json_for_scope(&cipher, headers, scope, conn).await?;
Ok(Json(json!({"cipher":cipher_json}))) Ok(Json(json!({"cipher":cipher_json})))
} }
@ -2027,7 +2286,7 @@ async fn archive_cipher(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await {
err!("Cipher is not accessible for the current user") err!("Cipher is not accessible for the current user")
} }
@ -2059,7 +2318,7 @@ async fn unarchive_cipher(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await { if !cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await {
err!("Cipher is not accessible for the current user") err!("Cipher is not accessible for the current user")
} }

255
src/api/core/events.rs

@ -7,12 +7,14 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{EmptyResult, JsonResult}, api::{EmptyResult, JsonResult},
auth::{AdminHeaders, Headers}, auth::{AccessEventLogsHeaders, Headers, may_access_event_logs},
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, models::{
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
}, },
util::parse_date, },
util::try_parse_date,
}; };
/// ############################################################################################################### /// ###############################################################################################################
@ -29,9 +31,36 @@ struct EventRange {
continuation_token: Option<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 +68,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 +86,62 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
}))) })))
} }
#[derive(Debug, Eq, PartialEq)]
enum CipherEventScope {
Organization(OrganizationId),
Personal,
}
impl CipherEventScope {
fn organization_id(&self) -> Option<&OrganizationId> {
match self {
Self::Organization(org_id) => Some(org_id),
Self::Personal => None,
}
}
}
fn cipher_event_scope(cipher: &Cipher, user_id: &UserId, membership: Option<&Membership>) -> Option<CipherEventScope> {
match &cipher.organization_uuid {
Some(org_id)
if membership.is_some_and(|membership| {
membership.user_uuid == *user_id && membership.org_uuid == *org_id && may_access_event_logs(membership)
}) =>
{
Some(CipherEventScope::Organization(org_id.clone()))
}
None if cipher.is_owned_by_user(user_id) => Some(CipherEventScope::Personal),
_ => None,
}
}
#[get("/ciphers/<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 { } else {
parse_date(&data.end) None
};
cipher_event_scope(&cipher, &headers.user.uuid, membership.as_ref())
} else {
None
}; };
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() if let Some(scope) = scope {
Event::find_by_cipher_uuid(&cipher_id, scope.organization_id(), &start_date, &end_date, &conn)
.await
.iter()
.map(Event::to_json)
.collect()
} else {
Vec::new()
}
} else { } else {
Vec::new() Vec::new()
}; };
@ -93,21 +158,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 +219,82 @@ struct EventCollection {
organization_id: Option<OrganizationId>, organization_id: Option<OrganizationId>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ClientEventKind {
User,
Cipher,
Organization,
OrganizationUser,
}
const MAX_CLIENT_EVENT_BATCH_SIZE: usize = 1_000;
fn validate_client_event_batch_size(event_count: usize) -> Result<(), crate::Error> {
if event_count > MAX_CLIENT_EVENT_BATCH_SIZE {
return Err(crate::Error::new(
"Event batch is too large",
format!("At most {MAX_CLIENT_EVENT_BATCH_SIZE} events are accepted per request"),
));
}
Ok(())
}
/// The client-generated event types upstream's `/events/collect` accepts. Anything else is ignored,
/// so that an authenticated client cannot write arbitrary event types into an organization's audit
/// log. Keep this in sync with upstream's `CollectController`: a type missing here is silently not
/// logged, which is why the newer item-type events below are listed explicitly rather than matched
/// by range.
fn client_event_kind(event_type: i32) -> Option<ClientEventKind> {
match event_type {
event_type if event_type == EventType::UserClientExportedVault as i32 => Some(ClientEventKind::User),
event_type
if event_type == EventType::CipherClientViewed as i32
|| event_type == EventType::CipherClientToggledPasswordVisible as i32
|| event_type == EventType::CipherClientToggledHiddenFieldVisible as i32
|| event_type == EventType::CipherClientToggledCardCodeVisible as i32
|| event_type == EventType::CipherClientCopiedPassword as i32
|| event_type == EventType::CipherClientCopiedHiddenField as i32
|| event_type == EventType::CipherClientCopiedCardCode as i32
|| event_type == EventType::CipherClientAutofilled as i32
|| event_type == EventType::CipherClientToggledCardNumberVisible as i32
|| event_type == EventType::CipherClientCopiedBankAccountNumber as i32
|| event_type == EventType::CipherClientCopiedBankAccountPin as i32
|| event_type == EventType::CipherClientToggledBankAccountNumberVisible as i32
|| event_type == EventType::CipherClientToggledBankAccountPinVisible as i32
|| event_type == EventType::CipherClientCopiedLicenseNumber as i32
|| event_type == EventType::CipherClientToggledLicenseNumberVisible as i32
|| event_type == EventType::CipherClientCopiedPassportNumber as i32
|| event_type == EventType::CipherClientToggledPassportNumberVisible as i32
|| event_type == EventType::CipherClientCopiedSwiftCode as i32
|| event_type == EventType::CipherClientToggledSwiftCodeVisible as i32
|| event_type == EventType::CipherClientCopiedIban as i32
|| event_type == EventType::CipherClientToggledIbanVisible as i32
|| event_type == EventType::CipherClientCopiedNationalIdentificationNumber as i32
|| event_type == EventType::CipherClientToggledNationalIdentificationNumberVisible as i32 =>
{
Some(ClientEventKind::Cipher)
}
event_type
if event_type == EventType::OrganizationClientExportedVault as i32
|| event_type == EventType::OrganizationAutoConfirmEnabledAdmin as i32
|| event_type == EventType::OrganizationAutoConfirmDisabledAdmin as i32
|| event_type == EventType::OrganizationInviteLinkClientCopied as i32 =>
{
Some(ClientEventKind::Organization)
}
// Upstream logs these through `LogOrganizationUserEventAsync`: they describe the acting
// user's own membership, not the organization as a whole.
event_type
if event_type == EventType::OrganizationUserNotificationBannerActionClicked as i32
|| event_type == EventType::OrganizationItemOrganizationAccepted as i32
|| event_type == EventType::OrganizationItemOrganizationDeclined as i32 =>
{
Some(ClientEventKind::OrganizationUser)
}
_ => None,
}
}
// Upstream: // 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 +304,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 +333,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,32 +351,26 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
} }
// Only the vault notification banner click is accepted from clients. The rest of ClientEventKind::OrganizationUser => {
// the 1500..=1599 range is written server-side and must not be forgeable by a client. if let Some(org_id) = &event.organization_id {
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => { log_client_org_user_event(
if let Some(org_id) = &event.organization_id
&& let Some(membership) =
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
{
log_event_impl(
event.r#type, event.r#type,
&membership.uuid,
org_id, org_id,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
Some(event_date), event_date,
&headers.ip.ip, &headers.ip.ip,
&conn, &conn,
) )
.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
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await && cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, &conn).await
&& let Some(org_id) = cipher.organization_uuid && let Some(org_id) = cipher.organization_uuid
{ {
log_event_impl( log_event_impl(
@ -245,6 +391,24 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
Ok(()) Ok(())
} }
/// Logs a client event against the membership the acting user holds in `org_id`. Without such a
/// membership there is nothing to log against, so a request naming another organization writes no
/// event at all instead of one pointing at a foreign or non-existent membership.
async fn log_client_org_user_event(
event_type: i32,
org_id: &OrganizationId,
act_user_id: &UserId,
device_type: i32,
event_date: NaiveDateTime,
ip: &IpAddr,
conn: &DbConn,
) {
if let Some(membership) = Membership::find_confirmed_by_user_and_org(act_user_id, org_id, conn).await {
log_event_impl(event_type, &membership.uuid, org_id, act_user_id, device_type, Some(event_date), ip, conn)
.await;
}
}
pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) {
if !CONFIG.org_events_enabled() { if !CONFIG.org_events_enabled() {
return; return;
@ -332,7 +496,14 @@ async fn log_event_impl(
1500..=1599 => { 1500..=1599 => {
event.org_user_uuid = Some(source_uuid.to_owned().into()); event.org_user_uuid = Some(source_uuid.to_owned().into());
} }
// 1600..=1699 Are organizational events, and they do not need the source_uuid // 1600..=1699 Are organizational events, and they do not need the source_uuid, except for
// the two item-organization events, which upstream logs against a membership.
event_type
if event_type == EventType::OrganizationItemOrganizationAccepted as i32
|| event_type == EventType::OrganizationItemOrganizationDeclined as i32 =>
{
event.org_user_uuid = Some(source_uuid.to_owned().into());
}
// Policy Events // Policy Events
1700..=1799 => { 1700..=1799 => {
event.policy_uuid = Some(source_uuid.to_owned().into()); event.policy_uuid = Some(source_uuid.to_owned().into());

1388
src/api/core/organizations.rs

File diff suppressed because it is too large

1
src/api/core/public.rs

@ -125,7 +125,6 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); 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;

8
src/api/core/two_factor/mod.rs

@ -215,8 +215,12 @@ pub async fn enforce_2fa_policy_for_org(
) -> EmptyResult { ) -> EmptyResult {
let org = Organization::find_by_uuid(org_id, conn).await.unwrap(); let org = Organization::find_by_uuid(org_id, conn).await.unwrap();
for member in Membership::find_confirmed_by_org(org_id, conn).await { for member in Membership::find_confirmed_by_org(org_id, conn).await {
// Don't enforce the policy for Admins and Owners. // Don't enforce the policy for Admins and Owners, nor for the member who just enabled it --
if member.atype < MembershipType::Admin && TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty() { // see `Membership::is_policy_enforcement_target`. Every other non-compliant member is
// revoked exactly as before.
if member.is_policy_enforcement_target(act_user_id)
&& TwoFactor::find_by_user(&member.user_uuid, conn).await.is_empty()
{
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap(); let user = User::find_by_uuid(&member.user_uuid, conn).await.unwrap();
mail::send_2fa_removed_from_org(&user.email, &org.name).await?; mail::send_2fa_removed_from_org(&user.email, &org.name).await?;

561
src/auth.rs

@ -709,6 +709,7 @@ pub struct OrgHeaders {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
pub user: User, pub user: User,
#[allow(dead_code)]
pub membership_type: MembershipType, pub membership_type: MembershipType,
pub membership_status: MembershipStatus, pub membership_status: MembershipStatus,
pub membership: Membership, pub membership: Membership,
@ -724,12 +725,64 @@ 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
} }
fn is_confirmed(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed
}
}
/// Upstream's `BasePermissionRequirement`: a confirmed Owner or Admin, or a Custom member holding the
/// permission itself. An unparsable stored role satisfies neither comparison and so fails closed.
///
/// The single definition of that rule. Everything which asks "may this member do X" goes through one
/// of the `may_*` predicates below, so the rule cannot drift between call sites.
pub(crate) fn has_org_permission(membership: &Membership, permission: impl FnOnce(&Membership) -> bool) -> bool {
membership.has_status(MembershipStatus::Confirmed)
&& (membership.atype >= MembershipType::Admin || permission(membership))
}
/// Upstream's `ManageUsersRequirement`.
fn may_manage_users(membership: &Membership) -> bool {
has_org_permission(membership, Membership::has_manage_users)
}
/// Upstream's `ManageGroupsRequirement`, which guards `GET /organizations/<org_id>/groups/<id>/details`
/// as well as creating, updating and deleting groups.
fn may_manage_groups(membership: &Membership) -> bool {
has_org_permission(membership, Membership::has_manage_groups)
}
/// Upstream's `ManageUsersOrGroupsRequirement`, which guards the group *details list* only.
fn may_manage_users_or_groups(membership: &Membership) -> bool {
has_org_permission(membership, |m| m.has_manage_users() || m.has_manage_groups())
}
/// Upstream's `ManagePoliciesRequirement`. Note that holding it does not make a member exempt from any
/// policy; only Owners and Admins are excluded from policy enforcement.
fn may_manage_policies(membership: &Membership) -> bool {
has_org_permission(membership, Membership::has_manage_policies)
}
/// Upstream's `AccessEventLogsRequirement`. Also used by the cipher event endpoints in `api::core::events`.
pub(crate) fn may_access_event_logs(membership: &Membership) -> bool {
has_org_permission(membership, Membership::has_access_event_logs)
}
/// Upstream's `AccessImportExportRequirement`. Also used by the organization import endpoint.
///
/// NOTE: there is deliberately no `may_access_reports` guard. Vaultwarden has no server-side report
/// endpoints -- clients compute reports from the organization cipher list -- so `accessReports` is
/// enforced where that list is served (`get_org_details`). A guard here would invite gating an endpoint
/// on "may call reports" instead of "may read these ciphers".
pub(crate) fn may_access_import_export(membership: &Membership) -> bool {
has_org_permission(membership, Membership::has_access_import_export)
} }
// org_id is usually the second path param ("/organizations/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
@ -814,6 +867,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,
@ -849,6 +905,96 @@ impl<'r> FromRequest<'r> for AdminHeaders {
} }
} }
// Macro to generate a request guard that permits a confirmed Admin/Owner, or a
// confirmed Custom member holding the given permission. The generated struct
// mirrors AdminHeaders so it can be used as a drop-in replacement on endpoints.
macro_rules! generate_manage_headers {
($name:ident, $check:ident, $err:literal) => {
#[allow(dead_code)]
pub struct $name {
pub host: String,
pub device: Device,
pub user: User,
pub membership_type: MembershipType,
// The caller's membership record. Holding the permission that opens an endpoint says
// nothing about *which* data the caller may reach, so handlers need the membership to
// apply the regular full-access/per-collection checks on top of the guard.
pub membership: Membership,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for $name {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if $check(&headers.membership) {
Outcome::Success(Self {
host: headers.host,
device: headers.device,
user: headers.user,
membership_type: headers.membership_type,
ip: headers.ip,
org_id: headers.membership.org_uuid.clone(),
membership: headers.membership,
})
} else {
err_handler!($err)
}
}
}
impl From<$name> for Headers {
fn from(h: $name) -> Headers {
Headers {
host: h.host,
device: h.device,
user: h.user,
ip: h.ip,
}
}
}
};
}
generate_manage_headers!(
ManageUsersHeaders,
may_manage_users,
"You need the 'Manage Users' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManageGroupsHeaders,
may_manage_groups,
"You need the 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
ManagePoliciesHeaders,
may_manage_policies,
"You need the 'Manage Policies' permission, or to be an Admin or Owner, to call this endpoint"
);
// Upstream's `ManageUsersOrGroupsRequirement`, which guards only the group *details list*
// (`GET /organizations/<org_id>/groups/details`). The single-group view is narrower
// (`ManageGroupsRequirement`) and therefore keeps `ManageGroupsHeaders`.
generate_manage_headers!(
ManageUsersOrGroupsHeaders,
may_manage_users_or_groups,
"You need the 'Manage Users' or 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
AccessEventLogsHeaders,
may_access_event_logs,
"You need the 'Access Event Logs' permission, or to be an Admin or Owner, to call this endpoint"
);
generate_manage_headers!(
AccessImportExportHeaders,
may_access_import_export,
"You need the 'Access Import/Export' permission, or to be an Admin or Owner, to call this endpoint"
);
// NOTE: no `AccessReportsHeaders`. See the note on `may_access_import_export` above:
// `accessReports` guards data (the organization cipher list), not a dedicated endpoint.
// col_id is usually the fourth path param ("/organizations/<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.
// First check the path, if this is not a valid uuid, try the query values. // First check the path, if this is not a valid uuid, try the query values.
@ -868,34 +1014,210 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
None None
} }
/// The ManagerHeaders are used to check if you are at least a Manager #[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// and have access to the specific collection provided via the <col_id>/collections/collectionId. enum CollectionManageAccess {
/// This does strict checking on the collection_id, ManagerHeadersLoose does not. Any,
pub struct ManagerHeaders { ExplicitManage,
Denied,
}
fn collection_access_by_role(membership: &Membership, custom_has_any_access: bool) -> CollectionManageAccess {
if !membership.has_status(MembershipStatus::Confirmed) {
return CollectionManageAccess::Denied;
}
match MembershipType::from_i32(membership.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => CollectionManageAccess::Any,
Some(MembershipType::Custom) if custom_has_any_access => CollectionManageAccess::Any,
// A member must prove an actual users_collections.manage / collections_groups.manage
// assignment. Neither membership nor group `access_all` is ever counted as one.
Some(MembershipType::Custom | MembershipType::User) => CollectionManageAccess::ExplicitManage,
None => CollectionManageAccess::Denied,
}
}
fn collection_edit_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(membership, membership.has_edit_any_collection())
}
fn collection_read_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(
membership,
membership.has_edit_any_collection() || membership.has_delete_any_collection(),
)
}
/// Upstream's `BulkCollectionOperations.ReadWithAccess`, which guards the *single* collection
/// `/details` endpoint: Owner/Admin, `Edit any collection`, `Delete any collection` and `Manage users`
/// reach every collection, everyone else needs a real per-collection Manage grant.
///
/// Deliberately not the same question as [`collection_read_access`], which models upstream's
/// `ReadAccess` (`GET /collections/<col_id>/users`) and does *not* accept `Manage users`. The two
/// upstream operations differ, so these two predicates differ as well — widening
/// `CollectionReadHeaders` instead would have silently changed the `/users` endpoint too.
///
/// `Manage groups` is absent on purpose: upstream grants it `ReadAllWithAccess` (the collection
/// *list*, see `may_read_all_collections_with_access`) but not `ReadWithAccess`.
fn collection_read_with_access(membership: &Membership) -> CollectionManageAccess {
collection_access_by_role(
membership,
membership.has_edit_any_collection() || membership.has_delete_any_collection() || membership.has_manage_users(),
)
}
/// Upstream authorizes `POST /collections/bulk-access` against **both**
/// `BulkCollectionOperations.ModifyUserAccess` and `BulkCollectionOperations.ModifyGroupAccess`, and
/// its authorization service only succeeds when every requirement passes. With Vaultwarden's
/// effective `allowAdminAccessToAllCollectionItems = true`, upstream resolves them as
///
/// * `ModifyUserAccess` = `Manage users` OR the regular collection-update authorization
/// * `ModifyGroupAccess` = `Manage groups` OR the regular collection-update authorization
///
/// Requiring both therefore reduces to: a caller who may update the collection anyway (Owner/Admin,
/// `Edit any collection`, or a per-collection Manage grant), or one holding *both* org-wide
/// permissions. Only `Manage users` or only `Manage groups` is not enough, because the other
/// requirement then still falls back to the update check — which is the point of an endpoint that
/// rewrites a collection's user *and* group assignments in the same request.
fn collection_modify_access(membership: &Membership) -> CollectionManageAccess {
if membership.has_status(MembershipStatus::Confirmed)
&& membership.has_manage_users()
&& membership.has_manage_groups()
{
return CollectionManageAccess::Any;
}
collection_edit_access(membership)
}
/// Collection deletion never falls back to a per-collection Manage grant.
///
/// Vaultwarden serializes `limitCollectionDeletion = true` unconditionally, and upstream gates
/// manage-based deletion on that setting being *off*: with the limit active only Owners, Admins and
/// holders of `Delete any collection` may delete. Accepting a stored `manage` grant here would break that
/// promise and make a per-collection Manage ACL double as a collection-deletion permission.
/// A Manage grant keeps its full meaning for editing (`collection_edit_access`).
fn collection_delete_access(membership: &Membership) -> CollectionManageAccess {
// Blanket authority or nothing -- `ExplicitManage` is never returned here. `can_delete_any_collection`
// is the same rule the rest of the tree asks, and fails closed on an uninterpretable stored role.
if membership.can_delete_any_collection() {
CollectionManageAccess::Any
} else {
CollectionManageAccess::Denied
}
}
async fn can_manage_collection(
access: CollectionManageAccess,
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
match access {
CollectionManageAccess::Any => true,
CollectionManageAccess::ExplicitManage => {
membership.has_explicit_collection_manage_access(collection_uuid, conn).await
}
CollectionManageAccess::Denied => false,
}
}
/// Whether `membership` may edit (rewrite the access of) `collection_uuid`, on exactly the same rules as
/// the path-based `ManagerHeaders` guard: Edit-any (or Admin/Owner) reaches every collection, otherwise
/// only those carrying a real per-collection Manage grant. Group `access_all` deliberately does not
/// qualify. Body-param endpoints cannot use `ManagerHeaders`, so they run this per collection instead.
pub(crate) async fn can_edit_collection(
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
can_manage_collection(collection_edit_access(membership), membership, collection_uuid, conn).await
}
/// Whether `membership` may read a collection's user/group access mappings.
///
/// The same rule as `CollectionReadHeaders`: Admin/Owner, Edit-any/Delete-any, or a real
/// per-collection Manage assignment. Ordinary read access and group `access_all` do not qualify.
pub(crate) async fn can_read_collection_access(
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
can_manage_collection(collection_read_access(membership), membership, collection_uuid, conn).await
}
/// Whether `membership` may read `collection_uuid` *together with* its user/group assignments —
/// upstream's `ReadWithAccess`, which guards `GET /organizations/<org_id>/collections/<col_id>/details`.
/// See [`collection_read_with_access`] for why this is not [`can_read_collection_access`].
pub(crate) async fn can_read_collection_with_access(
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
can_manage_collection(collection_read_with_access(membership), membership, collection_uuid, conn).await
}
/// Whether `membership` may rewrite both the user *and* the group assignments of `collection_uuid`,
/// as `POST /organizations/<org_id>/collections/bulk-access` does. See [`collection_modify_access`].
pub(crate) async fn can_modify_collection_access(
membership: &Membership,
collection_uuid: &CollectionId,
conn: &DbConn,
) -> bool {
can_manage_collection(collection_modify_access(membership), membership, collection_uuid, conn).await
}
// Collection-scoped request guards. All three resolve the same way -- a confirmed membership, a
// collection id on the route, and one of the `collection_*_access` predicates -- and differ only in
// which predicate they ask and what they say when it refuses.
//
// `Denied` is answered without taking a database connection. That is not a behaviour change over the
// hand-written guards this replaced: `collection_access_by_role` only answers `Denied` for an
// unconfirmed membership or an `atype` this build cannot interpret, and neither reaches this point --
// the status is checked above, and `OrgHeaders` refuses an unknown role outright ("Unknown user type in
// the database"). So for `ManagerHeaders` and `CollectionReadHeaders` the arm is unreachable, and
// `collection_delete_access` never answers `ExplicitManage`, so the delete guard consulted no
// assignment before either. Answering it here keeps all three on one path and cannot grant anything: a
// stored assignment is only ever consulted for `ExplicitManage`.
macro_rules! generate_collection_headers {
(
$(#[$doc:meta])*
$name:ident, $confirmed:ident, $access:ident, $confirm_err:literal, $denied_err:literal
) => {
$(#[$doc])*
pub struct $name {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
pub user: User, pub user: User,
pub ip: ClientIp, pub ip: ClientIp,
pub org_id: OrganizationId, pub org_id: OrganizationId,
} }
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ManagerHeaders { impl<'r> FromRequest<'r> for $name {
type Error = &'static str; type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_manager() { if !headers.$confirmed() {
if let Some(col_id) = get_col_id(request) { err_handler!($confirm_err)
}
let Some(col_id) = get_col_id(request) else {
err_handler!("Error getting the collection id")
};
match $access(&headers.membership) {
CollectionManageAccess::Any => {}
CollectionManageAccess::Denied => err_handler!($denied_err),
access @ CollectionManageAccess::ExplicitManage => {
let Outcome::Success(conn) = DbConn::from_request(request).await else { let Outcome::Success(conn) = DbConn::from_request(request).await else {
err_handler!("Error getting DB") err_handler!("Error getting DB")
}; };
if !Collection::is_coll_manageable_by_user(&col_id, &headers.membership.user_uuid, &conn).await { if !can_manage_collection(access, &headers.membership, &col_id, &conn).await {
err_handler!("The current user isn't a manager for this collection") err_handler!($denied_err)
}
} }
} else {
err_handler!("Error getting the collection id")
} }
Outcome::Success(Self { Outcome::Success(Self {
@ -905,14 +1227,11 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
ip: headers.ip, ip: headers.ip,
org_id: headers.membership.org_uuid, org_id: headers.membership.org_uuid,
}) })
} else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
} }
} }
}
impl From<ManagerHeaders> for Headers { impl From<$name> for Headers {
fn from(h: ManagerHeaders) -> Headers { fn from(h: $name) -> Headers {
Headers { Headers {
host: h.host, host: h.host,
device: h.device, device: h.device,
@ -920,10 +1239,48 @@ impl From<ManagerHeaders> for Headers {
ip: h.ip, ip: h.ip,
} }
} }
}
};
} }
/// The ManagerHeadersLoose is used when you at least need to be a Manager, generate_collection_headers!(
/// but there is no collection_id sent with the request (either in the path or as form data). /// ManagerHeaders authorizes collection updates. A Custom member with Edit any collection can
/// update every collection; otherwise the caller must hold a per-collection Manage permission.
/// Read and delete use separate guards so Edit cannot accidentally imply Delete.
ManagerHeaders,
is_confirmed,
collection_edit_access,
"You need to be a Manager, Admin or Owner to call this endpoint",
"The current user isn't a manager for this collection"
);
generate_collection_headers!(
/// Read access to a collection's access mappings -- upstream's `BulkCollectionOperations.ReadAccess`.
/// Delete any collection needs this visibility to render the standard collection view, but it does not
/// grant edit or cipher access, and -- unlike `ReadWithAccess`, see [`collection_read_with_access`] --
/// `Manage users` alone does not open it.
CollectionReadHeaders,
is_confirmed,
collection_read_access,
"You need collection read permission to call this endpoint",
"The current user isn't a manager for this collection"
);
generate_collection_headers!(
/// Delete is fully independent from the other two collection permissions. Vaultwarden advertises
/// `limitCollectionDeletion = true`, so deleting a collection requires Admin/Owner or the explicit
/// Delete any collection permission -- see `collection_delete_access` for why a per-collection Manage
/// grant deliberately does not qualify. That predicate never answers `ExplicitManage`, so a stored
/// assignment is never consulted here.
CollectionDeleteHeaders,
is_confirmed_and_manager,
collection_delete_access,
"You need collection delete permission to call this endpoint",
"You need the 'Delete any collection' permission to call this endpoint"
);
/// The ManagerHeadersLoose is used for organization endpoints whose exact permission depends on
/// request data or whose response is filtered by the caller's collection-management authority.
pub struct ManagerHeadersLoose { pub struct ManagerHeadersLoose {
pub host: String, pub host: String,
pub device: Device, pub device: Device,
@ -938,7 +1295,7 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await); let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.is_confirmed_and_manager() { if headers.membership.has_status(MembershipStatus::Confirmed) {
Outcome::Success(Self { Outcome::Success(Self {
host: headers.host, host: headers.host,
device: headers.device, device: headers.device,
@ -947,7 +1304,7 @@ impl<'r> FromRequest<'r> for ManagerHeadersLoose {
ip: headers.ip, ip: headers.ip,
}) })
} else { } else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint") err_handler!("You need to be a confirmed organization member to call this endpoint")
} }
} }
} }
@ -963,22 +1320,28 @@ impl From<ManagerHeadersLoose> for Headers {
} }
} }
impl ManagerHeaders { impl CollectionDeleteHeaders {
pub async fn from_loose( pub async fn from_loose(
h: ManagerHeadersLoose, h: ManagerHeadersLoose,
collections: &Vec<CollectionId>, collections: &Vec<CollectionId>,
conn: &DbConn, conn: &DbConn,
) -> Result<ManagerHeaders, Error> { ) -> Result<CollectionDeleteHeaders, Error> {
// Bulk delete answers to the same rule as the single-collection route: blanket authority or
// nothing. A per-collection Manage grant is not a delete permission.
if collection_delete_access(&h.membership) != CollectionManageAccess::Any {
err!("You need the 'Delete any collection' permission to call this endpoint")
}
for col_id in collections { for col_id in collections {
if uuid::Uuid::parse_str(col_id.as_ref()).is_err() { if uuid::Uuid::parse_str(col_id.as_ref()).is_err() {
err!("Collection Id is malformed!"); err!("Collection Id is malformed!");
} }
if !Collection::is_coll_manageable_by_user(col_id, &h.membership.user_uuid, conn).await { if Collection::find_by_uuid_and_org(col_id, &h.membership.org_uuid, conn).await.is_none() {
err!("Collection not found", "The current user isn't a manager for this collection") err!("Collection not found", "Collection does not exist or does not belong to this organization")
} }
} }
Ok(ManagerHeaders { Ok(CollectionDeleteHeaders {
host: h.host, host: h.host,
device: h.device, device: h.device,
user: h.user, user: h.user,
@ -1345,3 +1708,145 @@ pub async fn refresh_tokens(
Ok((device, auth_tokens)) Ok((device, auth_tokens))
} }
#[cfg(test)]
mod tests {
use super::{
CollectionManageAccess, collection_delete_access, collection_edit_access, collection_modify_access,
collection_read_access, collection_read_with_access, may_manage_groups, may_manage_policies, may_manage_users,
may_manage_users_or_groups,
};
use crate::db::models::{Membership, MembershipStatus as Status, MembershipType};
const OWNER: i32 = MembershipType::Owner as i32;
const ADMIN: i32 = MembershipType::Admin as i32;
const USER: i32 = MembershipType::User as i32;
const CUSTOM: i32 = MembershipType::Custom as i32;
const UNKNOWN: i32 = Membership::UNKNOWN_ATYPE;
fn confirmed(atype: i32, set: impl FnOnce(&mut Membership)) -> Membership {
Membership::for_test(atype, Status::Confirmed, set)
}
fn nothing(_: &mut Membership) {}
/// Every permission this file's guards read, so a row can show that none of them help.
fn all_permissions(m: &mut Membership) {
m.edit_any_collection = true;
m.delete_any_collection = true;
m.manage_users = true;
m.manage_groups = true;
m.manage_policies = true;
}
/// Who may edit, read, read-with-access, rewrite the access of, and delete a collection.
///
/// These five predicates model five *different* upstream operations and are deliberately not the
/// same rule; the differences between the columns are the point of this table. A change that makes
/// any two of them agree where they must not is what this test exists to catch.
///
/// `Any` reaches every collection of the organization, `ExplicitManage` only those carrying a real
/// `users_collections.manage` / `collections_groups.manage` grant, `Denied` none at all.
#[test]
fn collection_operation_access_matrix() {
use CollectionManageAccess::{Any, Denied, ExplicitManage as Explicit};
let owner = confirmed(OWNER, nothing);
let admin = confirmed(ADMIN, nothing);
let edit_any = confirmed(CUSTOM, |m| m.edit_any_collection = true);
let delete_any = confirmed(CUSTOM, |m| m.delete_any_collection = true);
let manage_users = confirmed(CUSTOM, |m| m.manage_users = true);
let manage_groups = confirmed(CUSTOM, |m| m.manage_groups = true);
let manage_both = confirmed(CUSTOM, |m| {
m.manage_users = true;
m.manage_groups = true;
});
let bare_custom = confirmed(CUSTOM, nothing);
let user = confirmed(USER, nothing);
let stale_user = confirmed(USER, all_permissions);
let revoked = Membership::for_test(CUSTOM, Status::Revoked, all_permissions);
let unknown = Membership::for_test(UNKNOWN, Status::Confirmed, all_permissions);
// (case, membership, edit, read, read-with-access, modify access, delete)
let cases = [
("Owner", &owner, Any, Any, Any, Any, Any),
("Admin", &admin, Any, Any, Any, Any, Any),
// Edit-any reaches every collection for editing and may read the access lists, but
// deletion never follows from it: Vaultwarden always serializes
// `limitCollectionDeletion = true`.
("Custom + editAnyCollection", &edit_any, Any, Any, Any, Any, Denied),
// Delete-any is the mirror image: it deletes and reads, but does not edit.
("Custom + deleteAnyCollection", &delete_any, Explicit, Any, Any, Explicit, Any),
// Manage-users reaches the single collection *details* view (upstream's `ReadWithAccess`)
// but not the `/users` access list (`ReadAccess`), which is a narrower operation.
("Custom + manageUsers", &manage_users, Explicit, Explicit, Any, Explicit, Denied),
// Manage-groups reaches neither: upstream grants it the collection *list*, not the single
// collection with its access.
("Custom + manageGroups", &manage_groups, Explicit, Explicit, Explicit, Explicit, Denied),
// `bulk-access` rewrites user *and* group assignments in one request, so upstream requires
// both permissions. Either alone still falls back to the regular update authorization.
("Custom + manageUsers + manageGroups", &manage_both, Explicit, Explicit, Any, Any, Denied),
// Without an org-wide permission a Custom member is exactly a User: only real
// per-collection Manage grants count, and deleting is out of reach entirely.
("Custom without permissions", &bare_custom, Explicit, Explicit, Explicit, Explicit, Denied),
("User", &user, Explicit, Explicit, Explicit, Explicit, Denied),
// Flags a role change left behind, an unconfirmed membership and a role this build cannot
// interpret all fail closed.
("User with stale permission flags", &stale_user, Explicit, Explicit, Explicit, Explicit, Denied),
("revoked Custom holding everything", &revoked, Denied, Denied, Denied, Denied, Denied),
("unknown role holding everything", &unknown, Denied, Denied, Denied, Denied, Denied),
];
for (case, m, edit, read, read_with_access, modify, delete) in cases {
assert_eq!(collection_edit_access(m), edit, "{case}: edit");
assert_eq!(collection_read_access(m), read, "{case}: read access lists");
assert_eq!(collection_read_with_access(m), read_with_access, "{case}: read with access");
assert_eq!(collection_modify_access(m), modify, "{case}: modify user and group access");
assert_eq!(collection_delete_access(m), delete, "{case}: delete");
}
}
/// The organization-wide permission guards behind `ManageUsersHeaders` and friends: a confirmed
/// Owner/Admin, or a Custom member holding *that* permission.
///
/// Catches a guard wired to the wrong flag, a lost status gate, and an unknown stored role
/// slipping through any of them.
#[test]
fn org_permission_guards_require_a_confirmed_role_or_the_matching_flag() {
let owner = confirmed(OWNER, nothing);
let admin = confirmed(ADMIN, nothing);
let invited_owner = Membership::for_test(OWNER, Status::Invited, nothing);
let users = confirmed(CUSTOM, |m| m.manage_users = true);
let groups = confirmed(CUSTOM, |m| m.manage_groups = true);
let policies = confirmed(CUSTOM, |m| m.manage_policies = true);
let bare_custom = confirmed(CUSTOM, nothing);
let user = confirmed(USER, nothing);
let stale_user = confirmed(USER, all_permissions);
let revoked = Membership::for_test(CUSTOM, Status::Revoked, all_permissions);
let unknown = Membership::for_test(UNKNOWN, Status::Confirmed, all_permissions);
// (case, membership, manage users, manage groups, either, manage policies)
let cases = [
("Owner", &owner, true, true, true, true),
("Admin", &admin, true, true, true, true),
// Admins and Owners hold every permission by role, but only once confirmed.
("invited Owner", &invited_owner, false, false, false, false),
("Custom + manageUsers", &users, true, false, true, false),
("Custom + manageGroups", &groups, false, true, true, false),
("Custom + managePolicies", &policies, false, false, false, true),
("Custom without permissions", &bare_custom, false, false, false, false),
("User", &user, false, false, false, false),
// Stale flags, an unconfirmed membership and an unknown role all fail closed.
("User with stale permission flags", &stale_user, false, false, false, false),
("revoked Custom holding everything", &revoked, false, false, false, false),
("unknown role holding everything", &unknown, false, false, false, false),
];
for (case, m, users, groups, users_or_groups, policies) in cases {
assert_eq!(may_manage_users(m), users, "{case}: manage users");
assert_eq!(may_manage_groups(m), groups, "{case}: manage groups");
assert_eq!(may_manage_users_or_groups(m), users_or_groups, "{case}: manage users or groups");
assert_eq!(may_manage_policies(m), policies, "{case}: manage policies");
}
}
}

12
src/config.rs

@ -750,6 +750,11 @@ make_config! {
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
db_connection_retries: u32, false, def, 15; db_connection_retries: u32, false, def, 15;
/// Legacy User access_all migration |> What the Custom-role migration does with a plain User membership that still carries the legacy access_all flag.
/// "refuse" stops startup and prints the recovery procedure, "drop" clears the flag, "materialize" writes the reach out as explicit collection
/// assignments (confirmed memberships only) and then clears it. Only read while that migration is pending.
legacy_user_access_all_migration: String, false, def, "refuse".to_owned();
/// Timeout when acquiring database connection /// Timeout when acquiring database connection
database_timeout: u64, false, def, 30; database_timeout: u64, false, def, 30;
@ -972,6 +977,13 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
} }
} }
if crate::db::LegacyUserAccessAllPolicy::from_config(&cfg.legacy_user_access_all_migration).is_none() {
err!(format!(
"Invalid LEGACY_USER_ACCESS_ALL_MIGRATION value `{}`, expected `refuse`, `drop` or `materialize`",
cfg.legacy_user_access_all_migration
));
}
if cfg.password_iterations < 100_000 { if cfg.password_iterations < 100_000 {
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
} }

1414
src/db/mod.rs

File diff suppressed because it is too large

517
src/db/models/cipher.rs

@ -26,6 +26,7 @@ use macros::UuidFromParam;
use super::{ use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, MembershipType, OrganizationId, User, UserId, MembershipStatus, MembershipType, OrganizationId, User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection},
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -68,6 +69,89 @@ pub enum RepromptType {
Password = 1, Password = 1,
} }
/// Whether `membership` holds organization-wide authority over its organization's ciphers.
///
/// Upstream gates every administrative cipher route on `CanEditCipherAsAdminAsync`,
/// `CanDeleteOrRestoreCipherAsAdminAsync` or `CanEditAllCiphersAsync`. All three first require
/// Owner, Admin or `Edit any collection`, and then resolve through `CanEditAllCiphersAsync` --
/// which is that very same set, because Vaultwarden always serializes
/// `allowAdminAccessToAllCollectionItems = true`. So all three reduce to this one predicate, and
/// the per-cipher fallbacks they contain for restricted admins are unreachable here.
///
/// Deliberately narrower than upstream's `ViewAllCollections` (which guards
/// `GET /ciphers/<id>/admin` and additionally admits `Delete any collection`): honouring that would
/// hand cipher *contents* to a permission that upstream's own `CanAccessAllCiphersAsync` -- and
/// therefore `GET /ciphers/organization-details` here -- deliberately keeps away from them. Where
/// the two upstream answers disagree, this takes the stricter one.
pub fn may_administer_org_ciphers(membership: &Membership) -> bool {
// `has_full_access()` is exactly "confirmed, and Owner/Admin or Custom holding
// `Edit any collection`".
membership.has_full_access()
}
/// Which authorization a cipher operation runs under.
///
/// Vaultwarden serves the organization's administrative cipher routes (`/ciphers/<id>/admin` and
/// friends) from the same handlers as the regular vault routes, so the handlers state the scope
/// explicitly and every authorization call site shows which one it uses.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CipherAccessScope {
/// The regular vault routes. Only ownership and the caller's per-collection/group assignments
/// count. `Edit any collection` deliberately does not widen `/sync`, `GET /ciphers` or the
/// non-admin `GET|PUT /ciphers/<id>`, so a Custom member holding it still sees exactly the
/// ciphers they are assigned to.
User,
/// The organization's administrative cipher routes, where a member with organization-wide
/// cipher authority reaches every cipher of that organization.
OrganizationAdmin,
}
impl CipherAccessScope {
/// Whether `membership` reaches every cipher of its organization in this scope.
///
/// Owner and Admin qualify in both scopes, which is the behaviour Vaultwarden has always had.
/// `Edit any collection` is administrative authority only, so it qualifies in
/// [`Self::OrganizationAdmin`] alone.
fn grants_org_wide_cipher_access(self, membership: &Membership) -> bool {
match self {
Self::User => membership.atype >= MembershipType::Admin,
Self::OrganizationAdmin => may_administer_org_ciphers(membership),
}
}
/// The scope a request asks for, for the one route that states it: the v2 attachment create.
///
/// Upstream's `PostAttachment` branches on the request's `adminRequest` flag -- `true`
/// authorizes with `CanEditCipherAsAdminAsync` and answers with a `CipherMiniResponse`,
/// anything else authorizes and answers as the regular vault route.
///
/// The flag only selects *which* predicate is evaluated, never what it answers:
/// [`Self::OrganizationAdmin`] still requires the caller to hold organization-wide cipher
/// authority, so a member without it gains nothing by setting the flag.
pub fn requested(admin_request: Option<bool>) -> Self {
if admin_request == Some(true) {
Self::OrganizationAdmin
} else {
Self::User
}
}
/// The scope for a route that cannot be told which one to use, resolved from the caller's own
/// membership in the cipher's organization.
///
/// The second leg of the v2 attachment upload is a bare file POST with no `adminRequest` field,
/// so upstream's `PostFileForExistingAttachment` recomputes the administrative context from the
/// caller instead (`orgAdmin = CanEditCipherAsAdminAsync(...)`). Nothing from the request feeds
/// into this, so that route cannot be talked into an administrative scope.
pub fn for_member(membership: Option<&Membership>) -> Self {
if membership.is_some_and(may_administer_org_ciphers) {
Self::OrganizationAdmin
} else {
Self::User
}
}
}
/// Local methods /// Local methods
impl Cipher { impl Cipher {
pub fn new(atype: i32, name: String) -> Self { pub fn new(atype: i32, name: String) -> Self {
@ -151,6 +235,35 @@ impl Cipher {
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
sync_type: CipherSyncType, sync_type: CipherSyncType,
conn: &DbConn, conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(host, user_uuid, cipher_sync_data, sync_type, CipherAccessScope::User, conn).await
}
/// [`Cipher::to_json`] for one of the organization's administrative cipher routes.
///
/// Same response shape those handlers have always produced; only the access flags differ. They
/// are resolved with [`CipherAccessScope::OrganizationAdmin`], so a member acting with
/// organization-wide cipher authority is reported as able to edit the cipher -- which is
/// exactly what the route authorized. Resolving them as `User` would answer a successfully
/// authorized admin request with `edit: false` and log an ownership assertion failure.
pub async fn to_json_org_admin(
&self,
host: &str,
user_uuid: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(host, user_uuid, None, CipherSyncType::User, CipherAccessScope::OrganizationAdmin, conn)
.await
}
async fn to_json_scoped(
&self,
host: &str,
user_uuid: &UserId,
cipher_sync_data: Option<&CipherSyncData>,
sync_type: CipherSyncType,
scope: CipherAccessScope,
conn: &DbConn,
) -> Result<Value, crate::Error> { ) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date}; use crate::util::{format_date, validate_and_format_date};
@ -179,7 +292,7 @@ impl Cipher {
// We don't need these values at all for Organizational syncs // We don't need these values at all for Organizational syncs
// Skip any other database calls if this is the case and just return false. // Skip any other database calls if this is the case and just return false.
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, scope, cipher_sync_data, conn).await {
(ro, hp, mn) (ro, hp, mn)
} else { } else {
error!("Cipher ownership assertion failure"); error!("Cipher ownership assertion failure");
@ -543,20 +656,22 @@ impl Cipher {
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
} }
/// Returns whether this cipher is owned by an org in which the user has full access. /// Returns whether this cipher is owned by an org in which the user reaches every cipher by
/// role, for the given [`CipherAccessScope`].
async fn is_in_full_access_org( async fn is_in_full_access_org(
&self, &self,
user_uuid: &UserId, user_uuid: &UserId,
scope: CipherAccessScope,
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> bool { ) -> bool {
if let Some(ref org_uuid) = self.organization_uuid { if let Some(ref org_uuid) = self.organization_uuid {
if let Some(cipher_sync_data) = cipher_sync_data { if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
return cached_member.has_full_access(); return scope.grants_org_wide_cipher_access(cached_member);
} }
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
return member.has_full_access(); return scope.grants_org_wide_cipher_access(&member);
} }
} }
false false
@ -589,14 +704,27 @@ impl Cipher {
pub async fn get_access_restrictions( pub async fn get_access_restrictions(
&self, &self,
user_uuid: &UserId, user_uuid: &UserId,
scope: CipherAccessScope,
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> Option<(bool, bool, bool)> { ) -> Option<(bool, bool, bool)> {
// Security: central fail-closed check binding cipher -> organization -> *confirmed* membership.
// It denies access from assignment rows that outlived a revoke (or are still only
// invited/accepted) and from cross-organization assignments another path might have persisted.
// The sync path (cipher_sync_data is Some) is left to the caller: it is built only from confirmed
// memberships and evaluated below against that cached data.
if cipher_sync_data.is_none()
&& let Some(ref org_uuid) = self.organization_uuid
&& Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await.is_none()
{
return None;
}
// Check whether this cipher is directly owned by the user, or is in // Check whether this cipher is directly owned by the user, or is in
// a collection that the user has full access to. If so, there are no // a collection that the user has full access to. If so, there are no
// access restrictions. // access restrictions.
if self.is_owned_by_user(user_uuid) if self.is_owned_by_user(user_uuid)
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_org(user_uuid, scope, cipher_sync_data, conn).await
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
{ {
return Some((false, false, true)); return Some((false, false, true));
@ -657,27 +785,33 @@ impl Cipher {
} }
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> { async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
let user_uuid = user_uuid.to_string(); let cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone();
conn.run(move |conn| { conn.run(move |conn| {
// Check whether this cipher is in any collections accessible to the // Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection. // user. If so, retrieve the access flags for each collection.
// The user must have a confirmed membership in the cipher's //
// organization, since users_collections rows are kept when a // Security: bind the assignment to a *confirmed* membership in the same organization as both
// membership is revoked or not confirmed yet. // the cipher and the collection, so a row left behind by a revoke, or pointing at another
// organization's collection, grants nothing. Defense in depth.
ciphers::table ciphers::table
.filter(ciphers::uuid.eq(&self.uuid)) .filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join( .inner_join(
users_organizations::table.on(ciphers::organization_uuid collections::table.on(collections::uuid
.eq(users_organizations::org_uuid.nullable()) .eq(ciphers_collections::collection_uuid)
.and(users_organizations::user_uuid.eq(user_uuid)) .and(collections::org_uuid.nullable().eq(ciphers::organization_uuid))),
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
) )
.inner_join( .inner_join(
users_collections::table.on(ciphers_collections::collection_uuid users_collections::table.on(ciphers_collections::collection_uuid
.eq(users_collections::collection_uuid) .eq(users_collections::collection_uuid)
// Only allow collection access via the confirmed membership. .and(users_collections::user_uuid.eq(user_uuid.clone()))),
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))), )
.inner_join(
users_organizations::table.on(users_organizations::user_uuid
.eq(user_uuid)
.and(users_organizations::org_uuid.eq(collections::org_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
) )
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn) .load::<(bool, bool, bool)>(conn)
@ -690,9 +824,14 @@ impl Cipher {
if !CONFIG.org_groups_enabled() { if !CONFIG.org_groups_enabled() {
return Vec::new(); return Vec::new();
} }
let cipher_uuid = self.uuid.clone();
let user_uuid = user_uuid.clone();
conn.run(move |conn| { conn.run(move |conn| {
// Security: bind the group assignment to a *confirmed* membership and require cipher,
// collection, group and membership to share one organization. The `collections` join is what
// stops a cross-organization collection<->group assignment reaching foreign ciphers.
ciphers::table ciphers::table
.filter(ciphers::uuid.eq(&self.uuid)) .filter(ciphers::uuid.eq(cipher_uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) .inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join( .inner_join(
collections_groups::table collections_groups::table
@ -700,13 +839,21 @@ impl Cipher {
) )
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid))) .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(collections_groups::groups_uuid)))
.inner_join( .inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)), users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
) )
.inner_join( .inner_join(
groups::table.on(groups::uuid groups::table.on(groups::uuid
.eq(collections_groups::groups_uuid) .eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))), .and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
) )
.inner_join(
collections::table.on(collections::uuid
.eq(ciphers_collections::collection_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
.and(collections::org_uuid.nullable().eq(ciphers::organization_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
// Only allow group access via a confirmed membership, since // Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked. // groups_users rows are kept when a membership is revoked.
@ -718,8 +865,13 @@ impl Cipher {
.await .await
} }
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_write_accessible_to_user(
match self.get_access_restrictions(user_uuid, None, conn).await { &self,
user_uuid: &UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> bool {
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
Some((read_only, _hide_passwords, manage)) => !read_only || manage, Some((read_only, _hide_passwords, manage)) => !read_only || manage,
None => false, None => false,
} }
@ -727,15 +879,20 @@ impl Cipher {
// used for checking if collection can be edited (only if user has access to a collection they // used for checking if collection can be edited (only if user has access to a collection they
// can write to and also passwords are not hidden to prevent privilege escalation) // can write to and also passwords are not hidden to prevent privilege escalation)
pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_in_editable_collection_by_user(
match self.get_access_restrictions(user_uuid, None, conn).await { &self,
user_uuid: &UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> bool {
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
None => false, None => false,
} }
} }
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
self.get_access_restrictions(user_uuid, None, conn).await.is_some() self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
} }
// Returns whether this cipher is a favorite of the specified user. // Returns whether this cipher is a favorite of the specified user.
@ -844,15 +1001,16 @@ 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
.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
.into_boxed(); .into_boxed();
if !visible_only { if !visible_only {
// Administrative organization scope, separate from the normal user vault.
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
); );
} }
@ -881,13 +1039,14 @@ 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
.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();
if !visible_only { if !visible_only {
// Administrative organization scope, separate from the normal user vault.
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
); );
} }
@ -1005,8 +1164,8 @@ impl Cipher {
) )
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
users_organizations::access_all custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.eq(true) // User has access all .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // 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)))
@ -1037,8 +1196,8 @@ impl Cipher {
) )
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
users_organizations::access_all custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.eq(true) // User has access all .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // 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))),
@ -1082,8 +1241,8 @@ impl Cipher {
) )
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
users_organizations::access_all custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.eq(true) // User has access all .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // 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)))
@ -1091,7 +1250,7 @@ impl Cipher {
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))) .and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@ -1115,12 +1274,12 @@ impl Cipher {
) )
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
users_organizations::access_all custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
.eq(true) // User has access all .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // 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)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@ -1161,8 +1320,8 @@ 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(custom_membership_with_edit_any_collection()) // 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.eq_any(ORG_ADMIN_ATYPES)) // 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
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
@ -1173,6 +1332,37 @@ impl Cipher {
}) })
.await .await
} }
pub async fn get_collections_with_cipher_by_organization(
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Vec<(CipherId, CollectionId)> {
conn.run(move |conn| {
ciphers_collections::table
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.filter(collections::org_uuid.eq(org_uuid))
.select(ciphers_collections::all_columns)
.load::<(CipherId, CollectionId)>(conn)
.unwrap_or_default()
})
.await
}
/// The organization's ciphers that have no collection assignment — upstream's
/// `GetUnassignedOrganizationCiphers`.
pub async fn find_unassigned_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
ciphers::table
.left_join(ciphers_collections::table.on(ciphers_collections::cipher_uuid.eq(ciphers::uuid)))
.filter(ciphers::organization_uuid.eq(org_uuid))
.filter(ciphers::user_uuid.is_null())
.filter(ciphers_collections::cipher_uuid.is_null())
.select(ciphers::all_columns)
.load::<Self>(conn)
.unwrap_or_default()
})
.await
}
} }
#[derive( #[derive(
@ -1192,3 +1382,250 @@ impl Cipher {
UuidFromParam, UuidFromParam,
)] )]
pub struct CipherId(String); pub struct CipherId(String);
#[cfg(test)]
mod tests {
use super::CipherAccessScope;
use crate::db::models::{Membership, MembershipStatus as Status, MembershipType};
const OWNER: i32 = MembershipType::Owner as i32;
const ADMIN: i32 = MembershipType::Admin as i32;
const USER: i32 = MembershipType::User as i32;
const CUSTOM: i32 = MembershipType::Custom as i32;
const UNKNOWN: i32 = Membership::UNKNOWN_ATYPE;
/// Who reaches *every* cipher of an organization, in each of the two scopes, and which scope the
/// v2 attachment upload resolves for the member.
///
/// The regular vault scope answers by role alone; the administrative scope additionally admits a
/// confirmed Custom member holding `Edit any collection` -- upstream's `CanEditAllCiphersAsync`,
/// which every `/ciphers/.../admin` route resolves through. The upload leg carries no
/// `adminRequest`, so its scope comes from the membership alone and is exactly that
/// administrative answer: nothing in the request can talk that route into it.
#[test]
fn cipher_access_scope_matrix() {
// (case, atype, status, edit_any_collection, regular vault scope, administrative scope)
let cases = [
("Owner", OWNER, Status::Confirmed, false, true, true),
("Admin", ADMIN, Status::Confirmed, false, true, true),
// The role this change adds: administrative authority, and nothing beyond it. The two
// scopes disagreeing here *is* the fix: `/sync`, `GET /ciphers` and the non-admin
// `GET|PUT /ciphers/<id>` keep answering from the member's own collection assignments.
("Custom + EditAny", CUSTOM, Status::Confirmed, true, false, true),
("Custom without EditAny", CUSTOM, Status::Confirmed, false, false, false),
("User", USER, Status::Confirmed, false, false, false),
// A permission flag is only meaningful on a Custom membership, so one left behind by a
// role change grants nothing.
("User with a stale EditAny flag", USER, Status::Confirmed, true, false, false),
// The permission only activates once the membership is confirmed.
("Custom + EditAny, invited", CUSTOM, Status::Invited, true, false, false),
("Custom + EditAny, accepted", CUSTOM, Status::Accepted, true, false, false),
("Custom + EditAny, revoked", CUSTOM, Status::Revoked, true, false, false),
// A role this build cannot interpret holds nothing, in either scope.
("unknown role", UNKNOWN, Status::Confirmed, true, false, false),
];
for (case, atype, status, edit_any_collection, user_scope, admin_scope) in cases {
let member = Membership::for_test(atype, status, |m| m.edit_any_collection = edit_any_collection);
assert_eq!(
CipherAccessScope::User.grants_org_wide_cipher_access(&member),
user_scope,
"{case}: regular vault scope"
);
assert_eq!(
CipherAccessScope::OrganizationAdmin.grants_org_wide_cipher_access(&member),
admin_scope,
"{case}: administrative scope"
);
// A route picks which scope it runs under, so the administrative one must never be the
// narrower of the two -- the worst case of picking it is the regular answer.
assert!(!user_scope || admin_scope, "{case}: this row expects the administrative scope to narrow");
let upload = if admin_scope {
CipherAccessScope::OrganizationAdmin
} else {
CipherAccessScope::User
};
assert_eq!(CipherAccessScope::for_member(Some(&member)), upload, "{case}: upload scope");
}
// No membership at all, e.g. a personal cipher.
assert_eq!(CipherAccessScope::for_member(None), CipherAccessScope::User);
}
/// The v2 attachment *create* is the one route upstream lets the request pick the flow for
/// (`adminRequest`). The flag only selects which predicate runs; the matrix above shows the
/// administrative one still asks whether the member holds the authority, so it is no way up.
#[test]
fn admin_request_scope_selection() {
assert_eq!(CipherAccessScope::requested(Some(true)), CipherAccessScope::OrganizationAdmin);
assert_eq!(CipherAccessScope::requested(Some(false)), CipherAccessScope::User);
assert_eq!(CipherAccessScope::requested(None), CipherAccessScope::User);
}
}
/// The scope decision as the database actually answers it.
///
/// The tests above pin the predicate; this one pins the query path that consumes it. It is the only
/// place that shows a Custom member holding `Edit any collection` reaching a cipher they have no
/// assignment for -- and not reaching it in the regular vault scope.
#[cfg(all(test, sqlite))]
mod db_scope_tests {
use super::{Cipher, CipherAccessScope};
use crate::db::models::UserId;
use crate::db::test_db::{TestDb, block_on};
/// Two organizations, and one cipher in each.
///
/// `u_custom` is a confirmed Custom member of org 1 holding `Edit any collection`, with **no**
/// `users_collections` row and no group -- exactly the member whose administrative reach is not
/// backed by an assignment. `u_assigned` is the plain User the collection is actually assigned
/// to, so the fixture proves the cipher is reachable at all. `u_revoked` holds the same Custom
/// permission with a revoked membership.
const FIXTURE: &str = "
INSERT INTO collections (uuid, org_uuid, name) VALUES
('col1', 'org1', 'c'),
('col2', 'org2', 'c');
INSERT INTO ciphers (uuid, created_at, updated_at, organization_uuid, atype, name, data) VALUES
('cipher1', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'org1', 1, 'n', '{}'),
('cipher2', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 'org2', 1, 'n', '{}');
INSERT INTO ciphers_collections (cipher_uuid, collection_uuid) VALUES
('cipher1', 'col1'),
('cipher2', 'col2');
INSERT INTO users_organizations (uuid, user_uuid, org_uuid, akey, status, atype, edit_any_collection) VALUES
('m_custom', 'u_custom', 'org1', '', 2, 4, TRUE),
('m_revoked', 'u_revoked', 'org1', '', -1, 4, TRUE),
('m_assigned', 'u_assigned', 'org1', '', 2, 2, FALSE);
INSERT INTO users_collections (user_uuid, collection_uuid, read_only, hide_passwords, manage) VALUES
('u_assigned', 'col1', FALSE, FALSE, FALSE);
";
/// Only the tables the cipher access queries join.
const SCHEMA: &str = "
CREATE TABLE collections (
uuid TEXT NOT NULL PRIMARY KEY,
org_uuid TEXT NOT NULL,
name TEXT NOT NULL,
external_id TEXT
);
CREATE TABLE ciphers (
uuid TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
user_uuid TEXT,
organization_uuid TEXT,
key TEXT,
atype INTEGER NOT NULL,
name TEXT NOT NULL,
notes TEXT,
fields TEXT,
data TEXT NOT NULL,
password_history TEXT,
deleted_at DATETIME,
reprompt INTEGER
);
CREATE TABLE ciphers_collections (
cipher_uuid TEXT NOT NULL,
collection_uuid TEXT NOT NULL,
PRIMARY KEY (cipher_uuid, collection_uuid)
);
CREATE TABLE users_collections (
user_uuid TEXT NOT NULL,
collection_uuid TEXT NOT NULL,
read_only BOOLEAN NOT NULL DEFAULT FALSE,
hide_passwords BOOLEAN NOT NULL DEFAULT FALSE,
manage BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (user_uuid, collection_uuid)
);
CREATE TABLE users_organizations (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL,
org_uuid TEXT NOT NULL,
invited_by_email TEXT,
akey TEXT NOT NULL,
status INTEGER NOT NULL,
atype INTEGER NOT NULL,
reset_password_key TEXT,
external_id TEXT,
manage_users BOOLEAN NOT NULL DEFAULT FALSE,
manage_groups BOOLEAN NOT NULL DEFAULT FALSE,
manage_policies BOOLEAN NOT NULL DEFAULT FALSE,
create_new_collections BOOLEAN NOT NULL DEFAULT FALSE,
edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE,
access_event_logs BOOLEAN NOT NULL DEFAULT FALSE,
access_import_export BOOLEAN NOT NULL DEFAULT FALSE,
access_reports BOOLEAN NOT NULL DEFAULT FALSE
);
";
/// A cipher in `org_uuid`, matching the seeded row of the same id.
fn cipher(uuid: &str, org_uuid: &str) -> Cipher {
let mut cipher = Cipher::new(1, String::from("n"));
cipher.uuid = uuid.to_owned().into();
cipher.organization_uuid = Some(org_uuid.to_owned().into());
cipher.data = String::from("{}");
cipher
}
#[test]
fn cipher_scope_is_enforced_against_the_database() {
let db = TestDb::new(&format!("{SCHEMA}{FIXTURE}"));
let own_org = cipher("cipher1", "org1");
let other_org = cipher("cipher2", "org2");
let custom: UserId = String::from("u_custom").into();
let revoked: UserId = String::from("u_revoked").into();
let assigned: UserId = String::from("u_assigned").into();
// `DbConn` has to be created *and* dropped inside the runtime: its `Drop` uses
// `spawn_blocking` to return the connection to the pool.
block_on(async {
let conn = db.conn();
// The fixture is meaningful: the cipher is reachable through a real assignment.
assert!(
own_org.is_accessible_to_user(&assigned, CipherAccessScope::User, &conn).await,
"the assigned member must reach the cipher, otherwise this fixture proves nothing"
);
// Custom + `Edit any collection`, with no assignment of its own: administrative reach...
assert!(
own_org.is_accessible_to_user(&custom, CipherAccessScope::OrganizationAdmin, &conn).await,
"Custom + EditAny must reach an unassigned org cipher on the administrative routes"
);
assert!(
own_org.is_write_accessible_to_user(&custom, CipherAccessScope::OrganizationAdmin, &conn).await,
"administrative reach includes writing"
);
// ...and nothing at all in the regular vault, which is what `/sync` and `GET /ciphers`
// answer from. A query that resolved the scope from the membership instead of from the
// route would make this true and hand the member the whole organization vault.
assert!(
!own_org.is_accessible_to_user(&custom, CipherAccessScope::User, &conn).await,
"Custom + EditAny must not reach an unassigned org cipher in the regular vault scope"
);
assert!(
!own_org.is_write_accessible_to_user(&custom, CipherAccessScope::User, &conn).await,
"Custom + EditAny must not write an unassigned org cipher in the regular vault scope"
);
// A revoked membership holds the same flag and reaches nothing, in either scope.
for scope in [CipherAccessScope::User, CipherAccessScope::OrganizationAdmin] {
assert!(
!own_org.is_accessible_to_user(&revoked, scope, &conn).await,
"a revoked membership must not reach org ciphers in any scope"
);
}
// Another organization's cipher stays out of reach even in the administrative scope:
// the authority is bound to the organization the membership belongs to.
for scope in [CipherAccessScope::User, CipherAccessScope::OrganizationAdmin] {
assert!(
!other_org.is_accessible_to_user(&custom, scope, &conn).await,
"a cipher of another organization must never be reachable"
);
}
});
}
}

202
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::{
@ -19,6 +20,7 @@ use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_membership_with_edit_any_collection},
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@ -52,6 +54,29 @@ pub struct CollectionCipher {
pub collection_uuid: CollectionId, pub collection_uuid: CollectionId,
} }
/// Serialize the assignment-level `manage` capability using the same role boundary as the collection
/// mutation guards. Read/write access is deliberately not management authority.
///
/// Belongs on what a member receives about themselves; the administrative lists echo a *stored* grant and
/// use `stored_assignment_manage` instead.
pub(super) fn assignment_manage_for_member(membership_type: i32, stored_manage: bool) -> bool {
match MembershipType::from_i32(membership_type) {
Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Custom | MembershipType::User) => stored_manage,
None => false,
}
}
/// Serialize a *stored* per-collection assignment row for the admin-console access lists.
///
/// The client writes the same value back when the dialog is saved, so reporting anything other than the
/// persisted bit would make an unrelated save silently strip it -- for a plain User that also revokes the
/// cipher write access `users_collections.manage` grants. Admins and Owners manage implicitly.
pub(super) fn stored_assignment_manage(membership_type: i32, stored_manage: bool) -> bool {
matches!(MembershipType::from_i32(membership_type), Some(MembershipType::Owner | MembershipType::Admin))
|| stored_manage
}
/// Local methods /// 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,41 +129,49 @@ 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
// Owners and Admins always have true. Users are not able to have full access
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => { Some(m) => {
// Only let a manager manage collections when the have full read/write access // What the client is told has to match what the collection guards allow, or it renders
let is_manager = m.atype == MembershipType::Manager; // the wrong controls. A stored grant therefore counts even for a member who already
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { // reaches every collection; reaching it through a group with `access_all` does not.
( let assignment = cipher_sync_data
cu.read_only, .user_collections
cu.hide_passwords, .get(&self.uuid)
is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)), .map(|cu| (cu.read_only, cu.hide_passwords, cu.manage))
) .or_else(|| {
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) { cipher_sync_data
( .user_collections_groups
cg.read_only, .get(&self.uuid)
cg.hide_passwords, .map(|cg| (cg.read_only, cg.hide_passwords, cg.manage))
is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)), });
) let stored_manage = assignment.is_some_and(|(_, _, manage)| manage);
} else { let manage = assignment_manage_for_member(m.atype, stored_manage);
(false, false, false) match assignment {
Some((read_only, hide_passwords, _)) if !m.has_full_access() => {
(read_only, hide_passwords, manage)
}
// Reaching every collection means nothing is read-only or hidden here.
_ => (false, false, manage),
} }
} }
_ => (true, true, false), _ => (true, true, false),
} }
} 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), // Same rule as the cached branch above: a member who reaches every collection still
Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => { // reports a real stored grant, so the serialized value matches the guards.
(false, false, true) Some(m) if m.has_full_access() => (
} false,
Some(m) => { false,
let is_manager = m.atype == MembershipType::Manager; assignment_manage_for_member(
m.atype,
m.has_explicit_collection_manage_access(&self.uuid, conn).await,
),
),
Some(m) if m.has_explicit_collection_manage_access(&self.uuid, conn).await => (false, false, true),
Some(_) => {
let read_only = !self.is_writable_by_user(user_uuid, conn).await; let 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),
} }
@ -252,8 +285,10 @@ 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)
custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
) )
.or( .or(
groups::access_all.eq(true), // access_all in groups groups::access_all.eq(true), // access_all in groups
@ -285,10 +320,14 @@ 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)
custom_membership_with_edit_any_collection()
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)),
),
)
.select(collections::all_columns) .select(collections::all_columns)
.distinct() .distinct()
.load::<Self>(conn) .load::<Self>(conn)
@ -373,9 +412,9 @@ impl Collection {
.eq(uuid) .eq(uuid)
.or( .or(
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true).or( custom_membership_with_edit_any_collection().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.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@ -410,9 +449,9 @@ impl Collection {
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.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( custom_membership_with_edit_any_collection().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.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
)) ))
.select(collections::all_columns) .select(collections::all_columns)
@ -455,8 +494,8 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(custom_membership_with_edit_any_collection()) // 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)))
@ -489,8 +528,8 @@ impl Collection {
) )
.filter( .filter(
users_organizations::atype users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin or owner .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner
.or(users_organizations::access_all.eq(true)) // access_all via membership .or(custom_membership_with_edit_any_collection()) // 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))),
@ -538,9 +577,9 @@ 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( custom_membership_with_edit_any_collection().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.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner
), ),
) )
.or( .or(
@ -564,72 +603,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_organizations::status.eq(MembershipStatus::Confirmed as i32))
.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,
@ -657,6 +632,7 @@ impl Collection {
) )
.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::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq_any([MembershipType::User as i32, 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)),
@ -973,11 +949,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": stored_assignment_manage(membership_type, self.manage),
|| self.manage
|| (membership_type == MembershipType::Manager
&& !self.read_only
&& !self.hide_passwords),
}) })
} }
} }

45
src/db/models/event.rs

@ -79,6 +79,21 @@ pub enum EventType {
CipherSoftDeleted = 1115, CipherSoftDeleted = 1115,
CipherRestored = 1116, CipherRestored = 1116,
CipherClientToggledCardNumberVisible = 1117, CipherClientToggledCardNumberVisible = 1117,
// CipherClientToggledTOTPSeedVisible = 1118, // Not accepted from clients by upstream either
CipherClientCopiedBankAccountNumber = 1119,
CipherClientCopiedBankAccountPin = 1120,
CipherClientToggledBankAccountNumberVisible = 1121,
CipherClientToggledBankAccountPinVisible = 1122,
CipherClientCopiedLicenseNumber = 1123,
CipherClientToggledLicenseNumberVisible = 1124,
CipherClientCopiedPassportNumber = 1125,
CipherClientToggledPassportNumberVisible = 1126,
CipherClientCopiedSwiftCode = 1127,
CipherClientToggledSwiftCodeVisible = 1128,
CipherClientCopiedIban = 1129,
CipherClientToggledIbanVisible = 1130,
CipherClientCopiedNationalIdentificationNumber = 1131,
CipherClientToggledNationalIdentificationNumberVisible = 1132,
// Collection // Collection
CollectionCreated = 1300, CollectionCreated = 1300,
@ -126,6 +141,11 @@ pub enum EventType {
// OrganizationDisabledKeyConnector = 1607, // Not supported // OrganizationDisabledKeyConnector = 1607, // Not supported
// OrganizationSponsorshipsSynced = 1608, // Not supported // OrganizationSponsorshipsSynced = 1608, // Not supported
// OrganizationCollectionManagementUpdated = 1609, // Not supported // OrganizationCollectionManagementUpdated = 1609, // Not supported
OrganizationItemOrganizationAccepted = 1618,
OrganizationItemOrganizationDeclined = 1619,
OrganizationAutoConfirmEnabledAdmin = 1620,
OrganizationAutoConfirmDisabledAdmin = 1621,
OrganizationInviteLinkClientCopied = 1627,
// Policy // Policy
PolicyUpdated = 1700, PolicyUpdated = 1700,
@ -330,20 +350,37 @@ impl Event {
pub async fn find_by_cipher_uuid( pub async fn find_by_cipher_uuid(
cipher_uuid: &CipherId, cipher_uuid: &CipherId,
org_uuid: Option<&OrganizationId>,
start: &NaiveDateTime, start: &NaiveDateTime,
end: &NaiveDateTime, end: &NaiveDateTime,
conn: &DbConn, conn: &DbConn,
) -> Vec<Self> { ) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| Self::find_by_cipher_uuid_impl(cipher_uuid, org_uuid, start, end, conn)).await
event::table }
fn find_by_cipher_uuid_impl(
cipher_uuid: &CipherId,
org_uuid: Option<&OrganizationId>,
start: &NaiveDateTime,
end: &NaiveDateTime,
conn: &mut crate::db::DbConnInner,
) -> Vec<Self> {
let query = event::table
.filter(event::cipher_uuid.eq(cipher_uuid)) .filter(event::cipher_uuid.eq(cipher_uuid))
.filter(event::event_date.between(start, end)) .filter(event::event_date.between(start, end))
.into_boxed();
// A cipher event request is authorized for exactly one scope: either the cipher's
// current organization or its personal owner. Apply that scope before PAGE_SIZE so
// rows from another scope cannot consume the page and hide older authorized events.
match org_uuid {
Some(org_uuid) => query.filter(event::org_uuid.eq(org_uuid)),
None => query.filter(event::org_uuid.is_null()),
}
.order_by(event::event_date.desc()) .order_by(event::event_date.desc())
.limit(Self::PAGE_SIZE) .limit(Self::PAGE_SIZE)
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error filtering events") .expect("Error filtering events")
})
.await
} }
pub async fn clean_events(conn: &DbConn) -> EmptyResult { pub async fn clean_events(conn: &DbConn) -> EmptyResult {

35
src/db/models/group.rs

@ -13,7 +13,7 @@ use crate::{
}; };
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId}; use super::{Collection, CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = groups)] #[diesel(table_name = groups)]
@ -84,9 +84,6 @@ impl Group {
} }
pub async fn to_json_details(&self, conn: &DbConn) -> Value { pub async fn to_json_details(&self, conn: &DbConn) -> Value {
// If both read_only and hide_passwords are false, then manage should be true
// You can't have an entry with read_only and manage, or hide_passwords and manage
// Or an entry with everything to false
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn) let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn)
.await .await
.iter() .iter()
@ -138,15 +135,13 @@ impl CollectionGroup {
} }
pub fn to_json_details_for_group(&self) -> Value { pub fn to_json_details_for_group(&self) -> Value {
// If both read_only and hide_passwords are false, then manage should be true // `manage` is a stored permission of its own and is reported exactly as stored: read/write
// You can't have an entry with read_only and manage, or hide_passwords and manage // access is not management.
// Or an entry with everything to false
// For backwards compatibility and migration proposes we keep checking read_only and hide_password
json!({ json!({
"id": self.groups_uuid, "id": self.groups_uuid,
"readOnly": self.read_only, "readOnly": self.read_only,
"hidePasswords": self.hide_passwords, "hidePasswords": self.hide_passwords,
"manage": self.manage || (!self.read_only && !self.hide_passwords), "manage": self.manage,
}) })
} }
} }
@ -261,6 +256,9 @@ impl Group {
pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool { pub async fn is_in_full_access_group(user_uuid: &UserId, org_uuid: &OrganizationId, conn: &DbConn) -> bool {
conn.run(move |conn| { conn.run(move |conn| {
// Security: the membership linked through `groups_users` must be confirmed and belong to the
// same organization as the group, or a cross-organization row would pass as full access to
// that organization.
groups::table groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join( .inner_join(
@ -317,6 +315,15 @@ impl Group {
impl CollectionGroup { impl CollectionGroup {
pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
// Security: never persist a cross-organization link between a collection and a group --
// attaching a foreign-tenant group to this organization's collection would grant its members
// access. Defense in depth, so no route can create one even if its own validation is wrong.
if Collection::find_by_uuid_and_org(&self.collections_uuid, org_uuid, conn).await.is_none()
|| Group::find_by_uuid_and_org(&self.groups_uuid, org_uuid, conn).await.is_none()
{
err!("Collection and group must belong to the same organization")
}
let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await; let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {
group_user.update_user_revision(conn).await; group_user.update_user_revision(conn).await;
@ -469,6 +476,16 @@ impl CollectionGroup {
impl GroupUser { impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
// Security: never persist a cross-organization link between a group and a membership -- that
// would grant a member of one organization access to another's collections through an
// access-all group. Defense in depth, so no route can create one even if its own validation is wrong.
let Some(member) = Membership::find_by_uuid(&self.users_organizations_uuid, conn).await else {
err!("Member not found while assigning to group")
};
if Group::find_by_uuid_and_org(&self.groups_uuid, &member.org_uuid, conn).await.is_none() {
err!("Group and member must belong to the same organization")
}
self.update_user_revision(conn).await; self.update_user_revision(conn).await;
let values = ( let values = (

4
src/db/models/mod.rs

@ -21,7 +21,7 @@ mod user;
pub use self::archive::Archive; pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
@ -30,6 +30,8 @@ pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
/// The single list of Custom-role permissions, see `organization::custom_role_permissions`.
pub(crate) use self::organization::custom_role_permissions;
pub use self::organization::{ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId, OrganizationId,

584
src/db/models/organization.rs

@ -1,4 +1,4 @@
use std::cmp::Ordering; use std::{cmp::Ordering, collections::HashSet};
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
@ -12,8 +12,8 @@ use crate::{
db::{ db::{
DbConn, DbConn,
schema::{ schema::{
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, ciphers_collections, collections, collections_groups, groups, groups_users, org_policies,
organizations, users, users_collections, users_organizations, organization_api_key, organizations, users, users_collections, users_organizations,
}, },
}, },
error::MapResult, error::MapResult,
@ -22,7 +22,7 @@ use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
TwoFactor, User, UserId, TwoFactor, User, UserId, collection::stored_assignment_manage,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -41,6 +41,7 @@ pub struct Organization {
#[diesel(table_name = users_organizations)] #[diesel(table_name = users_organizations)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))] #[diesel(primary_key(uuid))]
#[allow(clippy::struct_excessive_bools)]
pub struct Membership { pub struct Membership {
pub uuid: MembershipId, pub uuid: MembershipId,
pub user_uuid: UserId, pub user_uuid: UserId,
@ -48,12 +49,86 @@ 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,
pub reset_password_key: Option<String>, pub reset_password_key: Option<String>,
pub external_id: Option<String>, pub external_id: Option<String>,
pub manage_users: bool,
pub manage_groups: bool,
pub manage_policies: bool,
pub create_new_collections: bool,
pub edit_any_collection: bool,
pub delete_any_collection: bool,
pub access_event_logs: bool,
pub access_import_export: bool,
pub access_reports: bool,
}
/// The nine Custom-role permissions in one place: struct field, Bitwarden JSON key, accessor name.
///
/// Everything that needs the complete set -- the `Membership` accessors, the permissions object the
/// clients receive, and the request parser in `api::core::organizations` -- expands this list instead
/// of repeating it, so the set cannot drift apart between them.
macro_rules! custom_role_permissions {
($consumer:path) => {
$consumer! {
manage_users, "manageUsers", has_manage_users;
manage_groups, "manageGroups", has_manage_groups;
manage_policies, "managePolicies", has_manage_policies;
create_new_collections, "createNewCollections", has_create_new_collections;
edit_any_collection, "editAnyCollection", has_edit_any_collection;
delete_any_collection, "deleteAnyCollection", has_delete_any_collection;
access_event_logs, "accessEventLogs", has_access_event_logs;
access_import_export, "accessImportExport", has_access_import_export;
access_reports, "accessReports", has_access_reports;
}
};
}
pub(crate) use custom_role_permissions;
macro_rules! impl_membership_custom_permissions {
($($field:ident, $json_key:literal, $accessor:ident);* $(;)?) => {
impl Membership {
// The granular custom permission flags are only meaningful while the membership is of
// the Custom type. Gating them on the type here ensures that a stale flag left over from
// a type change (e.g. via the admin panel) can never grant anything.
$(
pub fn $accessor(&self) -> bool {
self.has_type(MembershipType::Custom) && self.$field
}
)*
pub fn clear_custom_permissions(&mut self) {
$( self.$field = false; )*
}
/// The permissions object the Bitwarden clients receive.
///
/// Type-gated through the accessors above, so a flag left behind on a non-Custom
/// membership is reported as `false` rather than as a grant.
pub fn custom_permissions_json(&self) -> Value {
json!({
$( $json_key: self.$accessor(), )*
"manageSso": false, // Not supported
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
}
}
};
}
custom_role_permissions!(impl_membership_custom_permissions);
/// Diesel equivalent of [`Membership::has_edit_any_collection`].
///
/// Keep the role check in this shared predicate so a stale flag on any non-Custom membership
/// remains inert in every collection-access query.
pub(super) fn custom_membership_with_edit_any_collection() -> diesel::dsl::And<
diesel::dsl::Eq<users_organizations::atype, i32>,
diesel::dsl::Eq<users_organizations::edit_any_collection, bool>,
> {
users_organizations::atype.eq(MembershipType::Custom as i32).and(users_organizations::edit_any_collection.eq(true))
} }
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -94,37 +169,50 @@ 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,
} }
impl MembershipType { impl MembershipType {
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
#[expect(
clippy::match_same_arms,
reason = "Specifically define `4|Custom` since this is a hack, not a default"
)]
match s { match s {
"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
// HACK: We convert the custom role to a manager role // client or stored request may still send value 3. Custom supersedes Manager, so accept
"4" | "Custom" => Some(MembershipType::Manager), // and fold it onto Custom.
"3" | "Manager" | "4" | "Custom" => Some(MembershipType::Custom),
_ => None, _ => None,
} }
} }
const fn access_rank(self) -> u8 {
match self {
Self::User => 0,
Self::Custom => 1,
Self::Admin => 2,
Self::Owner => 3,
}
}
} }
/// The stored `users_organizations.atype` values that carry organization-wide authority by role.
///
/// Queries enumerate the two values instead of comparing `atype <= Admin`: `<=` also matches every value
/// *below* `Owner`, so a corrupt or negative `atype` would satisfy the SQL check while every Rust guard
/// rejects it. Enumerating keeps both layers on the same answer.
pub(crate) const ORG_ADMIN_ATYPES: &[i32] = &[MembershipType::Owner as i32, MembershipType::Admin as i32];
impl Ord for MembershipType { impl Ord for MembershipType {
fn cmp(&self, other: &MembershipType) -> Ordering { fn cmp(&self, other: &MembershipType) -> Ordering {
// For easy comparison, map each variant to an access level (where 0 is lowest). // Roles are ordered by their authorization rank, not by their raw discriminant (Custom's
const ACCESS_LEVEL: [i32; 4] = [ // discriminant is 4 but it ranks between User and Admin). The discriminant is kept as a
3, // Owner // stable tie-breaker so `Ord` never disagrees with `Eq`.
2, // Admin self.access_rank().cmp(&other.access_rank()).then_with(|| (*self as i32).cmp(&(*other as i32)))
0, // User
1, // Manager && Custom
];
ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize])
} }
} }
@ -265,12 +353,20 @@ 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,
reset_password_key: None, reset_password_key: None,
external_id: None, external_id: None,
manage_users: false,
manage_groups: false,
manage_policies: false,
create_new_collections: false,
edit_any_collection: false,
delete_any_collection: false,
access_event_logs: false,
access_import_export: false,
access_reports: false,
} }
} }
@ -310,15 +406,6 @@ impl Membership {
} }
false false
} }
/// HACK: Convert the manager type to a custom type
/// It will be converted back on other locations
pub fn type_manager_as_custom(&self) -> i32 {
match self.atype {
3 => 4,
_ => self.atype,
}
}
} }
impl OrganizationApiKey { impl OrganizationApiKey {
@ -438,28 +525,14 @@ impl Membership {
pub async fn to_json(&self, conn: &DbConn) -> Value { pub async fn to_json(&self, conn: &DbConn) -> Value {
let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap(); let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap();
// HACK: Convert the manager type to a custom type let membership_type = self.atype;
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom(); let permissions = self.custom_permissions_json();
let permissions = json!({ // Edit any collection grants full read/edit access to every collection, but it must not
// TODO: Add full support for Custom User Roles // accidentally grant collection creation. The client treats limitCollectionCreation=false as
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role // an independent create grant, so compute it from the actual role/permission.
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission let limit_collection_creation = self.limit_collection_creation();
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": membership_type == 4 && self.access_all,
"editAnyCollection": membership_type == 4 && self.access_all,
"deleteAnyCollection": membership_type == 4 && self.access_all,
"manageGroups": false,
"managePolicies": false,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
// 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
json!({ json!({
@ -510,8 +583,7 @@ impl Membership {
"familySponsorshipValidUntil": null, "familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null, "familySponsorshipToDelete": null,
"accessSecretsManager": false, "accessSecretsManager": false,
// limit collection creation to managers with access_all permission to prevent issues "limitCollectionCreation": limit_collection_creation,
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false, "limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
@ -560,24 +632,16 @@ impl Membership {
Vec::new() Vec::new()
}; };
// Check if a user is in a group which has access to all collections let collections: Vec<Value> = if include_collections {
// If that is the case, we should not return individual collections!
// This is used by admins to view other members, so it must not depend on the membership status
let full_access_group =
CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&self.org_uuid, &self.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
// Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) {
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await .await
.into_iter() .into_iter()
.map(|cu| { .map(|collection_user| {
json!({ json!({
"id": cu.collection_uuid, "id": collection_user.collection_uuid,
"readOnly": cu.read_only, "readOnly": collection_user.read_only,
"hidePasswords": cu.hide_passwords, "hidePasswords": collection_user.hide_passwords,
"manage": cu.manage || (self.atype == MembershipType::Manager && !cu.read_only && !cu.hide_passwords), "manage": stored_assignment_manage(self.atype, collection_user.manage),
}) })
}) })
.collect() .collect()
@ -585,31 +649,12 @@ impl Membership {
Vec::new() Vec::new()
}; };
// HACK: Convert the manager type to a custom type let membership_type = self.atype;
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
// HACK: Only return permissions if the user is of type custom and has access_all // Only return a permissions object for custom-type members. Otherwise Bitwarden assumes
// Else Bitwarden will assume the defaults of all false // all-false defaults and the role itself supplies any elevated capabilities.
let permissions = if membership_type == 4 && self.access_all { let permissions = if membership_type == MembershipType::Custom as i32 {
json!({ self.custom_permissions_json()
// TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role
// Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"createNewCollections": true,
"editAnyCollection": true,
"deleteAnyCollection": true,
"manageGroups": false,
"managePolicies": false,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
} else { } else {
json!(null) json!(null)
}; };
@ -626,7 +671,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(),
@ -653,7 +700,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 =
@ -685,7 +732,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",
@ -706,7 +754,7 @@ impl Membership {
json!({ json!({
"id": self.uuid, "id": self.uuid,
"userId": self.user_uuid, "userId": self.user_uuid,
"type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type "type": self.atype,
"status": status, "status": status,
"name": user.name, "name": user.name,
"email": user.email, "email": user.email,
@ -786,7 +834,145 @@ impl Membership {
} }
pub fn has_full_access(&self) -> bool { pub fn has_full_access(&self) -> bool {
(self.access_all || self.atype >= MembershipType::Admin) && self.has_status(MembershipStatus::Confirmed) (self.has_edit_any_collection() || self.atype >= MembershipType::Admin)
&& self.has_status(MembershipStatus::Confirmed)
}
/// Whether this membership reaches every collection in the org regardless of per-collection
/// assignments -- Admins/Owners implicitly, and Custom members holding `edit_any_collection`. The
/// successor of the removed `access_all` flag: it backs the `accessAll` field the Bitwarden clients
/// still read, and intentionally does not gate on status, matching the old column. Authorization
/// decisions use the status-aware `has_full_access` instead.
pub fn grants_access_to_all_collections(&self) -> bool {
self.atype >= MembershipType::Admin || self.has_edit_any_collection()
}
/// Whether enabling an organization policy may revoke this membership as part of enforcing it.
///
/// Two exclusions, both applying to every policy whose enforcement revokes non-compliant members
/// (Two-Factor Authentication and Single Organization):
///
/// * Admins and Owners are never revoked. `atype < Admin` is deliberately the *ceiling* comparison
/// used everywhere else, so an unknown stored role stays sweepable.
/// * Nor is the member who made the change. Until the Custom role this was implied by the first rule;
/// `managePolicies` can now be held by a Custom member, who *is* sweepable and would otherwise
/// revoke themselves mid-request. Bitwarden excludes the acting user for the same reason.
///
/// Peers are still revoked exactly as before.
pub fn is_policy_enforcement_target(&self, acting_user: &UserId) -> bool {
self.atype < MembershipType::Admin && &self.user_uuid != acting_user
}
/// Check for an explicit per-collection Manage grant without treating any `access_all` value as such
/// a grant. Neither membership nor group `access_all` may manufacture one.
///
/// There is deliberately no live exception for legacy Managers whose authority came from an
/// organization-local `access_all` group. That legacy management authority is intentionally not
/// materialized into Custom membership permissions during migration. The group continues to grant
/// collection access dynamically, while any desired Custom collection-management permissions must
/// be assigned explicitly after the upgrade.
pub async fn has_explicit_collection_manage_access(&self, collection_uuid: &CollectionId, conn: &DbConn) -> bool {
!self.explicit_collection_manage_grants(Some(collection_uuid.clone()), conn).await.is_empty()
}
/// Every collection of this organization carrying a real per-collection Manage grant for this
/// membership, for callers that would otherwise ask the single-collection question once per
/// collection.
pub async fn explicitly_managed_collection_ids(&self, conn: &DbConn) -> HashSet<CollectionId> {
self.explicit_collection_manage_grants(None, conn).await.into_iter().collect()
}
/// The single definition of "holds a real per-collection Manage grant" -- for one collection or for
/// all of them, in one statement either way.
///
/// Both grant paths are resolved in the same query: a direct `users_collections.manage` row, or a
/// `collections_groups.manage` row reached through a group of *this* organization. The
/// `collections_groups` join hangs off `groups::uuid` rather than off `groups_users::groups_uuid`,
/// so a `groups_users` row pointing at another organization's group contributes nothing: the group
/// fails the organization check, `groups::uuid` is then NULL and the join cannot match. Every
/// collection considered is joined on the membership's own `org_uuid`, so no grant crosses
/// organizations.
///
/// `groups.access_all` is never read here. It grants collection *access* dynamically and is not a
/// management grant; counting it would make an access-all group double as one.
async fn explicit_collection_manage_grants(
&self,
collection_uuid: Option<CollectionId>,
conn: &DbConn,
) -> Vec<CollectionId> {
let membership_uuid = self.uuid.clone();
let user_uuid = self.user_uuid.clone();
let org_uuid = self.org_uuid.clone();
conn.run(move |conn| {
let grants = users_organizations::table
.inner_join(collections::table.on(collections::org_uuid.eq(users_organizations::org_uuid)))
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.eq(users_organizations::user_uuid))),
)
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
.left_join(
groups::table.on(groups::uuid
.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.left_join(
collections_groups::table.on(collections_groups::groups_uuid
.nullable()
.eq(groups::uuid.nullable())
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(users_organizations::uuid.eq(membership_uuid))
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_organizations::atype.eq_any([MembershipType::User as i32, MembershipType::Custom as i32]))
.filter(users_collections::manage.eq(true).or(collections_groups::manage.eq(true)))
.select(collections::uuid)
.distinct();
match collection_uuid {
Some(collection_uuid) => grants.filter(collections::uuid.eq(collection_uuid)).load(conn),
None => grants.load(conn),
}
.unwrap_or_default()
})
.await
}
/// `manageAllCollections` is a client-side aggregate checkbox, not a separately persisted
/// Bitwarden permission. It is selected exactly when all three child permissions are selected.
pub fn has_manage_all_collections(&self) -> bool {
self.has_create_new_collections() && self.has_edit_any_collection() && self.has_delete_any_collection()
}
/// Match Vaultwarden's existing collection-creation policy while keeping the Custom
/// permission independent from edit/delete.
pub fn can_create_new_collections(&self) -> bool {
if !self.has_status(MembershipStatus::Confirmed) {
return false;
}
match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => true,
Some(MembershipType::Custom) => self.create_new_collections,
Some(MembershipType::User) | None => false,
}
}
pub fn limit_collection_creation(&self) -> bool {
match MembershipType::from_i32(self.atype) {
Some(MembershipType::Owner | MembershipType::Admin) => false,
Some(MembershipType::Custom) => !self.create_new_collections,
Some(MembershipType::User) | None => true,
}
}
pub fn can_delete_any_collection(&self) -> bool {
self.has_status(MembershipStatus::Confirmed)
&& (self.atype >= MembershipType::Admin || self.has_delete_any_collection())
} }
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
@ -910,7 +1096,7 @@ impl Membership {
.await .await
} }
// Get all users which are either owner or admin, or a manager which can manage/access all // Get all users which are either owner or admin, or a Custom member which can access all collections
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> { 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
@ -918,10 +1104,8 @@ impl Membership {
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
users_organizations::atype users_organizations::atype
.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]) .eq_any(ORG_ADMIN_ATYPES)
.or(users_organizations::atype .or(custom_membership_with_edit_any_collection()),
.eq(MembershipType::Manager as i32)
.and(users_organizations::access_all.eq(true))),
) )
.load::<Self>(conn) .load::<Self>(conn)
.unwrap_or_default() .unwrap_or_default()
@ -1034,10 +1218,11 @@ 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.. custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
ciphers_collections::cipher_uuid.eq(&cipher_uuid), // ..or access to collection with cipher .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
)) .or(ciphers_collections::cipher_uuid.eq(&cipher_uuid)), // ..or access to collection with cipher
)
.select(users_organizations::all_columns) .select(users_organizations::all_columns)
.distinct() .distinct()
.load::<Self>(conn) .load::<Self>(conn)
@ -1080,28 +1265,6 @@ impl Membership {
.await .await
} }
pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool {
conn.run(move |conn| {
users_organizations::table
.inner_join(
ciphers::table.on(ciphers::uuid
.eq(cipher_uuid)
.and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
pub async fn find_by_collection_and_org( pub async fn find_by_collection_and_org(
collection_uuid: &CollectionId, collection_uuid: &CollectionId,
org_uuid: &OrganizationId, org_uuid: &OrganizationId,
@ -1111,10 +1274,11 @@ 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.. custom_membership_with_edit_any_collection() // Custom "Edit any collection" (successor of access_all)
users_collections::collection_uuid.eq(&collection_uuid), // ..or access to collection with cipher .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // or org admin/owner
)) .or(users_collections::collection_uuid.eq(&collection_uuid)), // ..or access to collection
)
.select(users_organizations::all_columns) .select(users_organizations::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading user organizations") .expect("Error loading user organizations")
@ -1226,16 +1390,160 @@ pub struct MembershipId(String);
#[derive(Clone, Debug, DieselNewType, Display, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, DieselNewType, Display, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrgApiKeyId(String); pub struct OrgApiKeyId(String);
/// Fixtures for the tests that exercise the Custom role, here and in the modules using it.
#[cfg(test)]
impl Membership {
/// An `atype` this build cannot interpret: a future build, a partial rollback or a hand-edited row.
pub const UNKNOWN_ATYPE: i32 = 99;
/// `atype` is a raw `i32` and `set` runs regardless of the role on purpose, so the tests can cover
/// a role this build does not know and a permission flag left behind by a role change.
pub fn for_test(atype: i32, status: MembershipStatus, set: impl FnOnce(&mut Self)) -> Self {
let mut membership =
Self::new(UserId::from(String::from("test-user")), OrganizationId::from(String::from("test-org")), None);
membership.atype = atype;
membership.status = status as i32;
set(&mut membership);
membership
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
const UNKNOWN_ATYPE: i32 = Membership::UNKNOWN_ATYPE;
fn membership(atype: i32) -> Membership {
Membership::for_test(atype, MembershipStatus::Confirmed, |_| {})
}
/// How roles rank against each other, and how a stored `atype` is read.
///
/// Every authorization guard in the tree asks `atype >= MembershipType::X` or
/// `atype < MembershipType::X` against a value straight from the database, so those two answers --
/// including the answers for a value this build does not know -- are the security semantics here.
#[test] #[test]
#[allow(non_snake_case)] fn membership_type_ordering_and_parsing() {
fn partial_cmp_MembershipType() { // Roles rank by authority, not by the stored discriminant: Custom is stored as 4 but sits
// between User and Admin.
assert!(MembershipType::Owner > MembershipType::Admin); assert!(MembershipType::Owner > MembershipType::Admin);
assert!(MembershipType::Admin > MembershipType::Manager); assert!(MembershipType::Admin > MembershipType::Custom);
assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Custom > MembershipType::User);
assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap());
// (stored atype, reaches Admin authority, is below Admin)
let stored = [
(MembershipType::Owner as i32, true, false),
(MembershipType::Admin as i32, true, false),
(MembershipType::Custom as i32, false, true),
(MembershipType::User as i32, false, true),
// An unknown role answers "no" to authority *and* "yes" to being below Admin. Both are
// deliberate: it never reaches administrative authority, and it stays sweepable by policy
// enforcement instead of becoming a row nothing can act on.
(UNKNOWN_ATYPE, false, true),
(-1, false, true),
];
for (atype, reaches_admin, below_admin) in stored {
assert_eq!(atype >= MembershipType::Admin, reaches_admin, "atype {atype} >= Admin");
assert_eq!(atype < MembershipType::Admin, below_admin, "atype {atype} < Admin");
}
// Wire values. Modern clients no longer offer the Manager role, but an old client or a stored
// request may still send 3; Custom supersedes it, so it is accepted and folded on.
let accepted = [
("0", MembershipType::Owner),
("Owner", MembershipType::Owner),
("1", MembershipType::Admin),
("Admin", MembershipType::Admin),
("2", MembershipType::User),
("User", MembershipType::User),
("3", MembershipType::Custom),
("Manager", MembershipType::Custom),
("4", MembershipType::Custom),
("Custom", MembershipType::Custom),
];
for (wire, expected) in accepted {
assert!(
MembershipType::from_str(wire) == Some(expected),
"{wire:?} must parse as the role stored as {}",
expected as i32
);
}
for rejected in ["", " ", "3 ", "5", "-1", "manager", "custom", "Manager\n"] {
assert!(MembershipType::from_str(rejected).is_none(), "{rejected:?} must not parse");
}
}
/// The nine granular permissions are stored as plain columns, so they outlive a role change. Every
/// reader gates them on the Custom type for that reason: a flag left behind on a User, an Admin or
/// a role this build cannot read must grant nothing.
#[test]
fn custom_permission_flags_are_type_gated() {
type Reader = fn(&Membership) -> bool;
type Setter = fn(&mut Membership, bool);
let permissions: [(&str, Reader, Setter); 9] = [
("manageUsers", Membership::has_manage_users, |m, v| m.manage_users = v),
("manageGroups", Membership::has_manage_groups, |m, v| m.manage_groups = v),
("managePolicies", Membership::has_manage_policies, |m, v| m.manage_policies = v),
("createNewCollections", Membership::has_create_new_collections, |m, v| m.create_new_collections = v),
("editAnyCollection", Membership::has_edit_any_collection, |m, v| m.edit_any_collection = v),
("deleteAnyCollection", Membership::has_delete_any_collection, |m, v| m.delete_any_collection = v),
("accessEventLogs", Membership::has_access_event_logs, |m, v| m.access_event_logs = v),
("accessImportExport", Membership::has_access_import_export, |m, v| m.access_import_export = v),
("accessReports", Membership::has_access_reports, |m, v| m.access_reports = v),
];
for (name, read, set) in permissions {
for atype in [
MembershipType::Owner as i32,
MembershipType::Admin as i32,
MembershipType::User as i32,
MembershipType::Custom as i32,
UNKNOWN_ATYPE,
] {
let mut member = membership(atype);
assert!(!read(&member), "{name} must be off while its column is false (atype {atype})");
set(&mut member, true);
assert_eq!(
read(&member),
atype == MembershipType::Custom as i32,
"{name} is only meaningful on a Custom membership (atype {atype})"
);
}
}
// Clearing has to reach every one of the nine; a forgotten field would leave authority behind
// on a member that was just moved off the Custom role.
let mut member = membership(MembershipType::Custom as i32);
for (_, _, set) in permissions {
set(&mut member, true);
}
member.clear_custom_permissions();
for (name, read, _) in permissions {
assert!(!read(&member), "{name} survived clear_custom_permissions");
}
// `manageAllCollections` is a client-side aggregate: selected exactly when all three child
// permissions are.
let all_collections = |atype, create, edit, delete| {
let mut member = membership(atype);
member.create_new_collections = create;
member.edit_any_collection = edit;
member.delete_any_collection = delete;
member
};
let custom = MembershipType::Custom as i32;
assert!(all_collections(custom, true, true, true).has_manage_all_collections());
for (missing, member) in [
("createNewCollections", all_collections(custom, false, true, true)),
("editAnyCollection", all_collections(custom, true, false, true)),
("deleteAnyCollection", all_collections(custom, true, true, false)),
// And, like every other reader, the aggregate is gated on the type.
("the Custom role", all_collections(MembershipType::User as i32, true, true, true)),
] {
assert!(!member.has_manage_all_collections(), "{missing} missing must clear the aggregate");
}
} }
} }

10
src/db/schema.rs

@ -237,12 +237,20 @@ 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,
reset_password_key -> Nullable<Text>, reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
create_new_collections -> Bool,
edit_any_collection -> Bool,
delete_any_collection -> Bool,
access_event_logs -> Bool,
access_import_export -> Bool,
access_reports -> Bool,
} }
} }

13
src/main.rs

@ -551,10 +551,21 @@ fn check_web_vault() {
} }
async fn create_db_pool() -> db::DbPool { async fn create_db_pool() -> db::DbPool {
match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries()).await { // A Custom-role preflight refusal is deterministic: it reads schema and ledger state that no amount
// of waiting changes, and retrying only reprinted the same answer under a misleading "Can't connect
// to database". The full recovery procedure is already logged, so stop and report the one-line reason.
match util::retry_db(db::DbPool::from_config, CONFIG.db_connection_retries(), |_| {
db::custom_role_preflight_refusal().is_none()
})
.await
{
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
if let Some(reason) = db::custom_role_preflight_refusal() {
error!("Not starting. {reason}");
} else {
error!("Error creating database pool: {e:?}"); error!("Error creating database pool: {e:?}");
}
exit(1); exit(1);
} }
} }

16
src/static/scripts/admin_users.js

@ -174,20 +174,21 @@ const ORG_TYPES = {
"bg": "blue" "bg": "blue"
}, },
"4": { "4": {
"name": "Manager", "name": "Custom",
"bg": "green" "bg": "teal"
}, },
}; };
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;
@ -195,7 +196,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.
@ -222,7 +225,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;

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

@ -130,10 +130,10 @@
<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>
<div class="radio"> <div class="radio">
<label><input type="radio" value="3" class="form-radio-input" name="user_type" id="userOrgTypeManager">&nbsp;Manager</label> <label><input type="radio" value="4" class="form-radio-input" name="user_type" id="userOrgTypeCustom">&nbsp;Custom</label>
</div> </div>
<div class="radio"> <div class="radio">
<label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label> <label><input type="radio" value="1" class="form-radio-input" name="user_type" id="userOrgTypeAdmin">&nbsp;Admin</label>

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

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

22
src/util.rs

@ -522,8 +522,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
@ -746,10 +754,16 @@ where
} }
} }
pub async fn retry_db<F, T, E>(mut func: F, max_tries: u32) -> Result<T, E> /// Retry `func` while the database is unavailable.
///
/// `should_retry` classifies a failure. Waiting only helps for a database that is not reachable *yet*; an
/// already-decided failure -- a migration preflight refusing this schema -- returns the same answer every
/// time, so retrying repeats its output under a misleading "Can't connect to database" heading.
pub async fn retry_db<F, T, E, R>(mut func: F, max_tries: u32, should_retry: R) -> Result<T, E>
where where
F: FnMut() -> Result<T, E>, F: FnMut() -> Result<T, E>,
E: std::error::Error, E: std::error::Error,
R: Fn(&E) -> bool,
{ {
let mut tries = 0; let mut tries = 0;
@ -759,6 +773,10 @@ where
Err(e) => { Err(e) => {
tries += 1; tries += 1;
if !should_retry(&e) {
return Err(e);
}
if tries >= max_tries && max_tries > 0 { if tries >= max_tries && max_tries > 0 {
return Err(e); return Err(e);
} }

Loading…
Cancel
Save