Browse Source

Merge 1aea203f34 into 660faee68e

pull/7452/merge
Raphaël Roumezin 1 day ago
committed by GitHub
parent
commit
b2f4eaa56f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      migrations/mysql/2026-07-21-120000_add_collection_management/down.sql
  2. 1
      migrations/mysql/2026-07-21-120000_add_collection_management/up.sql
  3. 1
      migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql
  4. 1
      migrations/postgresql/2026-07-21-120000_add_collection_management/up.sql
  5. 1
      migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql
  6. 1
      migrations/sqlite/2026-07-21-120000_add_collection_management/up.sql
  7. 5
      src/api/core/ciphers.rs
  8. 87
      src/api/core/organizations.rs
  9. 3
      src/auth.rs
  10. 24
      src/db/models/cipher.rs
  11. 4
      src/db/models/mod.rs
  12. 93
      src/db/models/organization.rs
  13. 1
      src/db/schema.rs
  14. 5
      src/static/templates/scss/vaultwarden.scss.hbs

1
migrations/mysql/2026-07-21-120000_add_collection_management/down.sql

@ -0,0 +1 @@
ALTER TABLE organizations DROP COLUMN collection_settings;

1
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;

1
migrations/postgresql/2026-07-21-120000_add_collection_management/down.sql

@ -0,0 +1 @@
ALTER TABLE organizations DROP COLUMN collection_settings;

1
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;

1
migrations/sqlite/2026-07-21-120000_add_collection_management/down.sql

@ -0,0 +1 @@
ALTER TABLE organizations DROP COLUMN collection_settings;

1
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;

5
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")
}

87
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<Route> {
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<bool>,
limit_collection_creation: Option<bool>,
limit_collection_deletion: Option<bool>,
limit_item_deletion: Option<bool>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FullCollectionData {
@ -340,6 +351,50 @@ async fn post_organization(
Ok(Json(org.to_json()))
}
#[put("/organizations/<org_id>/collection-management", data = "<data>")]
async fn put_organization_collection_management(
org_id: OrganizationId,
headers: OwnerHeaders,
data: Json<OrganizationCollectionManagementUpdateData>,
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<Value> {
@ -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);

3
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,
})

24
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 {

4
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};

93
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<DB> ToSql<SmallInt, DB> for OrganizationCollectionSettings
where
DB: diesel::backend::Backend,
i16: ToSql<SmallInt, DB>,
{
fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, DB>) -> diesel::serialize::Result {
self.0.to_sql(out)
}
}
impl<DB> FromSql<SmallInt, DB> for OrganizationCollectionSettings
where
DB: diesel::backend::Backend,
i16: FromSql<SmallInt, DB>,
{
fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
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<String>,
pub public_key: Option<String>,
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::<Self>(conn).ok()).await
}
pub async fn find_collection_settings_by_uuid(
uuid: &OrganizationId,
conn: &DbConn,
) -> Option<OrganizationCollectionSettings> {
conn.run(move |conn| {
organizations::table
.filter(organizations::uuid.eq(uuid))
.select(organizations::collection_settings)
.first::<OrganizationCollectionSettings>(conn)
.ok()
})
.await
}
pub async fn find_by_name(name: &str, conn: &DbConn) -> Option<Self> {
conn.run(move |conn| organizations::table.filter(organizations::name.eq(name)).first::<Self>(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

1
src/db/schema.rs

@ -126,6 +126,7 @@ table! {
billing_email -> Text,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
collection_settings -> SmallInt,
}
}

5
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;

Loading…
Cancel
Save