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..dbcd3e3f --- /dev/null +++ b/migrations/mysql/2026-07-21-120000_add_collection_management/down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..002c45f0 --- /dev/null +++ b/migrations/mysql/2026-07-21-120000_add_collection_management/up.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..dbcd3e3f --- /dev/null +++ b/migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..002c45f0 --- /dev/null +++ b/migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..dbcd3e3f --- /dev/null +++ b/migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..002c45f0 --- /dev/null +++ b/migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql @@ -0,0 +1 @@ +ALTER TABLE organizations ADD COLUMN collection_settings SMALLINT NOT NULL DEFAULT 1; \ No newline at end of file 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/api/core/organizations.rs b/src/api/core/organizations.rs index c7e79aed..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, @@ -39,6 +40,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 +128,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 +351,50 @@ 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") + }; + + 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?; + + 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 { @@ -506,7 +561,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.get_flag(OrgCollectionSetting::LimitCollectionCreation)) + { err!("You don't have permission to create collections") } @@ -712,9 +773,20 @@ 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.get_flag(OrgCollectionSetting::LimitCollectionDeletion) + { + err!("The current user isn't allowed to delete this collection") + } + log_event( EventType::CollectionDeleted as i32, &collection.uuid, @@ -1805,6 +1877,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 @@ -1829,7 +1905,10 @@ 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.get_flag(OrgCollectionSetting::LimitCollectionCreation)) + { 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); 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, }) diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 2fa6260a..93e81a23 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, @@ -25,7 +26,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)] @@ -187,6 +188,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() @@ -381,8 +384,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, + "restore": can_delete, }); } @@ -711,6 +714,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_collection_settings_by_uuid(org_id, conn).await { + Some(settings) => settings.get_flag(OrgCollectionSetting::LimitItemDeletion), + 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 { 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 72b1df0b..dd1c3d73 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -5,8 +5,9 @@ use std::{ use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; -use diesel::prelude::*; +use diesel::{deserialize::FromSql, prelude::*, serialize::ToSql, sql_types::SmallInt}; use num_traits::FromPrimitive; +#[cfg(feature = "sqlite")] use serde_json::Value; use crate::{ @@ -28,6 +29,53 @@ 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)] +pub struct OrganizationCollectionSettings(i16); + +impl OrganizationCollectionSettings { + pub fn get_flag(self, flag: OrgCollectionSetting) -> bool { + (self.0 & flag as i16) > 0 + } + + 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; + } + } +} + +impl ToSql for OrganizationCollectionSettings +where + DB: diesel::backend::Backend, + i16: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, DB>) -> diesel::serialize::Result { + self.0.to_sql(out) + } +} + +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(val)) + } +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organizations)] #[diesel(treat_none_as_null = true)] @@ -38,6 +86,8 @@ pub struct Organization { pub billing_email: String, pub private_key: Option, pub public_key: Option, + + pub collection_settings: OrganizationCollectionSettings, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -193,6 +243,11 @@ impl Organization { billing_email, private_key, public_key, + + // 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 @@ -219,9 +274,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.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, @@ -396,6 +452,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 } @@ -455,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, + "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, + "deleteAnyCollection": membership_type == 4 && self.access_all && !org.collection_settings.get_flag(OrgCollectionSetting::LimitCollectionCreation), "manageGroups": false, "managePolicies": false, "manageSso": false, // Not supported @@ -509,11 +579,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.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 diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..17332cd4 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -126,6 +126,7 @@ table! { billing_email -> Text, private_key -> Nullable, public_key -> Nullable, + collection_settings -> SmallInt, } } diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 5bbe5db2..dafa7b07 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;