Browse Source

Add "Create new collections" custom role permission

Bitwarden's custom role has a `Create new collections` permission which allows a
member to create collections without giving them access to every collection in
the organization. The three collection options were collapsed into the single
access_all flag and all of them had to be checked to set it, so that role could
not be configured.

Store the permission separately in a new create_new_collections column on
users_organizations so it can be granted on its own:

- `Manage all collections` is now represented by `Edit any collection` and
  `Delete any collection` alone, since those are the only options the parent
  checkbox still drives
- `Create new collections` is read and stored independently, and is implied by
  access_all
- post_organization_collections accepts the request when the member has either
  flag, and assigns the creator to the new collection so a member without
  access_all does not lose access to it right after creating it, unless they
  already assigned themselves
- limitCollectionCreation is reported accordingly so the clients enable the
  `New collection` button

The nested `Create new collections` option was hidden by the web-vault CSS
overrides, so show it as a stand-alone option. `Edit any collection` and
`Delete any collection` stay hidden, as those are still only supported together
through access_all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull/7547/head
Brandon George 2 months ago
parent
commit
81be0c9a76
  1. 1
      migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/down.sql
  2. 1
      migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/up.sql
  3. 1
      migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/down.sql
  4. 1
      migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/up.sql
  5. 1
      migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/down.sql
  6. 1
      migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/up.sql
  7. 38
      src/api/core/organizations.rs
  8. 33
      src/db/models/organization.rs
  9. 1
      src/db/schema.rs
  10. 15
      src/static/templates/scss/vaultwarden.scss.hbs

1
migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN create_new_collections;

1
migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/up.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;

1
migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN create_new_collections;

1
migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/up.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;

1
migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/down.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations DROP COLUMN create_new_collections;

1
migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/up.sql

@ -0,0 +1 @@
ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE;

38
src/api/core/organizations.rs

