mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-14 16:16:04 +02:00
[PR 4] Add Manage Users permission for Custom Roles (#7960)
This commit is contained in:
4 files changed
+301
-55
No files matched your search
+227
-49
@@ -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, ¬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")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-2
@@ -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,
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in new issue
Block a user