[PR 4] Add Manage Users permission for Custom Roles (#7960)

This commit is contained in:
Tom authored and GitHub committed 2026-11-14 13:57:32 +01:00
1 parent 644ddfa000
commit e03151d307
4 files changed
+301 -55

No files matched your search

+227 -49
View File
@@ -12,8 +12,9 @@ use crate::{
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
}, },
auth::{ auth::{
AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManagerHeaders, AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, CollectionUsersReadHeaders,
ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite, Headers, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders,
custom_member_may, decode_invite,
}, },
db::{ db::{
DbConn, 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) // 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() 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); || (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 // 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_collections() { let custom_collections =
Some(Collection::find_explicitly_manageable_by_user(&org_id, &member.user_uuid, &conn).await) if member.has_type(MembershipType::Custom) && !member.can_view_all_collection_access_details() {
} else { Some(Collection::find_explicitly_manageable_by_user(&org_id, &member.user_uuid, &conn).await)
None } else {
}; None
};
// Get all admins, owners and managers who can manage/access all // 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. // 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( async fn get_collection_users(
org_id: OrganizationId, org_id: OrganizationId,
col_id: CollectionId, col_id: CollectionId,
headers: CollectionReadHeaders, headers: CollectionUsersReadHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -984,7 +986,8 @@ async fn get_members(
err!("Organization not found", "Organization id's do not match"); 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); 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<CustomPermission>>,
collections: &[&CollectionId],
groups: &HashSet<GroupId>,
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/<org_id>/users/invite", data = "<data>")] #[post("/organizations/<org_id>/users/invite", data = "<data>")]
async fn send_invite( async fn send_invite(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<InviteData>, data: Json<InviteData>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id { 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()); let new_type = MembershipType::requested(requested_type, None, custom_permissions.as_ref());
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { check_role_manageable(headers.membership_type, new_type as i32)?;
err!("Only Owners can invite Managers, Admins or Owners") 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 // 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( async fn bulk_reinvite_members(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkMembershipIds>, data: Json<BulkMembershipIds>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -1275,7 +1335,7 @@ async fn bulk_reinvite_members(
async fn reinvite_member( async fn reinvite_member(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -1406,7 +1466,7 @@ struct BulkConfirmData {
async fn bulk_confirm_invite( async fn bulk_confirm_invite(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkConfirmData>, data: Json<BulkConfirmData>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
@@ -1455,7 +1515,7 @@ async fn confirm_invite(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
data: Json<ConfirmData>, data: Json<ConfirmData>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
@@ -1468,7 +1528,7 @@ async fn confirm_invite_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
key: &str, key: &str,
headers: &AdminHeaders, headers: &ManageUsersHeaders,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
@@ -1483,9 +1543,7 @@ async fn confirm_invite_impl(
err!("The specified user isn't a member of the organization") err!("The specified user isn't a member of the organization")
}; };
if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { // As in Bitwarden, confirming is open to everyone managing the members, whatever the role of the member
err!("Only Owners can confirm Managers, Admins or Owners")
}
if member_to_confirm.status != MembershipStatus::Accepted as i32 { if member_to_confirm.status != MembershipStatus::Accepted as i32 {
err!("User in invalid state") err!("User in invalid state")
@@ -1558,7 +1616,7 @@ async fn get_user(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
data: GetOrgUserData, data: GetOrgUserData,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -1591,7 +1649,7 @@ async fn edit_member(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
data: Json<EditUserData>, data: Json<EditUserData>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -1634,16 +1692,9 @@ async fn edit_member(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))); && data.permissions.get("createNewCollections") == Some(&json!(true)));
if new_type != member_to_edit.atype // Both the current and the requested role must be one the caller may manage
&& (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) check_role_manageable(headers.membership_type, member_to_edit.atype)?;
&& headers.membership_type != MembershipType::Owner check_role_manageable(headers.membership_type, new_type as i32)?;
{
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")
}
if member_to_edit.atype == MembershipType::Owner if member_to_edit.atype == MembershipType::Owner
&& new_type != 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.set_type(new_type);
member_to_edit.access_all = access_all; member_to_edit.access_all = access_all;
if let Some(permissions) = custom_permissions.as_ref().filter(|_| new_type == MembershipType::Custom) { 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. // We need to perform the check after changing the type since `admin` is exempt.
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; 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 { for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await {
c.delete(&conn).await?; c.delete(&conn).await?;
} }
@@ -1718,7 +1784,7 @@ async fn edit_member(
async fn bulk_delete_member( async fn bulk_delete_member(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkMembershipIds>, data: Json<BulkMembershipIds>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
@@ -1757,7 +1823,7 @@ async fn bulk_delete_member(
async fn delete_member( async fn delete_member(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
@@ -1767,7 +1833,7 @@ async fn delete_member(
async fn delete_member_impl( async fn delete_member_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
headers: &AdminHeaders, headers: &ManageUsersHeaders,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
@@ -1778,9 +1844,11 @@ async fn delete_member_impl(
err!("User to delete isn't member of the organization") err!("User to delete isn't member of the organization")
}; };
if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner { // As in Bitwarden, leaving the organization is its own route
err!("Only Owners can delete Admins or Owners") 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 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( async fn bulk_public_keys(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkMembershipIds>, data: Json<BulkMembershipIds>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -2522,7 +2590,7 @@ struct BulkRevokeMembershipIds {
async fn revoke_member( async fn revoke_member(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
revoke_member_impl(&org_id, &member_id, &headers, &conn).await revoke_member_impl(&org_id, &member_id, &headers, &conn).await
@@ -2532,7 +2600,7 @@ async fn revoke_member(
async fn bulk_revoke_members( async fn bulk_revoke_members(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkRevokeMembershipIds>, data: Json<BulkRevokeMembershipIds>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -2574,7 +2642,7 @@ async fn bulk_revoke_members(
async fn revoke_member_impl( async fn revoke_member_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
headers: &AdminHeaders, headers: &ManageUsersHeaders,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != &headers.org_id { 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 { if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can revoke other owners") 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 if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1
{ {
@@ -2618,7 +2689,7 @@ async fn revoke_member_impl(
async fn restore_member_vnext( async fn restore_member_vnext(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
// Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. // 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( async fn restore_member(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
restore_member_impl(&org_id, &member_id, &headers, &conn).await restore_member_impl(&org_id, &member_id, &headers, &conn).await
@@ -2640,7 +2711,7 @@ async fn restore_member(
async fn bulk_restore_members( async fn bulk_restore_members(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkMembershipIds>, data: Json<BulkMembershipIds>,
headers: AdminHeaders, headers: ManageUsersHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
@@ -2677,7 +2748,7 @@ async fn bulk_restore_members(
async fn restore_member_impl( async fn restore_member_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
headers: &AdminHeaders, headers: &ManageUsersHeaders,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
if org_id != &headers.org_id { 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 { if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can restore other owners") 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(); member.restore();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
@@ -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 /// 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 { async fn may_assign_collection_access(member: &Membership, conn: &DbConn) -> bool {
member.can_view_all_collections() member.can_view_all_collections()
|| member.has_custom_permission(CustomPermission::CreateNewCollections) || 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 || 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) && !headers.membership.has_type(MembershipType::Custom)
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await); && GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
let allowed = if details { let allowed = if details {
has_full_access has_full_access || headers.membership.has_custom_permission(CustomPermission::ManageUsers)
} else { } else {
has_full_access || may_assign_collection_access(&headers.membership, &conn).await has_full_access || may_assign_collection_access(&headers.membership, &conn).await
}; };
@@ -3489,3 +3564,106 @@ async fn rotate_api_key(
) -> JsonResult { ) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await 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::<Arc<WebSocketUsers>>().unwrap();
confirm_invite_impl(&org.uuid, &target.uuid, "encrypted-key", &headers, &conn, &notify.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")
);
});
}
}
+56 -2
View File
@@ -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<Self, Self::Error> {
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/<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.
@@ -906,6 +951,7 @@ fn get_col_id(request: &Request<'_>) -> Option<CollectionId> {
pub enum CollectionAction { pub enum CollectionAction {
Edit, Edit,
Read, Read,
ReadAccess,
Delete, Delete,
} }
@@ -930,8 +976,11 @@ pub async fn custom_member_may(
match action { match action {
CollectionAction::Delete => member.has_custom_permission(CustomPermission::DeleteAnyCollection), CollectionAction::Delete => member.has_custom_permission(CustomPermission::DeleteAnyCollection),
CollectionAction::Edit if member.can_edit_any_collection() => true, CollectionAction::Edit if member.can_edit_any_collection() => true,
CollectionAction::Read if member.can_view_all_collections() => true, // Individual access details may be read with Manage users. The separate /users route follows
CollectionAction::Edit | CollectionAction::Read => { // 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) Collection::find_explicitly_manageable_by_user(&member.org_uuid, &member.user_uuid, conn)
.await .await
.contains(col_id) .contains(col_id)
@@ -1020,6 +1069,11 @@ collection_headers!(
CollectionReadHeaders, CollectionReadHeaders,
CollectionAction::Read CollectionAction::Read
); );
collection_headers!(
/// The collection's /users route has narrower access than its /details route.
CollectionUsersReadHeaders,
CollectionAction::ReadAccess
);
collection_headers!( collection_headers!(
/// The same as ManagerHeaders, for the routes which delete the collection. /// The same as ManagerHeaders, for the routes which delete the collection.
CollectionDeleteHeaders, CollectionDeleteHeaders,
+12 -2
View File
@@ -247,6 +247,7 @@ pub enum CustomPermission {
/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored /// 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. /// grant of any other one grants nothing. They are implemented one at a time, so this grows with them.
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[ const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
CustomPermission::ManageUsers,
CustomPermission::CreateNewCollections, CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection, CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection, CustomPermission::DeleteAnyCollection,
@@ -341,7 +342,7 @@ pub(super) fn custom_edit_any_collection() -> CustomEditAnyCollection {
} }
#[cfg(test)] #[cfg(test)]
pub(crate) mod test_flag { pub mod test_flag {
thread_local! { thread_local! {
/// Overrides EXPERIMENTAL_CUSTOM_ROLES for the tests running on this thread. /// Overrides EXPERIMENTAL_CUSTOM_ROLES for the tests running on this thread.
pub static CUSTOM_ROLES_ENABLED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) }; pub static CUSTOM_ROLES_ENABLED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
@@ -1084,6 +1085,12 @@ impl Membership {
self.can_edit_any_collection() || self.has_custom_permission(CustomPermission::DeleteAnyCollection) 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<Self> { pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok() users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(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] #[test]
@@ -99,8 +99,12 @@ app-security > app-two-factor-setup > form {
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
{{#if custom_roles_enabled}} {{#if custom_roles_enabled}}
/* Only the collection permissions can be granted yet */ /* 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"], input[formcontrolname*="manage"]) { :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; @extend %vw-hide;
} }
{{else}} {{else}}