@ -506,10 +506,20 @@ async fn post_organization_collections(
let data: FullCollectionData = data.into_inner(); let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?; data.validate(&org_id, &conn).await?;
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all { if headers.membership.atype == MembershipType::Manager
&& !headers.membership.access_all
&& !headers.membership.create_new_collections
{
err!("You don't have permission to create collections") err!("You don't have permission to create collections")
} }
// A member with only the `Create new collections` permission does not have access to all
// collections, so they need to be assigned to the collection they just created. Otherwise they
// would lose access to it right after creating it. Skip this when they already assigned
// themselves, so the access they requested is not overwritten.
let assign_creator =
!headers.membership.has_full_access() && !data.users.iter().any(|u| u.id == headers.membership.uuid);
let collection = Collection::new(org_id.clone(), data.name, data.external_id); let collection = Collection::new(org_id.clone(), data.name, data.external_id);
collection.save(&conn).await?; collection.save(&conn).await?;
@ -550,6 +560,10 @@ async fn post_organization_collections(
.await?; .await?;
} }
if assign_creator {
CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, true, &conn).await?;
}
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
} }
@ -1068,12 +1082,18 @@ async fn send_invite(
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // 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 // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// Only `Edit any collection` and `Delete any collection` represent it, as `Create new collections`
// is a stand-alone permission which can be set without checking the parent checkbox
// If the box is not checked, the user will still be a manager, but not with the access_all permission // 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 let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4") || (raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)));
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
// The `Create new collections` permission is stored independently so a custom user can be
// allowed to create collections without gaining access to all of them. `access_all` implies it.
let create_new_collections =
access_all || (raw_type.eq("4") && data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut user_created: bool = false; let mut user_created: bool = false;
for email in &data.emails { for email in &data.emails {
@ -1116,6 +1136,7 @@ async fn send_invite(
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.access_all = access_all; new_member.access_all = access_all;
new_member.create_new_collections = create_new_collections;
new_member.atype = new_type; new_member.atype = new_type;
new_member.status = member_status; new_member.status = member_status;
new_member.save(&conn).await?; new_member.save(&conn).await?;
@ -1562,12 +1583,18 @@ async fn edit_member(
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // 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 // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// Only `Edit any collection` and `Delete any collection` represent it, as `Create new collections`
// is a stand-alone permission which can be set without checking the parent checkbox
// If the box is not checked, the user will still be a manager, but not with the access_all permission // 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 let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4") || (raw_type.eq("4")
&& data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("editAnyCollection") == Some(&json!(true))
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)));
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
// The `Create new collections` permission is stored independently so a custom user can be
// allowed to create collections without gaining access to all of them. `access_all` implies it.
let create_new_collections =
access_all || (raw_type.eq("4") && 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 { 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") err!("The specified user isn't member of the organization")
@ -1595,6 +1622,7 @@ async fn edit_member(
} }
member_to_edit.access_all = access_all; member_to_edit.access_all = access_all;
member_to_edit.create_new_collections = create_new_collections;
member_to_edit.atype = new_type as i32; member_to_edit.atype = new_type as i32;
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type

33
src/db/models/organization.rs

@ -57,6 +57,7 @@ pub struct Membership {
pub atype: i32, pub atype: i32,
pub reset_password_key: Option<String>, pub reset_password_key: Option<String>,
pub external_id: Option<String>, pub external_id: Option<String>,
pub create_new_collections: bool,
} }
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -274,6 +275,7 @@ impl Membership {
atype: MembershipType::User as i32, atype: MembershipType::User as i32,
reset_password_key: None, reset_password_key: None,
external_id: None, external_id: None,
create_new_collections: false,
} }
} }
@ -457,12 +459,14 @@ impl Membership {
let permissions = json!({ let permissions = json!({
// TODO: Add full support for Custom User Roles // TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role // 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 // Currently we use the custom role as a manager role and link the edit/delete Collection roles to mimic the access_all permission
"accessEventLogs": false, "accessEventLogs": false,
"accessImportExport": false, "accessImportExport": false,
"accessReports": false, "accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission // `createNewCollections` is stored independently so a custom user can be allowed to create
"createNewCollections": membership_type == 4 && self.access_all, // collections without gaining access to all of them. If `editAnyCollection` and
// `deleteAnyCollection` are also set the user gets the full `access_all` permission.
"createNewCollections": membership_type == 4 && (self.access_all || self.create_new_collections),
"editAnyCollection": membership_type == 4 && self.access_all, "editAnyCollection": membership_type == 4 && self.access_all,
"deleteAnyCollection": membership_type == 4 && self.access_all, "deleteAnyCollection": membership_type == 4 && self.access_all,
"manageGroups": false, "manageGroups": false,
@ -522,8 +526,9 @@ impl Membership {
"familySponsorshipValidUntil": null, "familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null, "familySponsorshipToDelete": null,
"accessSecretsManager": false, "accessSecretsManager": false,
// limit collection creation to managers with access_all permission to prevent issues // limit collection creation to managers with either the access_all or the
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all, // create_new_collections permission to prevent issues
"limitCollectionCreation": self.atype < MembershipType::Manager || !(self.access_all || self.create_new_collections),
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false, "limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
@ -624,20 +629,22 @@ impl Membership {
// It will be converted back on other locations // It will be converted back on other locations
let membership_type = self.type_manager_as_custom(); let membership_type = self.type_manager_as_custom();
// HACK: Only return permissions if the user is of type custom and has access_all // HACK: Only return permissions if the user is of type custom and has access_all or the
// Else Bitwarden will assume the defaults of all false // create_new_collections permission. Else Bitwarden will assume the defaults of all false
let permissions = if membership_type == 4 && self.access_all { let permissions = if membership_type == 4 && (self.access_all || self.create_new_collections) {
json!({ json!({
// TODO: Add full support for Custom User Roles // TODO: Add full support for Custom User Roles
// See: https://bitwarden.com/help/article/user-types-access-control/#custom-role // 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 // Currently we use the custom role as a manager role and link the edit/delete Collection roles to mimic the access_all permission
"accessEventLogs": false, "accessEventLogs": false,
"accessImportExport": false, "accessImportExport": false,
"accessReports": false, "accessReports": false,
// If the following 3 Collection roles are set to true a custom user has access all permission // `createNewCollections` is stored independently so a custom user can be allowed to create
"createNewCollections": true, // collections without gaining access to all of them. If `editAnyCollection` and
"editAnyCollection": true, // `deleteAnyCollection` are also set the user gets the full `access_all` permission.
"deleteAnyCollection": true, "createNewCollections": self.access_all || self.create_new_collections,
"editAnyCollection": self.access_all,
"deleteAnyCollection": self.access_all,
"manageGroups": false, "manageGroups": false,
"managePolicies": false, "managePolicies": false,
"manageSso": false, // Not supported "manageSso": false, // Not supported

1
src/db/schema.rs

@ -242,6 +242,7 @@ table! {
atype -> Integer, atype -> Integer,
reset_password_key -> Nullable<Text>, reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
create_new_collections -> Bool,
} }
} }

15
src/static/templates/scss/vaultwarden.scss.hbs

@ -116,11 +116,22 @@ app-security > app-two-factor-setup > form {
} }
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
: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"]),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { /* The options nested below `Manage all collections` are `Create new collections`, `Edit any
collection` and `Delete any collection`, in that order. Only the first one is supported, so hide
the two which follow it */
:is(bit-dialog, [bit-dialog]) app-nested-checkbox div.tw-ml-4 > div:nth-child(n + 2),
/* Keep all other nested options hidden */
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input):not(app-nested-checkbox *) {
@extend %vw-hide; @extend %vw-hide;
} }
/* Present `Create new collections` as a stand-alone option instead of a nested one, since the two
options it is nested with are hidden */
:is(bit-dialog, [bit-dialog]) app-nested-checkbox div.tw-ml-4 {
margin-left: 0;
}
/* Change collapsed menu icon to Vaultwarden */ /* Change collapsed menu icon to Vaultwarden */
bit-nav-logo bit-nav-item a:before { bit-nav-logo bit-nav-item a:before {
content: ""; content: "";

Loading…
Cancel
Save