From 1f6d45753389d8dad991771499bc935a56cf0394 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:30:54 +0200 Subject: [PATCH] Add custom role permissions: Manage Users, Groups, Policies --- .gitignore | 3 + .../down.sql | 3 + .../up.sql | 3 + .../down.sql | 3 + .../up.sql | 3 + .../down.sql | 3 + .../up.sql | 3 + src/api/core/organizations.rs | 121 +++++++++++------- src/auth.rs | 84 ++++++++++++ src/db/models/organization.rs | 73 +++++------ src/db/schema.rs | 3 + .../templates/scss/vaultwarden.scss.hbs | 6 +- 12 files changed, 218 insertions(+), 90 deletions(-) create mode 100644 migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql diff --git a/.gitignore b/.gitignore index e991430e..06d677ca 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ data # Web vault web-vault + +ssl/ +vcpkg/ diff --git a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..f1979ae7 --- /dev/null +++ b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations DROP COLUMN manage_users; +ALTER TABLE users_organizations DROP COLUMN manage_groups; +ALTER TABLE users_organizations DROP COLUMN manage_policies; diff --git a/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..11094951 --- /dev/null +++ b/migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..f1979ae7 --- /dev/null +++ b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations DROP COLUMN manage_users; +ALTER TABLE users_organizations DROP COLUMN manage_groups; +ALTER TABLE users_organizations DROP COLUMN manage_policies; diff --git a/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..11094951 --- /dev/null +++ b/migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..f1979ae7 --- /dev/null +++ b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations DROP COLUMN manage_users; +ALTER TABLE users_organizations DROP COLUMN manage_groups; +ALTER TABLE users_organizations DROP COLUMN manage_policies; diff --git a/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..11094951 --- /dev/null +++ b/migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dd68cd5b..c3940b3c 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -11,7 +11,10 @@ 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, ManageUsersHeaders, ManagerHeaders, + ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite, + }, db::{ DbConn, models::{ @@ -389,7 +392,13 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos err!("Organization not found", "Organization id's do not match"); } - if !headers.membership.has_full_access() { + // Custom users with a manage permission need to read the collection list (metadata only) + // to be able to assign collections to groups/members. This does NOT expose cipher contents. + let can_read_collection_list = headers.membership.has_full_access() + || headers.membership.manage_users + || headers.membership.manage_groups + || headers.membership.manage_policies; + if !can_read_collection_list { err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } @@ -1030,7 +1039,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 { @@ -1174,7 +1183,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 { @@ -1209,7 +1218,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 { @@ -1340,7 +1349,7 @@ struct BulkConfirmData { async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1384,7 +1393,7 @@ async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -1397,7 +1406,7 @@ async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, - headers: &AdminHeaders, + headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { @@ -1482,7 +1491,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 { @@ -1513,7 +1522,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 @@ -1524,7 +1533,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 { @@ -1532,23 +1541,30 @@ async fn edit_member( } let data: EditUserData = data.into_inner(); - // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // The from_str() will convert the custom role type into a manager role type let raw_type = &data.r#type.into_string(); - // MembershipType::from_str will convert custom (4) to manager (3) let Some(new_type) = MembershipType::from_str(raw_type) else { 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 + // For a Custom role, the "Manage all collections" parent checkbox is not sent to + // the server; we derive access_all from its three child checkboxes. Admins/Owners + // implicitly have access to all collections. let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") + || (new_type == MembershipType::Custom && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); + // Read the explicit Custom-role management permissions. These only apply to the + // Custom type; for every other type they are forced to false so that changing a + // member away from Custom clears any previously granted flags. + let perm = |key: &str| { + new_type == MembershipType::Custom && data.permissions.get(key) == Some(&json!(true)) + }; + let manage_users = perm("manageUsers"); + let manage_groups = perm("manageGroups"); + let manage_policies = perm("managePolicies"); + 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") }; @@ -1574,7 +1590,18 @@ async fn edit_member( } } + // Security: only Admins and Owners may grant the granular custom-role management + // permissions. A custom user with manage_users must not be able to grant these + // permissions (to themselves or others), which would be a privilege escalation. + if headers.membership_type < MembershipType::Admin + && (manage_users || manage_groups || manage_policies) + { + err!("Only Admins or Owners can grant custom management permissions") + } member_to_edit.access_all = access_all; + member_to_edit.manage_users = manage_users; + member_to_edit.manage_groups = manage_groups; + member_to_edit.manage_policies = manage_policies; member_to_edit.atype = new_type as i32; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type @@ -1631,7 +1658,7 @@ async fn edit_member( async fn bulk_delete_member( org_id: OrganizationId, data: Json, - headers: AdminHeaders, + headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { @@ -1667,7 +1694,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 { @@ -1677,7 +1704,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 { @@ -1722,7 +1749,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 { @@ -1931,8 +1958,8 @@ async fn post_bulk_collections(data: Json, headers: Headers } #[get("/organizations//policies")] -async fn list_policies(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { - if org_id != headers.org_id { +async fn list_policies(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { + if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let policies = OrgPolicy::find_by_org(&org_id, &conn).await; @@ -1997,7 +2024,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"); } @@ -2025,7 +2052,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 { @@ -2153,7 +2180,7 @@ async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, data: Json, - headers: AdminHeaders, + headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { let data: PolicyDataVnext = data.into_inner(); @@ -2232,7 +2259,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 @@ -2242,7 +2269,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 { @@ -2281,7 +2308,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 { @@ -2325,7 +2352,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. @@ -2337,7 +2364,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 @@ -2347,7 +2374,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 { @@ -2381,7 +2408,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 { @@ -2528,7 +2555,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 @@ -2537,7 +2564,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 { @@ -2572,7 +2599,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 { @@ -2613,7 +2640,7 @@ async fn add_update_group( collections: Vec, members: Vec, org_id: OrganizationId, - headers: &AdminHeaders, + headers: &ManageGroupsHeaders, conn: &DbConn, ) -> JsonResult { group.save(conn).await?; @@ -2653,7 +2680,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 { @@ -2674,21 +2701,21 @@ 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 { @@ -2720,7 +2747,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 { @@ -2739,7 +2766,7 @@ 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"); } @@ -2758,7 +2785,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 { @@ -2785,7 +2812,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 { @@ -2833,7 +2860,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 { diff --git a/src/auth.rs b/src/auth.rs index 2ad95036..66e9bbe7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -693,6 +693,7 @@ pub struct OrgHeaders { pub host: String, pub device: Device, pub user: User, + #[allow(dead_code)] pub membership_type: MembershipType, pub membership_status: MembershipStatus, pub membership: Membership, @@ -714,6 +715,24 @@ impl OrgHeaders { fn is_confirmed_and_owner(&self) -> bool { self.membership_status == MembershipStatus::Confirmed && self.membership_type == MembershipType::Owner } + fn is_confirmed(&self) -> bool { + self.membership_status == MembershipStatus::Confirmed + } + // Custom-role permission checks. Admins and Owners implicitly hold every + // permission; a Custom member holds a permission only if the matching flag + // is set on their Membership. + fn can_manage_users(&self) -> bool { + self.is_confirmed() + && (self.membership_type >= MembershipType::Admin || self.membership.manage_users) + } + fn can_manage_groups(&self) -> bool { + self.is_confirmed() + && (self.membership_type >= MembershipType::Admin || self.membership.manage_groups) + } + fn can_manage_policies(&self) -> bool { + self.is_confirmed() + && (self.membership_type >= MembershipType::Admin || self.membership.manage_policies) + } } // org_id is usually the second path param ("/organizations/"), @@ -827,6 +846,71 @@ impl<'r> FromRequest<'r> for AdminHeaders { } } +// Macro to generate a request guard that permits a confirmed Admin/Owner, or a +// confirmed Custom member holding the given permission. The generated struct +// mirrors AdminHeaders so it can be used as a drop-in replacement on endpoints. +macro_rules! generate_manage_headers { + ($name:ident, $check:ident, $err:literal) => { + #[allow(dead_code)] + pub struct $name { + pub host: String, + pub device: Device, + pub user: User, + pub membership_type: MembershipType, + pub ip: ClientIp, + pub org_id: OrganizationId, + } + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for $name { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = try_outcome!(OrgHeaders::from_request(request).await); + if headers.$check() { + 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 { + err_handler!($err) + } + } + } + + impl From<$name> for Headers { + fn from(h: $name) -> Headers { + Headers { + host: h.host, + device: h.device, + user: h.user, + ip: h.ip, + } + } + } + }; +} + +generate_manage_headers!( + ManageUsersHeaders, + can_manage_users, + "You need the 'Manage Users' permission, or to be an Admin or Owner, to call this endpoint" +); +generate_manage_headers!( + ManageGroupsHeaders, + can_manage_groups, + "You need the 'Manage Groups' permission, or to be an Admin or Owner, to call this endpoint" +); +generate_manage_headers!( + ManagePoliciesHeaders, + can_manage_policies, + "You need the 'Manage Policies' permission, or to be an Admin or Owner, 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 d604add4..0fbc45a1 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -57,6 +57,9 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + pub manage_users: bool, + pub manage_groups: bool, + pub manage_policies: bool, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -98,21 +101,17 @@ pub enum MembershipType { Admin = 1, User = 2, Manager = 3, + Custom = 4, } impl MembershipType { pub fn from_str(s: &str) -> Option { - #[expect( - clippy::match_same_arms, - reason = "Specifically define `4|Custom` since this is a hack, not a default" - )] match s { "0" | "Owner" => Some(MembershipType::Owner), "1" | "Admin" => Some(MembershipType::Admin), "2" | "User" => Some(MembershipType::User), "3" | "Manager" => Some(MembershipType::Manager), - // HACK: We convert the custom role to a manager role - "4" | "Custom" => Some(MembershipType::Manager), + "4" | "Custom" => Some(MembershipType::Custom), _ => None, } } @@ -121,11 +120,15 @@ impl MembershipType { impl Ord for MembershipType { fn cmp(&self, other: &MembershipType) -> Ordering { // For easy comparison, map each variant to an access level (where 0 is lowest). - const ACCESS_LEVEL: [i32; 4] = [ + // Custom is treated as a low-privilege base role (same level as Manager for + // ordering purposes); its elevated capabilities are governed by the explicit + // manage_* permission flags on the Membership, not by this ordering. + const ACCESS_LEVEL: [i32; 5] = [ 3, // Owner 2, // Admin 0, // User - 1, // Manager && Custom + 1, // Manager + 1, // Custom ]; ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) } @@ -267,6 +270,9 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + manage_users: false, + manage_groups: false, + manage_policies: false, } } @@ -306,15 +312,6 @@ impl Membership { } false } - - /// HACK: Convert the manager type to a custom type - /// It will be converted back on other locations - pub fn type_manager_as_custom(&self) -> i32 { - match self.atype { - 3 => 4, - _ => self.atype, - } - } } impl OrganizationApiKey { @@ -443,14 +440,10 @@ impl Membership { pub async fn to_json(&self, conn: &DbConn) -> Value { let org = Organization::find_by_uuid(&self.org_uuid, conn).await.unwrap(); - // HACK: Convert the manager type to a custom type - // It will be converted back on other locations - let membership_type = self.type_manager_as_custom(); + let membership_type = self.atype; 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 + // The 3 Collection roles below are linked to the access_all permission "accessEventLogs": false, "accessImportExport": false, "accessReports": false, @@ -458,10 +451,10 @@ impl Membership { "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, + "manageGroups": self.manage_groups, + "managePolicies": self.manage_policies, "manageSso": false, // Not supported - "manageUsers": false, + "manageUsers": self.manage_users, "manageResetPassword": false, "manageScim": false // Not supported (Not AGPLv3 Licensed) }); @@ -607,28 +600,24 @@ impl Membership { Vec::with_capacity(0) }; - // HACK: Convert the manager type to a custom type - // It will be converted back on other locations - let membership_type = self.type_manager_as_custom(); + let membership_type = self.atype; - // 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 { + // Only return a permissions object for custom-type members. A custom member + // may have access_all (the 3 collection roles) and/or any of the explicit + // manage_* flags; otherwise Bitwarden assumes all-false defaults. + let permissions = if membership_type == MembershipType::Custom as i32 { 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, + "createNewCollections": self.access_all, + "editAnyCollection": self.access_all, + "deleteAnyCollection": self.access_all, + "manageGroups": self.manage_groups, + "managePolicies": self.manage_policies, "manageSso": false, // Not supported - "manageUsers": false, + "manageUsers": self.manage_users, "manageResetPassword": false, "manageScim": false // Not supported (Not AGPLv3 Licensed) }) @@ -728,7 +717,7 @@ impl Membership { json!({ "id": self.uuid, "userId": self.user_uuid, - "type": self.type_manager_as_custom(), // HACK: Convert the manager type to a custom type + "type": self.atype, "status": status, "name": user.name, "email": user.email, diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..15c43b5f 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -242,6 +242,9 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + manage_users -> Bool, + manage_groups -> Bool, + manage_policies -> Bool, } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 477cdd34..c5f790ac 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -116,8 +116,12 @@ app-security > app-two-factor-setup > form { } /* Hide unsupported Custom Role options */ +/* Note: manageUsers and managePolicies are supported by Vaultwarden + and are intentionally NOT hidden here. */ bit-dialog div.tw-ml-4:has(bit-form-control input), -bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { +bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"]), +bit-dialog bit-form-control:has(input[formcontrolname="manageSso"]), +bit-dialog bit-form-control:has(input[formcontrolname="manageResetPassword"]) { @extend %vw-hide; }