Browse Source

Add custom role permissions: Manage Users, Groups, Policies

pull/7397/head
tom27052006 3 weeks ago
parent
commit
1f6d457533
  1. 3
      .gitignore
  2. 3
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/down.sql
  3. 3
      migrations/mysql/2026-06-30-120000_add_custom_role_permissions/up.sql
  4. 3
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/down.sql
  5. 3
      migrations/postgresql/2026-06-30-120000_add_custom_role_permissions/up.sql
  6. 3
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/down.sql
  7. 3
      migrations/sqlite/2026-06-30-120000_add_custom_role_permissions/up.sql
  8. 121
      src/api/core/organizations.rs
  9. 84
      src/auth.rs
  10. 73
      src/db/models/organization.rs
  11. 3
      src/db/schema.rs
  12. 6
      src/static/templates/scss/vaultwarden.scss.hbs

3
.gitignore

@ -14,3 +14,6 @@ data
# Web vault
web-vault
ssl/
vcpkg/

3
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;

3
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;

3
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;

3
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;

3
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;

3
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;

121
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<InviteData>,
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<BulkMembershipIds>,
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<BulkConfirmData>,
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<ConfirmData>,
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<EditUserData>,
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<EditUserData>,
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<BulkMembershipIds>,
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<BulkMembershipIds>,
headers: AdminHeaders,
headers: ManageUsersHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -1931,8 +1958,8 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
}
#[get("/organizations/<org_id>/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/<org_id>/policies/<pol_type>", 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<PolicyData>,
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<PolicyDataVnext>,
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<BulkRevokeMembershipIds>,
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<BulkMembershipIds>,
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<GroupRequest>,
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/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: OrganizationId,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
data: Json<GroupRequest>,
conn: DbConn,
) -> JsonResult {
@ -2572,7 +2599,7 @@ async fn put_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
@ -2613,7 +2640,7 @@ async fn add_update_group(
collections: Vec<CollectionData>,
members: Vec<MembershipId>,
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/<org_id>/groups/<group_id>")]
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<BulkGroupIds>,
headers: AdminHeaders,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
@ -2739,7 +2766,7 @@ async fn bulk_delete_groups(
}
#[get("/organizations/<org_id>/groups/<group_id>", 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<Vec<MembershipId>>,
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 {

84
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/<org_id>"),
@ -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<Self, Self::Error> {
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/<org_id>/collections/<col_id>"),
// 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.

73
src/db/models/organization.rs

@ -57,6 +57,9 @@ pub struct Membership {
pub atype: i32,
pub reset_password_key: Option<String>,
pub external_id: Option<String>,
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<Self> {
#[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,

3
src/db/schema.rs

@ -242,6 +242,9 @@ table! {
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
manage_users -> Bool,
manage_groups -> Bool,
manage_policies -> Bool,
}
}

6
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;
}

Loading…
Cancel
Save