From 521669ec8a78e2b2cef3a5090718c667a65374c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:58:36 +0200 Subject: [PATCH 1/3] feat: add collection management settings and persistance TODO: implement security checks --- .../down.sql | 4 ++ .../up.sql | 4 ++ .../down.sql | 4 ++ .../up.sql | 4 ++ .../down.sql | 4 ++ .../up.sql | 4 ++ src/api/core/organizations.rs | 56 +++++++++++++++++++ src/db/models/organization.rs | 27 ++++++--- src/db/schema.rs | 4 ++ .../templates/scss/vaultwarden.scss.hbs | 5 -- 10 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 migrations/mysql/2026-07-21-120000_add_collection_management/down.sql create mode 100644 migrations/mysql/2026-07-21-120000_add_collection_management/up.sql create mode 100644 migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql create mode 100644 migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql create mode 100644 migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql create mode 100644 migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql diff --git a/migrations/mysql/2026-07-21-120000_add_collection_management/down.sql b/migrations/mysql/2026-07-21-120000_add_collection_management/down.sql new file mode 100644 index 00000000..300a4665 --- /dev/null +++ b/migrations/mysql/2026-07-21-120000_add_collection_management/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE organizations DROP COLUMN allow_admin_access_to_all_collection_items; +ALTER TABLE organizations DROP COLUMN limit_collection_creation; +ALTER TABLE organizations DROP COLUMN limit_collection_deletion; +ALTER TABLE organizations DROP COLUMN limit_item_deletion; \ No newline at end of file diff --git a/migrations/mysql/2026-07-21-120000_add_collection_management/up.sql b/migrations/mysql/2026-07-21-120000_add_collection_management/up.sql new file mode 100644 index 00000000..276752b8 --- /dev/null +++ b/migrations/mysql/2026-07-21-120000_add_collection_management/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE organizations ADD COLUMN allow_admin_access_to_all_collection_items BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE organizations ADD COLUMN limit_collection_creation BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE organizations ADD COLUMN limit_collection_deletion BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE organizations ADD COLUMN limit_item_deletion BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql b/migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql new file mode 100644 index 00000000..300a4665 --- /dev/null +++ b/migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE organizations DROP COLUMN allow_admin_access_to_all_collection_items; +ALTER TABLE organizations DROP COLUMN limit_collection_creation; +ALTER TABLE organizations DROP COLUMN limit_collection_deletion; +ALTER TABLE organizations DROP COLUMN limit_item_deletion; \ No newline at end of file diff --git a/migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql b/migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql new file mode 100644 index 00000000..276752b8 --- /dev/null +++ b/migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE organizations ADD COLUMN allow_admin_access_to_all_collection_items BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE organizations ADD COLUMN limit_collection_creation BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE organizations ADD COLUMN limit_collection_deletion BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE organizations ADD COLUMN limit_item_deletion BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql b/migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql new file mode 100644 index 00000000..300a4665 --- /dev/null +++ b/migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE organizations DROP COLUMN allow_admin_access_to_all_collection_items; +ALTER TABLE organizations DROP COLUMN limit_collection_creation; +ALTER TABLE organizations DROP COLUMN limit_collection_deletion; +ALTER TABLE organizations DROP COLUMN limit_item_deletion; \ No newline at end of file diff --git a/migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql b/migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql new file mode 100644 index 00000000..276752b8 --- /dev/null +++ b/migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE organizations ADD COLUMN allow_admin_access_to_all_collection_items BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE organizations ADD COLUMN limit_collection_creation BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE organizations ADD COLUMN limit_collection_deletion BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE organizations ADD COLUMN limit_item_deletion BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c7e79aed..2cfbb3b4 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -39,6 +39,7 @@ pub fn routes() -> Vec { get_collection_users, put_organization, post_organization, + put_organization_collection_management, post_organization_collections, post_bulk_access_collections, post_organization_collection_update, @@ -126,6 +127,15 @@ struct OrganizationUpdateData { name: String, } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct OrganizationCollectionManagementUpdateData { + allow_admin_access_to_all_collection_items: Option, + limit_collection_creation: Option, + limit_collection_deletion: Option, + limit_item_deletion: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct FullCollectionData { @@ -340,6 +350,52 @@ async fn post_organization( Ok(Json(org.to_json())) } +#[put("/organizations//collection-management", data = "")] +async fn put_organization_collection_management( + org_id: OrganizationId, + headers: OwnerHeaders, + data: Json, + conn: DbConn, +) -> JsonResult { + if org_id != headers.org_id { + err!("Organization not found", "Organization id's do not match"); + } + + let data: OrganizationCollectionManagementUpdateData = data.into_inner(); + + let Some(mut org) = Organization::find_by_uuid(&org_id, &conn).await else { + err!("Organization not found") + }; + + if let Some(flag) = data.allow_admin_access_to_all_collection_items { + org.allow_admin_access_to_all_collection_items = flag; + }; + if let Some(flag) = data.limit_collection_creation { + org.limit_collection_creation = flag; + }; + if let Some(flag) = data.limit_collection_deletion { + org.limit_collection_deletion = flag; + }; + if let Some(flag) = data.limit_item_deletion { + org.limit_item_deletion = flag; + }; + + org.save(&conn).await?; + + log_event( + EventType::OrganizationUpdated as i32, + org_id.as_ref(), + &org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + &conn, + ) + .await; + + Ok(Json(org.to_json())) +} + // GET /api/collections?writeOnly=false #[get("/collections")] async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72b1df0b..56cdd340 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -38,6 +38,11 @@ pub struct Organization { pub billing_email: String, pub private_key: Option, pub public_key: Option, + + pub allow_admin_access_to_all_collection_items: bool, + pub limit_collection_creation: bool, + pub limit_collection_deletion: bool, + pub limit_item_deletion: bool, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -193,6 +198,10 @@ impl Organization { billing_email, private_key, public_key, + allow_admin_access_to_all_collection_items: true, + limit_collection_creation: false, + limit_collection_deletion: false, + limit_item_deletion: false, } } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -219,9 +228,10 @@ impl Organization { "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), - "allowAdminAccessToAllCollectionItems": true, - "limitCollectionCreation": true, - "limitCollectionDeletion": true, + "allowAdminAccessToAllCollectionItems": self.allow_admin_access_to_all_collection_items, + "limitCollectionCreation": self.limit_collection_creation, + "limitCollectionDeletion": self.limit_collection_deletion, + "limitItemDeletion": self.limit_item_deletion, "businessName": self.name, "businessAddress1": null, @@ -509,11 +519,12 @@ 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, - "limitCollectionDeletion": true, - "limitItemDeletion": false, - "allowAdminAccessToAllCollectionItems": true, + + "allowAdminAccessToAllCollectionItems": org.allow_admin_access_to_all_collection_items, + "limitCollectionCreation": org.limit_collection_creation, + "limitCollectionDeletion": org.limit_collection_deletion, + "limitItemDeletion": org.limit_item_deletion, + "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..c146b23d 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -126,6 +126,10 @@ table! { billing_email -> Text, private_key -> Nullable, public_key -> Nullable, + allow_admin_access_to_all_collection_items -> Bool, + limit_collection_creation -> Bool, + limit_collection_deletion -> Bool, + limit_item_deletion -> Bool, } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 477cdd34..e7fbb3c8 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -100,11 +100,6 @@ app-organization-plans > form > bit-section:nth-child(2) { @extend %vw-hide; } -/* Hide Collection Management Form */ -app-org-account form.ng-untouched:nth-child(5) { - @extend %vw-hide; -} - /* Hide 'Member Access' Report Card from Org Reports */ app-org-reports-home > app-report-list > div.tw-inline-grid > div:nth-child(6) { @extend %vw-hide; From c5a080dd0c3c5856e41647116632255dc0f023ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:36:22 +0200 Subject: [PATCH 2/3] feat: implement `limitItemDeletion` checks --- src/api/core/ciphers.rs | 5 ++--- src/db/models/cipher.rs | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 14e9f72f..bc7fd895 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -13,8 +13,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, - auth::ClientVersion, - auth::{Headers, OrgIdGuard, OwnerHeaders}, + auth::{ClientVersion, Headers, OrgIdGuard, OwnerHeaders}, config::PathType, crypto, db::{ @@ -1778,7 +1777,7 @@ async fn delete_cipher_by_uuid( err!("Cipher doesn't exist") }; - if !cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await { + if !cipher.is_deletable_by_user(&headers.user.uuid, conn).await { err!("Cipher can't be deleted by user") } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 8357c9eb..1cfc81ba 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -25,7 +25,7 @@ use macros::UuidFromParam; use super::{ Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, MembershipType, OrganizationId, User, UserId, + MembershipStatus, MembershipType, Organization, OrganizationId, User, UserId, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -723,6 +723,21 @@ impl Cipher { } } + pub async fn is_deletable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { + let manage_required = match self.organization_uuid { + Some(ref org_id) => match Organization::find_by_uuid(&org_id, &conn).await { + Some(org) => org.limit_item_deletion, + None => false, + }, + None => false, + }; + + match self.get_access_restrictions(user_uuid, None, conn).await { + Some((read_only, _hide_passwords, manage)) => (!manage_required && !read_only) || manage, + None => false, + } + } + // used for checking if collection can be edited (only if user has access to a collection they // can write to and also passwords are not hidden to prevent privilege escalation) pub async fn is_in_editable_collection_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { From 5e7cf66d67d3546fabb4c73ecd5e061596ccb235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:11:07 +0200 Subject: [PATCH 3/3] fix: reflect ability to delete in display apis --- src/db/models/cipher.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 1cfc81ba..f8e8545e 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -187,6 +187,8 @@ impl Cipher { (false, false, false) }; + let can_delete = self.is_deletable_by_user(user_uuid, conn).await; + let fields_json: Vec<_> = self .fields .as_ref() @@ -393,8 +395,8 @@ impl Cipher { json_object["viewPassword"] = json!(!hide_passwords); // The new key used by clients since v2025.6.0 json_object["permissions"] = json!({ - "delete": !read_only, - "restore": !read_only, + "delete": can_delete && self.deleted_at.is_none(), + "restore": can_delete && self.deleted_at.is_none(), }); }