[PR 1] Add experimental Custom Roles foundation (#7847)

This commit is contained in:
Tom authored and GitHub committed 2026-11-04 22:11:52 +01:00
1 parent 82fc47e83e
commit 904b80115d
20 files changed
+621 -68

No files matched your search

+32 -6
View File
@@ -1,4 +1,4 @@
use std::{env, sync::LazyLock};
use std::{collections::HashMap, env, sync::LazyLock};
use reqwest::Method;
use rocket::{
@@ -26,6 +26,7 @@ use crate::{
models::{
Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
custom_roles_enabled,
},
},
error::{Error, MapResult},
@@ -256,6 +257,7 @@ struct AdminTemplateData {
logged_in: bool,
urlpath: String,
sso_enabled: bool,
custom_roles_enabled: bool,
}
impl AdminTemplateData {
@@ -266,6 +268,7 @@ impl AdminTemplateData {
logged_in: true,
urlpath: CONFIG.domain_path(),
sso_enabled: CONFIG.sso_enabled(),
custom_roles_enabled: custom_roles_enabled(),
}
}
@@ -390,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<Stri
usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));
// The clients are shown a Manager as a Custom member, but the admin panel tells the two roles apart
let roles: HashMap<String, i32> = Membership::find_confirmed_by_user(&u.uuid, &conn)
.await
.into_iter()
.map(|member| (member.org_uuid.to_string(), member.atype))
.collect();
for org in usr["organizations"].as_array_mut().into_iter().flatten() {
if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) {
org["type"] = json!(atype);
}
}
users_json.push(usr);
}
@@ -565,10 +580,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
err!("The specified user isn't member of the organization")
};
let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
new_type as i32
} else {
err!("Invalid type")
let new_type = match MembershipType::from_str(&data.user_type.into_string()) {
// Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles
// are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one.
Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => {
MembershipType::Manager
}
Some(new_type) => new_type,
None => err!("Invalid type"),
};
if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
@@ -578,7 +597,14 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
}
}
member_to_edit.atype = new_type;
// A Manager only becomes a Custom member if that changes nothing they may do
if new_type == MembershipType::Custom
&& let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await
{
err!(loss)
}
member_to_edit.set_type(new_type);
// This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
+38 -18
View File
@@ -15,10 +15,10 @@ use crate::{
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
OrganizationId, TwoFactor, TwoFactorType, User, UserId,
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser,
CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation,
Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization,
OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled,
},
},
mail,
@@ -1008,14 +1008,12 @@ async fn send_invite(
data.validate(&org_id, &conn).await?;
// 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
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
let raw_type = &data.r#type.into_string();
// Membership::from_str will convert custom (4) to manager (3)
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
new_type as i32
} else {
let Some(requested_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type")
};
let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled());
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can invite Managers, Admins or Owners")
@@ -1025,11 +1023,19 @@ async fn send_invite(
// 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")
|| (new_type == MembershipType::Manager
&& 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)));
// Checked before anything is created, so a rejected permission leaves nothing behind
let custom_permissions = if new_type == MembershipType::Custom {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
let mut user_created: bool;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
@@ -1071,8 +1077,11 @@ async fn send_invite(
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.set_type(new_type);
new_member.access_all = access_all;
new_member.atype = new_type;
if let Some(permissions) = &custom_permissions {
new_member.set_custom_permissions(permissions);
}
new_member.status = member_status;
new_member.save(&conn).await?;
@@ -1508,24 +1517,32 @@ 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
// Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3)
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 {
let Some(requested_type) = MembershipType::from_str(raw_type) else {
err!("Invalid type")
};
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")
};
let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled());
// 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")
|| (new_type == MembershipType::Manager
&& 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 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")
// While Custom roles are disabled, a Custom member keeps their stored permissions untouched
let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() {
Some(CustomPermission::requested(&data.permissions)?)
} else {
None
};
if new_type != member_to_edit.atype
@@ -1549,8 +1566,11 @@ async fn edit_member(
}
}
member_to_edit.set_type(new_type);
member_to_edit.access_all = access_all;
member_to_edit.atype = new_type as i32;
if let Some(permissions) = &custom_permissions {
member_to_edit.set_custom_permissions(permissions);
}
// 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.
+2 -1
View File
@@ -20,7 +20,7 @@ use crate::{
crypto::sha256_hex,
db::{
DbConn,
models::{AttachmentId, CipherId},
models::{AttachmentId, CipherId, custom_roles_enabled},
},
error::Error,
util::{Cached, EtagCached},
@@ -90,6 +90,7 @@ fn vaultwarden_css() -> EtagCached<String> {
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({
"custom_roles_enabled": custom_roles_enabled(),
"email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,