From 7664d014d3816c69bc6fcd6998838ead110ba729 Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Mon, 24 Aug 2026 18:14:59 +0200 Subject: [PATCH 1/7] feat(vaultwarden-user-custom-role): update organization.rs --- src/db/models/organization.rs | 39 +++++++++++++++++++++++++++++++++++ src/db/schema.rs | 1 + 2 files changed, 40 insertions(+) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..b5946f2b 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,43 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + pub permissions: Option, +} + +#[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, +} + +impl OrganizationUserPermissions { + pub fn from_payload_map(payload: HashMap) -> serde_json::Result { + let value = Value::Object(payload.into_iter().collect()); + serde_json::from_value(value) + } + + pub fn from_db_json(raw: Option<&str>) -> serde_json::Result> { + raw.map(serde_json::from_str).transpose() + } + + pub fn to_db_json(&self) -> serde_json::Result { + serde_json::to_string(self) + } + + pub fn has_manage_all_collections(&self) -> bool { + self.edit_any_collection && self.delete_any_collection && self.create_new_collections + } } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -274,6 +312,7 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + permissions: None, } } diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..dc407f62 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, } } From 136a069dee6200f682f9cd7bfcf245025f6aa94b Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Mon, 24 Aug 2026 18:15:19 +0200 Subject: [PATCH 2/7] feat(vaultwarden-user-custom-role): update migrations --- .../down.sql | 1 + .../2026-08-24-120000_add_users_organizations_permissions/up.sql | 1 + .../down.sql | 1 + .../2026-08-24-120000_add_users_organizations_permissions/up.sql | 1 + .../down.sql | 1 + .../2026-08-24-120000_add_users_organizations_permissions/up.sql | 1 + 6 files changed, 6 insertions(+) create mode 100644 migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/down.sql create mode 100644 migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql create mode 100644 migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/down.sql create mode 100644 migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql create mode 100644 migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/down.sql create mode 100644 migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql 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..c3f8340d --- /dev/null +++ b/migrations/mysql/2026-08-24-120000_add_users_organizations_permissions/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN permissions TEXT 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..c3f8340d --- /dev/null +++ b/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN permissions TEXT 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..c3f8340d --- /dev/null +++ b/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql @@ -0,0 +1 @@ +ALTER TABLE users_organizations ADD COLUMN permissions TEXT DEFAULT NULL; From 39037817dede8f9f7941bf5c49704a0133c66ecb Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Mon, 24 Aug 2026 18:25:34 +0200 Subject: [PATCH 3/7] feat(org): persist custom role permissions and add permission-aware org guards --- src/api/core/organizations.rs | 255 ++++++++++++++++++++++++++-------- src/auth.rs | 140 ++++++++++++++++++- src/db/models/mod.rs | 2 +- src/db/models/organization.rs | 151 ++++++++++++++------ 4 files changed, 444 insertions(+), 104 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..f0777ba2 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -11,13 +11,17 @@ 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::{ + 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 +111,130 @@ 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_json = match custom_permissions { + Some(custom_permissions) => match custom_permissions.to_db_json() { + Ok(serialized) => Some(serialized), + Err(error) => err!(format!("Invalid custom role permissions payload: {error:#}")), + }, + None => None, + }; + + Ok((access_all, permissions_json)) +} + +#[cfg(test)] +mod tests { + use super::*; + + 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, serialized_permissions) = + resolve_membership_custom_permissions("4", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(access_all); + + let stored_json = serialized_permissions.expect("custom role payload should be persisted"); + let parsed = OrganizationUserPermissions::from_db_json(Some(&stored_json)).unwrap().unwrap(); + + 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, serialized_permissions) = + resolve_membership_custom_permissions("3", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(!access_all); + assert!(serialized_permissions.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, serialized_permissions) = + resolve_membership_custom_permissions("Custom", MembershipType::Manager as i32, &payload).unwrap(); + + assert!(!access_all); + + let stored_json = serialized_permissions.expect("custom role payload should be persisted"); + let parsed = OrganizationUserPermissions::from_db_json(Some(&stored_json)).unwrap().unwrap(); + + assert!(parsed.manage_users); + assert!(parsed.create_new_collections); + assert!(parsed.edit_any_collection); + assert!(!parsed.delete_any_collection); + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgData { @@ -1043,7 +1171,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 +1194,8 @@ 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_json) = + resolve_membership_custom_permissions(raw_type, new_type, &data.permissions)?; let mut user_created: bool = false; for email in &data.emails { @@ -1118,6 +1240,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_json.clone(); new_member.save(&conn).await?; if CONFIG.mail_enabled() { @@ -1194,7 +1317,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 +1352,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 +1483,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1404,7 +1527,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1417,7 +1540,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 +1625,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 +1656,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 +1667,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 +1683,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_json) = + 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 +1713,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_json; // 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 +1772,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 +1808,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 +1818,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 +1872,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 +2084,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 +2150,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 +2187,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 +2307,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 +2384,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 +2394,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 +2433,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 +2447,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 +2480,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 +2492,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 +2502,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 +2536,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 +2550,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 +2703,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 +2712,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 +2747,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 +2788,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 +2828,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 +2849,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 +2900,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 +2919,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 +2943,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 +2970,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 +3018,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 +3101,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 +3119,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 +3130,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 +3190,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 +3226,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 +3239,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"), } } diff --git a/src/auth.rs b/src/auth.rs index 762088e5..97b38b40 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/"), @@ -843,6 +847,138 @@ impl<'r> FromRequest<'r> for AdminHeaders { } } +pub struct ManageUsersHeaders { + pub host: String, + 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 ManageUsersHeaders { + 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::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/Owner or have Manage Users permission to call this endpoint") + } + } +} + +pub struct ManageResetPasswordHeaders { + pub host: String, + 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 { + 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/Owner or have Manage Reset Password permission to call this endpoint") + } + } +} + +pub struct ManageGroupsHeaders { + pub host: String, + 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 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 { + 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/Owner or have Manage Groups permission to call this endpoint") + } + } +} + +pub struct ManagePoliciesHeaders { + pub host: String, + 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 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 { + 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/Owner or have Manage Policies permission to call this endpoint") + } + } +} + // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. 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 b5946f2b..0cf44bea 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -78,9 +78,45 @@ pub struct OrganizationUserPermissions { 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 { + 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, + } + } +} + impl OrganizationUserPermissions { - pub fn from_payload_map(payload: HashMap) -> serde_json::Result { - let value = Value::Object(payload.into_iter().collect()); + pub fn from_payload_map(payload: &HashMap) -> serde_json::Result { + let value = Value::Object(payload.clone().into_iter().collect()); serde_json::from_value(value) } @@ -92,9 +128,39 @@ impl OrganizationUserPermissions { serde_json::to_string(self) } + pub fn is_enabled(&self, permission: OrganizationUserPermission) -> bool { + match permission { + OrganizationUserPermission::AccessEventLogs => self.access_event_logs, + OrganizationUserPermission::AccessImportExport => self.access_import_export, + OrganizationUserPermission::AccessReports => self.access_reports, + OrganizationUserPermission::CreateNewCollections => self.create_new_collections, + OrganizationUserPermission::EditAnyCollection => self.edit_any_collection, + OrganizationUserPermission::DeleteAnyCollection => self.delete_any_collection, + OrganizationUserPermission::ManageGroups => self.manage_groups, + OrganizationUserPermission::ManagePolicies => self.manage_policies, + OrganizationUserPermission::ManageSso => self.manage_sso, + OrganizationUserPermission::ManageUsers => self.manage_users, + OrganizationUserPermission::ManageResetPassword => self.manage_reset_password, + OrganizationUserPermission::ManageScim => self.manage_scim, + } + } + 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)] @@ -361,6 +427,39 @@ impl Membership { _ => self.atype, } } + + pub fn custom_permissions(&self) -> Option { + let legacy_permissions = OrganizationUserPermissions::from_legacy_access_all(self.access_all); + + match OrganizationUserPermissions::from_db_json(self.permissions.as_deref()) { + Ok(Some(permissions)) => Some(permissions), + Ok(None) => legacy_permissions, + Err(error) => { + warn!("Invalid custom permissions for membership {}: {error:#}", self.uuid); + 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 { @@ -493,24 +592,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!({ @@ -663,27 +749,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) }; From 89cf5f1e904b9e2d2a3efe7490ad57bca0bf3613 Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Mon, 24 Aug 2026 18:43:31 +0200 Subject: [PATCH 4/7] fix(auth): remove unused guard fields for strict CI lints --- src/auth.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 97b38b40..81931d78 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -819,10 +819,7 @@ impl<'r> FromRequest<'r> for OrgHeaders { pub struct AdminHeaders { pub host: String, - pub device: Device, pub user: User, - pub membership_type: MembershipType, - pub ip: ClientIp, pub org_id: OrganizationId, } @@ -835,10 +832,7 @@ impl<'r> FromRequest<'r> for AdminHeaders { if headers.is_confirmed_and_admin() { Outcome::Success(Self { host: headers.host, - device: headers.device, user: headers.user, - membership_type: headers.membership_type, - ip: headers.ip, org_id: headers.membership.org_uuid, }) } else { @@ -848,7 +842,6 @@ impl<'r> FromRequest<'r> for AdminHeaders { } pub struct ManageUsersHeaders { - pub host: String, pub device: Device, pub user: User, pub membership_type: MembershipType, @@ -866,7 +859,6 @@ impl<'r> FromRequest<'r> for ManageUsersHeaders { 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, @@ -881,7 +873,6 @@ impl<'r> FromRequest<'r> for ManageUsersHeaders { } pub struct ManageResetPasswordHeaders { - pub host: String, pub device: Device, pub user: User, pub membership_type: MembershipType, @@ -899,7 +890,6 @@ impl<'r> FromRequest<'r> for ManageResetPasswordHeaders { let is_admin_or_owner = headers.is_confirmed_and_admin(); if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageResetPassword) { Outcome::Success(Self { - host: headers.host, device: headers.device, user: headers.user, membership_type: headers.membership_type, @@ -914,11 +904,8 @@ impl<'r> FromRequest<'r> for ManageResetPasswordHeaders { } pub struct ManageGroupsHeaders { - pub host: String, pub device: Device, pub user: User, - pub membership_type: MembershipType, - pub is_admin_or_owner: bool, pub ip: ClientIp, pub org_id: OrganizationId, } @@ -932,11 +919,8 @@ impl<'r> FromRequest<'r> for ManageGroupsHeaders { let is_admin_or_owner = headers.is_confirmed_and_admin(); if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManageGroups) { 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, }) @@ -947,11 +931,8 @@ impl<'r> FromRequest<'r> for ManageGroupsHeaders { } pub struct ManagePoliciesHeaders { - pub host: String, pub device: Device, pub user: User, - pub membership_type: MembershipType, - pub is_admin_or_owner: bool, pub ip: ClientIp, pub org_id: OrganizationId, } @@ -965,11 +946,8 @@ impl<'r> FromRequest<'r> for ManagePoliciesHeaders { let is_admin_or_owner = headers.is_confirmed_and_admin(); if is_admin_or_owner || headers.has_permission(OrganizationUserPermission::ManagePolicies) { 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, }) From 2724ca6ce452ff1dd897595159e6d91c96b830fd Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Mon, 24 Aug 2026 18:49:49 +0200 Subject: [PATCH 5/7] chore: run rustfmt and document permissions bool shape for clippy --- src/api/core/organizations.rs | 11 ++++------- src/db/models/organization.rs | 1 + 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index f0777ba2..71a9686a 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -20,8 +20,8 @@ use crate::{ models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, - OrganizationUserPermissions, User, UserId, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, OrganizationUserPermissions, + User, UserId, }, }, mail, @@ -133,9 +133,7 @@ fn resolve_membership_custom_permissions( 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); + || custom_permissions.as_ref().is_some_and(OrganizationUserPermissions::has_manage_all_collections); let permissions_json = match custom_permissions { Some(custom_permissions) => match custom_permissions.to_db_json() { @@ -1194,8 +1192,7 @@ async fn send_invite( err!("Only Owners can invite Managers, Admins or Owners") } - let (access_all, permissions_json) = - resolve_membership_custom_permissions(raw_type, new_type, &data.permissions)?; + let (access_all, permissions_json) = resolve_membership_custom_permissions(raw_type, new_type, &data.permissions)?; let mut user_created: bool = false; for email in &data.emails { diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 0cf44bea..1fbe71c9 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -61,6 +61,7 @@ pub struct Membership { pub permissions: Option, } +#[allow(clippy::struct_excessive_bools)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct OrganizationUserPermissions { From 9dee038f7787520146dbb3682b13841f3d578b82 Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Tue, 25 Aug 2026 16:16:59 +0200 Subject: [PATCH 6/7] feat(org-rbac): store custom permissions as bitflags Store organization custom permissions as integer bitflags and keep API JSON compatibility by mapping payload permissions to/from mask. Use finer authorization guards for org events/export (AccessEventLogs/AccessImportExport). Create permissions as integer directly in initial migrations and drop intermediate conversion migration path for cleaner pre-release history. --- .../up.sql | 2 +- .../up.sql | 2 +- .../up.sql | 2 +- src/api/core/events.rs | 11 ++- src/api/core/organizations.rs | 48 ++++----- src/auth.rs | 48 ++++++++- src/db/models/organization.rs | 99 ++++++++++++++++--- src/db/schema.rs | 2 +- 8 files changed, 168 insertions(+), 46 deletions(-) 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 index c3f8340d..2cee0e2d 100644 --- 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 @@ -1 +1 @@ -ALTER TABLE users_organizations ADD COLUMN permissions TEXT DEFAULT NULL; +ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL; 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 index c3f8340d..2cee0e2d 100644 --- 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 @@ -1 +1 @@ -ALTER TABLE users_organizations ADD COLUMN permissions TEXT DEFAULT NULL; +ALTER TABLE users_organizations ADD COLUMN permissions INTEGER DEFAULT NULL; 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 index c3f8340d..2cee0e2d 100644 --- 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 @@ -1 +1 @@ -ALTER TABLE users_organizations ADD COLUMN permissions TEXT DEFAULT NULL; +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 5518fa3c..012be88f 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, 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 71a9686a..a2a162e0 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -12,8 +12,9 @@ use crate::{ core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{ - AdminHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageResetPasswordHeaders, - ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite, + AccessImportExportHeaders, AdminHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, + ManageResetPasswordHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, + OwnerHeaders, decode_invite, }, db::{ DbConn, @@ -129,21 +130,15 @@ fn resolve_membership_custom_permissions( raw_type: &str, new_type: i32, permissions: &HashMap, -) -> Result<(bool, Option), crate::Error> { +) -> 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_json = match custom_permissions { - Some(custom_permissions) => match custom_permissions.to_db_json() { - Ok(serialized) => Some(serialized), - Err(error) => err!(format!("Invalid custom role permissions payload: {error:#}")), - }, - None => None, - }; + let permissions_mask = custom_permissions.map(|custom_permissions| custom_permissions.to_mask()); - Ok((access_all, permissions_json)) + Ok((access_all, permissions_mask)) } #[cfg(test)] @@ -171,13 +166,16 @@ mod tests { fn invite_edit_store_custom_permissions_and_get_user_round_trip() { let payload = custom_permissions_payload(); - let (access_all, serialized_permissions) = + let (access_all, permission_mask) = resolve_membership_custom_permissions("4", MembershipType::Manager as i32, &payload).unwrap(); assert!(access_all); - let stored_json = serialized_permissions.expect("custom role payload should be persisted"); - let parsed = OrganizationUserPermissions::from_db_json(Some(&stored_json)).unwrap().unwrap(); + let stored_mask = permission_mask.expect("custom role payload should be persisted"); + assert_eq!(stored_mask, 637); + + let parsed = OrganizationUserPermissions::from_mask(stored_mask); + assert_eq!(stored_mask, parsed.to_mask()); assert_eq!( json!(parsed), @@ -202,11 +200,11 @@ mod tests { fn invite_edit_non_custom_role_keeps_permissions_unset() { let payload = custom_permissions_payload(); - let (access_all, serialized_permissions) = + let (access_all, permission_mask) = resolve_membership_custom_permissions("3", MembershipType::Manager as i32, &payload).unwrap(); assert!(!access_all); - assert!(serialized_permissions.is_none()); + assert!(permission_mask.is_none()); } #[test] @@ -218,13 +216,15 @@ mod tests { ("manageUsers".to_owned(), json!(true)), ]); - let (access_all, serialized_permissions) = + let (access_all, permission_mask) = resolve_membership_custom_permissions("Custom", MembershipType::Manager as i32, &payload).unwrap(); assert!(!access_all); - let stored_json = serialized_permissions.expect("custom role payload should be persisted"); - let parsed = OrganizationUserPermissions::from_db_json(Some(&stored_json)).unwrap().unwrap(); + let stored_mask = permission_mask.expect("custom role payload should be persisted"); + assert_eq!(stored_mask, 536); + + let parsed = OrganizationUserPermissions::from_mask(stored_mask); assert!(parsed.manage_users); assert!(parsed.create_new_collections); @@ -1192,7 +1192,7 @@ async fn send_invite( err!("Only Owners can invite Managers, Admins or Owners") } - let (access_all, permissions_json) = resolve_membership_custom_permissions(raw_type, new_type, &data.permissions)?; + 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 { @@ -1237,7 +1237,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_json.clone(); + new_member.permissions = permissions_mask; new_member.save(&conn).await?; if CONFIG.mail_enabled() { @@ -1680,7 +1680,7 @@ async fn edit_member( err!("Invalid type") }; - let (access_all, permissions_json) = + 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 { @@ -1710,7 +1710,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_json; + 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. @@ -3316,7 +3316,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 81931d78..b57eeecb 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -818,7 +818,6 @@ impl<'r> FromRequest<'r> for OrgHeaders { } pub struct AdminHeaders { - pub host: String, pub user: User, pub org_id: OrganizationId, } @@ -831,7 +830,6 @@ impl<'r> FromRequest<'r> for AdminHeaders { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_admin() { Outcome::Success(Self { - host: headers.host, user: headers.user, org_id: headers.membership.org_uuid, }) @@ -957,6 +955,52 @@ impl<'r> FromRequest<'r> for ManagePoliciesHeaders { } } +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") + } + } +} + // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. // First check the path, if this is not a valid uuid, try the query values. diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 1fbe71c9..561574f3 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -58,7 +58,7 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, - pub permissions: Option, + pub permissions: Option, } #[allow(clippy::struct_excessive_bools)] @@ -96,6 +96,19 @@ pub enum OrganizationUserPermission { } impl OrganizationUserPermission { + 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 fn from_key(key: &str) -> Option { match key { "accessEventLogs" | "access_event_logs" => Some(Self::AccessEventLogs), @@ -113,6 +126,23 @@ impl OrganizationUserPermission { _ => None, } } + + pub 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 { @@ -121,12 +151,62 @@ impl OrganizationUserPermissions { serde_json::from_value(value) } - pub fn from_db_json(raw: Option<&str>) -> serde_json::Result> { - raw.map(serde_json::from_str).transpose() + pub fn from_mask(mask: i32) -> Self { + Self { + access_event_logs: mask & OrganizationUserPermission::AccessEventLogs.bit() != 0, + access_import_export: mask & OrganizationUserPermission::AccessImportExport.bit() != 0, + access_reports: mask & OrganizationUserPermission::AccessReports.bit() != 0, + create_new_collections: mask & OrganizationUserPermission::CreateNewCollections.bit() != 0, + edit_any_collection: mask & OrganizationUserPermission::EditAnyCollection.bit() != 0, + delete_any_collection: mask & OrganizationUserPermission::DeleteAnyCollection.bit() != 0, + manage_groups: mask & OrganizationUserPermission::ManageGroups.bit() != 0, + manage_policies: mask & OrganizationUserPermission::ManagePolicies.bit() != 0, + manage_sso: mask & OrganizationUserPermission::ManageSso.bit() != 0, + manage_users: mask & OrganizationUserPermission::ManageUsers.bit() != 0, + manage_reset_password: mask & OrganizationUserPermission::ManageResetPassword.bit() != 0, + manage_scim: mask & OrganizationUserPermission::ManageScim.bit() != 0, + } } - pub fn to_db_json(&self) -> serde_json::Result { - serde_json::to_string(self) + 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 { @@ -432,14 +512,7 @@ impl Membership { pub fn custom_permissions(&self) -> Option { let legacy_permissions = OrganizationUserPermissions::from_legacy_access_all(self.access_all); - match OrganizationUserPermissions::from_db_json(self.permissions.as_deref()) { - Ok(Some(permissions)) => Some(permissions), - Ok(None) => legacy_permissions, - Err(error) => { - warn!("Invalid custom permissions for membership {}: {error:#}", self.uuid); - legacy_permissions - } - } + self.permissions.map(OrganizationUserPermissions::from_mask).or(legacy_permissions) } pub fn has_permission(&self, permission: OrganizationUserPermission) -> bool { diff --git a/src/db/schema.rs b/src/db/schema.rs index dc407f62..87fa4111 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -242,7 +242,7 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, - permissions -> Nullable, + permissions -> Nullable, } } From 8a808be247f2a96335de23e969ef157691afe4bb Mon Sep 17 00:00:00 2001 From: Sacha ATHIAS Date: Tue, 25 Aug 2026 16:56:14 +0200 Subject: [PATCH 7/7] chore(org-rbac): harden bitmask model and tests Document stable permission bit layout, sanitize unknown bits, and replace magic mask values with symbolic bit combinations in tests. Add edge-case coverage for empty custom permissions and unknown bits; clarify signed INTEGER migration intent for Diesel i32 compatibility. --- .../up.sql | 1 + .../up.sql | 1 + .../up.sql | 1 + src/api/core/organizations.rs | 29 ++++++- src/db/models/organization.rs | 77 ++++++++++++------- 5 files changed, 80 insertions(+), 29 deletions(-) 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 index 2cee0e2d..9b3d8a67 100644 --- 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 @@ -1 +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/up.sql b/migrations/postgresql/2026-08-24-120000_add_users_organizations_permissions/up.sql index 2cee0e2d..9b3d8a67 100644 --- 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 @@ -1 +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/up.sql b/migrations/sqlite/2026-08-24-120000_add_users_organizations_permissions/up.sql index 2cee0e2d..9b3d8a67 100644 --- 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 @@ -1 +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/organizations.rs b/src/api/core/organizations.rs index a2a162e0..bfef573a 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -144,6 +144,7 @@ fn resolve_membership_custom_permissions( #[cfg(test)] mod tests { use super::*; + use crate::db::models::OrganizationUserPermission; fn custom_permissions_payload() -> HashMap { HashMap::from([ @@ -172,7 +173,14 @@ mod tests { assert!(access_all); let stored_mask = permission_mask.expect("custom role payload should be persisted"); - assert_eq!(stored_mask, 637); + 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()); @@ -222,7 +230,10 @@ mod tests { assert!(!access_all); let stored_mask = permission_mask.expect("custom role payload should be persisted"); - assert_eq!(stored_mask, 536); + 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); @@ -231,6 +242,20 @@ mod tests { 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)] diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 561574f3..9cd937fe 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -96,6 +96,8 @@ pub enum OrganizationUserPermission { } 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; @@ -108,6 +110,18 @@ impl OrganizationUserPermission { 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 { @@ -127,7 +141,7 @@ impl OrganizationUserPermission { } } - pub fn bit(self) -> i32 { + pub const fn bit(self) -> i32 { match self { Self::AccessEventLogs => Self::ACCESS_EVENT_LOGS_BIT, Self::AccessImportExport => Self::ACCESS_IMPORT_EXPORT_BIT, @@ -152,19 +166,27 @@ impl OrganizationUserPermissions { } 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: mask & OrganizationUserPermission::AccessEventLogs.bit() != 0, - access_import_export: mask & OrganizationUserPermission::AccessImportExport.bit() != 0, - access_reports: mask & OrganizationUserPermission::AccessReports.bit() != 0, - create_new_collections: mask & OrganizationUserPermission::CreateNewCollections.bit() != 0, - edit_any_collection: mask & OrganizationUserPermission::EditAnyCollection.bit() != 0, - delete_any_collection: mask & OrganizationUserPermission::DeleteAnyCollection.bit() != 0, - manage_groups: mask & OrganizationUserPermission::ManageGroups.bit() != 0, - manage_policies: mask & OrganizationUserPermission::ManagePolicies.bit() != 0, - manage_sso: mask & OrganizationUserPermission::ManageSso.bit() != 0, - manage_users: mask & OrganizationUserPermission::ManageUsers.bit() != 0, - manage_reset_password: mask & OrganizationUserPermission::ManageResetPassword.bit() != 0, - manage_scim: mask & OrganizationUserPermission::ManageScim.bit() != 0, + 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, } } @@ -210,20 +232,7 @@ impl OrganizationUserPermissions { } pub fn is_enabled(&self, permission: OrganizationUserPermission) -> bool { - match permission { - OrganizationUserPermission::AccessEventLogs => self.access_event_logs, - OrganizationUserPermission::AccessImportExport => self.access_import_export, - OrganizationUserPermission::AccessReports => self.access_reports, - OrganizationUserPermission::CreateNewCollections => self.create_new_collections, - OrganizationUserPermission::EditAnyCollection => self.edit_any_collection, - OrganizationUserPermission::DeleteAnyCollection => self.delete_any_collection, - OrganizationUserPermission::ManageGroups => self.manage_groups, - OrganizationUserPermission::ManagePolicies => self.manage_policies, - OrganizationUserPermission::ManageSso => self.manage_sso, - OrganizationUserPermission::ManageUsers => self.manage_users, - OrganizationUserPermission::ManageResetPassword => self.manage_reset_password, - OrganizationUserPermission::ManageScim => self.manage_scim, - } + self.to_mask() & permission.bit() != 0 } pub fn has_manage_all_collections(&self) -> bool { @@ -1467,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); + } }