diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 5b6f5cb0..2703b9ea 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -12,8 +12,9 @@ use crate::{ core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{ - AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManagerHeaders, - ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite, + AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, CollectionUsersReadHeaders, + Headers, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, + custom_member_may, decode_invite, }, db::{ DbConn, @@ -412,12 +413,13 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea // check if current user has full access to the organization (either directly or via any group) let has_full_access_to_org = member.has_full_access() || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); - // A Custom member gets every collection when they may see all of them, else the ones they may manage - let custom_collections = if member.has_type(MembershipType::Custom) && !member.can_view_all_collections() { - Some(Collection::find_explicitly_manageable_by_user(&org_id, &member.user_uuid, &conn).await) - } else { - None - }; + // Manage users may read collection access details without editing the collection itself. + let custom_collections = + if member.has_type(MembershipType::Custom) && !member.can_view_all_collection_access_details() { + Some(Collection::find_explicitly_manageable_by_user(&org_id, &member.user_uuid, &conn).await) + } else { + None + }; // Get all admins, owners and managers who can manage/access all // Those are currently not listed in the col_users but need to be listed too. @@ -846,7 +848,7 @@ async fn get_org_collection_detail( async fn get_collection_users( org_id: OrganizationId, col_id: CollectionId, - headers: CollectionReadHeaders, + headers: CollectionUsersReadHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -984,7 +986,8 @@ async fn get_members( err!("Organization not found", "Organization id's do not match"); } - if !headers.membership.has_full_access() { + if !headers.membership.has_full_access() && !headers.membership.has_custom_permission(CustomPermission::ManageUsers) + { err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } @@ -1073,11 +1076,64 @@ impl InviteData { } } +/// Whether a Custom member with Manage users may manage the members of `target_type`: only Users and Custom members. +fn custom_member_manages(target_type: i32) -> bool { + target_type == MembershipType::User || target_type == MembershipType::Custom +} + +/// Whether a member of `caller_type` may manage the members of `target_type`, as in Bitwarden: Owners every role, +/// Admins every role but Owner, and Custom members with Manage users only Users and Custom members. +fn check_role_manageable(caller_type: MembershipType, target_type: i32) -> EmptyResult { + match caller_type { + MembershipType::Owner => Ok(()), + MembershipType::Admin if target_type != MembershipType::Owner => Ok(()), + MembershipType::Custom if custom_member_manages(target_type) => Ok(()), + _ if target_type == MembershipType::Owner => err!("Only Owners can manage Owners"), + _ => err!("You can only manage Users and Custom members"), + } +} + +/// Manage users controls member access to any collection in their organization, separately from editing its metadata. +fn custom_member_may_assign_collection(caller: &Membership, collection: &Collection) -> bool { + caller.org_uuid == collection.org_uuid && caller.has_custom_permission(CustomPermission::ManageUsers) +} + +/// Checks what a Custom member with Manage users gives a member: only permissions they have themselves, and access to +/// collections and groups in their organization. As in Bitwarden, Manage users may change any group membership. +async fn check_custom_member_grants( + caller: &Membership, + permissions: Option<&HashSet>, + collections: &[&CollectionId], + groups: &HashSet, + conn: &DbConn, +) -> EmptyResult { + if !caller.has_custom_permission(CustomPermission::ManageUsers) { + err!("You need Manage users to change member access") + } + if permissions.is_some_and(|permissions| permissions.iter().any(|p| !caller.has_custom_permission(*p))) { + err!("Custom users can only grant the same custom permissions that they have") + } + for collection_id in collections { + let Some(collection) = Collection::find_by_uuid_and_org(collection_id, &caller.org_uuid, conn).await else { + err!("Collection not found in Organization") + }; + if !custom_member_may_assign_collection(caller, &collection) { + err!("You can only give access to collections in your organization") + } + } + for group_id in groups { + if Group::find_by_uuid_and_org(group_id, &caller.org_uuid, conn).await.is_none() { + err!("Group not found in Organization") + } + } + Ok(()) +} + #[post("/organizations//users/invite", data = "")] async fn send_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -1100,8 +1156,12 @@ async fn send_invite( }; let new_type = MembershipType::requested(requested_type, None, custom_permissions.as_ref()); - if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can invite Managers, Admins or Owners") + check_role_manageable(headers.membership_type, new_type as i32)?; + if headers.membership_type == MembershipType::Custom { + let collections: Vec<&CollectionId> = data.collections.iter().flatten().map(|c| &c.id).collect(); + let groups = data.groups.iter().cloned().collect(); + check_custom_member_grants(&headers.membership, custom_permissions.as_ref(), &collections, &groups, &conn) + .await?; } // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag @@ -1237,7 +1297,7 @@ async fn send_invite( async fn bulk_reinvite_members( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -1275,7 +1335,7 @@ async fn bulk_reinvite_members( async fn reinvite_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -1406,7 +1466,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1455,7 +1515,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1468,7 +1528,7 @@ async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1483,9 +1543,7 @@ async fn confirm_invite_impl( err!("The specified user isn't a member of the organization") }; - if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can confirm Managers, Admins or Owners") - } + // As in Bitwarden, confirming is open to everyone managing the members, whatever the role of the member if member_to_confirm.status != MembershipStatus::Accepted as i32 { err!("User in invalid state") @@ -1558,7 +1616,7 @@ async fn get_user( org_id: OrganizationId, member_id: MembershipId, data: GetOrgUserData, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -1591,7 +1649,7 @@ async fn edit_member( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -1634,16 +1692,9 @@ async fn edit_member( && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - if new_type != member_to_edit.atype - && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) - && headers.membership_type != MembershipType::Owner - { - err!("Only Owners can grant and remove Admin or Owner privileges") - } - - if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { - err!("Only Owners can edit Owner users") - } + // Both the current and the requested role must be one the caller may manage + check_role_manageable(headers.membership_type, member_to_edit.atype)?; + check_role_manageable(headers.membership_type, new_type as i32)?; if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner @@ -1655,6 +1706,21 @@ async fn edit_member( } } + // A Custom member with Manage users only gives what they have themselves. Manage users authorizes member access + // to every collection in this organization. + if headers.membership_type == MembershipType::Custom { + let collections: Vec<&CollectionId> = data.collections.iter().flatten().map(|c| &c.id).collect(); + let groups = data.groups.iter().flatten().cloned().collect(); + check_custom_member_grants( + &headers.membership, + custom_permissions.as_ref().filter(|_| new_type == MembershipType::Custom), + &collections, + &groups, + &conn, + ) + .await?; + } + member_to_edit.set_type(new_type); member_to_edit.access_all = access_all; if let Some(permissions) = custom_permissions.as_ref().filter(|_| new_type == MembershipType::Custom) { @@ -1665,7 +1731,7 @@ async fn edit_member( // We need to perform the check after changing the type since `admin` is exempt. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; - // Delete all the odd collections + // Replace the member's direct collection assignments with those in the request. for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { c.delete(&conn).await?; } @@ -1718,7 +1784,7 @@ async fn edit_member( async fn bulk_delete_member( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1757,7 +1823,7 @@ async fn bulk_delete_member( async fn delete_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1767,7 +1833,7 @@ async fn delete_member( async fn delete_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1778,9 +1844,11 @@ async fn delete_member_impl( err!("User to delete isn't member of the organization") }; - if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { - err!("Only Owners can delete Admins or Owners") + // As in Bitwarden, leaving the organization is its own route + if member_to_delete.user_uuid == headers.user.uuid { + err!("You cannot remove yourself") } + check_role_manageable(headers.membership_type, member_to_delete.atype)?; if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32 { @@ -1821,7 +1889,7 @@ async fn delete_member_impl( async fn bulk_public_keys( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2522,7 +2590,7 @@ struct BulkRevokeMembershipIds { async fn revoke_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { revoke_member_impl(&org_id, &member_id, &headers, &conn).await @@ -2532,7 +2600,7 @@ async fn revoke_member( async fn bulk_revoke_members( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2574,7 +2642,7 @@ async fn bulk_revoke_members( async fn revoke_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -2588,6 +2656,9 @@ async fn revoke_member_impl( if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only owners can revoke other owners") } + if headers.membership_type == MembershipType::Custom && !custom_member_manages(member.atype) { + err!("You can only manage Users and Custom members") + } if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { @@ -2618,7 +2689,7 @@ async fn revoke_member_impl( async fn restore_member_vnext( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { // Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. @@ -2630,7 +2701,7 @@ async fn restore_member_vnext( async fn restore_member( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { restore_member_impl(&org_id, &member_id, &headers, &conn).await @@ -2640,7 +2711,7 @@ async fn restore_member( async fn bulk_restore_members( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2677,7 +2748,7 @@ async fn bulk_restore_members( async fn restore_member_impl( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -2691,6 +2762,9 @@ async fn restore_member_impl( if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only owners can restore other owners") } + if headers.membership_type == MembershipType::Custom && !custom_member_manages(member.atype) { + err!("You can only manage Users and Custom members") + } member.restore(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type @@ -2716,10 +2790,11 @@ async fn restore_member_impl( } /// Whether a member may give others access to collections, and so needs to see the members and groups: by seeing -/// or creating any collection, or by the manage option on a collection. +/// or creating any collection, by managing the members, or by the manage option on a collection. async fn may_assign_collection_access(member: &Membership, conn: &DbConn) -> bool { member.can_view_all_collections() || member.has_custom_permission(CustomPermission::CreateNewCollections) + || member.has_custom_permission(CustomPermission::ManageUsers) || Collection::has_manageable_collection_by_user(&member.org_uuid, &member.user_uuid, conn).await } @@ -2742,7 +2817,7 @@ async fn get_groups_data( && !headers.membership.has_type(MembershipType::Custom) && GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await); let allowed = if details { - has_full_access + has_full_access || headers.membership.has_custom_permission(CustomPermission::ManageUsers) } else { has_full_access || may_assign_collection_access(&headers.membership, &conn).await }; @@ -3489,3 +3564,106 @@ async fn rotate_api_key( ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(sqlite)] + use crate::db::{models::test_flag, test_db}; + + #[test] + fn custom_members_manage_users_and_custom_members_only() { + for role in [0, 1, 2, 3, 4, 99, -1] { + assert_eq!(custom_member_manages(role), matches!(role, 2 | 4)); + } + } + + #[cfg(sqlite)] + #[test] + fn manage_users_grants_are_confined_to_permission_and_organization() { + test_db::run(async |db| { + let conn = db.conn(); + let (org, _, mut member) = test_db::custom_member(&conn, CustomPermission::ManageUsers).await; + let foreign = test_db::organization(&conn).await; + for org_id in [&org.uuid, &foreign.uuid] { + let collection = test_db::collection(&conn, org_id).await; + let mut group = Group::new(org_id.clone(), "Group".into(), true, None); + group.save(&conn).await.unwrap(); + let empty = HashSet::new(); + for (collections, groups) in [ + (&[&collection.uuid][..], empty.clone()), + (&[][..], HashSet::from([group.uuid.clone()])), + (&[&collection.uuid][..], HashSet::from([group.uuid])), + ] { + // A group with access to all collections may be given as well, as in Bitwarden + assert_eq!( + check_custom_member_grants(&member, None, collections, &groups, &conn).await.is_ok(), + org_id == &org.uuid + ); + } + } + let collection = test_db::collection(&conn, &org.uuid).await; + assert!(!Collection::can_access_collection(&member, &collection.uuid, &conn).await); + let escalated = HashSet::from([CustomPermission::EditAnyCollection]); + let empty = HashSet::new(); + for (enabled, status) in [(true, 2), (true, 1), (true, -126), (false, 2)] { + test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled)); + member.status = status; + assert_eq!( + custom_member_may(CollectionAction::Read, &member, &collection.uuid, &conn).await, + enabled && status == 2 + ); + assert!(!custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await); + assert!(!custom_member_may(CollectionAction::ReadAccess, &member, &collection.uuid, &conn).await); + assert_eq!( + check_custom_member_grants(&member, None, &[], &empty, &conn).await.is_ok(), + enabled && status == 2 + ); + assert!(check_custom_member_grants(&member, Some(&escalated), &[], &empty, &conn).await.is_err()); + } + }); + } + + #[cfg(sqlite)] + async fn users_headers(member: &Membership, conn: &DbConn) -> ManageUsersHeaders { + ManageUsersHeaders { + device: Device::new("test".to_owned().into(), member.user_uuid.clone(), "test".into(), 1), + user: User::find_by_uuid(&member.user_uuid, conn).await.unwrap(), + membership: Membership::find_by_uuid(&member.uuid, conn).await.unwrap(), + membership_type: MembershipType::Custom, + org_id: member.org_uuid.clone(), + ip: crate::auth::ClientIp { + ip: "127.0.0.1".parse().unwrap(), + }, + } + } + + #[cfg(sqlite)] + #[test] + fn manage_users_can_reinvite_and_confirm_higher_role_members() { + use crate::api::notifications::{WS_USERS, WebSocketUsers}; + use std::sync::Arc; + test_db::run(async |db| { + let conn = db.conn(); + let (org, _, actor) = test_db::custom_member(&conn, CustomPermission::ManageUsers).await; + let (_, mut target) = test_db::member(&conn, &org.uuid, MembershipType::Owner, &[]).await; + target.status = MembershipStatus::Invited as i32; + target.save(&conn).await.unwrap(); + let headers = users_headers(&actor, &conn).await; + reinvite_member_impl(&org.uuid, &target.uuid, &headers.user.email, &conn).await.unwrap(); + assert!( + Membership::find_by_uuid(&target.uuid, &conn).await.unwrap().has_status(MembershipStatus::Accepted) + ); + let rocket = rocket::build().manage(Arc::clone(&WS_USERS)); + let notify = rocket.state::>().unwrap(); + confirm_invite_impl(&org.uuid, &target.uuid, "encrypted-key", &headers, &conn, ¬ify.into()) + .await + .unwrap(); + let after = Membership::find_by_uuid(&target.uuid, &conn).await.unwrap(); + assert_eq!( + (after.status, after.atype, after.akey.as_str()), + (2, MembershipType::Owner as i32, "encrypted-key") + ); + }); + } +} diff --git a/src/auth.rs b/src/auth.rs index d6f526c2..fec9bad6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -882,6 +882,51 @@ impl<'r> FromRequest<'r> for AdminHeaders { } } +/// Generates the request guard of the routes a Custom member may use with a permission. Like AdminHeaders it lets a +/// confirmed Owner or Admin through, and a confirmed Custom member holding the permission. +macro_rules! permission_headers { + ($(#[$doc:meta])* $name:ident, $permission:expr) => { + $(#[$doc])* + pub struct $name { + pub device: Device, + pub user: User, + pub membership_type: MembershipType, + pub membership: Membership, + pub ip: ClientIp, + pub org_id: OrganizationId, + } + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for $name { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + if headers.is_confirmed_and_admin() || headers.membership.has_custom_permission($permission) { + Outcome::Success(Self { + device: headers.device, + user: headers.user, + membership_type: headers.membership_type, + org_id: headers.membership.org_uuid.clone(), + membership: headers.membership, + ip: headers.ip, + }) + } else { + // Forbidden as in Bitwarden's permission requirements + error!(target: "auth", "The current user is missing the permission for this endpoint"); + Outcome::Error((rocket::http::Status::Forbidden, "You don't have permission to call this endpoint")) + } + } + } + }; +} + +permission_headers!( + /// For the routes that manage the members of an organization. + ManageUsersHeaders, + CustomPermission::ManageUsers +); + // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. @@ -906,6 +951,7 @@ fn get_col_id(request: &Request<'_>) -> Option { pub enum CollectionAction { Edit, Read, + ReadAccess, Delete, } @@ -930,8 +976,11 @@ pub async fn custom_member_may( match action { CollectionAction::Delete => member.has_custom_permission(CustomPermission::DeleteAnyCollection), CollectionAction::Edit if member.can_edit_any_collection() => true, - CollectionAction::Read if member.can_view_all_collections() => true, - CollectionAction::Edit | CollectionAction::Read => { + // Individual access details may be read with Manage users. The separate /users route follows + // Bitwarden's ReadAccess operation, which does not grant that permission on its own. + CollectionAction::Read if member.can_view_all_collection_access_details() => true, + CollectionAction::ReadAccess if member.can_view_all_collections() => true, + CollectionAction::Edit | CollectionAction::Read | CollectionAction::ReadAccess => { Collection::find_explicitly_manageable_by_user(&member.org_uuid, &member.user_uuid, conn) .await .contains(col_id) @@ -1020,6 +1069,11 @@ collection_headers!( CollectionReadHeaders, CollectionAction::Read ); +collection_headers!( + /// The collection's /users route has narrower access than its /details route. + CollectionUsersReadHeaders, + CollectionAction::ReadAccess +); collection_headers!( /// The same as ManagerHeaders, for the routes which delete the collection. CollectionDeleteHeaders, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index b9d81f72..7eb2260b 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -247,6 +247,7 @@ pub enum CustomPermission { /// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored /// grant of any other one grants nothing. They are implemented one at a time, so this grows with them. const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[ + CustomPermission::ManageUsers, CustomPermission::CreateNewCollections, CustomPermission::EditAnyCollection, CustomPermission::DeleteAnyCollection, @@ -341,7 +342,7 @@ pub(super) fn custom_edit_any_collection() -> CustomEditAnyCollection { } #[cfg(test)] -pub(crate) mod test_flag { +pub mod test_flag { thread_local! { /// Overrides EXPERIMENTAL_CUSTOM_ROLES for the tests running on this thread. pub static CUSTOM_ROLES_ENABLED: std::cell::Cell> = const { std::cell::Cell::new(None) }; @@ -1084,6 +1085,12 @@ impl Membership { self.can_edit_any_collection() || self.has_custom_permission(CustomPermission::DeleteAnyCollection) } + /// Whether this member may read every collection's member and group assignments in the Admin Console. + /// Manage users grants this access without granting the plain all-collections listing or metadata editing. + pub fn can_view_all_collection_access_details(&self) -> bool { + self.can_view_all_collections() || self.has_custom_permission(CustomPermission::ManageUsers) + } + pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { conn.run(move |conn| { users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::(conn).ok() @@ -1603,7 +1610,10 @@ mod tests { } } } - assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, [CreateNewCollections, EditAnyCollection, DeleteAnyCollection]); + assert_eq!( + SUPPORTED_CUSTOM_PERMISSIONS, + [ManageUsers, CreateNewCollections, EditAnyCollection, DeleteAnyCollection] + ); } #[test] diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 2427b082..5328e82e 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -99,8 +99,12 @@ app-security > app-two-factor-setup > form { /* Hide unsupported Custom Role options */ {{#if custom_roles_enabled}} -/* Only the collection permissions can be granted yet */ -:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { +/* Only the collection permissions and Manage users can be granted yet */ +:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"]), +:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageGroups"]), +:is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="managePolicies"]), +: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; } {{else}}