mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-14 00:36:04 +02:00
[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:
10 files changed
+795
-268
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user