From 84ccfc626d7676595740b15f466958e0047e9142 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:45:45 +0200 Subject: [PATCH] Add Custom Roles foundation --- .env.template | 9 + .../down.sql | 22 ++ .../up.sql | 13 + .../down.sql | 20 + .../up.sql | 13 + .../down.sql | 19 + .../up.sql | 11 + src/api/admin.rs | 38 +- src/api/core/organizations.rs | 56 ++- src/api/web.rs | 3 +- src/config.rs | 4 + src/db/mod.rs | 71 ++++ src/db/models/cipher.rs | 12 +- src/db/models/collection.rs | 14 +- src/db/models/mod.rs | 4 +- src/db/models/organization.rs | 343 ++++++++++++++++-- src/db/schema.rs | 9 + src/static/scripts/admin_users.js | 16 +- src/static/templates/admin/users.hbs | 5 + .../templates/scss/vaultwarden.scss.hbs | 7 + 20 files changed, 621 insertions(+), 68 deletions(-) create mode 100644 migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql diff --git a/.env.template b/.env.template index 272c7738..6f8d41cf 100644 --- a/.env.template +++ b/.env.template @@ -489,6 +489,15 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false +## EXPERIMENTAL FEATURE: Custom roles +## Enables the Custom member role with granular permissions for organizations. +## This is still being developed: only the permissions this version enforces can be granted. +## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS. +## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled. +## Disabled by default because this is an experimental feature! +## KNOW WHAT YOU ARE DOING! +# EXPERIMENTAL_CUSTOM_ROLES=false + ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..85ebcbfb --- /dev/null +++ b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,22 @@ +-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the +-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything +-- is dropped: change their role first, or restore the backup taken before the upgrade. +-- A failed attempt leaves the temporary table on this connection. Clear it before retrying. +DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade; +CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); +INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); +INSERT INTO custom_role_downgrade + SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' + FROM users_organizations WHERE atype = 4 LIMIT 1; +DROP TEMPORARY TABLE custom_role_downgrade; + +ALTER TABLE users_organizations + DROP COLUMN manage_users, + DROP COLUMN manage_groups, + DROP COLUMN manage_policies, + DROP COLUMN create_new_collections, + DROP COLUMN edit_any_collection, + DROP COLUMN delete_any_collection, + DROP COLUMN access_event_logs, + DROP COLUMN access_import_export, + DROP COLUMN access_reports; diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..d28b0d64 --- /dev/null +++ b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,13 @@ +-- The granular permissions of the Custom member role. They only take effect on a Custom membership +-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. +-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..0cc5dd03 --- /dev/null +++ b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,20 @@ +-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the +-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their +-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. +CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); +INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); +INSERT INTO custom_role_downgrade + SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' + FROM users_organizations WHERE atype = 4 LIMIT 1; +DROP TABLE custom_role_downgrade; + +ALTER TABLE users_organizations + DROP COLUMN manage_users, + DROP COLUMN manage_groups, + DROP COLUMN manage_policies, + DROP COLUMN create_new_collections, + DROP COLUMN edit_any_collection, + DROP COLUMN delete_any_collection, + DROP COLUMN access_event_logs, + DROP COLUMN access_import_export, + DROP COLUMN access_reports; diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..d28b0d64 --- /dev/null +++ b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,13 @@ +-- The granular permissions of the Custom member role. They only take effect on a Custom membership +-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. +-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..11c30bec --- /dev/null +++ b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,19 @@ +-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the +-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their +-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. +CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); +INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); +INSERT INTO custom_role_downgrade + SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' + FROM users_organizations WHERE atype = 4 LIMIT 1; +DROP TABLE custom_role_downgrade; + +ALTER TABLE users_organizations DROP COLUMN manage_users; +ALTER TABLE users_organizations DROP COLUMN manage_groups; +ALTER TABLE users_organizations DROP COLUMN manage_policies; +ALTER TABLE users_organizations DROP COLUMN create_new_collections; +ALTER TABLE users_organizations DROP COLUMN edit_any_collection; +ALTER TABLE users_organizations DROP COLUMN delete_any_collection; +ALTER TABLE users_organizations DROP COLUMN access_event_logs; +ALTER TABLE users_organizations DROP COLUMN access_import_export; +ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..c708c693 --- /dev/null +++ b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,11 @@ +-- The granular permissions of the Custom member role. They only take effect on a Custom membership +-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. +ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE diff --git a/src/api/admin.rs b/src/api/admin.rs index 2a84f611..61a87e32 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -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}, @@ -253,6 +254,7 @@ struct AdminTemplateData { logged_in: bool, urlpath: String, sso_enabled: bool, + custom_roles_enabled: bool, } impl AdminTemplateData { @@ -263,6 +265,7 @@ impl AdminTemplateData { logged_in: true, urlpath: CONFIG.domain_path(), sso_enabled: CONFIG.sso_enabled(), + custom_roles_enabled: custom_roles_enabled(), } } @@ -387,6 +390,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult = 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); } @@ -554,10 +569,14 @@ async fn update_membership_type(data: Json, 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 { @@ -567,7 +586,14 @@ async fn update_membership_type(data: Json, 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?; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 6fccc850..312ec697 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -15,10 +15,10 @@ use crate::{ db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, - EventType, 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, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled, }, }, mail, @@ -1002,14 +1002,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") @@ -1019,11 +1017,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; @@ -1065,8 +1071,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?; @@ -1496,24 +1505,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 @@ -1537,8 +1554,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. diff --git a/src/api/web.rs b/src/api/web.rs index aa67e614..1f4043a8 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -19,7 +19,7 @@ use crate::{ crypto::sha256_hex, db::{ DbConn, - models::{AttachmentId, CipherId}, + models::{AttachmentId, CipherId, custom_roles_enabled}, }, error::Error, util::{Cached, EtagCached}, @@ -89,6 +89,7 @@ fn vaultwarden_css() -> EtagCached> { // 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, diff --git a/src/config.rs b/src/config.rs index a56748f4..fb99c73a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -796,6 +796,10 @@ make_config! { /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; + /// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations. + /// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled. + experimental_custom_roles: bool, false, def, false; + /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; diff --git a/src/db/mod.rs b/src/db/mod.rs index 2eae3f3c..9e774f8f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -534,3 +534,74 @@ mod postgresql_migrations { Ok(()) } } + +/// A throwaway SQLite database with the real schema, for tests that need to run queries. +#[cfg(all(test, sqlite))] +pub mod test_db { + use super::sqlite_migrations::MIGRATIONS; + use diesel::connection::SimpleConnection; + use diesel_migrations::MigrationHarness; + + #[test] + fn custom_role_migration_preserves_memberships_and_guards_downgrade() { + use super::schema::users_organizations::dsl::*; + use diesel::{migration::MigrationSource, prelude::*}; + let mut conn = SqliteConnection::establish(":memory:").unwrap(); + for migration in conn.pending_migrations(MIGRATIONS).unwrap() { + if migration.name().version().to_string() == "20261010120000" { + break; + } + conn.run_migration(&migration).unwrap(); + } + conn.batch_execute( + "PRAGMA foreign_keys=OFF; + INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b'); + INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES + ('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1), + ('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);", + ) + .unwrap(); + let rows = |conn: &mut SqliteConnection| { + users_organizations + .select((uuid, user_uuid, org_uuid, access_all, akey, status, atype)) + .order(uuid) + .load::<(String, String, String, bool, String, i32, i32)>(conn) + .unwrap() + }; + let before = rows(&mut conn); + conn.run_pending_migrations(MIGRATIONS).unwrap(); + assert_eq!(rows(&mut conn), before); + let grants = |conn: &mut SqliteConnection| { + diesel::select(diesel::dsl::sql::( + "(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies + OR create_new_collections OR edit_any_collection OR delete_any_collection + OR access_event_logs OR access_import_export OR access_reports)", + )) + .get_result::(conn) + .unwrap() + }; + assert_eq!(grants(&mut conn), 0); + let migration = MigrationSource::::migrations(&MIGRATIONS) + .unwrap() + .into_iter() + .find(|m| m.name().version().to_string() == "20261010120000") + .unwrap(); + for member_status in [1, 2] { + conn.batch_execute(&format!( + "UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'" + )) + .unwrap(); + let custom = rows(&mut conn); + assert!(conn.revert_migration(&*migration).is_err()); + assert_eq!(rows(&mut conn), custom); + assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::(&mut conn).unwrap()); + } + conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'") + .unwrap(); + conn.revert_migration(&*migration).unwrap(); + assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err()); + conn.run_pending_migrations(MIGRATIONS).unwrap(); + assert_eq!(grants(&mut conn), 0); + assert_eq!(rows(&mut conn), before); + } +} diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f39b0bfc..e36fa64c 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -25,7 +25,7 @@ use macros::UuidFromParam; use super::{ Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, MembershipType, OrganizationId, User, UserId, + MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -853,7 +853,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner ); } @@ -888,7 +888,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner ); } @@ -1092,7 +1092,7 @@ impl Cipher { .or(collections_groups::collections_uuid .is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1121,7 +1121,7 @@ impl Cipher { .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1163,7 +1163,7 @@ impl Cipher { ) .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_organizations::access_all.eq(true)) // User has access all - .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner .or_filter(groups::access_all.eq(true)) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 21387e4d..68c48ce9 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -18,7 +18,7 @@ use macros::UuidFromParam; use super::{ CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, + User, UserId, organization::ORG_ADMIN_ATYPES, }; // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs @@ -375,7 +375,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( @@ -412,7 +412,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), )) .select(collections::all_columns) @@ -455,7 +455,7 @@ impl Collection { ) .filter( users_organizations::atype - .le(MembershipType::Admin as i32) // Org admin or owner + .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -489,7 +489,7 @@ impl Collection { ) .filter( users_organizations::atype - .le(MembershipType::Admin as i32) // Org admin or owner + .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -540,7 +540,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( @@ -600,7 +600,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..13e2cbd6 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -31,8 +31,8 @@ pub use self::folder::{Folder, FolderCipher, FolderId}; 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, + CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, + OrganizationApiKey, OrganizationId, custom_roles_enabled, }; 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 72684782..46462583 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,4 +1,7 @@ -use std::cmp::Ordering; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, +}; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; @@ -21,8 +24,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, - TwoFactor, User, UserId, + Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, + OrgPolicyType, TwoFactor, User, UserId, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -41,6 +44,7 @@ pub struct Organization { #[diesel(table_name = users_organizations)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] +#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")] pub struct Membership { pub uuid: MembershipId, pub user_uuid: UserId, @@ -54,6 +58,17 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + + // The permissions of a Custom role, only accessed through `CustomPermission`. + manage_users: bool, + manage_groups: bool, + manage_policies: bool, + create_new_collections: bool, + edit_any_collection: bool, + delete_any_collection: bool, + access_event_logs: bool, + access_import_export: bool, + access_reports: bool, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -89,42 +104,68 @@ impl MembershipStatus { } } -#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)] pub enum MembershipType { Owner = 0, Admin = 1, User = 2, + // The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead. Manager = 3, + // Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled. + 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, } } + + /// The role a member invite or edit from the Bitwarden clients asks for, given the member's current + /// role (`None` for an invite). + /// + /// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a + /// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing + /// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never + /// turns one into the other. + pub fn requested( + requested: MembershipType, + current: Option<&Membership>, + custom_roles_enabled: bool, + ) -> MembershipType { + if requested != MembershipType::Custom { + return requested; + } + match current { + Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom, + Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager, + _ if custom_roles_enabled => MembershipType::Custom, + _ => MembershipType::Manager, + } + } + + /// The authority of a role, which is what roles are compared by. A Custom member gets its authority from + /// its permissions, never from its role, so the role itself ranks right above User and below Manager. + const fn rank(self) -> u8 { + match self { + MembershipType::User => 0, + MembershipType::Custom => 1, + MembershipType::Manager => 2, + MembershipType::Admin => 3, + MembershipType::Owner => 4, + } + } } 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] = [ - 3, // Owner - 2, // Admin - 0, // User - 1, // Manager && Custom - ]; - ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) + self.rank().cmp(&other.rank()) } } @@ -180,6 +221,91 @@ impl PartialOrd for i32 { } } +/// The granular permissions of the Custom role. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CustomPermission { + ManageUsers, + ManageGroups, + ManagePolicies, + CreateNewCollections, + EditAnyCollection, + DeleteAnyCollection, + AccessEventLogs, + AccessImportExport, + AccessReports, +} + +/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored +/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them. +const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[]; + +impl CustomPermission { + pub const ALL: [CustomPermission; 9] = [ + CustomPermission::ManageUsers, + CustomPermission::ManageGroups, + CustomPermission::ManagePolicies, + CustomPermission::CreateNewCollections, + CustomPermission::EditAnyCollection, + CustomPermission::DeleteAnyCollection, + CustomPermission::AccessEventLogs, + CustomPermission::AccessImportExport, + CustomPermission::AccessReports, + ]; + + /// The key of the permission in the `permissions` object of the Bitwarden clients. + pub const fn json_key(self) -> &'static str { + match self { + CustomPermission::ManageUsers => "manageUsers", + CustomPermission::ManageGroups => "manageGroups", + CustomPermission::ManagePolicies => "managePolicies", + CustomPermission::CreateNewCollections => "createNewCollections", + CustomPermission::EditAnyCollection => "editAnyCollection", + CustomPermission::DeleteAnyCollection => "deleteAnyCollection", + CustomPermission::AccessEventLogs => "accessEventLogs", + CustomPermission::AccessImportExport => "accessImportExport", + CustomPermission::AccessReports => "accessReports", + } + } + + pub fn is_supported(self) -> bool { + SUPPORTED_CUSTOM_PERMISSIONS.contains(&self) + } + + /// The permissions a member invite or edit grants a Custom role. + /// + /// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this + /// replaces the member's permissions, so a malformed value must not silently remove one. Granting a + /// permission this version does not enforce is rejected as well. The clients also send permissions + /// Vaultwarden does not know at all, like `manageSso`, those are ignored. + pub fn requested(permissions: &HashMap) -> Result, crate::Error> { + let mut granted = HashSet::new(); + for permission in CustomPermission::ALL { + match permissions.get(permission.json_key()) { + None | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) if permission.is_supported() => { + granted.insert(permission); + } + Some(Value::Bool(true)) => { + err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key())) + } + Some(_) => { + err!(format!("The Custom role permission '{}' must be true or false", permission.json_key())) + } + } + } + Ok(granted) + } +} + +/// The stored roles with authority over the whole organization. Queries match them by value, comparing +/// `atype <= Admin` would also match any unknown value below it. +pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32]; + +/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise. +pub fn custom_roles_enabled() -> bool { + CONFIG.experimental_custom_roles() +} + /// Local methods impl Organization { pub fn new(name: String, billing_email: &str, private_key: Option, public_key: Option) -> Self { @@ -271,9 +397,92 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + + manage_users: false, + manage_groups: false, + manage_policies: false, + create_new_collections: false, + edit_any_collection: false, + delete_any_collection: false, + access_event_logs: false, + access_import_export: false, + access_reports: false, + } + } + + fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool { + match permission { + CustomPermission::ManageUsers => &mut self.manage_users, + CustomPermission::ManageGroups => &mut self.manage_groups, + CustomPermission::ManagePolicies => &mut self.manage_policies, + CustomPermission::CreateNewCollections => &mut self.create_new_collections, + CustomPermission::EditAnyCollection => &mut self.edit_any_collection, + CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection, + CustomPermission::AccessEventLogs => &mut self.access_event_logs, + CustomPermission::AccessImportExport => &mut self.access_import_export, + CustomPermission::AccessReports => &mut self.access_reports, + } + } + + /// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are + /// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy + /// `access_all` flag, its reach only comes from its permissions. + pub fn set_type(&mut self, new_type: MembershipType) { + if self.atype != new_type as i32 { + for permission in CustomPermission::ALL { + *self.custom_permission_mut(permission) = false; + } + } + if new_type == MembershipType::Custom { + self.access_all = false; + } + self.atype = new_type as i32; + } + + /// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a + /// permission this version does not enforce is kept as it is: it grants nothing here. + pub fn set_custom_permissions(&mut self, permissions: &HashSet) { + for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) { + *self.custom_permission_mut(permission) = permissions.contains(&permission); } } + /// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A + /// conversion must not silently change their access, and a Custom role can't keep access to all collections + /// (which also puts every collection in the Manager's own vault), nor manage any collection yet. + pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> { + if !self.has_type(MembershipType::Manager) { + return None; + } + if self.access_all { + return Some( + "This Manager has access to all collections, which a Custom member can't have: the collections they \ + aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \ + or keep the Manager role.", + ); + } + // Whatever their status, the collections they manage directly, through a group or through a group with + // access to all collections + let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await + || CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + .await + .iter() + .any(|assignment| assignment.manage); + for group_user in GroupUser::find_by_member(&self.uuid, conn).await { + if manages { + break; + } + manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn) + .await + .iter() + .any(|assignment| assignment.manage); + } + manages.then_some( + "This Manager manages collections, with the manage permission or through a group with access to all \ + collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.", + ) + } + pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -711,6 +920,12 @@ impl Membership { } pub async fn save(&self, conn: &DbConn) -> EmptyResult { + // The legacy `access_all` flag reaches every collection, which a Custom role may only do through its + // permissions, so it must never be stored on one. `set_type` takes care of this. + if self.has_type(MembershipType::Custom) && self.access_all { + err!("A Custom role membership can't have access to all collections") + } + User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: @@ -1227,13 +1442,93 @@ pub struct OrgApiKeyId(String); #[cfg(test)] mod tests { use super::*; + use MembershipType::{Admin, Custom, Manager, Owner, User}; + + fn member(role: MembershipType) -> Membership { + let mut member = Membership::new("user".into(), "org".into(), None); + member.atype = role as i32; + member.status = MembershipStatus::Confirmed as i32; + member + } #[test] - #[allow(non_snake_case)] - fn partial_cmp_MembershipType() { - assert!(MembershipType::Owner > MembershipType::Admin); - assert!(MembershipType::Admin > MembershipType::Manager); - assert!(MembershipType::Manager > MembershipType::User); - assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); + fn membership_type_authority() { + assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User); + assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin); + for raw in [99, -1] { + assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true)); + } + for (value, expected) in [ + ("3", Some(Manager)), + ("Manager", Some(Manager)), + ("4", Some(Custom)), + ("Custom", Some(Custom)), + ("5", None), + ] { + assert_eq!(MembershipType::from_str(value), expected); + } + } + + #[test] + fn requested_membership_type() { + for (current, disabled, enabled) in [ + (None, Manager, Custom), + (Some(User), Manager, Custom), + (Some(Manager), Manager, Manager), + (Some(Custom), Custom, Custom), + ] { + let current = current.map(member); + for (flag, expected) in [(false, disabled), (true, enabled)] { + assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected); + } + } + assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User); + assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User); + for requested in [Owner, Admin, Manager] { + assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested); + } + } + + #[test] + fn custom_permission_requests() { + let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap(); + assert!(CustomPermission::requested(&ignored).unwrap().is_empty()); + for permission in CustomPermission::ALL { + for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] { + let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]); + let result = CustomPermission::requested(&request); + if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) { + assert_eq!(result.unwrap(), HashSet::from([permission])); + } else { + assert!(result.is_err(), "{permission:?}: {value}"); + } + } + } + assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []); + } + + #[test] + fn role_changes_drop_custom_permissions() { + for (from, to) in [ + (Custom, Owner), + (Custom, Admin), + (Custom, Manager), + (Custom, User), + (User, Custom), + (Manager, Custom), + (Custom, Custom), + (Admin, User), + ] { + let mut member = member(from); + member.access_all = true; + for permission in CustomPermission::ALL { + *member.custom_permission_mut(permission) = true; + } + member.set_type(to); + for permission in CustomPermission::ALL { + assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}"); + } + assert_eq!(member.access_all, to != Custom); + } } } diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..e1b9a78d 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -244,6 +244,15 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + manage_users -> Bool, + manage_groups -> Bool, + manage_policies -> Bool, + create_new_collections -> Bool, + edit_any_collection -> Bool, + delete_any_collection -> Bool, + access_event_logs -> Bool, + access_import_export -> Bool, + access_reports -> Bool, } } diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index 63ee2d7b..63128627 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -173,10 +173,14 @@ const ORG_TYPES = { "name": "User", "bg": "blue" }, - "4": { + "3": { "name": "Manager", "bg": "green" }, + "4": { + "name": "Custom", + "bg": "teal" + }, }; const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); @@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) { const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; // Get org specific values const userOrgType = event.relatedTarget.dataset.vwOrgType; - const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; const orgName = event.relatedTarget.dataset.vwOrgName; const orgUuid = event.relatedTarget.dataset.vwOrgUuid; + document.getElementById("userOrgTypeForm").reset(); document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid; - document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; + // The Custom role can only be selected while it is enabled + const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`); + if (userOrgTypeRadio) { + userOrgTypeRadio.checked = true; + } }, false); // Prevent accidental submission of the form with valid elements after the modal has been hidden. @@ -222,7 +230,7 @@ function updateUserOrgType(event) { function initUserTable() { // Color all the org buttons per type document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { - const orgType = ORG_TYPES[e.dataset.vwOrgType]; + const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" }; e.style.backgroundColor = orgType.bg; if (orgType.font !== undefined) { e.style.color = orgType.font; diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs index b1dfb17d..22d8205c 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -135,6 +135,11 @@
+ {{#if custom_roles_enabled}} +
+ +
+ {{/if}}
diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index d062c026..fc6d22c6 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -98,10 +98,17 @@ app-security > app-two-factor-setup > form { } /* Hide unsupported Custom Role options */ +{{#if custom_roles_enabled}} :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { @extend %vw-hide; } +{{else}} +:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), +:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { + @extend %vw-hide; +} +{{/if}} /* Change collapsed menu icon to Vaultwarden */ bit-nav-logo bit-nav-item a:before {