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 01/10] 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 02/10] 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 03/10] 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(), }); } From 888902cf81f7ba4d94dea355ee61940dcae6d5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:19:30 +0200 Subject: [PATCH 04/10] fix: change db schema for lighter queries The main issue this is solving is that most of the endpoints where the settings need to be checked do not need the rest of the Organization structure. Also, depending on the DB backend, this might be a storage size improvement compared to storing 4 bools. --- .../down.sql | 5 +- .../up.sql | 5 +- .../down.sql | 5 +- .../up.sql | 5 +- .../down.sql | 5 +- .../up.sql | 5 +- src/api/core/organizations.rs | 17 +-- src/db/models/cipher.rs | 4 +- src/db/models/organization.rs | 125 +++++++++++++++--- src/db/schema.rs | 5 +- 10 files changed, 122 insertions(+), 59 deletions(-) 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 index 300a4665..dbcd3e3f 100644 --- a/migrations/mysql/2026-07-21-120000_add_collection_management/down.sql +++ b/migrations/mysql/2026-07-21-120000_add_collection_management/down.sql @@ -1,4 +1 @@ -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 +ALTER TABLE organizations DROP COLUMN collection_settings; \ 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 index 276752b8..002c45f0 100644 --- a/migrations/mysql/2026-07-21-120000_add_collection_management/up.sql +++ b/migrations/mysql/2026-07-21-120000_add_collection_management/up.sql @@ -1,4 +1 @@ -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 +ALTER TABLE organizations ADD COLUMN collection_settings SMALLINT NOT NULL DEFAULT 1; \ 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 index 300a4665..dbcd3e3f 100644 --- a/migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql +++ b/migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql @@ -1,4 +1 @@ -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 +ALTER TABLE organizations DROP COLUMN collection_settings; \ 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 index 276752b8..002c45f0 100644 --- a/migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql +++ b/migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql @@ -1,4 +1 @@ -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 +ALTER TABLE organizations ADD COLUMN collection_settings SMALLINT NOT NULL DEFAULT 1; \ 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 index 300a4665..dbcd3e3f 100644 --- a/migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql +++ b/migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql @@ -1,4 +1 @@ -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 +ALTER TABLE organizations DROP COLUMN collection_settings; \ 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 index 276752b8..002c45f0 100644 --- a/migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql +++ b/migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql @@ -1,4 +1 @@ -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 +ALTER TABLE organizations ADD COLUMN collection_settings SMALLINT NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 2cfbb3b4..f77cceb3 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -367,18 +367,11 @@ async fn put_organization_collection_management( 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.collection_settings.allow_admin_access_to_all_collection_items = + data.allow_admin_access_to_all_collection_items.unwrap_or(false); + org.collection_settings.limit_collection_creation = data.limit_collection_creation.unwrap_or(false); + org.collection_settings.limit_collection_deletion = data.limit_collection_deletion.unwrap_or(false); + org.collection_settings.limit_item_deletion = data.limit_item_deletion.unwrap_or(false); org.save(&conn).await?; diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f8e8545e..9f1dd782 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -727,8 +727,8 @@ 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, + Some(ref org_id) => match Organization::find_collection_settings_by_uuid(&org_id, &conn).await { + Some(settings) => settings.limit_item_deletion, None => false, }, None => false, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 56cdd340..6a85ade0 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -5,8 +5,15 @@ use std::{ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; +use diesel::{ + deserialize::FromSql, + prelude::*, + serialize::{IsNull, ToSql}, + sql_types::SmallInt, +}; use num_traits::FromPrimitive; +#[cfg(feature = "sqlite")] +use num_traits::ToPrimitive; use serde_json::Value; use crate::{ @@ -28,6 +35,77 @@ use super::{ OrgPolicyType, TwoFactor, User, UserId, }; +#[derive(Debug, Clone, Copy, FromSqlRow, AsExpression)] +#[diesel(sql_type = SmallInt)] +pub struct OrganizationCollectionSettings { + pub allow_admin_access_to_all_collection_items: bool, + pub limit_collection_creation: bool, + pub limit_collection_deletion: bool, + pub limit_item_deletion: bool, +} + +impl From<&OrganizationCollectionSettings> for i16 { + fn from(value: &OrganizationCollectionSettings) -> Self { + (0b1 * (value.allow_admin_access_to_all_collection_items as i16)) + | (0b10 * (value.limit_collection_creation as i16)) + | (0b100 * (value.limit_collection_deletion as i16)) + | (0b1000 * (value.limit_item_deletion as i16)) + } +} + +#[cfg(feature = "sqlite")] +use diesel::sqlite::Sqlite; +#[cfg(feature = "sqlite")] +impl ToSql for OrganizationCollectionSettings +where + i16: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { + out.set_value(i16::from(self).to_i32()); + Ok(IsNull::No) + } +} + +#[cfg(feature = "postgresql")] +use diesel::pg::Pg; +#[cfg(feature = "postgresql")] +impl ToSql for OrganizationCollectionSettings +where + i16: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Pg>) -> diesel::serialize::Result { + >::to_sql(&i16::from(self), &mut out.reborrow()) + } +} + +#[cfg(feature = "mysql")] +use diesel::mysql::Mysql; +#[cfg(feature = "mysql")] +impl ToSql for OrganizationCollectionSettings +where + i16: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Mysql>) -> diesel::serialize::Result { + >::to_sql(&i16::from(self), &mut out.reborrow()) + } +} + +impl FromSql for OrganizationCollectionSettings +where + DB: diesel::backend::Backend, + i16: FromSql, +{ + fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result { + let val = i16::from_sql(bytes)?; + Ok(Self { + allow_admin_access_to_all_collection_items: (val & 0b1) != 0, + limit_collection_creation: (val & 0b10) != 0, + limit_collection_deletion: (val & 0b100) != 0, + limit_item_deletion: (val & 0b1000) != 0, + }) + } +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organizations)] #[diesel(treat_none_as_null = true)] @@ -39,10 +117,7 @@ pub struct Organization { 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, + pub collection_settings: OrganizationCollectionSettings, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -198,10 +273,12 @@ 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, + collection_settings: OrganizationCollectionSettings { + 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 @@ -228,10 +305,10 @@ impl Organization { "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), - "allowAdminAccessToAllCollectionItems": self.allow_admin_access_to_all_collection_items, - "limitCollectionCreation": self.limit_collection_creation, - "limitCollectionDeletion": self.limit_collection_deletion, - "limitItemDeletion": self.limit_item_deletion, + "allowAdminAccessToAllCollectionItems": self.collection_settings.allow_admin_access_to_all_collection_items, + "limitCollectionCreation": self.collection_settings.limit_collection_creation, + "limitCollectionDeletion": self.collection_settings.limit_collection_deletion, + "limitItemDeletion": self.collection_settings.limit_item_deletion, "businessName": self.name, "businessAddress1": null, @@ -406,6 +483,20 @@ impl Organization { conn.run(move |conn| organizations::table.filter(organizations::uuid.eq(uuid)).first::(conn).ok()).await } + pub async fn find_collection_settings_by_uuid( + uuid: &OrganizationId, + conn: &DbConn, + ) -> Option { + conn.run(move |conn| { + organizations::table + .filter(organizations::uuid.eq(uuid)) + .select(organizations::collection_settings) + .first::(conn) + .ok() + }) + .await + } + pub async fn find_by_name(name: &str, conn: &DbConn) -> Option { conn.run(move |conn| organizations::table.filter(organizations::name.eq(name)).first::(conn).ok()).await } @@ -520,10 +611,10 @@ impl Membership { "familySponsorshipToDelete": null, "accessSecretsManager": false, - "allowAdminAccessToAllCollectionItems": org.allow_admin_access_to_all_collection_items, - "limitCollectionCreation": org.limit_collection_creation, - "limitCollectionDeletion": org.limit_collection_deletion, - "limitItemDeletion": org.limit_item_deletion, + "allowAdminAccessToAllCollectionItems": org.collection_settings.allow_admin_access_to_all_collection_items, + "limitCollectionCreation": org.collection_settings.limit_collection_creation, + "limitCollectionDeletion": org.collection_settings.limit_collection_deletion, + "limitItemDeletion": org.collection_settings.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 c146b23d..17332cd4 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -126,10 +126,7 @@ 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, + collection_settings -> SmallInt, } } From be185837a070aa991c644189e8d82ec220c5e00b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:50:27 +0200 Subject: [PATCH 05/10] feat: implement `limitCollectionCreation` checks --- src/api/core/organizations.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index f77cceb3..15607f58 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -555,7 +555,13 @@ 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 { + let Some(org_settings) = Organization::find_collection_settings_by_uuid(&org_id, &conn).await else { + err!("Can't find organization details") + }; + + if headers.membership.atype == MembershipType::Manager + && (!headers.membership.access_all || org_settings.limit_collection_creation) + { err!("You don't have permission to create collections") } @@ -1854,6 +1860,10 @@ async fn post_org_import( if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } + let Some(org_settings) = Organization::find_collection_settings_by_uuid(&org_id, &conn).await else { + err!("Can't find organization details") + }; + let data: ImportData = data.into_inner(); // Validate the import before continuing @@ -1878,7 +1888,9 @@ async fn post_org_import( } else { // We do not allow users or managers which can not manage all collections to create new collections // If there is any collection other than an existing import collection, abort the import. - if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { + if headers.membership.atype <= MembershipType::Manager + && (!headers.membership.has_full_access() || org_settings.limit_collection_creation) + { err!(Compact, "The current user isn't allowed to create new collections") } let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); From 2d4843e92dbee3276782ae8b7b7c203539fc9291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:05:15 +0200 Subject: [PATCH 06/10] feat: implement `limitCollectionDeletion` checks --- src/api/core/organizations.rs | 9 +++++++++ src/auth.rs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 15607f58..d5f57eb1 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -767,9 +767,18 @@ async fn delete_organization_collection_impl( if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } + let Some(org_settings) = Organization::find_collection_settings_by_uuid(&org_id, &conn).await else { + err!("Can't find organization details") + }; + let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else { err!("Collection not found", "Collection does not exist or does not belong to this organization") }; + + if headers.membership_type <= MembershipType::Manager && org_settings.limit_collection_deletion { + err!("The current user isn't allowed to delete this collection") + } + log_event( EventType::CollectionDeleted as i32, &collection.uuid, diff --git a/src/auth.rs b/src/auth.rs index 88a59b4b..f12f0e91 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -868,6 +868,7 @@ pub struct ManagerHeaders { pub host: String, pub device: Device, pub user: User, + pub membership_type: MembershipType, pub ip: ClientIp, pub org_id: OrganizationId, } @@ -895,6 +896,7 @@ impl<'r> FromRequest<'r> for ManagerHeaders { host: headers.host, device: headers.device, user: headers.user, + membership_type: headers.membership_type, ip: headers.ip, org_id: headers.membership.org_uuid, }) @@ -975,6 +977,7 @@ impl ManagerHeaders { host: h.host, device: h.device, user: h.user, + membership_type: MembershipType::from_i32(h.membership.atype).unwrap(), ip: h.ip, org_id: h.membership.org_uuid, }) From f05f9eddf3a32bbe4528a3cbeca5e90935030d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:29:10 +0200 Subject: [PATCH 07/10] fix: clippy refactors --- src/api/core/organizations.rs | 2 +- src/db/models/cipher.rs | 2 +- src/db/models/organization.rs | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index d5f57eb1..caed3705 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -767,7 +767,7 @@ async fn delete_organization_collection_impl( if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } - let Some(org_settings) = Organization::find_collection_settings_by_uuid(&org_id, &conn).await else { + let Some(org_settings) = Organization::find_collection_settings_by_uuid(org_id, conn).await else { err!("Can't find organization details") }; diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 9f1dd782..ccc71f50 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -727,7 +727,7 @@ 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_collection_settings_by_uuid(&org_id, &conn).await { + Some(ref org_id) => match Organization::find_collection_settings_by_uuid(org_id, conn).await { Some(settings) => settings.limit_item_deletion, None => false, }, diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 6a85ade0..1d21dcc2 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -37,6 +37,7 @@ use super::{ #[derive(Debug, Clone, Copy, FromSqlRow, AsExpression)] #[diesel(sql_type = SmallInt)] +#[allow(clippy::struct_excessive_bools)] pub struct OrganizationCollectionSettings { pub allow_admin_access_to_all_collection_items: bool, pub limit_collection_creation: bool, @@ -46,10 +47,10 @@ pub struct OrganizationCollectionSettings { impl From<&OrganizationCollectionSettings> for i16 { fn from(value: &OrganizationCollectionSettings) -> Self { - (0b1 * (value.allow_admin_access_to_all_collection_items as i16)) - | (0b10 * (value.limit_collection_creation as i16)) - | (0b100 * (value.limit_collection_deletion as i16)) - | (0b1000 * (value.limit_item_deletion as i16)) + i16::from(value.allow_admin_access_to_all_collection_items) + | (0b10 * i16::from(value.limit_collection_creation)) + | (0b100 * i16::from(value.limit_collection_deletion)) + | (0b1000 * i16::from(value.limit_item_deletion)) } } From 9929b087c24e17d541f6f9f2a700031369b7de3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:17:46 +0200 Subject: [PATCH 08/10] fix: `limitItemDeletion`, `limitCollectionCreation` and `limitCollectionDeletion` display behaviour --- src/db/models/cipher.rs | 4 ++-- src/db/models/organization.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index ccc71f50..fcb7f30e 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -395,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": can_delete && self.deleted_at.is_none(), - "restore": can_delete && self.deleted_at.is_none(), + "delete": can_delete, + "restore": can_delete, }); } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 1d21dcc2..14e37621 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -557,9 +557,9 @@ impl Membership { "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": membership_type == 4 && self.access_all && !org.collection_settings.limit_collection_creation, "editAnyCollection": membership_type == 4 && self.access_all, - "deleteAnyCollection": membership_type == 4 && self.access_all, + "deleteAnyCollection": membership_type == 4 && self.access_all && !org.collection_settings.limit_collection_deletion, "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported From b6be6472f9b8a4be99a3bce111415def41998d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:06:05 +0200 Subject: [PATCH 09/10] fix: Change model to use i16 newtype for `OrganizationCollectionSettings` --- src/api/core/organizations.rs | 29 ++++++--- src/db/models/cipher.rs | 3 +- src/db/models/mod.rs | 4 +- src/db/models/organization.rs | 112 ++++++++++++---------------------- 4 files changed, 63 insertions(+), 85 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index caed3705..9d0262d3 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -6,9 +6,9 @@ use serde_json::Value; use crate::{ CONFIG, - api::admin::FAKE_ADMIN_UUID, api::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, + admin::FAKE_ADMIN_UUID, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite}, @@ -17,7 +17,8 @@ use crate::{ models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + OrgCollectionSetting, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, + UserId, }, }, mail, @@ -367,11 +368,16 @@ async fn put_organization_collection_management( err!("Organization not found") }; - org.collection_settings.allow_admin_access_to_all_collection_items = - data.allow_admin_access_to_all_collection_items.unwrap_or(false); - org.collection_settings.limit_collection_creation = data.limit_collection_creation.unwrap_or(false); - org.collection_settings.limit_collection_deletion = data.limit_collection_deletion.unwrap_or(false); - org.collection_settings.limit_item_deletion = data.limit_item_deletion.unwrap_or(false); + org.collection_settings.set_flag( + OrgCollectionSetting::AllowAdminAccessToAllCollectionItems, + data.allow_admin_access_to_all_collection_items.unwrap_or(false), + ); + org.collection_settings + .set_flag(OrgCollectionSetting::LimitCollectionCreation, data.limit_collection_creation.unwrap_or(false)); + org.collection_settings + .set_flag(OrgCollectionSetting::LimitCollectionDeletion, data.limit_collection_deletion.unwrap_or(false)); + org.collection_settings + .set_flag(OrgCollectionSetting::LimitItemDeletion, data.limit_item_deletion.unwrap_or(false)); org.save(&conn).await?; @@ -560,7 +566,7 @@ async fn post_organization_collections( }; if headers.membership.atype == MembershipType::Manager - && (!headers.membership.access_all || org_settings.limit_collection_creation) + && (!headers.membership.access_all || org_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation)) { err!("You don't have permission to create collections") } @@ -775,7 +781,9 @@ async fn delete_organization_collection_impl( err!("Collection not found", "Collection does not exist or does not belong to this organization") }; - if headers.membership_type <= MembershipType::Manager && org_settings.limit_collection_deletion { + if headers.membership_type <= MembershipType::Manager + && org_settings.get_flag(OrgCollectionSetting::LimitCollectionDeletion) + { err!("The current user isn't allowed to delete this collection") } @@ -1898,7 +1906,8 @@ async fn post_org_import( // We do not allow users or managers which can not manage all collections to create new collections // If there is any collection other than an existing import collection, abort the import. if headers.membership.atype <= MembershipType::Manager - && (!headers.membership.has_full_access() || org_settings.limit_collection_creation) + && (!headers.membership.has_full_access() + || org_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation)) { err!(Compact, "The current user isn't allowed to create new collections") } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index fcb7f30e..483dfa7b 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -13,6 +13,7 @@ use crate::{ }, db::{ DbConn, + models::OrgCollectionSetting, schema::{ ciphers, ciphers_collections, collections, collections_groups, folders, folders_ciphers, groups, groups_users, users_collections, users_organizations, @@ -728,7 +729,7 @@ 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_collection_settings_by_uuid(org_id, conn).await { - Some(settings) => settings.limit_item_deletion, + Some(settings) => settings.get_flag(OrgCollectionSetting::LimitItemDeletion), None => false, }, None => false, diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..f42f3aad 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -31,8 +31,8 @@ pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ - Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, - OrganizationId, + Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrgCollectionSetting, Organization, + OrganizationApiKey, OrganizationId, }; pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 14e37621..710d41f8 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -5,15 +5,9 @@ use std::{ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; -use diesel::{ - deserialize::FromSql, - prelude::*, - serialize::{IsNull, ToSql}, - sql_types::SmallInt, -}; +use diesel::{deserialize::FromSql, prelude::*, serialize::ToSql, sql_types::SmallInt}; use num_traits::FromPrimitive; #[cfg(feature = "sqlite")] -use num_traits::ToPrimitive; use serde_json::Value; use crate::{ @@ -35,59 +29,39 @@ use super::{ OrgPolicyType, TwoFactor, User, UserId, }; +#[repr(i16)] +pub enum OrgCollectionSetting { + AllowAdminAccessToAllCollectionItems = 0b1, + LimitCollectionCreation = 0b10, + LimitCollectionDeletion = 0b100, + LimitItemDeletion = 0b1000, +} + #[derive(Debug, Clone, Copy, FromSqlRow, AsExpression)] #[diesel(sql_type = SmallInt)] -#[allow(clippy::struct_excessive_bools)] -pub struct OrganizationCollectionSettings { - pub allow_admin_access_to_all_collection_items: bool, - pub limit_collection_creation: bool, - pub limit_collection_deletion: bool, - pub limit_item_deletion: bool, -} +pub struct OrganizationCollectionSettings(i16); -impl From<&OrganizationCollectionSettings> for i16 { - fn from(value: &OrganizationCollectionSettings) -> Self { - i16::from(value.allow_admin_access_to_all_collection_items) - | (0b10 * i16::from(value.limit_collection_creation)) - | (0b100 * i16::from(value.limit_collection_deletion)) - | (0b1000 * i16::from(value.limit_item_deletion)) +impl OrganizationCollectionSettings { + pub fn get_flag(self, flag: OrgCollectionSetting) -> bool { + (self.0 | flag as i16) > 0 } -} -#[cfg(feature = "sqlite")] -use diesel::sqlite::Sqlite; -#[cfg(feature = "sqlite")] -impl ToSql for OrganizationCollectionSettings -where - i16: ToSql, -{ - fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Sqlite>) -> diesel::serialize::Result { - out.set_value(i16::from(self).to_i32()); - Ok(IsNull::No) - } -} - -#[cfg(feature = "postgresql")] -use diesel::pg::Pg; -#[cfg(feature = "postgresql")] -impl ToSql for OrganizationCollectionSettings -where - i16: ToSql, -{ - fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Pg>) -> diesel::serialize::Result { - >::to_sql(&i16::from(self), &mut out.reborrow()) + pub fn set_flag(&mut self, flag: OrgCollectionSetting, val: bool) { + if val { + self.0 |= flag as i16; + } else { + self.0 &= i16::MAX - flag as i16; + } } } -#[cfg(feature = "mysql")] -use diesel::mysql::Mysql; -#[cfg(feature = "mysql")] -impl ToSql for OrganizationCollectionSettings +impl ToSql for OrganizationCollectionSettings where - i16: ToSql, + DB: diesel::backend::Backend, + i16: ToSql, { - fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, Mysql>) -> diesel::serialize::Result { - >::to_sql(&i16::from(self), &mut out.reborrow()) + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, DB>) -> diesel::serialize::Result { + self.0.to_sql(out) } } @@ -98,12 +72,7 @@ where { fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result { let val = i16::from_sql(bytes)?; - Ok(Self { - allow_admin_access_to_all_collection_items: (val & 0b1) != 0, - limit_collection_creation: (val & 0b10) != 0, - limit_collection_deletion: (val & 0b100) != 0, - limit_item_deletion: (val & 0b1000) != 0, - }) + Ok(Self(val)) } } @@ -274,12 +243,11 @@ impl Organization { billing_email, private_key, public_key, - collection_settings: OrganizationCollectionSettings { - allow_admin_access_to_all_collection_items: true, - limit_collection_creation: false, - limit_collection_deletion: false, - limit_item_deletion: false, - }, + + // Collection Management Settings default to AllowAdminAccessToAllCollectionItems + collection_settings: OrganizationCollectionSettings( + OrgCollectionSetting::AllowAdminAccessToAllCollectionItems as i16, + ), } } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationResponseModel.cs @@ -306,10 +274,10 @@ impl Organization { "useApi": true, "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "useResetPassword": CONFIG.mail_enabled(), - "allowAdminAccessToAllCollectionItems": self.collection_settings.allow_admin_access_to_all_collection_items, - "limitCollectionCreation": self.collection_settings.limit_collection_creation, - "limitCollectionDeletion": self.collection_settings.limit_collection_deletion, - "limitItemDeletion": self.collection_settings.limit_item_deletion, + "allowAdminAccessToAllCollectionItems": self.collection_settings.get_flag(OrgCollectionSetting::AllowAdminAccessToAllCollectionItems), + "limitCollectionCreation": self.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation), + "limitCollectionDeletion": self.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionDeletion), + "limitItemDeletion": self.collection_settings.get_flag(OrgCollectionSetting::LimitItemDeletion), "businessName": self.name, "businessAddress1": null, @@ -557,9 +525,9 @@ impl Membership { "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 && !org.collection_settings.limit_collection_creation, + "createNewCollections": membership_type == 4 && self.access_all && !org.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation), "editAnyCollection": membership_type == 4 && self.access_all, - "deleteAnyCollection": membership_type == 4 && self.access_all && !org.collection_settings.limit_collection_deletion, + "deleteAnyCollection": membership_type == 4 && self.access_all && !org.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation), "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported @@ -612,10 +580,10 @@ impl Membership { "familySponsorshipToDelete": null, "accessSecretsManager": false, - "allowAdminAccessToAllCollectionItems": org.collection_settings.allow_admin_access_to_all_collection_items, - "limitCollectionCreation": org.collection_settings.limit_collection_creation, - "limitCollectionDeletion": org.collection_settings.limit_collection_deletion, - "limitItemDeletion": org.collection_settings.limit_item_deletion, + "allowAdminAccessToAllCollectionItems": org.collection_settings.get_flag(OrgCollectionSetting::AllowAdminAccessToAllCollectionItems), + "limitCollectionCreation": org.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation), + "limitCollectionDeletion": org.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionDeletion), + "limitItemDeletion": org.collection_settings.get_flag(OrgCollectionSetting::LimitItemDeletion), "userIsManagedByOrganization": false, // Means not managed via the Members UI, like SSO "userIsClaimedByOrganization": false, // The new key instead of the obsolete userIsManagedByOrganization From a624aeceb58a40661e182ce7275cf7d8a3008740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Roumezin?= <94066559+RaphaelRoumezin@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:41:28 +0200 Subject: [PATCH 10/10] fix: flag check bug in bitwise ops --- src/db/models/organization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 710d41f8..dd1c3d73 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -43,7 +43,7 @@ pub struct OrganizationCollectionSettings(i16); impl OrganizationCollectionSettings { pub fn get_flag(self, flag: OrgCollectionSetting) -> bool { - (self.0 | flag as i16) > 0 + (self.0 & flag as i16) > 0 } pub fn set_flag(&mut self, flag: OrgCollectionSetting, val: bool) {