diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 19139127..78c27b39 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -22,9 +22,9 @@ use crate::{ DbConn, DbPool, models::{ Archive, Attachment, AttachmentId, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, - CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, - KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, - UserId, is_data_blob_encrypted, + CollectionGroup, CollectionId, CollectionUser, CustomPermission, EventType, Favorite, Folder, FolderCipher, + FolderId, Group, KeyId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, + Send, User, UserId, is_data_blob_encrypted, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -539,7 +539,8 @@ async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt data.last_known_revision_date = None; let mut cipher = Cipher::new(data.r#type); - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true).await?; + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate, true, false) + .await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) } @@ -613,9 +614,21 @@ pub async fn update_cipher_from_data( nt: &Notify<'_>, ut: UpdateType, emit_side_effects: bool, + allow_unassigned_org_import: bool, ) -> EmptyResult { enforce_personal_ownership_policy(Some(&data), headers, conn).await?; - save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut, emit_side_effects).await + save_cipher_data( + cipher, + data, + headers, + shared_to_collections, + conn, + nt, + ut, + emit_side_effects, + allow_unassigned_org_import, + ) + .await } /// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply. @@ -626,7 +639,7 @@ pub async fn rotate_cipher_data( conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { - save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None, false).await + save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None, false, false).await } /// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved @@ -652,6 +665,7 @@ async fn save_cipher_data( nt: &Notify<'_>, ut: UpdateType, emit_side_effects: bool, + allow_unassigned_org_import: bool, ) -> EmptyResult { let is_blob = data.is_blob(); data.validate_content(is_blob)?; @@ -674,6 +688,8 @@ async fn save_cipher_data( // Write access to the cipher itself only counts when it already belongs to this organization. if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty()) || member.can_edit_any_collection() + || (allow_unassigned_org_import + && member.has_custom_permission(CustomPermission::AccessImportExport)) || (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::User, conn).await) { @@ -945,7 +961,8 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC cipher_data.organization_id = None; let mut cipher = Cipher::new(cipher_data.r#type); - update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true).await?; + update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None, true, false) + .await?; } let mut user = headers.user; @@ -1036,7 +1053,8 @@ async fn put_cipher_impl( err!("Organization mismatch. Please resync the client before updating the cipher") } - update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate, true).await?; + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate, true, false) + .await?; Ok(Json(cipher_json_for_scope(&cipher, &headers, scope, &conn).await?)) } @@ -1501,6 +1519,7 @@ async fn share_cipher_by_uuid( nt, ut, false, + false, ) .await?; for col_id in &shared_to_collections { diff --git a/src/api/core/events.rs b/src/api/core/events.rs index a256d854..49678c68 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -7,11 +7,12 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult}, - auth::{AdminHeaders, Headers}, + auth::{AccessEventLogsHeaders, Headers}, db::{ DbConn, DbPool, models::{ - Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId, + Cipher, CipherAccessScope, CipherId, CustomPermission, Event, EventType, Membership, MembershipId, + MembershipType, OrganizationId, UserId, }, }, error::MapResult, @@ -42,7 +43,12 @@ impl EventRange { // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 #[get("/organizations//events?")] -async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_org_events( + org_id: OrganizationId, + data: EventRange, + headers: AccessEventLogsHeaders, + conn: DbConn, +) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -72,12 +78,21 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Headers, conn: DbConn) -> JsonResult { // Return an empty vec when org events are disabled. // This prevents client errors + // Only an Owner or Admin of the organization of the cipher, or a Custom member there with Access event logs, gets + // its events, and only the ones of that organization let events_json: Vec = if CONFIG.org_events_enabled() - && Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await + && let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await + && let Some(org_id) = cipher.organization_uuid + && let Some(member) = Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, &conn).await + && (member.atype >= MembershipType::Admin || member.has_custom_permission(CustomPermission::AccessEventLogs)) { let (start_date, end_date) = data.date_range()?; - Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() + Event::find_by_cipher_uuid(&cipher_id, &org_id, &start_date, &end_date, &conn) + .await + .iter() + .map(Event::to_json) + .collect() } else { Vec::new() }; @@ -94,7 +109,7 @@ async fn get_user_events( org_id: OrganizationId, member_id: MembershipId, data: EventRange, - headers: AdminHeaders, + headers: AccessEventLogsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 4593e6b0..0c59eedf 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -12,9 +12,9 @@ use crate::{ core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{ - AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, CollectionUsersReadHeaders, - Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, - OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite, + AccessImportExportHeaders, AdminHeaders, CollectionAction, CollectionDeleteHeaders, CollectionReadHeaders, + CollectionUsersReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, + ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, custom_member_may, decode_invite, }, db::{ DbConn, @@ -372,10 +372,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos err!("Organization not found", "Organization id's do not match"); } - // Manage groups may list organization collections for group access administration without editing them. - let collections = if headers.membership.can_view_all_collections() - || headers.membership.has_custom_permission(CustomPermission::ManageGroups) - { + let collections = if headers.membership.can_list_all_collections() { get_org_collections_impl(&org_id, &conn).await } else if headers.membership.has_type(MembershipType::Custom) { // A Custom member gets the collections they may manage @@ -908,7 +905,7 @@ async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: Db err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); } - if !headers.membership.can_edit_any_collection() { + if !headers.membership.can_list_all_ciphers() { err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } @@ -1618,7 +1615,10 @@ async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeade if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + // A Custom member may also need basic member details for event logs or reports. if headers.membership.has_type(MembershipType::Custom) + && !headers.membership.has_custom_permission(CustomPermission::AccessEventLogs) + && !headers.membership.has_custom_permission(CustomPermission::AccessReports) && !may_assign_collection_access(&headers.membership, &conn).await { err_code!("Resource not found.", "User does not have access", Status::NotFound.code); @@ -1998,21 +1998,40 @@ async fn validate_org_import_collections( imported: &[ImportCollectionData], org_id: &OrganizationId, membership: &Membership, + import_export: bool, conn: &DbConn, ) -> ApiResult> { let existing: HashMap = Collection::find_by_organization(org_id, conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); + let manageable = if membership.atype < MembershipType::Admin + && !membership.can_edit_any_collection() + && !membership.has_type(MembershipType::Manager) + { + Some( + Collection::find_explicitly_manageable_by_user(org_id, &membership.user_uuid, conn) + .await + .into_iter() + .collect::>(), + ) + } else { + None + }; for col in imported { if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) { - if membership.atype < MembershipType::Admin - && !membership.can_edit_any_collection() - && !collection.is_writable_by_user(&membership.user_uuid, conn).await - { - err!(Compact, "The current user isn't allowed to manage this collection") + if membership.atype < MembershipType::Admin && !membership.can_edit_any_collection() { + let can_import = if let Some(manageable) = &manageable { + manageable.contains(&collection.uuid) + } else { + collection.is_writable_by_user(&membership.user_uuid, conn).await + }; + if !can_import { + err!(Compact, "The current user isn't allowed to manage this collection") + } } } else if membership.atype <= MembershipType::Manager && !membership.has_full_access() && !membership.has_custom_permission(CustomPermission::CreateNewCollections) + && !import_export { err!(Compact, "The current user isn't allowed to create new collections") } @@ -2045,6 +2064,8 @@ async fn post_org_import( // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; + // Access import/export includes unassigned organization items. It is effective only for confirmed Custom members. + let import_export = headers.membership.has_custom_permission(CustomPermission::AccessImportExport); let relations = validate_org_import_relationships(&data)?; let ImportData { ciphers: imported_ciphers, @@ -2070,7 +2091,8 @@ async fn post_org_import( // Keep authorization and every write on the same connection. A failure at any later cipher or // relationship must also roll back newly created collections and their Manage assignments. conn.transaction(async || { - let existing = validate_org_import_collections(&imported_collections, &org_id, &membership, &conn).await?; + let existing = + validate_org_import_collections(&imported_collections, &org_id, &membership, import_export, &conn).await?; let mut collections: Vec = Vec::with_capacity(imported_collections.len()); for col in imported_collections { let collection_uuid = if let Some(collection) = col.id.as_ref().and_then(|id| existing.get(id)) { @@ -2104,6 +2126,7 @@ async fn post_org_import( &nt, UpdateType::None, false, + import_export, ) .await?; for collection_id in collection_ids { @@ -2142,6 +2165,7 @@ mod org_import_tests { ("index", MembershipType::Custom, CustomPermission::CreateNewCollections), ("disabled", MembershipType::Custom, CustomPermission::CreateNewCollections), ("export", MembershipType::Owner, CustomPermission::CreateNewCollections), + ("export", MembershipType::Custom, CustomPermission::AccessImportExport), ] { test_flag::CUSTOM_ROLES_ENABLED.set(Some(case != "disabled")); let org = test_db::organization(&conn).await; @@ -2859,7 +2883,10 @@ async fn get_groups_data( || headers.membership.has_custom_permission(CustomPermission::ManageUsers) || headers.membership.has_custom_permission(CustomPermission::ManageGroups) } else { - has_full_access || may_assign_collection_access(&headers.membership, &conn).await + // Reports need the basic group list, not group details or management rights. + has_full_access + || headers.membership.has_custom_permission(CustomPermission::AccessReports) + || may_assign_collection_access(&headers.membership, &conn).await }; if !allowed { err_code!("Resource not found.", "User does not have access", Status::NotFound.code); @@ -3700,11 +3727,11 @@ async fn put_reset_password_enrollment( // NOTE: It seems clients can't handle uppercase-first keys!! // We need to convert all keys so they have the first character to be a lowercase. // Else the export will be just an empty JSON file. -// We currently only support exports by members of the Admin or Owner status. +// Exports the whole vault of the organization, for an Owner, an Admin or a Custom member with Access import/export. // Vaultwarden does not yet support exporting only managed collections! // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 #[get("/organizations//export")] -async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult { +async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } @@ -3834,6 +3861,7 @@ mod tests { #[cfg(sqlite)] async fn users_headers(member: &Membership, conn: &DbConn) -> ManageUsersHeaders { ManageUsersHeaders { + host: "http://localhost".into(), device: Device::new("test".to_owned().into(), member.user_uuid.clone(), "test".into(), 1), user: User::find_by_uuid(&member.user_uuid, conn).await.unwrap(), membership: Membership::find_by_uuid(&member.uuid, conn).await.unwrap(), @@ -3916,6 +3944,7 @@ mod tests { for first_enable in [true, false] { let headers = users_headers(&actor, &conn).await; let headers = ManagePoliciesHeaders { + host: headers.host, device: headers.device, user: headers.user, membership: headers.membership, @@ -3952,4 +3981,58 @@ mod tests { } }); } + #[cfg(sqlite)] + async fn directory_headers(member: &Membership, conn: &DbConn) -> ManagerHeadersLoose { + let headers = users_headers(member, conn).await; + ManagerHeadersLoose { + host: headers.host, + device: headers.device, + user: headers.user, + membership: headers.membership, + ip: headers.ip, + } + } + + #[cfg(sqlite)] + #[test] + fn access_reports_directory_authorization() { + test_db::run(async |db| { + let conn = db.conn(); + let (org, _, mut member) = test_db::custom_member(&conn, CustomPermission::AccessReports).await; + let foreign = test_db::organization(&conn).await; + for (enabled, status, permission, org_id, allowed) in [ + (true, 2, true, &org.uuid, true), + (true, 2, true, &foreign.uuid, false), + (true, 2, false, &org.uuid, false), + (true, 1, true, &org.uuid, false), + (true, -126, true, &org.uuid, false), + (false, 2, true, &org.uuid, false), + ] { + test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled)); + member.status = status; + member.set_custom_permissions(&if permission { + HashSet::from([CustomPermission::AccessReports]) + } else { + HashSet::new() + }); + member.save(&conn).await.unwrap(); + assert!(!may_assign_collection_access(&member, &conn).await); + assert_eq!( + get_groups(org_id.clone(), directory_headers(&member, &conn).await, db.conn()).await.is_ok(), + allowed + ); + let users = + get_org_user_mini_details(org_id.clone(), directory_headers(&member, &conn).await, db.conn()).await; + assert_eq!(users.is_ok(), allowed); + if allowed { + assert_eq!(users.unwrap().0["data"].as_array().unwrap().len(), 1); + } + assert!( + get_groups_details(org_id.clone(), directory_headers(&member, &conn).await, db.conn()) + .await + .is_err() + ); + } + }); + } } diff --git a/src/auth.rs b/src/auth.rs index b19b9664..0a43b41b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -847,7 +847,6 @@ impl<'r> FromRequest<'r> for OrgHeaders { } pub struct AdminHeaders { - pub host: String, pub device: Device, pub user: User, pub membership_type: MembershipType, @@ -869,7 +868,6 @@ impl<'r> FromRequest<'r> for AdminHeaders { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_admin() { Outcome::Success(Self { - host: headers.host, device: headers.device, user: headers.user, membership_type: headers.membership_type, @@ -889,6 +887,7 @@ macro_rules! permission_headers { $(#[$doc])* #[allow(dead_code, reason = "Not every route reads every field")] pub struct $name { + pub host: String, pub device: Device, pub user: User, pub membership_type: MembershipType, @@ -905,6 +904,7 @@ macro_rules! permission_headers { let headers = try_outcome!(OrgHeaders::from_request(request).await); if headers.is_confirmed_and_admin() || headers.membership.has_custom_permission($permission) { Outcome::Success(Self { + host: headers.host, device: headers.device, user: headers.user, membership_type: headers.membership_type, @@ -937,6 +937,16 @@ permission_headers!( ManageGroupsHeaders, CustomPermission::ManageGroups ); +permission_headers!( + /// For the routes that read the event logs of an organization. + AccessEventLogsHeaders, + CustomPermission::AccessEventLogs +); +permission_headers!( + /// For the routes that export the vault of an organization. + AccessImportExportHeaders, + CustomPermission::AccessImportExport +); // col_id is usually the fourth path param ("/organizations//collections/"), // but there could be cases where it is a query value. diff --git a/src/db/models/event.rs b/src/db/models/event.rs index e020bb3d..84a73a0d 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -331,6 +331,7 @@ impl Event { pub async fn find_by_cipher_uuid( cipher_uuid: &CipherId, + org_uuid: &OrganizationId, start: &NaiveDateTime, end: &NaiveDateTime, conn: &DbConn, @@ -338,6 +339,7 @@ impl Event { conn.run(move |conn| { event::table .filter(event::cipher_uuid.eq(cipher_uuid)) + .filter(event::org_uuid.eq(org_uuid)) .filter(event::event_date.between(start, end)) .order_by(event::event_date.desc()) .limit(Self::PAGE_SIZE) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 26c02509..8115d95c 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -15,7 +15,7 @@ use crate::{ db::{ DbConn, schema::{ - ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, + ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, organizations, users, users_collections, users_organizations, }, }, @@ -257,7 +257,8 @@ pub enum CustomPermission { } /// 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. +/// grant of any other one grants nothing. Every stored permission is enforced now, a permission added later only +/// belongs here once every endpoint it opens enforces it. const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[ CustomPermission::ManageUsers, CustomPermission::ManageGroups, @@ -265,6 +266,9 @@ const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[ CustomPermission::CreateNewCollections, CustomPermission::EditAnyCollection, CustomPermission::DeleteAnyCollection, + CustomPermission::AccessEventLogs, + CustomPermission::AccessImportExport, + CustomPermission::AccessReports, ]; impl CustomPermission { @@ -1105,6 +1109,25 @@ impl Membership { self.can_view_all_collections() || self.has_custom_permission(CustomPermission::ManageUsers) } + /// Whether the member may list all organization collections in the Admin Console. This endpoint returns + /// collection metadata without user or group access details. Group management and import/export need the + /// complete collection list, but neither permission grants individual collection editing or cipher access. + pub fn can_list_all_collections(&self) -> bool { + self.can_view_all_collections() + || self.has_custom_permission(CustomPermission::ManageGroups) + || self.has_custom_permission(CustomPermission::AccessImportExport) + } + + /// Whether this member may list every cipher of the organization in the Admin Console, like Bitwarden's + /// CanAccessAllCiphers: every member who may edit any collection, and a Custom member with Access import/export + /// (the export holds every cipher anyway) or Access reports (the reports run on every cipher). The list adds + /// nothing to the member's own vault. + pub fn can_list_all_ciphers(&self) -> bool { + self.can_edit_any_collection() + || self.has_custom_permission(CustomPermission::AccessImportExport) + || self.has_custom_permission(CustomPermission::AccessReports) + } + pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option { conn.run(move |conn| { users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::(conn).ok() @@ -1484,28 +1507,6 @@ impl Membership { .await } - pub async fn user_has_ge_admin_access_to_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> bool { - conn.run(move |conn| { - users_organizations::table - .inner_join( - ciphers::table.on(ciphers::uuid - .eq(cipher_uuid) - .and(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))), - ) - .filter(users_organizations::user_uuid.eq(user_uuid)) - .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) - .filter( - users_organizations::atype.eq_any(vec![MembershipType::Owner as i32, MembershipType::Admin as i32]), - ) - .count() - .first::(conn) - .ok() - .unwrap_or(0) - != 0 - }) - .await - } - pub async fn find_by_collection_and_org( collection_uuid: &CollectionId, org_uuid: &OrganizationId, @@ -1710,7 +1711,17 @@ mod tests { } assert_eq!( SUPPORTED_CUSTOM_PERMISSIONS, - [ManageUsers, ManageGroups, ManagePolicies, CreateNewCollections, EditAnyCollection, DeleteAnyCollection] + [ + ManageUsers, + ManageGroups, + ManagePolicies, + CreateNewCollections, + EditAnyCollection, + DeleteAnyCollection, + AccessEventLogs, + AccessImportExport, + AccessReports + ] ); } @@ -1773,6 +1784,14 @@ mod tests { (allowed, edit, view), "{role:?} {all} {enabled} {status} {permission:?}" ); + assert_eq!( + (member.can_list_all_collections(), member.can_list_all_ciphers()), + ( + view || (allowed && matches!(permission, ManageGroups | AccessImportExport)), + edit || (allowed && matches!(permission, AccessImportExport | AccessReports)) + ), + "{role:?} {enabled} {status} {permission:?}" + ); } } } @@ -1787,12 +1806,13 @@ mod tests { test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled)); assert!(CustomPermission::ALL.into_iter().all(|p| !member.has_custom_permission(p))); assert!(!member.can_edit_any_collection() && !member.can_view_all_collections()); + assert!(!member.can_list_all_collections() && !member.can_list_all_ciphers()); } } let empty = member(Custom); assert!(!empty.can_edit_any_collection() && !empty.can_view_all_collections()); assert!(empty.shown_custom_permissions().is_empty()); - + assert!(!empty.can_list_all_collections() && !empty.can_list_all_ciphers()); test_flag::CUSTOM_ROLES_ENABLED.set(None); } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index c2cda8ed..a4c270bc 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -99,8 +99,7 @@ app-security > app-two-factor-setup > form { /* Hide unsupported Custom Role options */ {{#if custom_roles_enabled}} -/* Only the collection permissions, Manage users, Manage groups and Manage policies can be granted yet */ -:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"]), +/* Not supported: Manage SSO and Manage account recovery */ :is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageSso"]), :is(bit-dialog, [bit-dialog]) bit-form-control:has(input[formcontrolname="manageResetPassword"]) { @extend %vw-hide;