diff --git a/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/down.sql b/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/down.sql new file mode 100644 index 00000000..7ffdc7fd --- /dev/null +++ b/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN permissions; diff --git a/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql b/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql new file mode 100644 index 00000000..9b3d8a67 --- /dev/null +++ b/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql @@ -0,0 +1,2 @@ +-- Store permission bits as a signed integer to match Rust/Diesel i32. +ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL; diff --git a/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/down.sql b/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/down.sql new file mode 100644 index 00000000..7ffdc7fd --- /dev/null +++ b/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN permissions; diff --git a/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql b/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql new file mode 100644 index 00000000..9b3d8a67 --- /dev/null +++ b/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql @@ -0,0 +1,2 @@ +-- Store permission bits as a signed integer to match Rust/Diesel i32. +ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL; diff --git a/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/down.sql b/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/down.sql new file mode 100644 index 00000000..7ffdc7fd --- /dev/null +++ b/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/down.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations DROP COLUMN permissions; diff --git a/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql b/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql new file mode 100644 index 00000000..9b3d8a67 --- /dev/null +++ b/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql @@ -0,0 +1,2 @@ +-- Store permission bits as a signed integer to match Rust/Diesel i32. +ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL; diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 2c437a36..1b6c9bda 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -7,7 +7,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{EmptyResult, JsonResult}, - auth::{AdminHeaders, Headers}, + auth::{AccessEventLogsHeaders, Headers}, db::{ DbConn, DbPool, models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, @@ -31,7 +31,12 @@ struct EventRange { // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 #[get("/organizations//events?")] -async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_org_events( + org_id: OrganizationId, + data: EventRange, + headers: AccessEventLogsHeaders, + conn: DbConn, +) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -93,7 +98,7 @@ async fn get_user_events( org_id: OrganizationId, member_id: MembershipId, data: EventRange, - headers: AdminHeaders, + headers: AccessEventLogsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 9082297f..ebdda0b0 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -11,13 +11,18 @@ use crate::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, - auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, + auth::{ + AccessImportExportHeaders, AdminHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, + ManageResetPasswordHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, + OwnerHeaders, decode_invite, + }, db::{ DbConn, models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, OrganizationUserPermissions, + User, UserId, }, }, mail, @@ -107,6 +112,152 @@ pub fn routes() -> Vec { ] } +fn parse_custom_permissions( + raw_type: &str, + permissions: &HashMap, +) -> Result, crate::Error> { + if raw_type != "4" && raw_type != "Custom" { + return Ok(None); + } + + match OrganizationUserPermissions::from_payload_map(permissions) { + Ok(parsed) => Ok(Some(parsed)), + Err(error) => err!(format!("Invalid custom role permissions payload: {error:#}")), + } +} + +fn resolve_membership_custom_permissions( + raw_type: &str, + new_type: i32, + permissions: &HashMap, +) -> Result<(bool, Option), crate::Error> { + let custom_permissions = parse_custom_permissions(raw_type, permissions)?; + + let access_all = new_type >= MembershipType::Admin + || custom_permissions.as_ref().is_some_and(OrganizationUserPermissions::has_manage_all_collections); + + let permissions_mask = custom_permissions.map(|custom_permissions| custom_permissions.to_mask()); + + Ok((access_all, permissions_mask)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::models::OrganizationUserPermission; + + fn custom_permissions_payload() -> HashMap { + HashMap::from([ + ("accessEventLogs".to_owned(), json!(true)), + ("accessImportExport".to_owned(), json!(false)), + ("accessReports".to_owned(), json!(true)), + ("createNewCollections".to_owned(), json!(true)), + ("editAnyCollection".to_owned(), json!(true)), + ("deleteAnyCollection".to_owned(), json!(true)), + ("manageGroups".to_owned(), json!(true)), + ("managePolicies".to_owned(), json!(false)), + ("manageSso".to_owned(), json!(false)), + ("manageUsers".to_owned(), json!(true)), + ("manageResetPassword".to_owned(), json!(false)), + ("manageScim".to_owned(), json!(false)), + ]) + } + + #[test] + fn invite_edit_store_custom_permissions_and_get_user_round_trip() { + let payload = custom_permissions_payload(); + + let (access_all, permission_mask) = + resolve_membership_custom_permissions("4", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(access_all); + + let stored_mask = permission_mask.expect("custom role payload should be persisted"); + let expected_mask = OrganizationUserPermission::AccessEventLogs.bit() + | OrganizationUserPermission::AccessReports.bit() + | OrganizationUserPermission::CreateNewCollections.bit() + | OrganizationUserPermission::EditAnyCollection.bit() + | OrganizationUserPermission::DeleteAnyCollection.bit() + | OrganizationUserPermission::ManageGroups.bit() + | OrganizationUserPermission::ManageUsers.bit(); + assert_eq!(stored_mask, expected_mask); + + let parsed = OrganizationUserPermissions::from_mask(stored_mask); + assert_eq!(stored_mask, parsed.to_mask()); + + assert_eq!( + json!(parsed), + json!({ + "accessEventLogs": true, + "accessImportExport": false, + "accessReports": true, + "createNewCollections": true, + "editAnyCollection": true, + "deleteAnyCollection": true, + "manageGroups": true, + "managePolicies": false, + "manageSso": false, + "manageUsers": true, + "manageResetPassword": false, + "manageScim": false + }) + ); + } + + #[test] + fn invite_edit_non_custom_role_keeps_permissions_unset() { + let payload = custom_permissions_payload(); + + let (access_all, permission_mask) = + resolve_membership_custom_permissions("3", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(!access_all); + assert!(permission_mask.is_none()); + } + + #[test] + fn invite_edit_custom_partial_collection_permissions_do_not_grant_access_all() { + let payload = HashMap::from([ + ("createNewCollections".to_owned(), json!(true)), + ("editAnyCollection".to_owned(), json!(true)), + ("deleteAnyCollection".to_owned(), json!(false)), + ("manageUsers".to_owned(), json!(true)), + ]); + + let (access_all, permission_mask) = + resolve_membership_custom_permissions("Custom", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(!access_all); + + let stored_mask = permission_mask.expect("custom role payload should be persisted"); + let expected_mask = OrganizationUserPermission::CreateNewCollections.bit() + | OrganizationUserPermission::EditAnyCollection.bit() + | OrganizationUserPermission::ManageUsers.bit(); + assert_eq!(stored_mask, expected_mask); + + let parsed = OrganizationUserPermissions::from_mask(stored_mask); + + assert!(parsed.manage_users); + assert!(parsed.create_new_collections); + assert!(parsed.edit_any_collection); + assert!(!parsed.delete_any_collection); + } + + #[test] + fn invite_edit_custom_empty_permissions_persist_zero_mask() { + let payload = HashMap::new(); + + let (access_all, permission_mask) = + resolve_membership_custom_permissions("Custom", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(!access_all); + assert_eq!(permission_mask, Some(0)); + + let parsed = OrganizationUserPermissions::from_mask(permission_mask.unwrap()); + assert_eq!(parsed, OrganizationUserPermissions::default()); + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgData { @@ -1043,7 +1194,7 @@ impl InviteData { async fn send_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -1066,14 +1217,7 @@ async fn send_invite( err!("Only Owners can invite Managers, Admins or Owners") } - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag - // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes - // If the box is not checked, the user will still be a manager, but not with the access_all permission - let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") - && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); + let (access_all, permissions_mask) = resolve_membership_custom_permissions(raw_type, new_type, &data.permissions)?; let mut user_created: bool = false; for email in &data.emails { @@ -1118,6 +1262,7 @@ async fn send_invite( new_member.access_all = access_all; new_member.atype = new_type; new_member.status = member_status; + new_member.permissions = permissions_mask; new_member.save(&conn).await?; if CONFIG.mail_enabled() { @@ -1194,7 +1339,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 { @@ -1229,7 +1374,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 { @@ -1360,7 +1505,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1404,7 +1549,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1417,7 +1562,7 @@ async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1502,7 +1647,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 { @@ -1533,7 +1678,7 @@ async fn put_member( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { edit_member(org_id, member_id, data, headers, conn).await @@ -1544,7 +1689,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 { @@ -1560,14 +1705,8 @@ async fn edit_member( err!("Invalid type") }; - // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag - // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes - // If the box is not checked, the user will still be a manager, but not with the access_all permission - let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") - && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); + let (access_all, permissions_mask) = + resolve_membership_custom_permissions(raw_type, new_type as i32, &data.permissions)?; let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("The specified user isn't member of the organization") @@ -1596,6 +1735,7 @@ async fn edit_member( member_to_edit.access_all = access_all; member_to_edit.atype = new_type as i32; + member_to_edit.permissions = permissions_mask; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. @@ -1654,7 +1794,7 @@ async fn edit_member( async fn bulk_delete_member( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1690,7 +1830,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 { @@ -1700,7 +1840,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 { @@ -1754,7 +1894,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 { @@ -1966,7 +2106,7 @@ async fn post_bulk_collections(data: Json, headers: Headers } #[get("/organizations//policies")] -async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn list_policies(org_id: OrganizationId, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -2032,7 +2172,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH } #[get("/organizations//policies/", rank = 3)] -async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -2069,7 +2209,7 @@ async fn put_policy( org_id: OrganizationId, pol_type: i32, data: Json, - headers: AdminHeaders, + headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2189,7 +2329,7 @@ async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, data: Json, - headers: AdminHeaders, + headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { put_policy(org_id, pol_type, data, headers, conn).await @@ -2266,7 +2406,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 @@ -2276,7 +2416,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 { @@ -2315,7 +2455,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 { @@ -2329,6 +2469,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.is_admin_or_owner && member.atype != MembershipType::User { + err!("Custom roles can only revoke users") + } if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { @@ -2359,7 +2502,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. @@ -2371,7 +2514,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 @@ -2381,7 +2524,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 { @@ -2415,7 +2558,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 { @@ -2429,6 +2572,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.is_admin_or_owner && member.atype != MembershipType::User { + err!("Custom roles can only restore users") + } member.restore(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type @@ -2579,7 +2725,7 @@ async fn post_group( org_id: OrganizationId, group_id: GroupId, data: Json, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { put_group(org_id, group_id, data, headers, conn).await @@ -2588,7 +2734,7 @@ async fn post_group( #[post("/organizations//groups", data = "")] async fn post_groups( org_id: OrganizationId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, data: Json, conn: DbConn, ) -> JsonResult { @@ -2623,7 +2769,7 @@ async fn put_group( org_id: OrganizationId, group_id: GroupId, data: Json, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2664,7 +2810,7 @@ async fn add_update_group( collections: Vec, members: Vec, org_id: OrganizationId, - headers: &AdminHeaders, + headers: &ManageGroupsHeaders, conn: &DbConn, ) -> JsonResult { group.save(conn).await?; @@ -2704,7 +2850,7 @@ async fn add_update_group( async fn get_group_details( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2725,21 +2871,26 @@ async fn get_group_details( async fn post_delete_group( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await } #[delete("/organizations//groups/")] -async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult { +async fn delete_group( + org_id: OrganizationId, + group_id: GroupId, + headers: ManageGroupsHeaders, + conn: DbConn, +) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await } async fn delete_group_impl( org_id: &OrganizationId, group_id: &GroupId, - headers: &AdminHeaders, + headers: &ManageGroupsHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { @@ -2771,7 +2922,7 @@ async fn delete_group_impl( async fn bulk_delete_groups( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -2790,7 +2941,12 @@ async fn bulk_delete_groups( } #[get("/organizations//groups/", rank = 2)] -async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_group( + org_id: OrganizationId, + group_id: GroupId, + headers: ManageGroupsHeaders, + conn: DbConn, +) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -2809,7 +2965,7 @@ async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHead async fn get_group_members( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -2836,7 +2992,7 @@ async fn get_group_members( async fn put_group_members( org_id: OrganizationId, group_id: GroupId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, data: Json>, conn: DbConn, ) -> EmptyResult { @@ -2884,7 +3040,7 @@ async fn post_delete_group_member( org_id: OrganizationId, group_id: GroupId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { @@ -2967,7 +3123,7 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders async fn put_recover_account( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageResetPasswordHeaders, data: Json, conn: DbConn, nt: Notify<'_>, @@ -2985,7 +3141,7 @@ async fn put_recover_account( async fn put_reset_password( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageResetPasswordHeaders, data: Json, conn: DbConn, nt: Notify<'_>, @@ -2996,7 +3152,7 @@ async fn put_reset_password( async fn recover_account( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageResetPasswordHeaders, reset_request: OrganizationUserRecoverAccountRequest, conn: DbConn, nt: Notify<'_>, @@ -3056,7 +3212,7 @@ async fn recover_account( async fn get_reset_password_details( org_id: OrganizationId, member_id: MembershipId, - headers: AdminHeaders, + headers: ManageResetPasswordHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { @@ -3092,7 +3248,7 @@ async fn get_reset_password_details( async fn check_reset_password_applicable_and_permissions( org_id: &OrganizationId, member_id: &MembershipId, - headers: &AdminHeaders, + headers: &ManageResetPasswordHeaders, conn: &DbConn, ) -> EmptyResult { check_reset_password_applicable(org_id, conn).await?; @@ -3105,6 +3261,7 @@ async fn check_reset_password_applicable_and_permissions( match headers.membership_type { MembershipType::Owner => Ok(()), MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()), + MembershipType::Manager if !headers.is_admin_or_owner && target_user.atype == MembershipType::User => Ok(()), _ => err!("No permission to reset this user's password"), } } @@ -3184,7 +3341,7 @@ async fn put_reset_password_enrollment( // Vaultwarden does not yet support exporting only managed collections! // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 #[get("/organizations//export")] -async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } diff --git a/src/auth.rs b/src/auth.rs index 762088e5..b57eeecb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -29,8 +29,8 @@ use crate::{ DbConn, models::{ AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, - Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId, - SendId, User, UserId, UserStampException, + Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, + OrganizationUserPermission, SendFileId, SendId, User, UserId, UserStampException, }, }, error::Error, @@ -730,6 +730,10 @@ impl OrgHeaders { fn is_confirmed_and_owner(&self) -> bool { self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner } + + fn has_permission(&self, permission: OrganizationUserPermission) -> bool { + self.membership.has_permission(permission) + } } // org_id is usually the second path param ("/organizations/"), @@ -814,31 +818,185 @@ impl<'r> FromRequest<'r> for OrgHeaders { } pub struct AdminHeaders { - pub host: String, + pub user: User, + pub org_id: OrganizationId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminHeaders { + 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() { + Outcome::Success(Self { + user: headers.user, + org_id: headers.membership.org_uuid, + }) + } else { + err_handler!("You need to be Admin or Owner to call this endpoint") + } + } +} + +pub struct ManageUsersHeaders { pub device: Device, pub user: User, pub membership_type: MembershipType, + pub is_admin_or_owner: bool, pub ip: ClientIp, pub org_id: OrganizationId, } #[rocket::async_trait] -impl<'r> FromRequest<'r> for AdminHeaders { +impl<'r> FromRequest<'r> for ManageUsersHeaders { 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() { + let is_admin_or_owner = headers.is_confirmed_and_admin(); + if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageUsers) { Outcome::Success(Self { - host: headers.host, device: headers.device, user: headers.user, membership_type: headers.membership_type, + is_admin_or_owner, ip: headers.ip, org_id: headers.membership.org_uuid, }) } else { - err_handler!("You need to be Admin or Owner to call this endpoint") + err_handler!("You need to be Admin/Owner or have Manage Users permission to call this endpoint") + } + } +} + +pub struct ManageResetPasswordHeaders { + pub device: Device, + pub user: User, + pub membership_type: MembershipType, + pub is_admin_or_owner: bool, + pub ip: ClientIp, + pub org_id: OrganizationId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ManageResetPasswordHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + let is_admin_or_owner = headers.is_confirmed_and_admin(); + if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageResetPassword) { + Outcome::Success(Self { + device: headers.device, + user: headers.user, + membership_type: headers.membership_type, + is_admin_or_owner, + ip: headers.ip, + org_id: headers.membership.org_uuid, + }) + } else { + err_handler!("You need to be Admin/Owner or have Manage Reset Password permission to call this endpoint") + } + } +} + +pub struct ManageGroupsHeaders { + pub device: Device, + pub user: User, + pub ip: ClientIp, + pub org_id: OrganizationId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ManageGroupsHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + let is_admin_or_owner = headers.is_confirmed_and_admin(); + if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageGroups) { + Outcome::Success(Self { + device: headers.device, + user: headers.user, + ip: headers.ip, + org_id: headers.membership.org_uuid, + }) + } else { + err_handler!("You need to be Admin/Owner or have Manage Groups permission to call this endpoint") + } + } +} + +pub struct ManagePoliciesHeaders { + pub device: Device, + pub user: User, + pub ip: ClientIp, + pub org_id: OrganizationId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ManagePoliciesHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + let is_admin_or_owner = headers.is_confirmed_and_admin(); + if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManagePolicies) { + Outcome::Success(Self { + device: headers.device, + user: headers.user, + ip: headers.ip, + org_id: headers.membership.org_uuid, + }) + } else { + err_handler!("You need to be Admin/Owner or have Manage Policies permission to call this endpoint") + } + } +} + +pub struct AccessEventLogsHeaders { + pub org_id: OrganizationId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AccessEventLogsHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + let is_admin_or_owner = headers.is_confirmed_and_admin(); + if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::AccessEventLogs) { + Outcome::Success(Self { + org_id: headers.membership.org_uuid, + }) + } else { + err_handler!("You need to be Admin/Owner or have Access Event Logs permission to call this endpoint") + } + } +} + +pub struct AccessImportExportHeaders { + pub host: String, + pub user: User, + pub org_id: OrganizationId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AccessImportExportHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + let is_admin_or_owner = headers.is_confirmed_and_admin(); + if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::AccessImportExport) { + Outcome::Success(Self { + host: headers.host, + user: headers.user, + org_id: headers.membership.org_uuid, + }) + } else { + err_handler!("You need to be Admin/Owner or have Access Import Export permission to call this endpoint") } } } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..0edb9503 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -32,7 +32,7 @@ pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, - OrganizationId, + OrganizationId, OrganizationUserPermission, OrganizationUserPermissions, }; pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..9cd937fe 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -7,6 +7,7 @@ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use num_traits::FromPrimitive; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ @@ -57,6 +58,199 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + pub permissions: Option, +} + +#[allow(clippy::struct_excessive_bools)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OrganizationUserPermissions { + pub access_event_logs: bool, + pub access_import_export: bool, + pub access_reports: bool, + pub create_new_collections: bool, + pub edit_any_collection: bool, + pub delete_any_collection: bool, + pub manage_groups: bool, + pub manage_policies: bool, + pub manage_sso: bool, + pub manage_users: bool, + pub manage_reset_password: bool, + pub manage_scim: bool, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum OrganizationUserPermission { + AccessEventLogs, + AccessImportExport, + AccessReports, + CreateNewCollections, + EditAnyCollection, + DeleteAnyCollection, + ManageGroups, + ManagePolicies, + ManageSso, + ManageUsers, + ManageResetPassword, + ManageScim, +} + +impl OrganizationUserPermission { + // Stable bit layout persisted in users_organizations.permissions. + // Do not reorder or reuse these bit positions for different semantics. + pub const ACCESS_EVENT_LOGS_BIT: i32 = 1 << 0; + pub const ACCESS_IMPORT_EXPORT_BIT: i32 = 1 << 1; + pub const ACCESS_REPORTS_BIT: i32 = 1 << 2; + pub const CREATE_NEW_COLLECTIONS_BIT: i32 = 1 << 3; + pub const EDIT_ANY_COLLECTION_BIT: i32 = 1 << 4; + pub const DELETE_ANY_COLLECTION_BIT: i32 = 1 << 5; + pub const MANAGE_GROUPS_BIT: i32 = 1 << 6; + pub const MANAGE_POLICIES_BIT: i32 = 1 << 7; + pub const MANAGE_SSO_BIT: i32 = 1 << 8; + pub const MANAGE_USERS_BIT: i32 = 1 << 9; + pub const MANAGE_RESET_PASSWORD_BIT: i32 = 1 << 10; + pub const MANAGE_SCIM_BIT: i32 = 1 << 11; + pub const KNOWN_BITS_MASK: i32 = Self::ACCESS_EVENT_LOGS_BIT + | Self::ACCESS_IMPORT_EXPORT_BIT + | Self::ACCESS_REPORTS_BIT + | Self::CREATE_NEW_COLLECTIONS_BIT + | Self::EDIT_ANY_COLLECTION_BIT + | Self::DELETE_ANY_COLLECTION_BIT + | Self::MANAGE_GROUPS_BIT + | Self::MANAGE_POLICIES_BIT + | Self::MANAGE_SSO_BIT + | Self::MANAGE_USERS_BIT + | Self::MANAGE_RESET_PASSWORD_BIT + | Self::MANAGE_SCIM_BIT; + + pub fn from_key(key: &str) -> Option { + match key { + "accessEventLogs" | "access_event_logs" => Some(Self::AccessEventLogs), + "accessImportExport" | "access_import_export" => Some(Self::AccessImportExport), + "accessReports" | "access_reports" => Some(Self::AccessReports), + "createNewCollections" | "create_new_collections" => Some(Self::CreateNewCollections), + "editAnyCollection" | "edit_any_collection" => Some(Self::EditAnyCollection), + "deleteAnyCollection" | "delete_any_collection" => Some(Self::DeleteAnyCollection), + "manageGroups" | "manage_groups" => Some(Self::ManageGroups), + "managePolicies" | "manage_policies" => Some(Self::ManagePolicies), + "manageSso" | "manage_sso" => Some(Self::ManageSso), + "manageUsers" | "manage_users" => Some(Self::ManageUsers), + "manageResetPassword" | "manage_reset_password" => Some(Self::ManageResetPassword), + "manageScim" | "manage_scim" => Some(Self::ManageScim), + _ => None, + } + } + + pub const fn bit(self) -> i32 { + match self { + Self::AccessEventLogs => Self::ACCESS_EVENT_LOGS_BIT, + Self::AccessImportExport => Self::ACCESS_IMPORT_EXPORT_BIT, + Self::AccessReports => Self::ACCESS_REPORTS_BIT, + Self::CreateNewCollections => Self::CREATE_NEW_COLLECTIONS_BIT, + Self::EditAnyCollection => Self::EDIT_ANY_COLLECTION_BIT, + Self::DeleteAnyCollection => Self::DELETE_ANY_COLLECTION_BIT, + Self::ManageGroups => Self::MANAGE_GROUPS_BIT, + Self::ManagePolicies => Self::MANAGE_POLICIES_BIT, + Self::ManageSso => Self::MANAGE_SSO_BIT, + Self::ManageUsers => Self::MANAGE_USERS_BIT, + Self::ManageResetPassword => Self::MANAGE_RESET_PASSWORD_BIT, + Self::ManageScim => Self::MANAGE_SCIM_BIT, + } + } +} + +impl OrganizationUserPermissions { + pub fn from_payload_map(payload: &HashMap) -> serde_json::Result { + let value = Value::Object(payload.clone().into_iter().collect()); + serde_json::from_value(value) + } + + pub fn from_mask(mask: i32) -> Self { + let sanitized_mask = mask & OrganizationUserPermission::KNOWN_BITS_MASK; + if sanitized_mask != mask { + debug!( + "Ignoring unknown organization permission bits in mask {mask:#x} (known bits {:#x})", + OrganizationUserPermission::KNOWN_BITS_MASK + ); + } + + Self { + access_event_logs: sanitized_mask & OrganizationUserPermission::AccessEventLogs.bit() != 0, + access_import_export: sanitized_mask & OrganizationUserPermission::AccessImportExport.bit() != 0, + access_reports: sanitized_mask & OrganizationUserPermission::AccessReports.bit() != 0, + create_new_collections: sanitized_mask & OrganizationUserPermission::CreateNewCollections.bit() != 0, + edit_any_collection: sanitized_mask & OrganizationUserPermission::EditAnyCollection.bit() != 0, + delete_any_collection: sanitized_mask & OrganizationUserPermission::DeleteAnyCollection.bit() != 0, + manage_groups: sanitized_mask & OrganizationUserPermission::ManageGroups.bit() != 0, + manage_policies: sanitized_mask & OrganizationUserPermission::ManagePolicies.bit() != 0, + manage_sso: sanitized_mask & OrganizationUserPermission::ManageSso.bit() != 0, + manage_users: sanitized_mask & OrganizationUserPermission::ManageUsers.bit() != 0, + manage_reset_password: sanitized_mask & OrganizationUserPermission::ManageResetPassword.bit() != 0, + manage_scim: sanitized_mask & OrganizationUserPermission::ManageScim.bit() != 0, + } + } + + pub fn to_mask(&self) -> i32 { + let mut mask = 0; + if self.access_event_logs { + mask |= OrganizationUserPermission::AccessEventLogs.bit(); + } + if self.access_import_export { + mask |= OrganizationUserPermission::AccessImportExport.bit(); + } + if self.access_reports { + mask |= OrganizationUserPermission::AccessReports.bit(); + } + if self.create_new_collections { + mask |= OrganizationUserPermission::CreateNewCollections.bit(); + } + if self.edit_any_collection { + mask |= OrganizationUserPermission::EditAnyCollection.bit(); + } + if self.delete_any_collection { + mask |= OrganizationUserPermission::DeleteAnyCollection.bit(); + } + if self.manage_groups { + mask |= OrganizationUserPermission::ManageGroups.bit(); + } + if self.manage_policies { + mask |= OrganizationUserPermission::ManagePolicies.bit(); + } + if self.manage_sso { + mask |= OrganizationUserPermission::ManageSso.bit(); + } + if self.manage_users { + mask |= OrganizationUserPermission::ManageUsers.bit(); + } + if self.manage_reset_password { + mask |= OrganizationUserPermission::ManageResetPassword.bit(); + } + if self.manage_scim { + mask |= OrganizationUserPermission::ManageScim.bit(); + } + mask + } + + pub fn is_enabled(&self, permission: OrganizationUserPermission) -> bool { + self.to_mask() & permission.bit() != 0 + } + + pub fn has_manage_all_collections(&self) -> bool { + self.edit_any_collection && self.delete_any_collection && self.create_new_collections + } + + pub fn from_legacy_access_all(access_all: bool) -> Option { + if !access_all { + return None; + } + + Some(Self { + create_new_collections: true, + edit_any_collection: true, + delete_any_collection: true, + ..Self::default() + }) + } } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -274,6 +468,7 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + permissions: None, } } @@ -322,6 +517,32 @@ impl Membership { _ => self.atype, } } + + pub fn custom_permissions(&self) -> Option { + let legacy_permissions = OrganizationUserPermissions::from_legacy_access_all(self.access_all); + + self.permissions.map(OrganizationUserPermissions::from_mask).or(legacy_permissions) + } + + pub fn has_permission(&self, permission: OrganizationUserPermission) -> bool { + if !self.has_status(MembershipStatus::Confirmed) { + return false; + } + + if self.atype >= MembershipType::Admin { + return true; + } + + if self.atype != MembershipType::Manager { + return false; + } + + self.custom_permissions().is_some_and(|permissions| permissions.is_enabled(permission)) + } + + pub fn has_permission_key(&self, key: &str) -> bool { + OrganizationUserPermission::from_key(key).is_some_and(|permission| self.has_permission(permission)) + } } impl OrganizationApiKey { @@ -454,24 +675,11 @@ impl Membership { // It will be converted back on other locations let membership_type = self.type_manager_as_custom(); - let permissions = json!({ - // TODO: Add full support for Custom User Roles - // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role - // Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission - "accessEventLogs": false, - "accessImportExport": false, - "accessReports": false, - // If the following 3 Collection roles are set to true a custom user has access all permission - "createNewCollections": membership_type == 4 && self.access_all, - "editAnyCollection": membership_type == 4 && self.access_all, - "deleteAnyCollection": membership_type == 4 && self.access_all, - "manageGroups": false, - "managePolicies": false, - "manageSso": false, // Not supported - "manageUsers": false, - "manageResetPassword": false, - "manageScim": false // Not supported (Not AGPLv3 Licensed) - }); + let permissions = if membership_type == 4 { + json!(self.custom_permissions().unwrap_or_default()) + } else { + json!(OrganizationUserPermissions::default()) + }; // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs json!({ @@ -624,27 +832,10 @@ impl Membership { // It will be converted back on other locations let membership_type = self.type_manager_as_custom(); - // HACK: Only return permissions if the user is of type custom and has access_all - // Else Bitwarden will assume the defaults of all false - let permissions = if membership_type == 4 && self.access_all { - json!({ - // TODO: Add full support for Custom User Roles - // See: https://bitwarden.com/help/article/user-types-access-control/#custom-role - // Currently we use the custom role as a manager role and link the 3 Collection roles to mimic the access_all permission - "accessEventLogs": false, - "accessImportExport": false, - "accessReports": false, - // If the following 3 Collection roles are set to true a custom user has access all permission - "createNewCollections": true, - "editAnyCollection": true, - "deleteAnyCollection": true, - "manageGroups": false, - "managePolicies": false, - "manageSso": false, // Not supported - "manageUsers": false, - "manageResetPassword": false, - "manageScim": false // Not supported (Not AGPLv3 Licensed) - }) + // HACK: Keep the Manager->Custom response conversion. If no stored permissions exist, + // preserve the old access_all-based fallback behavior. + let permissions = if membership_type == 4 { + self.custom_permissions().map_or_else(|| json!(null), |permissions| json!(permissions)) } else { json!(null) }; @@ -1285,4 +1476,18 @@ mod tests { assert!(MembershipType::Manager > MembershipType::User); assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); } + + #[test] + fn organization_user_permissions_round_trip_all_known_bits() { + for mask in 0..=OrganizationUserPermission::KNOWN_BITS_MASK { + let permissions = OrganizationUserPermissions::from_mask(mask); + assert_eq!(permissions.to_mask(), mask); + } + } + + #[test] + fn organization_user_permissions_ignore_unknown_bits() { + let permissions = OrganizationUserPermissions::from_mask(-1); + assert_eq!(permissions.to_mask(), OrganizationUserPermission::KNOWN_BITS_MASK); + } } diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..87fa4111 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -242,6 +242,7 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + permissions -> Nullable, } }