[PR 3] Add collection permissions for Custom Roles (#7951)

* Add Custom Roles collection permissions

* Return NotFound for denied collection access

* Grant Manage on imported collections to every Custom member
This commit is contained in:
Tom authored and GitHub committed 2026-11-13 20:24:59 +01:00
1 parent d2d1294098
commit a8de8c7820
10 files changed
+795 -268

No files matched your search

+65
View File
@@ -626,6 +626,7 @@ pub mod test_db {
}
// DbConn::run uses block_in_place, requiring a multi-threaded runtime.
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
super::models::test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
test(&TestDb::new()).await;
});
}
@@ -758,4 +759,68 @@ pub mod test_db {
}
});
}
#[test]
fn collection_and_cipher_access_requires_active_membership_and_enabled_groups() {
use super::models::{Cipher, CipherAccessScope, CollectionCipher, CollectionUser, test_flag};
use crate::{
api::core::{CipherSyncData, CipherSyncType},
auth::{CollectionAction, custom_member_may},
};
run(async |db| {
let conn = db.conn();
let (org, user, mut member) = custom_member(&conn, CustomPermission::EditAnyCollection).await;
let collection = collection(&conn, &org.uuid).await;
let mut cipher = Cipher::new(2);
cipher.name = "Cipher".into();
cipher.organization_uuid = Some(org.uuid.clone());
cipher.save(&conn).await.unwrap();
CollectionCipher::save(&cipher.uuid, &collection.uuid, &conn).await.unwrap();
for (enabled, status, allowed) in [(true, 2, true), (false, 2, false), (true, 1, false)] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
member.status = status;
member.save(&conn).await.unwrap();
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::User, &conn).await);
assert_eq!(
cipher.is_write_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await,
allowed
);
}
test_flag::CUSTOM_ROLES_ENABLED.set(Some(true));
member.status = 2;
member.save(&conn).await.unwrap();
let foreign = organization(&conn).await;
cipher.organization_uuid = Some(foreign.uuid);
assert!(!cipher.is_accessible_to_user(&user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await);
cipher.organization_uuid = Some(org.uuid.clone());
member.set_custom_permissions(&std::collections::HashSet::new());
member.save(&conn).await.unwrap();
let sql=format!("INSERT INTO groups (uuid,organizations_uuid,name,access_all,creation_date,revision_date)
VALUES ('g','{}','Group',FALSE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP);
INSERT INTO groups_users (groups_uuid,users_organizations_uuid) VALUES ('g','{}');
INSERT INTO collections_groups (collections_uuid,groups_uuid,read_only,hide_passwords,manage) VALUES ('{}','g',FALSE,FALSE,TRUE);",
org.uuid,member.uuid,collection.uuid);
conn.run(move |c| c.batch_execute(&sql)).await.unwrap();
for status in [2, 1, -3, -126] {
member.status = status;
member.save(&conn).await.unwrap();
let allowed = status == 2 && crate::CONFIG.org_groups_enabled();
assert_eq!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await, allowed);
let sync = CipherSyncData::new(&user.uuid, CipherSyncType::User, &conn).await;
for cached in [None, Some(&sync)] {
assert_eq!(
cipher
.get_access_restrictions(&user.uuid, CipherAccessScope::User, cached, &conn)
.await
.is_some(),
allowed
);
}
}
member.status = 2;
member.save(&conn).await.unwrap();
CollectionUser::save(&user.uuid, &collection.uuid, false, false, true, &conn).await.unwrap();
assert!(custom_member_may(CollectionAction::Edit, &member, &collection.uuid, &conn).await);
});
}
}
+32 -5
View File
@@ -25,7 +25,8 @@ use macros::UuidFromParam;
use super::{
Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES,
MembershipStatus, OrganizationId, User, UserId,
organization::{ORG_ADMIN_ATYPES, custom_edit_any_collection},
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -230,6 +231,23 @@ impl Cipher {
.await
}
pub async fn to_json_org_admin_collections(
&self,
host: &str,
user_uuid: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
self.to_json_scoped(
host,
user_uuid,
None,
CipherSyncType::Organization,
CipherAccessScope::OrganizationAdmin,
conn,
)
.await
}
async fn to_json_scoped(
&self,
host: &str,
@@ -352,7 +370,7 @@ impl Cipher {
Cow::from(Vec::new())
}
} else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await)
Cow::from(self.get_admin_collections(user_uuid.clone(), scope, conn).await)
};
// There are three types of cipher response models in upstream
@@ -1194,7 +1212,12 @@ impl Cipher {
}
}
pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec<CollectionId> {
pub async fn get_admin_collections(
&self,
user_uuid: UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> Vec<CollectionId> {
if CONFIG.org_groups_enabled() {
conn.run(move |conn| {
ciphers_collections::table
@@ -1234,7 +1257,9 @@ impl Cipher {
.or(collections_groups::collections_uuid
.is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or(custom_edit_any_collection()
.and::<_, diesel::sql_types::Bool>(scope == CipherAccessScope::OrganizationAdmin)),
)
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
@@ -1263,7 +1288,9 @@ 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.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner
.or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner
.or(custom_edit_any_collection()
.and::<_, diesel::sql_types::Bool>(scope == CipherAccessScope::OrganizationAdmin)),
)
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
+42 -10
View File
@@ -18,7 +18,7 @@ use macros::UuidFromParam;
use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
User, UserId, organization::ORG_ADMIN_ATYPES,
User, UserId, custom_roles_enabled, organization::ORG_ADMIN_ATYPES,
};
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@@ -109,18 +109,22 @@ impl Collection {
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => {
// Only let a manager manage collections when the have full read/write access
// A Custom member only manages a collection with the manage option
let is_manager = m.atype == MembershipType::Manager;
let is_custom = m.has_type(MembershipType::Custom) && custom_roles_enabled();
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
(
cu.read_only,
cu.hide_passwords,
is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)),
(is_manager && (cu.manage || (!cu.read_only && !cu.hide_passwords)))
|| (is_custom && cu.manage),
)
} else if let Some(cg) = cipher_sync_data.user_collections_groups.get(&self.uuid) {
(
cg.read_only,
cg.hide_passwords,
is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)),
(is_manager && (cg.manage || (!cg.read_only && !cg.hide_passwords)))
|| (is_custom && cg.manage),
)
} else {
(false, false, false)
@@ -134,6 +138,17 @@ impl Collection {
Some(m) if m.atype == MembershipType::Manager && self.is_manageable_by_user(user_uuid, conn).await => {
(false, false, true)
}
// A Custom member reads and writes every collection with Edit any collection in the Admin Console, but
// only manages a collection with the manage option on it
Some(m) if m.has_type(MembershipType::Custom) && custom_roles_enabled() => {
let manage = self.is_explicitly_manageable_by_user(user_uuid, conn).await;
if manage || m.can_edit_any_collection() {
(false, false, manage)
} else {
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
(read_only, self.hide_passwords_for_user(user_uuid, conn).await, false)
}
}
Some(m) => {
let is_manager = m.atype == MembershipType::Manager;
let read_only = !self.is_writable_by_user(user_uuid, conn).await;
@@ -635,8 +650,23 @@ impl Collection {
user_uuid: &UserId,
conn: &DbConn,
) -> bool {
!Self::find_explicitly_manageable_by_user(org_uuid, user_uuid, conn).await.is_empty()
}
pub async fn is_explicitly_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::find_explicitly_manageable_by_user(&self.org_uuid, user_uuid, conn).await.contains(&self.uuid)
}
// The collections of the org the user has manage access to by the manage option, directly or via a group.
// Unlike is_coll_manageable_by_user, neither the role nor any access_all counts.
pub async fn find_explicitly_manageable_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
conn: &DbConn,
) -> Vec<CollectionId> {
let org_uuid = org_uuid.to_string();
let user_uuid = user_uuid.to_string();
let groups_enabled = CONFIG.org_groups_enabled();
conn.run(move |conn| {
collections::table
.left_join(
@@ -658,14 +688,16 @@ impl Collection {
.filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
users_collections::manage.eq(true).or(users_collections::user_uuid
.is_null()
.or(users_organizations::atype.eq(MembershipType::Manager as i32))
.and(collections_groups::manage.eq(true))
.and::<_, diesel::sql_types::Bool>(groups_enabled)),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
.select(collections::uuid)
.distinct()
.load::<CollectionId>(conn)
.unwrap_or_default()
})
.await
}
+2
View File
@@ -33,6 +33,8 @@ pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher, FolderId};
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
#[cfg(all(test, sqlite))]
pub(crate) use self::organization::test_flag;
pub use self::organization::{
CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization,
OrganizationApiKey, OrganizationId, custom_roles_enabled,
+204 -90
View File
@@ -24,8 +24,8 @@ use crate::{
use macros::UuidFromParam;
use super::{
Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
OrgPolicyType, TwoFactor, User, UserId,
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType,
TwoFactor, User, UserId,
};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@@ -134,22 +134,28 @@ impl MembershipType {
/// 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.
/// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled, in which case
/// `custom_permissions` are the permissions requested along with it. An existing Manager or Custom member
/// keeps their role when the request repeats it, so an unrelated edit never turns one into the other. A
/// Manager only becomes a Custom member when the permissions they are shown with are changed.
pub fn requested(
requested: MembershipType,
current: Option<&Membership>,
custom_roles_enabled: bool,
custom_permissions: Option<&HashSet<CustomPermission>>,
) -> 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,
match (current, custom_permissions) {
(Some(member), _) if member.has_type(MembershipType::Custom) => MembershipType::Custom,
(Some(member), Some(permissions))
if member.has_type(MembershipType::Manager) && *permissions != member.legacy_manager_permissions() =>
{
MembershipType::Custom
}
(Some(member), _) if member.has_type(MembershipType::Manager) => MembershipType::Manager,
(_, Some(_)) => MembershipType::Custom,
(_, None) => MembershipType::Manager,
}
}
@@ -240,7 +246,11 @@ 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.
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[];
const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
];
impl CustomPermission {
pub const ALL: [CustomPermission; 9] = [
@@ -306,9 +316,38 @@ pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, Mem
/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise.
pub fn custom_roles_enabled() -> bool {
#[cfg(test)]
if let Some(enabled) = test_flag::CUSTOM_ROLES_ENABLED.get() {
return enabled;
}
CONFIG.experimental_custom_roles()
}
type CustomEditAnyCollection = diesel::dsl::And<
diesel::dsl::And<
diesel::dsl::Eq<users_organizations::atype, i32>,
diesel::dsl::Eq<users_organizations::edit_any_collection, bool>,
>,
bool,
diesel::sql_types::Bool,
>;
/// SQL: a Custom member with Edit any collection, while Custom roles are enabled.
pub(super) fn custom_edit_any_collection() -> CustomEditAnyCollection {
users_organizations::atype
.eq(MembershipType::Custom as i32)
.and(users_organizations::edit_any_collection.eq(true))
.and::<_, diesel::sql_types::Bool>(custom_roles_enabled())
}
#[cfg(test)]
pub(crate) mod test_flag {
thread_local! {
/// Overrides EXPERIMENTAL_CUSTOM_ROLES for the tests running on this thread.
pub static CUSTOM_ROLES_ENABLED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
}
/// Local methods
impl Organization {
pub fn new(name: String, billing_email: &str, private_key: Option<String>, public_key: Option<String>) -> Self {
@@ -452,40 +491,36 @@ impl Membership {
}
}
/// 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> {
/// Why this legacy Manager can't become a Custom member with `permissions` without losing what they may do now, if
/// they can't. A conversion must not silently change their access. A Custom role can't keep access to all
/// collections, which also puts every collection in the Manager's own vault, unless the permissions it is shown as
/// are given up too. And it only manages every collection with Edit any collection, not through a group with access
/// to all collections, which stays dynamic. The collections they manage with the manage permission stay managed.
pub async fn custom_role_conversion_loss(
&self,
permissions: &HashSet<CustomPermission>,
conn: &DbConn,
) -> Option<&'static str> {
if !self.has_type(MembershipType::Manager) {
return None;
}
if self.access_all {
if self.access_all && permissions.is_superset(&self.legacy_manager_permissions()) {
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.",
aren't assigned to would disappear from their vault. Uncheck Manage all collections for them to give \
that access up, 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);
if !permissions.contains(&CustomPermission::EditAnyCollection)
&& GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await
{
return Some(
"This Manager manages every collection through a group with access to all collections, which a Custom \
member only does with Edit any collection. Grant that permission, or remove them from the group \
first.",
);
}
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.",
)
None
}
fn custom_permission(&self, permission: CustomPermission) -> bool {
@@ -517,6 +552,41 @@ impl Membership {
self.has_status(MembershipStatus::Confirmed) && self.grants_custom_permission(permission)
}
/// The permissions the "Manage all collections" a legacy Manager is shown with stands for: their `access_all`.
pub fn legacy_manager_permissions(&self) -> HashSet<CustomPermission> {
if self.has_type(MembershipType::Manager) && self.access_all {
HashSet::from([
CustomPermission::CreateNewCollections,
CustomPermission::EditAnyCollection,
CustomPermission::DeleteAnyCollection,
])
} else {
HashSet::new()
}
}
/// The permissions the clients are shown, which a legacy Manager is shown as a Custom member with.
pub fn shown_custom_permissions(&self) -> HashSet<CustomPermission> {
if self.has_type(MembershipType::Manager) {
self.legacy_manager_permissions()
} else {
CustomPermission::ALL.into_iter().filter(|p| self.grants_custom_permission(*p)).collect()
}
}
fn custom_permissions_json(&self) -> Value {
let shown = self.shown_custom_permissions();
let mut permissions = json!({
"manageSso": false, // Not supported
"manageResetPassword": false,
"manageScim": false, // Not supported (Not AGPLv3 Licensed)
});
for permission in CustomPermission::ALL {
permissions[permission.json_key()] = json!(shown.contains(&permission));
}
permissions
}
pub fn restore(&mut self) -> bool {
if self.status < MembershipStatus::Invited as i32 {
self.status += ACTIVATE_REVOKE_DIFF;
@@ -685,24 +755,8 @@ impl Membership {
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
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
"accessEventLogs": false,
"accessImportExport": false,
"accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission
"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,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
});
// A Manager is shown with the 3 Collection permissions set, when they have the access_all permission
let permissions = self.custom_permissions_json();
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({
@@ -832,29 +886,11 @@ impl Membership {
// It will be converted back on other locations
let membership_type = self.type_manager_as_custom();
// 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 {
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,
"manageSso": false, // Not supported
"manageUsers": false,
"manageResetPassword": false,
"manageScim": false // Not supported (Not AGPLv3 Licensed)
})
} else {
// Only return permissions if there are any, else Bitwarden will assume the defaults of all false
let permissions = if self.shown_custom_permissions().is_empty() {
json!(null)
} else {
self.custom_permissions_json()
};
json!({
@@ -1042,6 +1078,12 @@ impl Membership {
self.has_full_access() || self.has_custom_permission(CustomPermission::EditAnyCollection)
}
/// Whether this member may see every collection and who has access to them in the Admin Console. Deleting any
/// collection needs that, but not the items in them.
pub fn can_view_all_collections(&self) -> bool {
self.can_edit_any_collection() || self.has_custom_permission(CustomPermission::DeleteAnyCollection)
}
pub async fn find_by_uuid(uuid: &MembershipId, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| {
users_organizations::table.filter(users_organizations::uuid.eq(uuid)).first::<Self>(conn).ok()
@@ -1164,6 +1206,8 @@ impl Membership {
}
// Get all users which are either owner or admin, or a manager which can manage/access all
// A Custom member with Edit any collection is not listed: the Admin Console sends this list back when a collection
// is saved, which would assign them to it for good, while the others are skipped there for their access_all.
pub async fn find_confirmed_and_manage_all_by_org(org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_organizations::table
@@ -1484,6 +1528,7 @@ pub struct OrgApiKeyId(String);
#[cfg(test)]
mod tests {
use super::*;
use CustomPermission::*;
use MembershipType::{Admin, Custom, Manager, Owner, User};
fn member(role: MembershipType) -> Membership {
@@ -1513,21 +1558,33 @@ mod tests {
#[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 none = HashSet::new();
let edit = HashSet::from([EditAnyCollection]);
let legacy = HashSet::from([CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
for (current, all, permissions, disabled, enabled) in [
(None, false, &none, Manager, Custom),
(Some(User), false, &edit, Manager, Custom),
(Some(Owner), false, &none, Manager, Custom),
(Some(Manager), false, &none, Manager, Manager),
(Some(Manager), true, &legacy, Manager, Manager),
(Some(Manager), false, &edit, Manager, Custom),
(Some(Manager), true, &none, Manager, Custom),
(Some(Manager), true, &edit, Manager, Custom),
(Some(Custom), false, &none, 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);
let current = current.map(|role| {
let mut m = member(role);
m.access_all = all;
m
});
for (permissions, expected) in [(None, disabled), (Some(permissions), enabled)] {
assert_eq!(MembershipType::requested(Custom, current.as_ref(), permissions), expected);
}
}
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User);
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User);
assert_eq!(MembershipType::requested(User, Some(&member(Custom)), None), User);
assert_eq!(MembershipType::requested(User, Some(&member(Manager)), None), User);
for requested in [Owner, Admin, Manager] {
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested);
assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), None), requested);
}
}
@@ -1546,7 +1603,7 @@ mod tests {
}
}
}
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []);
assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, [CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
}
#[test]
@@ -1573,4 +1630,61 @@ mod tests {
assert_eq!(member.access_all, to != Custom);
}
}
#[test]
fn permission_matrix_preserves_role_status_flag_and_collection_boundaries() {
let legacy = HashSet::from([CreateNewCollections, EditAnyCollection, DeleteAnyCollection]);
for (role, all) in
[(Owner, false), (Admin, false), (Manager, false), (Manager, true), (Custom, false), (User, false)]
{
for permission in CustomPermission::ALL {
let mut member = member(role);
member.access_all = all;
*member.custom_permission_mut(permission) = true;
for enabled in [false, true] {
test_flag::CUSTOM_ROLES_ENABLED.set(Some(enabled));
let shown = if role == Manager && all {
legacy.clone()
} else if role == Custom && enabled && permission.is_supported() {
HashSet::from([permission])
} else {
HashSet::new()
};
assert_eq!(member.shown_custom_permissions(), shown);
for status in [-128, -127, -126, -3, -2, -1, 0, 1, 2] {
member.status = status;
let allowed = enabled && role == Custom && status == 2 && permission.is_supported();
let full = status == 2 && (role == Owner || role == Admin || all);
let edit = full || (allowed && permission == EditAnyCollection);
let view = full || (allowed && matches!(permission, EditAnyCollection | DeleteAnyCollection));
assert_eq!(
(
member.has_custom_permission(permission),
member.can_edit_any_collection(),
member.can_view_all_collections()
),
(allowed, edit, view),
"{role:?} {all} {enabled} {status} {permission:?}"
);
}
}
}
}
for raw in [99, -1] {
let mut member = member(Custom);
member.atype = raw;
for p in CustomPermission::ALL {
*member.custom_permission_mut(p) = true;
}
for enabled in [false, true] {
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());
}
}
let empty = member(Custom);
assert!(!empty.can_edit_any_collection() && !empty.can_view_all_collections());
assert!(empty.shown_custom_permissions().is_empty());
test_flag::CUSTOM_ROLES_ENABLED.set(None);
}
}