diff --git a/migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/down.sql b/migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/down.sql new file mode 100644 index 00000000..6e970a18 --- /dev/null +++ b/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; diff --git a/migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/up.sql b/migrations/mysql/2026-07-24-000000_add_create_new_collections_permission/up.sql new file mode 100644 index 00000000..0702ffb0 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/down.sql b/migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/down.sql new file mode 100644 index 00000000..6e970a18 --- /dev/null +++ b/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; diff --git a/migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/up.sql b/migrations/postgresql/2026-07-24-000000_add_create_new_collections_permission/up.sql new file mode 100644 index 00000000..0702ffb0 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/down.sql b/migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/down.sql new file mode 100644 index 00000000..6e970a18 --- /dev/null +++ b/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; diff --git a/migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/up.sql b/migrations/sqlite/2026-07-24-000000_add_create_new_collections_permission/up.sql new file mode 100644 index 00000000..0702ffb0 --- /dev/null +++ b/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; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 989ca47d..9b124812 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -506,10 +506,20 @@ async fn post_organization_collections( let data: FullCollectionData = data.into_inner(); 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") } + // 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); collection.save(&conn).await?; @@ -550,6 +560,10 @@ async fn post_organization_collections( .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)) } @@ -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 // 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 let access_all = new_type >= MembershipType::Admin || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); + && data.permissions.get("deleteAnyCollection") == 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; 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())); new_member.access_all = access_all; + new_member.create_new_collections = create_new_collections; new_member.atype = new_type; new_member.status = member_status; 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 // 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 let access_all = new_type >= MembershipType::Admin || (raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) - && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) - && data.permissions.get("createNewCollections") == Some(&json!(true))); + && data.permissions.get("deleteAnyCollection") == 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 { 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.create_new_collections = create_new_collections; 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 diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index bdb69864..abf9660e 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -57,6 +57,7 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + pub create_new_collections: bool, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -274,6 +275,7 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + create_new_collections: false, } } @@ -457,12 +459,14 @@ impl Membership { 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 + // 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, "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, + // `createNewCollections` is stored independently so a custom user can be allowed to create + // 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, "deleteAnyCollection": membership_type == 4 && self.access_all, "manageGroups": false, @@ -522,8 +526,9 @@ impl Membership { "familySponsorshipValidUntil": null, "familySponsorshipToDelete": null, "accessSecretsManager": false, - // limit collection creation to managers with access_all permission to prevent issues - "limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all, + // limit collection creation to managers with either the access_all or the + // create_new_collections permission to prevent issues + "limitCollectionCreation": self.atype < MembershipType::Manager || !(self.access_all || self.create_new_collections), "limitCollectionDeletion": true, "limitItemDeletion": false, "allowAdminAccessToAllCollectionItems": true, @@ -624,20 +629,22 @@ 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 { + // HACK: Only return permissions if the user is of type custom and has access_all or the + // create_new_collections permission. Else Bitwarden will assume the defaults of all false + let permissions = if membership_type == 4 && (self.access_all || self.create_new_collections) { 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 + // 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, "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, + // `createNewCollections` is stored independently so a custom user can be allowed to create + // collections without gaining access to all of them. If `editAnyCollection` and + // `deleteAnyCollection` are also set the user gets the full `access_all` permission. + "createNewCollections": self.access_all || self.create_new_collections, + "editAnyCollection": self.access_all, + "deleteAnyCollection": self.access_all, "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..eecdc19c 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -242,6 +242,7 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + create_new_collections -> Bool, } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 5bbe5db2..fb928ac3 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -116,11 +116,22 @@ app-security > app-two-factor-setup > form { } /* 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; } +/* 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 */ bit-nav-logo bit-nav-item a:before { content: "";