Browse Source

Merge f1886466a9 into 061694d0cb

pull/7776/merge
Tom 10 hours ago
committed by GitHub
parent
commit
994ac14764
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .env.template
  2. 0
      migrations/mysql/2026-09-23-120000_add_default_user_collection/down.sql
  3. 6
      migrations/mysql/2026-09-23-120000_add_default_user_collection/up.sql
  4. 0
      migrations/postgresql/2026-09-23-120000_add_default_user_collection/down.sql
  5. 6
      migrations/postgresql/2026-09-23-120000_add_default_user_collection/up.sql
  6. 0
      migrations/sqlite/2026-09-23-120000_add_default_user_collection/down.sql
  7. 6
      migrations/sqlite/2026-09-23-120000_add_default_user_collection/up.sql
  8. 584
      src/api/core/ciphers.rs
  9. 7
      src/api/core/events.rs
  10. 418
      src/api/core/organizations.rs
  11. 1
      src/config.rs
  12. 287
      src/db/models/cipher.rs
  13. 472
      src/db/models/collection.rs
  14. 2
      src/db/models/event.rs
  15. 2
      src/db/models/mod.rs
  16. 10
      src/db/models/org_policy.rs
  17. 79
      src/db/models/organization.rs
  18. 28
      src/db/models/user.rs
  19. 2
      src/db/schema.rs
  20. 42
      src/util.rs

1
.env.template

@ -404,6 +404,7 @@
## - "pm-30529-webauthn-related-origins": ## - "pm-30529-webauthn-related-origins":
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "pm-20558-migrate-myvault-to-myitems": On mobile, ask members that the organization data ownership policy applies to, and that still have personal items, to transfer them to their My Items or to leave the organization (Android, iOS)
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0) ## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)

0
migrations/mysql/2026-09-23-120000_add_default_user_collection/down.sql

6
migrations/mysql/2026-09-23-120000_add_default_user_collection/up.sql

@ -0,0 +1,6 @@
-- Older releases ignore both columns after a downgrade: a My Items collection becomes a regular one there, which
-- Owners, Admins and members with access to all collections see with its items.
ALTER TABLE collections ADD COLUMN default_user_uuid CHAR(36);
ALTER TABLE collections ADD COLUMN default_user_collection_email TEXT;
-- A member has at most one My Items collection per organization. NULL (a shared collection) never conflicts.
CREATE UNIQUE INDEX collections_default_user_uuid ON collections (org_uuid, default_user_uuid);

0
migrations/postgresql/2026-09-23-120000_add_default_user_collection/down.sql

6
migrations/postgresql/2026-09-23-120000_add_default_user_collection/up.sql

@ -0,0 +1,6 @@
-- Older releases ignore both columns after a downgrade: a My Items collection becomes a regular one there, which
-- Owners, Admins and members with access to all collections see with its items.
ALTER TABLE collections ADD COLUMN default_user_uuid TEXT;
ALTER TABLE collections ADD COLUMN default_user_collection_email TEXT;
-- A member has at most one My Items collection per organization. NULL (a shared collection) never conflicts.
CREATE UNIQUE INDEX collections_default_user_uuid ON collections (org_uuid, default_user_uuid);

0
migrations/sqlite/2026-09-23-120000_add_default_user_collection/down.sql

6
migrations/sqlite/2026-09-23-120000_add_default_user_collection/up.sql

@ -0,0 +1,6 @@
-- Older releases ignore both columns after a downgrade: a My Items collection becomes a regular one there, which
-- Owners, Admins and members with access to all collections see with its items.
ALTER TABLE collections ADD COLUMN default_user_uuid TEXT;
ALTER TABLE collections ADD COLUMN default_user_collection_email TEXT;
-- A member has at most one My Items collection per organization. NULL (a shared collection) never conflicts.
CREATE UNIQUE INDEX collections_default_user_uuid ON collections (org_uuid, default_user_uuid);

584
src/api/core/ciphers.rs

File diff suppressed because it is too large

7
src/api/core/events.rs

@ -10,7 +10,9 @@ use crate::{
auth::{AdminHeaders, Headers}, auth::{AdminHeaders, Headers},
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId}, models::{
Cipher, CipherAccessScope, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId,
},
}, },
util::parse_date, util::parse_date,
}; };
@ -224,7 +226,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
// user can actually access it instead of trusting the provided cipher uuid. // user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await // Like upstream, also for the members' My Items an admin opened from a report
&& cipher.is_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await
&& let Some(org_id) = cipher.organization_uuid && let Some(org_id) = cipher.organization_uuid
{ {
log_event_impl( log_event_impl(

418
src/api/core/organizations.rs

@ -15,15 +15,15 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Cipher, CipherAccessScope, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
UserId, OrganizationId, TwoFactor, TwoFactorType, User, UserId,
}, },
}, },
mail, mail,
sso::FAKE_SSO_IDENTIFIER, sso::FAKE_SSO_IDENTIFIER,
util::{NumberOrString, convert_json_key_lcase_first}, util::{NumberOrString, convert_json_key_lcase_first, is_valid_enc_string},
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@ -77,6 +77,7 @@ pub fn routes() -> Vec<Route> {
get_organization_public_key, get_organization_public_key,
bulk_public_keys, bulk_public_keys,
revoke_member, revoke_member,
revoke_self,
bulk_revoke_members, bulk_revoke_members,
restore_member, restore_member,
restore_member_vnext, restore_member_vnext,
@ -130,7 +131,8 @@ struct OrganizationUpdateData {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct FullCollectionData { struct FullCollectionData {
name: String, // Optional on updates only: the clients send none for a former My Items collection, like upstream
name: Option<String>,
groups: Vec<CollectionGroupData>, groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>, users: Vec<CollectionMembershipData>,
external_id: Option<String>, external_id: Option<String>,
@ -439,7 +441,10 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
.collect(); .collect();
let mut data = Vec::new(); let mut data = Vec::new();
for col in Collection::find_by_organization(&org_id, &conn).await { // Like upstream, the members' My Items collections are not managed here
for col in
Collection::find_by_organization(&org_id, &conn).await.into_iter().filter(|c| !c.is_default_user_collection())
{
// check whether the current user has access to the given collection // check whether the current user has access to the given collection
let assigned = has_full_access_to_org let assigned = has_full_access_to_org
|| CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await || CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await
@ -490,8 +495,14 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
}))) })))
} }
// The shared collections only, like upstream
async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value { async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value {
Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::<Value>() Collection::find_by_organization(org_id, conn)
.await
.iter()
.filter(|c| !c.is_default_user_collection())
.map(Collection::to_json)
.collect::<Value>()
} }
#[post("/organizations/<org_id>/collections", data = "<data>")] #[post("/organizations/<org_id>/collections", data = "<data>")]
@ -511,7 +522,10 @@ async fn post_organization_collections(
err!("You don't have permission to create collections") err!("You don't have permission to create collections")
} }
let collection = Collection::new(org_id.clone(), data.name, data.external_id); let Some(name) = data.name else {
err!("The Name field is required.")
};
let collection = Collection::new(org_id.clone(), name, data.external_id);
collection.save(&conn).await?; collection.save(&conn).await?;
log_event( log_event(
@ -578,15 +592,26 @@ async fn post_bulk_access_collections(
err!("Can't find organization details") err!("Can't find organization details")
} }
// The collections and members are checked below, the groups only here. // Like upstream, check the groups, members and collections before changing any collection
let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id)) err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
} }
for col_id in data.collection_ids { let mut members = Vec::with_capacity(data.users.len());
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { for user in &data.users {
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
err!("User is not part of organization")
};
if !member.access_all {
members.push((member, user));
}
}
let mut collections = Vec::with_capacity(data.collection_ids.len());
for col_id in &data.collection_ids {
let Some(collection) = Collection::find_by_uuid_and_org(col_id, &org_id, &conn).await else {
err!("Collection not found") err!("Collection not found")
}; };
@ -594,6 +619,16 @@ async fn post_bulk_access_collections(
err!("Collection not found", "The current user isn't a manager for this collection") err!("Collection not found", "The current user isn't a manager for this collection")
} }
if collection.is_default_user_collection() {
err!("You cannot add access to collections with the type as DefaultUserCollection.")
}
collections.push(collection);
}
for collection in collections {
let col_id = collection.uuid.clone();
// update collection modification date // update collection modification date
collection.save(&conn).await?; collection.save(&conn).await?;
@ -616,15 +651,7 @@ async fn post_bulk_access_collections(
} }
CollectionUser::delete_all_by_collection(&col_id, &conn).await?; CollectionUser::delete_all_by_collection(&col_id, &conn).await?;
for user in &data.users { for (member, user) in &members {
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
err!("User is not part of organization")
};
if member.access_all {
continue;
}
CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn) CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &conn)
.await?; .await?;
} }
@ -666,7 +693,16 @@ async fn post_organization_collection_update(
err!("Collection not found") err!("Collection not found")
}; };
collection.name = data.name; if collection.is_default_user_collection() {
err!("You cannot edit a collection with the type as DefaultUserCollection.")
}
// A former My Items collection is shown by its former owner's email address, its name stays as it is
if collection.default_user_collection_email.is_none()
&& let Some(name) = data.name.filter(|n| !n.trim().is_empty())
{
collection.name = name;
}
collection.external_id = match data.external_id { collection.external_id = match data.external_id {
Some(external_id) if !external_id.trim().is_empty() => Some(external_id), Some(external_id) if !external_id.trim().is_empty() => Some(external_id),
_ => None, _ => None,
@ -723,6 +759,9 @@ async fn delete_organization_collection_impl(
let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else { 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") err!("Collection not found", "Collection does not exist or does not belong to this organization")
}; };
if collection.is_default_user_collection() {
err!("You cannot delete a collection with the type as DefaultUserCollection.")
}
log_event( log_event(
EventType::CollectionDeleted, EventType::CollectionDeleted,
&collection.uuid, &collection.uuid,
@ -778,6 +817,16 @@ async fn bulk_delete_organization_collections(
let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?; let headers = ManagerHeaders::from_loose(headers, &collections, &conn).await?;
// Like upstream, reject them all before deleting any
for col_id in &collections {
if Collection::find_by_uuid_and_org(col_id, &org_id, &conn)
.await
.is_some_and(|c| c.is_default_user_collection())
{
err!("You cannot delete collections with the type as DefaultUserCollection.")
}
}
for col_id in collections { for col_id in collections {
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?; delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?;
} }
@ -883,18 +932,29 @@ struct OrgIdData {
organization_id: OrganizationId, organization_id: OrganizationId,
} }
#[derive(FromForm)]
struct OrgDetailsData {
#[field(name = "organizationId")]
organization_id: OrganizationId,
// Set by the organization reports, which also cover the members' My Items
#[field(name = "includeMemberItems", default = false)]
include_member_items: bool,
}
#[get("/ciphers/organization-details?<data..>")] #[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { async fn get_org_details(data: OrgDetailsData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid { if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
} }
// Together with the Manager guard, this is the organization-wide cipher authority upstream requires to include
// the members' My Items as well
if !headers.membership.has_full_access() { if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
} }
Ok(Json(json!({ Ok(Json(json!({
"data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, "data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, data.include_member_items, &conn).await?,
"object": "list", "object": "list",
"continuationToken": null, "continuationToken": null,
}))) })))
@ -904,14 +964,32 @@ async fn get_org_details_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
host: &str, host: &str,
user_id: &UserId, user_id: &UserId,
include_member_items: bool,
conn: &DbConn, conn: &DbConn,
) -> Result<Value, crate::Error> { ) -> Result<Value, crate::Error> {
let ciphers = Cipher::find_by_org(org_id, conn).await; let mut ciphers = Cipher::find_by_org(org_id, conn).await;
if !include_member_items {
// Like upstream, leave out the items that are only in the members' My Items collections
let my_items_only = CollectionCipher::find_my_items_only_by_orgs(vec![org_id.clone()], conn).await;
ciphers.retain(|c| !my_items_only.contains(&c.uuid));
}
let my_items = CollectionCipher::find_my_items_by_org(org_id, conn).await;
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
let mut ciphers_json = Vec::with_capacity(ciphers.len()); let mut ciphers_json = Vec::with_capacity(ciphers.len());
for c in ciphers { for c in ciphers {
ciphers_json.push(c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?); let mut details = c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?;
// Like upstream, the members' items list all their collections, including the My Items ones, and the
// organization's items none of those
if let Some(cipher_my_items) = my_items.get(&c.uuid)
&& let Some(collection_ids) = details["collectionIds"].as_array_mut()
{
collection_ids.retain(|id| id.as_str().is_none_or(|id| !cipher_my_items.iter().any(|c| c.as_ref() == id)));
if include_member_items {
collection_ids.extend(cipher_my_items.iter().map(|c| json!(c)));
}
}
ciphers_json.push(details);
} }
Ok(json!(ciphers_json)) Ok(json!(ciphers_json))
} }
@ -1023,8 +1101,10 @@ struct InviteData {
impl InviteData { impl InviteData {
async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
// A My Items collection can't be assigned, it fails like a collection of another organization, like upstream
let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); let org_collection_ids: HashSet<&CollectionId> =
org_collections.iter().filter(|c| !c.is_default_user_collection()).map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) { if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
} }
@ -1349,12 +1429,30 @@ async fn accept_invite(
struct ConfirmData { struct ConfirmData {
id: Option<MembershipId>, id: Option<MembershipId>,
key: Option<String>, key: Option<String>,
// The encrypted name of the member's My Items collection, sent by the clients whenever they confirm or restore
// a member, and when they demote one from Owner or Admin
default_user_collection_name: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct BulkConfirmData { struct BulkConfirmData {
keys: Option<Vec<ConfirmData>>, keys: Option<Vec<ConfirmData>>,
default_user_collection_name: Option<String>,
}
/// Like upstream's `[EncryptedString]` and `[EncryptedStringLength(1000)]`, the name of a My Items collection has to
/// be an encrypted string: the collection can't be renamed afterwards.
fn check_default_user_collection_name(name: Option<&str>) -> EmptyResult {
if let Some(name) = name {
if name.len() > 1000 {
err!("The field DefaultUserCollectionName exceeds the maximum encrypted value length of 1000 characters.")
}
if !is_valid_enc_string(name) {
err!("DefaultUserCollectionName is not a valid encrypted string.")
}
}
Ok(())
} }
#[post("/organizations/<org_id>/users/confirm", data = "<data>")] #[post("/organizations/<org_id>/users/confirm", data = "<data>")]
@ -1369,6 +1467,7 @@ async fn bulk_confirm_invite(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data = data.into_inner(); let data = data.into_inner();
check_default_user_collection_name(data.default_user_collection_name.as_deref())?;
let mut bulk_response = Vec::new(); let mut bulk_response = Vec::new();
match data.keys { match data.keys {
@ -1376,10 +1475,14 @@ async fn bulk_confirm_invite(
for invite in keys { for invite in keys {
let member_id = invite.id.unwrap(); let member_id = invite.id.unwrap();
let user_key = invite.key.unwrap_or_default(); let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { let collection_name = data.default_user_collection_name.as_deref();
Ok(()) => String::new(), let err_msg =
Err(e) => format!("{e:?}"), match confirm_invite_impl(&org_id, &member_id, &user_key, collection_name, &headers, &conn, &nt)
}; .await
{
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
};
bulk_response.push(json!( bulk_response.push(json!(
{ {
@ -1410,14 +1513,25 @@ async fn confirm_invite(
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
let data = data.into_inner(); let data = data.into_inner();
check_default_user_collection_name(data.default_user_collection_name.as_deref())?;
let user_key = data.key.unwrap_or_default(); let user_key = data.key.unwrap_or_default();
confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await confirm_invite_impl(
&org_id,
&member_id,
&user_key,
data.default_user_collection_name.as_deref(),
&headers,
&conn,
&nt,
)
.await
} }
async fn confirm_invite_impl( async fn confirm_invite_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
key: &str, key: &str,
default_collection_name: Option<&str>,
headers: &AdminHeaders, headers: &AdminHeaders,
conn: &DbConn, conn: &DbConn,
nt: &Notify<'_>, nt: &Notify<'_>,
@ -1472,13 +1586,18 @@ async fn confirm_invite_impl(
mail::send_invite_confirmed(&address, &org_name).await?; mail::send_invite_confirmed(&address, &org_name).await?;
} }
let save_result = member_to_confirm.save(conn).await; let mut result = member_to_confirm.save(conn).await;
// Only once the membership is stored as confirmed, like upstream: a concurrent policy update then either
// finds this member confirmed or this check finds the policy enabled, so the member always gets one.
if result.is_ok() {
result = Collection::create_default_user_collection(&member_to_confirm, default_collection_name, conn).await;
}
if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
} }
save_result result
} }
#[get("/organizations/<org_id>/users/mini-details", rank = 1)] #[get("/organizations/<org_id>/users/mini-details", rank = 1)]
@ -1529,6 +1648,7 @@ struct EditUserData {
groups: Option<Vec<GroupId>>, groups: Option<Vec<GroupId>>,
#[serde(default)] #[serde(default)]
permissions: HashMap<String, Value>, permissions: HashMap<String, Value>,
default_user_collection_name: Option<String>,
} }
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)] #[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
@ -1554,6 +1674,7 @@ async fn edit_member(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data: EditUserData = data.into_inner(); let data: EditUserData = data.into_inner();
check_default_user_collection_name(data.default_user_collection_name.as_deref())?;
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type // The from_str() will convert the custom role type into a manager role type
@ -1597,6 +1718,8 @@ async fn edit_member(
} }
} }
// Upstream creates the My Items collection of a member demoted from Owner or Admin
let demoted = member_to_edit.atype >= MembershipType::Admin && new_type < MembershipType::Admin;
member_to_edit.access_all = access_all; member_to_edit.access_all = access_all;
member_to_edit.atype = new_type as i32; member_to_edit.atype = new_type as i32;
@ -1604,15 +1727,26 @@ async fn edit_member(
// We need to perform the check after changing the type since `admin` is exempt. // We need to perform the check after changing the type since `admin` is exempt.
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
// Delete all the odd collections // Like upstream, reject a My Items collection before changing anything
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { let org_collections: HashMap<CollectionId, Collection> =
c.delete(&conn).await?; Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
if !access_all
&& data
.collections
.iter()
.flatten()
.any(|col| org_collections.get(&col.id).is_some_and(Collection::is_default_user_collection))
{
err!("Default collections cannot be assigned to a member.")
} }
// Delete all the odd collections, but keep the member's My Items
CollectionUser::delete_all_but_my_items_by_user_and_org(&member_to_edit.user_uuid, &org_id, &conn).await?;
// If no accessAll, add the collections received // If no accessAll, add the collections received
if !access_all { if !access_all {
for col in data.collections.iter().flatten() { for col in data.collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { match org_collections.get(&col.id) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
Some(collection) => { Some(collection) => {
CollectionUser::save( CollectionUser::save(
@ -1650,7 +1784,17 @@ async fn edit_member(
) )
.await; .await;
member_to_edit.save(&conn).await member_to_edit.save(&conn).await?;
// Only once the demotion is stored, like upstream, see `confirm_invite_impl()`
if demoted {
Collection::create_default_user_collection(
&member_to_edit,
data.default_user_collection_name.as_deref(),
&conn,
)
.await?;
}
Ok(())
} }
#[delete("/organizations/<org_id>/users", data = "<data>")] #[delete("/organizations/<org_id>/users", data = "<data>")]
@ -1794,7 +1938,8 @@ async fn bulk_public_keys(
} }
use super::ciphers::CipherData; use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data; use super::ciphers::{ValidatedCollections, import_folders, update_cipher_from_data};
use super::folders::FolderData;
// The import endpoint only ever uses the name/id/external_id of a collection. // The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them // Bitwarden's own server ignores `groups`/`users` here too, so do not make them
@ -1813,6 +1958,11 @@ struct ImportData {
ciphers: Vec<CipherData>, ciphers: Vec<CipherData>,
collections: Vec<ImportCollectionData>, collections: Vec<ImportCollectionData>,
collection_relationships: Vec<RelationsData>, collection_relationships: Vec<RelationsData>,
// The clients keep the folders of an import into My Items
#[serde(default)]
folders: Vec<FolderData>,
#[serde(default)]
folder_relationships: Vec<RelationsData>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -1851,23 +2001,29 @@ async fn post_org_import(
let existing_collections: HashMap<CollectionId, Collection> = let existing_collections: HashMap<CollectionId, Collection> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); // Check every collection before creating any
for col in data.collections { for col in &data.collections {
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)); if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) {
let collection_uuid = if let Some(collection) = existing {
// When not an Owner or Admin, check if the member is allowed to write to the collection. // When not an Owner or Admin, check if the member is allowed to write to the collection.
if headers.membership.atype < MembershipType::Admin // Only its owner can import into a My Items collection.
if (headers.membership.atype < MembershipType::Admin || collection.is_default_user_collection())
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
{ {
err!(Compact, "The current user isn't allowed to manage this collection") err!(Compact, "The current user isn't allowed to manage this collection")
} }
collection.uuid.clone() } else if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() {
} else {
// We do not allow users or managers which can not manage all collections to create new collections // 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 there is any collection other than an existing import collection, abort the import.
if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() { err!(Compact, "The current user isn't allowed to create new collections")
err!(Compact, "The current user isn't allowed to create new collections") }
} }
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections {
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
let collection_uuid = if let Some(collection) = existing {
collection.uuid.clone()
} else {
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); let new_collection = Collection::new(org_id.clone(), col.name, col.external_id);
new_collection.save(&conn).await?; new_collection.save(&conn).await?;
new_collection.uuid new_collection.uuid
@ -1885,10 +2041,13 @@ async fn post_org_import(
let headers: Headers = headers.into(); let headers: Headers = headers.into();
let folder_relations = data.folder_relationships.into_iter().map(|r| (r.key, r.value));
let cipher_folders = import_folders(data.folders, folder_relations, &headers.user.uuid, &conn).await?;
let mut ciphers: Vec<CipherId> = Vec::with_capacity(data.ciphers.len()); let mut ciphers: Vec<CipherId> = Vec::with_capacity(data.ciphers.len());
for mut cipher_data in data.ciphers { for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
// Always clear folder_id's via an organization import // Only the folder relationships of the import set a folder, never the client-provided folderId
cipher_data.folder_id = None; cipher_data.folder_id = cipher_folders.get(&index).cloned();
// Replace the client-provided, unvalidated organizationId with the real target org // Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone()); cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
@ -1896,7 +2055,7 @@ async fn post_org_import(
&mut cipher, &mut cipher,
cipher_data, cipher_data,
&headers, &headers,
Some(collections.clone()), Some(ValidatedCollections::Checked(collections.clone())),
&conn, &conn,
&nt, &nt,
UpdateType::None, UpdateType::None,
@ -1960,22 +2119,44 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
} }
} }
let mut ciphers = Vec::with_capacity(data.cipher_ids.len());
for cipher_id in &data.cipher_ids { for cipher_id in &data.cipher_ids {
// Only act on existing cipher uuid's // Only act on existing cipher uuid's
// Do not abort the operation just ignore it, it could be a cipher was just deleted for example // Do not abort the operation just ignore it, it could be a cipher was just deleted for example
if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await if let Some(cipher) = Cipher::find_by_uuid_and_org(cipher_id, &data.organization_id, &conn).await
&& cipher.is_write_accessible_to_user(&headers.user.uuid, &conn).await && cipher.is_write_accessible_to_user(&headers.user.uuid, CipherAccessScope::OrganizationAdmin, &conn).await
{ {
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection ciphers.push(cipher);
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting. }
if data.remove_collections { }
for collection in &data.collection_ids {
CollectionCipher::delete(&cipher.uuid, collection, &conn).await?; // Only the user's own My Items collection can be among them, check all ciphers before changing any
} let my_items =
} else { user_collections.values().find(|c| c.is_default_user_collection()).filter(|_| !data.remove_collections);
for collection in &data.collection_ids { if let Some(my_items) = my_items {
CollectionCipher::save(&cipher.uuid, collection, &conn).await?; for cipher in &ciphers {
} my_items.check_cipher_assignment(
&HashSet::from_iter(cipher.get_accessible_collections(headers.user.uuid.clone(), &conn).await),
&CollectionCipher::find_my_items_of_cipher(&cipher.uuid, &conn).await,
)?;
}
}
for cipher in ciphers {
// When selecting a specific collection from the left filter list, and use the bulk option, you can remove an item from that collection
// In these cases the client will call this endpoint twice, once for adding the new collections and a second for deleting.
if data.remove_collections {
for collection in &data.collection_ids {
CollectionCipher::delete(&cipher.uuid, collection, &conn).await?;
}
} else {
// Moved into My Items, an unassigned cipher is taken away from the members with organization-wide access,
// so they sync as well
if my_items.is_some() {
cipher.update_users_revision(&conn).await;
}
for collection in &data.collection_ids {
CollectionCipher::save(&cipher.uuid, collection, &conn).await?;
} }
} }
} }
@ -2076,10 +2257,14 @@ struct PolicyData {
#[derive(Deserialize)] #[derive(Deserialize)]
struct PutPolicy { struct PutPolicy {
policy: PolicyData, policy: PolicyData,
// Ignore metadata for now as we do not yet support this metadata: Option<PolicyMetadata>,
// "metadata": { }
// "defaultUserCollectionName": "2.xx|xx==|xx="
// } #[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PolicyMetadata {
// Sent with the organization data ownership policy, see `ConfirmData`
default_user_collection_name: Option<String>,
} }
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")] #[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
@ -2093,11 +2278,17 @@ async fn put_policy(
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data: PolicyData = data.into_inner().policy; let PutPolicy {
policy: data,
metadata,
} = data.into_inner();
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
err!("Invalid or unsupported policy type") err!("Invalid or unsupported policy type")
}; };
if pol_type_enum == OrgPolicyType::PersonalOwnership {
check_default_user_collection_name(metadata.as_ref().and_then(|m| m.default_user_collection_name.as_deref()))?;
}
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Bitwarden only allows the Reset Password policy when Single Org policy is enabled
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
@ -2198,6 +2389,18 @@ async fn put_policy(
) )
.await; .await;
// Only once the policy is stored, like upstream: a concurrent confirm, restore or demotion then either finds
// the policy enabled or is among the confirmed members loaded here.
// Unlike upstream, this runs on every enabled update, not only when the policy gets enabled. The clients send
// the name on every save, so saving the policy again creates the ones still missing: those of the members of
// organizations that enabled the policy before upgrading, of members confirmed by clients that sent no name,
// and of a failed earlier attempt.
if pol_type_enum == OrgPolicyType::PersonalOwnership && data.enabled {
let collection_name = metadata.as_ref().and_then(|m| m.default_user_collection_name.as_deref());
let members = Membership::find_confirmed_by_org(&org_id, &conn).await;
Collection::create_default_user_collections(&members, collection_name, &conn).await?;
}
Ok(Json(policy.to_json())) Ok(Json(policy.to_json()))
} }
@ -2290,6 +2493,39 @@ async fn revoke_member(
revoke_member_impl(&org_id, &member_id, &headers, &conn).await revoke_member_impl(&org_id, &member_id, &headers, &conn).await
} }
// Called by the clients when a member declines to transfer their personal items into their My Items collection,
// as the organization data ownership policy asks of them.
#[put("/organizations/<org_id>/users/revoke-self")]
async fn revoke_self(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
let mut member = headers.membership;
if !OrgPolicy::is_personal_ownership_enforced_for(&member, &conn).await {
err!(
"User is not eligible for self-revocation. The organization data ownership policy must be enabled and the user must be a confirmed member."
)
}
member.revoke();
member.save(&conn).await?;
log_event(
EventType::OrganizationUserSelfRevoked,
&member.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
nt.send_user_update(UpdateType::SyncOrgKeys, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(())
}
#[put("/organizations/<org_id>/users/revoke", data = "<data>")] #[put("/organizations/<org_id>/users/revoke", data = "<data>")]
async fn bulk_revoke_members( async fn bulk_revoke_members(
org_id: OrganizationId, org_id: OrganizationId,
@ -2373,16 +2609,23 @@ async fn revoke_member_impl(
Ok(()) Ok(())
} }
#[put("/organizations/<org_id>/users/<member_id>/restore/vnext")] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RestoreMemberData {
default_user_collection_name: Option<String>,
}
#[put("/organizations/<org_id>/users/<member_id>/restore/vnext", data = "<data>")]
async fn restore_member_vnext( async fn restore_member_vnext(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
data: Json<RestoreMemberData>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
// Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. let collection_name = data.into_inner().default_user_collection_name;
// Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member check_default_user_collection_name(collection_name.as_deref())?;
restore_member_impl(&org_id, &member_id, &headers, &conn).await restore_member_impl(&org_id, &member_id, collection_name.as_deref(), &headers, &conn).await
} }
#[put("/organizations/<org_id>/users/<member_id>/restore")] #[put("/organizations/<org_id>/users/<member_id>/restore")]
@ -2392,13 +2635,20 @@ async fn restore_member(
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
restore_member_impl(&org_id, &member_id, &headers, &conn).await restore_member_impl(&org_id, &member_id, None, &headers, &conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkRestoreMemberData {
ids: Vec<MembershipId>,
default_user_collection_name: Option<String>,
} }
#[put("/organizations/<org_id>/users/restore", data = "<data>")] #[put("/organizations/<org_id>/users/restore", data = "<data>")]
async fn bulk_restore_members( async fn bulk_restore_members(
org_id: OrganizationId, org_id: OrganizationId,
data: Json<BulkMembershipIds>, data: Json<BulkRestoreMemberData>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
@ -2406,10 +2656,12 @@ async fn bulk_restore_members(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data = data.into_inner(); let data = data.into_inner();
let collection_name = data.default_user_collection_name.as_deref();
check_default_user_collection_name(collection_name)?;
let mut bulk_response = Vec::new(); let mut bulk_response = Vec::new();
for member_id in data.ids { for member_id in data.ids {
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { let err_msg = match restore_member_impl(&org_id, &member_id, collection_name, &headers, &conn).await {
Ok(()) => String::new(), Ok(()) => String::new(),
Err(e) => format!("{e:?}"), Err(e) => format!("{e:?}"),
}; };
@ -2433,6 +2685,7 @@ async fn bulk_restore_members(
async fn restore_member_impl( async fn restore_member_impl(
org_id: &OrganizationId, org_id: &OrganizationId,
member_id: &MembershipId, member_id: &MembershipId,
default_collection_name: Option<&str>,
headers: &AdminHeaders, headers: &AdminHeaders,
conn: &DbConn, conn: &DbConn,
) -> EmptyResult { ) -> EmptyResult {
@ -2464,6 +2717,9 @@ async fn restore_member_impl(
conn, conn,
) )
.await; .await;
// Only once the restore is stored, like upstream, see `confirm_invite_impl()`
Collection::create_default_user_collection(&member, default_collection_name, conn).await?;
} }
Some(_) => err!("User is already active"), Some(_) => err!("User is already active"),
None => err!("User not found in organization"), None => err!("User not found in organization"),
@ -2566,6 +2822,12 @@ impl GroupRequest {
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
} }
if org_collections
.iter()
.any(|c| c.is_default_user_collection() && self.collections.iter().any(|g| g.id == c.uuid))
{
err!("You cannot modify group access for collections with the type as DefaultUserCollection.")
}
let org_memberships = Membership::find_by_org(org_id, conn).await; let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
@ -3231,7 +3493,7 @@ async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbC
Ok(Json(json!({ Ok(Json(json!({
"collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await), "collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await),
"ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?), "ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, false, &conn).await?),
}))) })))
} }

1
src/config.rs

@ -1445,6 +1445,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"pm-30529-webauthn-related-origins", "pm-30529-webauthn-related-origins",
// Vault Team // Vault Team
"pm-32009-new-item-types", "pm-32009-new-item-types",
"pm-20558-migrate-myvault-to-myitems",
]; ];
impl Config { impl Config {

287
src/db/models/cipher.rs

@ -28,6 +28,49 @@ use super::{
MembershipStatus, MembershipType, OrganizationId, User, UserId, MembershipStatus, MembershipType, OrganizationId, User, UserId,
}; };
/// Which routes a cipher operation is authorized for. Vaultwarden serves the organization's administrative cipher
/// routes (`/ciphers/<id>/admin` and friends) from the same handlers as the regular vault routes, so the handlers
/// state it explicitly.
///
/// It only makes a difference for the items stored only in a member's My Items collection: like upstream, members
/// with organization-wide cipher authority reach those through the administrative routes, never in their vault.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CipherAccessScope {
User,
OrganizationAdmin,
}
impl CipherAccessScope {
/// The scope of the v2 attachment create, the one route that states it (`adminRequest`), like upstream's
/// `PostAttachment`. It only selects which check runs, never what it answers.
pub fn requested(admin_request: Option<bool>) -> Self {
if admin_request == Some(true) {
Self::OrganizationAdmin
} else {
Self::User
}
}
/// The scope of a route that isn't told which one to use, the second leg of the v2 attachment upload. Like
/// upstream's `PostFileForExistingAttachment`, it follows the caller's own membership, not the request.
pub fn for_member(membership: Option<&Membership>) -> Self {
if membership.is_some_and(may_administer_org_ciphers) {
Self::OrganizationAdmin
} else {
Self::User
}
}
}
/// Upstream's organization-wide cipher authority (`ViewAllCollections`, `CanEditAllCiphersAsync`): a confirmed Owner
/// or Admin, or a Manager with access to all collections, which the clients show as a Custom member holding
/// `Edit any collection`.
fn may_administer_org_ciphers(membership: &Membership) -> bool {
membership.has_status(MembershipStatus::Confirmed)
&& (membership.atype >= MembershipType::Admin
|| (membership.atype == MembershipType::Manager && membership.access_all))
}
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = ciphers)] #[diesel(table_name = ciphers)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
@ -179,7 +222,15 @@ impl Cipher {
// We don't need these values at all for Organizational syncs // We don't need these values at all for Organizational syncs
// Skip any other database calls if this is the case and just return false. // Skip any other database calls if this is the case and just return false.
let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User { let (read_only, hide_passwords, _) = if sync_type == CipherSyncType::User {
if let Some((ro, hp, mn)) = self.get_access_restrictions(user_uuid, cipher_sync_data, conn).await { let mut restrictions =
self.get_access_restrictions(user_uuid, CipherAccessScope::User, cipher_sync_data, conn).await;
if restrictions.is_none() {
// A cipher that only the administrative routes open, answered from one of them
restrictions = self
.get_access_restrictions(user_uuid, CipherAccessScope::OrganizationAdmin, cipher_sync_data, conn)
.await;
}
if let Some((ro, hp, mn)) = restrictions {
(ro, hp, mn) (ro, hp, mn)
} else { } else {
error!("Cipher ownership assertion failure"); error!("Cipher ownership assertion failure");
@ -417,6 +468,18 @@ impl Cipher {
None => { None => {
// Belongs to Organization, need to update affected users // Belongs to Organization, need to update affected users
if let Some(ref org_uuid) = self.organization_uuid { if let Some(ref org_uuid) = self.organization_uuid {
// My Items-only ciphers belong in the owner's vault, not in the vault/revision stream of
// members that merely have organization- or group-wide access.
if let Some(owners) = CollectionCipher::find_my_items_owners_if_only(&self.uuid, conn).await {
for owner in owners {
if Membership::find_confirmed_by_user_and_org(&owner, org_uuid, conn).await.is_some() {
User::update_uuid_revision(&owner, conn).await;
user_uuids.push(owner);
}
}
return user_uuids;
}
// users having access to the collection // users having access to the collection
let mut collection_users = Membership::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await; let mut collection_users = Membership::find_by_cipher_and_org(&self.uuid, org_uuid, conn).await;
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
@ -461,6 +524,64 @@ impl Cipher {
} }
} }
/// Saves the cipher and adds it to the collections in one transaction, so it can't end up in the organization
/// without them.
pub async fn save_with_collections(&mut self, collection_uuids: &[CollectionId], conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await;
self.updated_at = Utc::now().naive_utc();
let rows: Vec<_> = collection_uuids
.iter()
.map(|c| (ciphers_collections::cipher_uuid.eq(&self.uuid), ciphers_collections::collection_uuid.eq(c)))
.collect();
let cipher = &*self;
db_run! { conn:
mysql {
conn.transaction::<_, diesel::result::Error, _>(|conn| {
diesel::insert_into(ciphers::table)
.values(cipher)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(cipher)
.execute(conn)?;
if !rows.is_empty() {
diesel::insert_into(ciphers_collections::table)
.values(&rows)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)?;
}
Ok(())
})
.map_res("Error saving cipher")
}
postgresql, sqlite {
conn.transaction::<_, diesel::result::Error, _>(|conn| {
diesel::insert_into(ciphers::table)
.values(cipher)
.on_conflict(ciphers::uuid)
.do_update()
.set(cipher)
.execute(conn)?;
if !rows.is_empty() {
diesel::insert_into(ciphers_collections::table)
.values(&rows)
.on_conflict((ciphers_collections::cipher_uuid, ciphers_collections::collection_uuid))
.do_nothing()
.execute(conn)?;
}
Ok(())
})
.map_res("Error saving cipher")
}
}?;
for collection_uuid in collection_uuids {
CollectionCipher::update_users_revision(collection_uuid, conn).await;
}
Ok(())
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;
@ -485,6 +606,24 @@ impl Cipher {
Ok(()) Ok(())
} }
/// Purges the organization vault like upstream's `Cipher_DeleteByOrganizationId`: the items stored in a
/// member's My Items collection are kept, they are only removed from the shared collections.
pub async fn purge_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let my_items = CollectionCipher::find_my_items_by_org(org_uuid, conn).await;
for cipher in Self::find_by_org(org_uuid, conn).await {
if !my_items.contains_key(&cipher.uuid) {
cipher.delete(conn).await?;
}
}
if !my_items.is_empty() {
CollectionCipher::delete_all_shared_by_organization(org_uuid, conn).await?;
let members = Membership::find_by_org(org_uuid, conn).await;
User::update_uuid_revisions(members.into_iter().map(|m| m.user_uuid).collect(), conn).await;
}
Ok(())
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
for cipher in Self::find_owned_by_user(user_uuid, conn).await { for cipher in Self::find_owned_by_user(user_uuid, conn).await {
cipher.delete(conn).await?; cipher.delete(conn).await?;
@ -543,23 +682,36 @@ impl Cipher {
self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid self.user_uuid.is_some() && self.user_uuid.as_ref().unwrap() == user_uuid
} }
/// Returns whether this cipher is owned by an org in which the user has full access. /// What the user's membership in the cipher's organization grants over all of its ciphers: whether it has
async fn is_in_full_access_org( /// full access, and whether it holds organization-wide cipher authority. Both are false without a confirmed
/// membership.
async fn org_wide_access(
&self, &self,
user_uuid: &UserId, user_uuid: &UserId,
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> bool { ) -> (bool, bool) {
if let Some(ref org_uuid) = self.organization_uuid { let Some(ref org_uuid) = self.organization_uuid else {
if let Some(cipher_sync_data) = cipher_sync_data { return (false, false);
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { };
return cached_member.has_full_access(); let access = |member: &Membership| (member.has_full_access(), may_administer_org_ciphers(member));
} if let Some(cipher_sync_data) = cipher_sync_data {
} else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await { cipher_sync_data.members.get(org_uuid).map_or((false, false), access)
return member.has_full_access(); } else {
} Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn)
.await
.as_ref()
.map_or((false, false), access)
}
}
/// Whether the cipher is assigned to My Items collections only.
async fn is_my_items_only(&self, cipher_sync_data: Option<&CipherSyncData>, conn: &DbConn) -> bool {
if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.my_items_only_ciphers.contains(&self.uuid)
} else {
CollectionCipher::find_my_items_owners_if_only(&self.uuid, conn).await.is_some()
} }
false
} }
/// Returns whether this cipher is owned by an group in which the user has full access. /// Returns whether this cipher is owned by an group in which the user has full access.
@ -589,15 +741,31 @@ impl Cipher {
pub async fn get_access_restrictions( pub async fn get_access_restrictions(
&self, &self,
user_uuid: &UserId, user_uuid: &UserId,
scope: CipherAccessScope,
cipher_sync_data: Option<&CipherSyncData>, cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn, conn: &DbConn,
) -> Option<(bool, bool, bool)> { ) -> Option<(bool, bool, bool)> {
if let Some(org_uuid) = self.organization_uuid.as_ref()
&& let Some(cipher_sync_data) = cipher_sync_data
&& !cipher_sync_data.members.contains_key(org_uuid)
{
// Cached collection rows can outlive a revoked membership. They are not effective without a confirmed
// membership in the same organization.
return None;
}
// Check whether this cipher is directly owned by the user, or is in // Check whether this cipher is directly owned by the user, or is in
// a collection that the user has full access to. If so, there are no // a collection that the user has full access to. If so, there are no
// access restrictions. // access restrictions.
if self.is_owned_by_user(user_uuid) if self.is_owned_by_user(user_uuid) {
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await return Some((false, false, true));
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await }
let (full_access, administers) = self.org_wide_access(user_uuid, cipher_sync_data, conn).await;
// Organization-wide access doesn't reach the items stored only in a member's My Items collection. Like
// upstream, only the administrative routes open those, for members with organization-wide cipher authority.
if (full_access || self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await)
&& ((scope == CipherAccessScope::OrganizationAdmin && administers)
|| !self.is_my_items_only(cipher_sync_data, conn).await)
{ {
return Some((false, false, true)); return Some((false, false, true));
} }
@ -718,8 +886,13 @@ impl Cipher {
.await .await
} }
pub async fn is_write_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_write_accessible_to_user(
match self.get_access_restrictions(user_uuid, None, conn).await { &self,
user_uuid: &UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> bool {
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
Some((read_only, _hide_passwords, manage)) => !read_only || manage, Some((read_only, _hide_passwords, manage)) => !read_only || manage,
None => false, None => false,
} }
@ -727,15 +900,20 @@ impl Cipher {
// used for checking if collection can be edited (only if user has access to a collection they // 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) // 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 { pub async fn is_in_editable_collection_by_user(
match self.get_access_restrictions(user_uuid, None, conn).await { &self,
user_uuid: &UserId,
scope: CipherAccessScope,
conn: &DbConn,
) -> bool {
match self.get_access_restrictions(user_uuid, scope, None, conn).await {
Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage, Some((read_only, hide_passwords, manage)) => (!read_only && !hide_passwords) || manage,
None => false, None => false,
} }
} }
pub async fn is_accessible_to_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_accessible_to_user(&self, user_uuid: &UserId, scope: CipherAccessScope, conn: &DbConn) -> bool {
self.get_access_restrictions(user_uuid, None, conn).await.is_some() self.get_access_restrictions(user_uuid, scope, None, conn).await.is_some()
} }
// Returns whether this cipher is a favorite of the specified user. // Returns whether this cipher is a favorite of the specified user.
@ -813,10 +991,12 @@ impl Cipher {
cipher_uuids: &Vec<CipherId>, cipher_uuids: &Vec<CipherId>,
conn: &DbConn, conn: &DbConn,
) -> Vec<Self> { ) -> Vec<Self> {
// access_all and admin rights don't reach the items stored only in another member's My Items collection
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
conn.run(move |conn| { conn.run(move |conn| {
let mut query = ciphers::table let mut query = ciphers::table
.left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) .left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.left_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.left_join( .left_join(
users_organizations::table.on(ciphers::organization_uuid users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable()) .eq(users_organizations::org_uuid.nullable())
@ -844,15 +1024,17 @@ impl Cipher {
.and(collections_groups::groups_uuid.eq(groups::uuid))), .and(collections_groups::groups_uuid.eq(groups::uuid))),
) )
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null())) // access_all in org
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.or_filter(groups::access_all.eq(true)) // Access via groups .or_filter(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) // Access via groups
.or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups .or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups
.into_boxed(); .into_boxed();
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin/owner
.and(collections::default_user_uuid.is_null()),
); );
} }
@ -868,6 +1050,7 @@ impl Cipher {
conn.run(move |conn| { conn.run(move |conn| {
let mut query = ciphers::table let mut query = ciphers::table
.left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid))) .left_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.left_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.left_join( .left_join(
users_organizations::table.on(ciphers::organization_uuid users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable()) .eq(users_organizations::org_uuid.nullable())
@ -881,13 +1064,15 @@ impl Cipher {
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))), .and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
) )
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null())) // access_all in org
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.into_boxed(); .into_boxed();
if !visible_only { if !visible_only {
query = query.or_filter( query = query.or_filter(
users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner users_organizations::atype
.le(MembershipType::Admin as i32) // Org admin/owner
.and(collections::default_user_uuid.is_null()),
); );
} }
@ -1007,10 +1192,11 @@ impl Cipher {
.filter( .filter(
users_organizations::access_all users_organizations::access_all
.eq(true) // User has access all .eq(true) // User has access all
.and(collections::default_user_uuid.is_null())
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(groups::access_all.eq(true)) // Access via groups .or(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) // Access via groups
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))), .and(collections_groups::read_only.eq(false))),
@ -1039,6 +1225,7 @@ impl Cipher {
.filter( .filter(
users_organizations::access_all users_organizations::access_all
.eq(true) // User has access all .eq(true) // User has access all
.and(collections::default_user_uuid.is_null())
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))), .and(users_collections::read_only.eq(false))),
@ -1051,6 +1238,17 @@ impl Cipher {
} }
} }
/// The cipher's collections the user sees it in, whatever they may do there: its `collectionIds` in the user's
/// sync, and upstream's `GetManyByUserIdCipherIdAsync`. Unlike `get_collections()`, whose callers need the
/// writable assignments.
pub async fn get_accessible_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec<CollectionId> {
Self::get_collections_with_cipher_by_user(user_uuid, Some(self.uuid.clone()), conn)
.await
.into_iter()
.map(|(_, collection_uuid)| collection_uuid)
.collect()
}
pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec<CollectionId> { pub async fn get_admin_collections(&self, user_uuid: UserId, conn: &DbConn) -> Vec<CollectionId> {
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
conn.run(move |conn| { conn.run(move |conn| {
@ -1084,14 +1282,17 @@ impl Cipher {
.filter( .filter(
users_organizations::access_all users_organizations::access_all
.eq(true) // User has access all .eq(true) // User has access all
.and(collections::default_user_uuid.is_null())
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(groups::access_all.eq(true)) // Access via groups .or(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) // Access via groups
.or(collections_groups::collections_uuid .or(collections_groups::collections_uuid
.is_not_null() // Access via groups .is_not_null() // Access via groups
.and(collections_groups::read_only.eq(false))) .and(collections_groups::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype
.le(MembershipType::Admin as i32) // User is admin or owner
.and(collections::default_user_uuid.is_null())),
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@ -1117,10 +1318,13 @@ impl Cipher {
.filter( .filter(
users_organizations::access_all users_organizations::access_all
.eq(true) // User has access all .eq(true) // User has access all
.and(collections::default_user_uuid.is_null())
.or(users_collections::user_uuid .or(users_collections::user_uuid
.eq(user_uuid) // User has access to collection .eq(user_uuid) // User has access to collection
.and(users_collections::read_only.eq(false))) .and(users_collections::read_only.eq(false)))
.or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner .or(users_organizations::atype
.le(MembershipType::Admin as i32) // User is admin or owner
.and(collections::default_user_uuid.is_null())),
) )
.select(ciphers_collections::collection_uuid) .select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn) .load::<CollectionId>(conn)
@ -1132,12 +1336,14 @@ impl Cipher {
/// Return a Vec with (cipher_uuid, collection_uuid) /// Return a Vec with (cipher_uuid, collection_uuid)
/// This is used during a full sync so we only need one query for all collections accessible. /// This is used during a full sync so we only need one query for all collections accessible.
/// With `cipher_uuid`, only the ones of that cipher.
pub async fn get_collections_with_cipher_by_user( pub async fn get_collections_with_cipher_by_user(
user_uuid: UserId, user_uuid: UserId,
cipher_uuid: Option<CipherId>,
conn: &DbConn, conn: &DbConn,
) -> Vec<(CipherId, CollectionId)> { ) -> Vec<(CipherId, CollectionId)> {
conn.run(move |conn| { conn.run(move |conn| {
ciphers_collections::table let mut query = ciphers_collections::table
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid))) .inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.inner_join( .inner_join(
users_organizations::table.on(users_organizations::org_uuid users_organizations::table.on(users_organizations::org_uuid
@ -1160,12 +1366,23 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid) .eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))), .and(collections_groups::groups_uuid.eq(groups::uuid))),
) )
// access_all and admin rights don't reach another member's My Items collection
.or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection
.or_filter(users_organizations::access_all.eq(true)) // User has access all .or_filter(users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null())) // User has access all
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner .or_filter(
.or_filter(groups::access_all.eq(true)) //Access via group users_organizations::atype
.le(MembershipType::Admin as i32) // User is admin or owner
.and(collections::default_user_uuid.is_null()),
)
.or_filter(groups::access_all.eq(true).and(collections::default_user_uuid.is_null())) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.into_boxed();
// After the alternatives above, so it narrows all of them
if let Some(cipher_uuid) = cipher_uuid {
query = query.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid));
}
query
.select(ciphers_collections::all_columns) .select(ciphers_collections::all_columns)
.distinct() .distinct()
.load::<(CipherId, CollectionId)>(conn) .load::<(CipherId, CollectionId)>(conn)

472
src/db/models/collection.rs

@ -1,6 +1,13 @@
use std::collections::{HashMap, HashSet};
use derive_more::{AsRef, Deref, Display, From}; use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::{
dsl::{count, count_star},
prelude::*,
result::{DatabaseErrorKind, Error as DieselError},
};
use serde_json::Value; use serde_json::Value;
use tokio::time::{Duration, sleep};
use crate::{ use crate::{
CONFIG, CONFIG,
@ -17,8 +24,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy,
User, UserId, OrganizationId, User, UserId,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
@ -31,6 +38,10 @@ pub struct Collection {
pub org_uuid: OrganizationId, pub org_uuid: OrganizationId,
pub name: String, pub name: String,
pub external_id: Option<String>, pub external_id: Option<String>,
/// The member whose "My Items" collection (upstream's `DefaultUserCollection`) this is, `None` for a shared one.
pub default_user_uuid: Option<UserId>,
/// The email address of the former owner of a My Items collection that was turned into a shared one.
pub default_user_collection_email: Option<String>,
} }
#[derive(Identifiable, Queryable, Insertable)] #[derive(Identifiable, Queryable, Insertable)]
@ -60,23 +71,54 @@ impl Collection {
org_uuid, org_uuid,
name, name,
external_id: None, external_id: None,
default_user_uuid: None,
default_user_collection_email: None,
}; };
new_model.set_external_id(external_id); new_model.set_external_id(external_id);
new_model new_model
} }
pub fn is_default_user_collection(&self) -> bool {
self.default_user_uuid.is_some()
}
/// Like upstream, an item that is only in shared collections can't be put (back) into a My Items collection.
/// `current_collections` are the item's collections the acting user sees, before the change. Neither can an
/// item of another member's My Items, which the acting user may administer but doesn't see:
/// `cipher_my_items` are all the My Items collections the item is in.
pub fn check_cipher_assignment(
&self,
current_collections: &HashSet<CollectionId>,
cipher_my_items: &HashSet<CollectionId>,
) -> EmptyResult {
if !self.is_default_user_collection() {
return Ok(());
}
if !current_collections.is_empty() && !current_collections.contains(&self.uuid) {
err!(
"The cipher(s) cannot be assigned to a default collection when only assigned to non-default collections."
)
}
if cipher_my_items.iter().any(|c| *c != self.uuid) {
err!(
"The cipher(s) cannot be assigned to a default collection when in another member's default collection."
)
}
Ok(())
}
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
json!({ json!({
"externalId": self.external_id, "externalId": self.external_id,
"id": self.uuid, "id": self.uuid,
"organizationId": self.org_uuid, "organizationId": self.org_uuid,
"name": self.name, "name": self.name,
// Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection. // Collection types are either 0: SharedCollection or 1: DefaultUserCollection ("My Items").
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs
"type": 0, "type": i32::from(self.is_default_user_collection()),
// This is only used together with MyItems/DefaultUserCollection, which we do not yet support. // Set when a My Items collection outlived its owner's membership, the clients show it as the name.
"defaultUserCollectionEmail": null, "defaultUserCollectionEmail": self.default_user_collection_email,
"object": "collection", "object": "collection",
}) })
} }
@ -108,8 +150,9 @@ impl Collection {
// Owners and Admins always have true. Users are not able to have full access // Owners and Admins always have true. Users are not able to have full access
Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager), Some(m) if m.has_full_access() => (false, false, m.atype >= MembershipType::Manager),
Some(m) => { Some(m) => {
// Only let a manager manage collections when the have full read/write access // Only let a manager manage collections when the have full read/write access.
let is_manager = m.atype == MembershipType::Manager; // The owner of a My Items collection, its only member, manages it whatever their type, like upstream.
let is_manager = m.atype == MembershipType::Manager || self.is_default_user_collection();
if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) { if let Some(cu) = cipher_sync_data.user_collections.get(&self.uuid) {
( (
cu.read_only, cu.read_only,
@ -135,7 +178,7 @@ impl Collection {
(false, false, true) (false, false, true)
} }
Some(m) => { Some(m) => {
let is_manager = m.atype == MembershipType::Manager; let is_manager = m.atype == MembershipType::Manager || self.is_default_user_collection();
let read_only = !self.is_writable_by_user(user_uuid, conn).await; let read_only = !self.is_writable_by_user(user_uuid, conn).await;
let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await; let hide_passwords = self.hide_passwords_for_user(user_uuid, conn).await;
(read_only, hide_passwords, is_manager && !read_only && !hide_passwords) (read_only, hide_passwords, is_manager && !read_only && !hide_passwords)
@ -167,6 +210,20 @@ impl Collection {
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;
if self.is_default_user_collection() {
// Only ever updated here. They are created by `create_default_user_collections()`: on MySQL, the upsert
// below also fires on the unique `(org_uuid, default_user_uuid)` index and would overwrite the member's
// existing My Items collection.
return conn
.run(move |conn| {
diesel::update(collections::table.filter(collections::uuid.eq(&self.uuid)))
.set(self)
.execute(conn)
.map_res("Error saving collection")
})
.await;
}
db_run! { conn: db_run! { conn:
mysql { mysql {
diesel::insert_into(collections::table) diesel::insert_into(collections::table)
@ -211,6 +268,12 @@ impl Collection {
} }
pub async fn update_users_revision(&self, conn: &DbConn) { pub async fn update_users_revision(&self, conn: &DbConn) {
if let Some(owner) = &self.default_user_uuid {
if Membership::find_confirmed_by_user_and_org(owner, &self.org_uuid, conn).await.is_some() {
User::update_uuid_revision(owner, conn).await;
}
return;
}
for member in &Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await { for member in &Membership::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn).await {
User::update_uuid_revision(&member.user_uuid, conn).await; User::update_uuid_revision(&member.user_uuid, conn).await;
} }
@ -251,12 +314,13 @@ impl Collection {
.filter( .filter(
users_collections::user_uuid users_collections::user_uuid
.eq(user_uuid) .eq(user_uuid)
// access_all never reaches another member's My Items collection
.or( .or(
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true), // access_all in Organization users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null()), // access_all in Organization
) )
.or( .or(
groups::access_all.eq(true), // access_all in groups groups::access_all.eq(true).and(collections::default_user_uuid.is_null()), // access_all in groups
) )
.or( .or(
// access via groups // access via groups
@ -287,7 +351,8 @@ impl Collection {
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_collections::user_uuid.eq(user_uuid).or( .filter(users_collections::user_uuid.eq(user_uuid).or(
// Directly accessed collection // Directly accessed collection
users_organizations::access_all.eq(true), // access_all in Organization // access_all in Organization, which never reaches another member's My Items collection
users_organizations::access_all.eq(true).and(collections::default_user_uuid.is_null()),
)) ))
.select(collections::all_columns) .select(collections::all_columns)
.distinct() .distinct()
@ -339,6 +404,199 @@ impl Collection {
.await .await
} }
pub async fn find_default_by_user_and_org(
user_uuid: &UserId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> Option<Self> {
conn.run(move |conn| {
collections::table
.filter(collections::org_uuid.eq(org_uuid))
.filter(collections::default_user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
})
.await
}
/// Creates the member's My Items collection, with the name the client encrypted for it, when upstream does:
/// the member is confirmed, not an Owner or Admin, and the organization data ownership policy is enabled.
/// Without a name nothing is created, like upstream.
pub async fn create_default_user_collection(member: &Membership, name: Option<&str>, conn: &DbConn) -> EmptyResult {
if !OrgPolicy::is_personal_ownership_enforced_for(member, conn).await {
return Ok(());
}
Self::create_default_user_collections(std::slice::from_ref(member), name, conn).await
}
/// Creates all missing My Items collections for an enabled organization data ownership policy in one
/// transaction, and gives the members a missing access to their existing one back, which a member update racing
/// the creation could remove in earlier versions. The caller is responsible for checking that the policy is
/// enabled. Re-running this is safe and repairs incomplete provisioning from an earlier request. The unique index
/// on `(org_uuid, default_user_uuid)` makes sure a member never gets a second one, also when requests race.
pub async fn create_default_user_collections(
members: &[Membership],
name: Option<&str>,
conn: &DbConn,
) -> EmptyResult {
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
return Ok(());
};
let eligible: Vec<(OrganizationId, UserId)> = members
.iter()
.filter(|member| member.has_status(MembershipStatus::Confirmed) && member.atype < MembershipType::Admin)
.map(|member| (member.org_uuid.clone(), member.user_uuid.clone()))
.collect();
let Some((org_uuid, _)) = eligible.first() else {
return Ok(());
};
if eligible.iter().any(|(member_org_uuid, _)| member_org_uuid != org_uuid) {
err!("Cannot create My Items collections for members of different organizations")
}
let org_uuid = org_uuid.clone();
let user_uuids: Vec<UserId> = eligible.into_iter().map(|(_, user_uuid)| user_uuid).collect();
// The body of the transaction below, for the members it found still eligible: gives them back a missing
// access to their existing My Items collection and creates the missing ones. Returns the members whose
// access changed.
macro_rules! provision {
($conn:ident, $org_uuid:ident, $members:ident, $name:ident) => {{
let missing_access = collections::table
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.nullable().eq(collections::default_user_uuid))),
)
.filter(collections::org_uuid.eq(&$org_uuid))
.filter(collections::default_user_uuid.eq_any(&$members))
.filter(users_collections::user_uuid.is_null())
.select((
collections::default_user_uuid.assume_not_null(),
collections::uuid,
false.into_sql::<diesel::sql_types::Bool>(),
false.into_sql::<diesel::sql_types::Bool>(),
true.into_sql::<diesel::sql_types::Bool>(),
));
let restored = diesel::insert_into(users_collections::table)
.values(missing_access)
.into_columns((
users_collections::user_uuid,
users_collections::collection_uuid,
users_collections::read_only,
users_collections::hide_passwords,
users_collections::manage,
))
.execute($conn)?;
let existing: HashSet<UserId> = collections::table
.filter(collections::org_uuid.eq(&$org_uuid))
.filter(collections::default_user_uuid.eq_any(&$members))
.select(collections::default_user_uuid)
.load::<Option<UserId>>($conn)?
.into_iter()
.flatten()
.collect();
let mut created = Vec::new();
for user_uuid in $members.iter().filter(|user_uuid| !existing.contains(*user_uuid)) {
let mut collection = Self::new($org_uuid.clone(), $name.clone(), None);
collection.default_user_uuid = Some(user_uuid.clone());
let owner_access = CollectionUser {
user_uuid: user_uuid.clone(),
collection_uuid: collection.uuid.clone(),
read_only: false,
hide_passwords: false,
manage: true,
};
diesel::insert_into(collections::table).values(&collection).execute($conn)?;
diesel::insert_into(users_collections::table).values(&owner_access).execute($conn)?;
created.push(user_uuid.clone());
}
Ok::<_, DieselError>(if restored > 0 {
$members
} else {
created
})
}};
}
// A concurrent request can create one of these between the read and insert. Retry when the unique index
// rejects the insert, which MySQL and MariaDB often report as a deadlock instead of a unique violation; the
// next read omits the collections created by the racer.
for attempt in 0..3 {
if attempt > 0 {
// Give the concurrent request time to commit, so the next read sees what it created
sleep(Duration::from_millis(100)).await;
}
let (org_uuid, name) = (org_uuid.clone(), name.to_owned());
// Only the members that are still confirmed, and neither an Owner nor an Admin, when the transaction runs
// get one. The members were loaded before, and a removal converting their My Items collection could have
// run meanwhile or run concurrently: see `Membership::delete()`, which removes the membership first.
let eligible = users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid.clone()))
.filter(users_organizations::user_uuid.eq_any(user_uuids.clone()))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::atype.eq_any([MembershipType::User as i32, MembershipType::Manager as i32]),
)
.select(users_organizations::user_uuid);
let result = db_run! { conn:
mysql, postgresql {
// Locking the memberships makes a concurrent removal wait until the collections are created, so
// it converts them, or makes this wait until the removal is done, so it doesn't find the member.
conn.transaction(|conn| {
let members = eligible.for_update().load::<UserId>(conn)?;
provision!(conn, org_uuid, members, name)
})
}
sqlite {
// No row locks: taking the database write lock before reading serializes this with the removal.
conn.immediate_transaction(|conn| {
let members = eligible.load::<UserId>(conn)?;
provision!(conn, org_uuid, members, name)
})
}
};
match result {
Ok(changed) => {
User::update_uuid_revisions(changed, conn).await;
return Ok(());
}
Err(DieselError::DatabaseError(
DatabaseErrorKind::UniqueViolation | DatabaseErrorKind::SerializationFailure,
_,
)) => {}
Err(e) => return Err::<(), _>(e).map_res("Error creating My Items collections"),
}
}
err!("A concurrent request kept changing the My Items collections; please retry")
}
/// Offboards what remains of a deleted account in the organizations, once its memberships are removed, like
/// upstream's `User_DeleteById`: turns the My Items collections still attributed to the user into shared ones
/// named by their email address, and removes their collection access, which would otherwise block the deletion.
/// A member removal racing the creation of a My Items collection could leave both behind in earlier versions.
pub async fn release_all_by_user(user_uuid: &UserId, email: &str, conn: &DbConn) -> EmptyResult {
let email = email.to_owned();
conn.run(move |conn| {
conn.transaction::<_, DieselError, _>(|conn| {
diesel::update(collections::table.filter(collections::default_user_uuid.eq(user_uuid)))
.set((
collections::default_user_uuid.eq(None::<UserId>),
collections::default_user_collection_email.eq(email),
))
.execute(conn)?;
diesel::delete(users_collections::table.filter(users_collections::user_uuid.eq(user_uuid)))
.execute(conn)?;
Ok(())
})
.map_res("Error removing the user's collection access")
})
.await
}
pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option<Self> { pub async fn find_by_uuid_and_user(uuid: &CollectionId, user_uuid: UserId, conn: &DbConn) -> Option<Self> {
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
conn.run(move |conn| { conn.run(move |conn| {
@ -424,6 +682,12 @@ impl Collection {
} }
pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_writable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
// Only its owner can put items into a My Items collection, admin rights and access_all don't reach into it.
// Like any collection access upstream, only while the owner is a confirmed member of the organization.
if let Some(owner) = &self.default_user_uuid {
return owner == user_uuid
&& Membership::find_confirmed_by_user_and_org(user_uuid, &self.org_uuid, conn).await.is_some();
}
let user_uuid = user_uuid.to_string(); let user_uuid = user_uuid.to_string();
if CONFIG.org_groups_enabled() { if CONFIG.org_groups_enabled() {
conn.run(move |conn| { conn.run(move |conn| {
@ -656,6 +920,8 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))), .and(collections_groups::collections_uuid.eq(collections::uuid))),
) )
.filter(collections::org_uuid.eq(&org_uuid)) .filter(collections::org_uuid.eq(&org_uuid))
// Like upstream, only shared collections count: every member manages their own My Items collection
.filter(collections::default_user_uuid.is_null())
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter( .filter(
// Manage permission on a collection assigned directly or via a group. // Manage permission on a collection assigned directly or via a group.
@ -882,6 +1148,38 @@ impl CollectionUser {
.await .await
} }
/// Removes the user from all the organization's collections except their own My Items collection. In one
/// statement, like upstream's `OrganizationUser_UpdateWithCollections`, so it also keeps a My Items
/// collection another request creates meanwhile.
pub async fn delete_all_but_my_items_by_user_and_org(
user_uuid: &UserId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
conn.run(move |conn| {
diesel::delete(
users_collections::table.filter(users_collections::user_uuid.eq(user_uuid)).filter(
users_collections::collection_uuid.eq_any(
collections::table
.filter(collections::org_uuid.eq(org_uuid))
.filter(
collections::default_user_uuid
.is_null()
.or(collections::default_user_uuid.ne(user_uuid)),
)
.select(collections::uuid),
),
),
)
.execute(conn)
.map(|_| ())
.map_res("Error removing user from collections")
})
.await
}
pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn has_access_to_collection_by_user(col_id: &CollectionId, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::find_by_collection_and_user(col_id, user_uuid, conn).await.is_some() Self::find_by_collection_and_user(col_id, user_uuid, conn).await.is_some()
} }
@ -956,6 +1254,154 @@ impl CollectionCipher {
collection.update_users_revision(conn).await; collection.update_users_revision(conn).await;
} }
} }
/// Changes the cipher's collections in one transaction: adds it to `add` and removes it from `remove`.
pub async fn update_for_cipher(
cipher_uuid: &CipherId,
add: Vec<CollectionId>,
remove: Vec<CollectionId>,
conn: &DbConn,
) -> EmptyResult {
// Before the change, like `save()` and `delete()`, so the members that lose the cipher sync as well
for collection_uuid in add.iter().chain(&remove) {
Self::update_users_revision(collection_uuid, conn).await;
}
let rows: Vec<_> = add
.iter()
.map(|c| (ciphers_collections::cipher_uuid.eq(cipher_uuid), ciphers_collections::collection_uuid.eq(c)))
.collect();
let remove_rows = ciphers_collections::table
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
.filter(ciphers_collections::collection_uuid.eq_any(&remove));
db_run! { conn:
mysql {
conn.transaction::<_, DieselError, _>(|conn| {
if !rows.is_empty() {
diesel::insert_into(ciphers_collections::table)
.values(&rows)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)?;
}
diesel::delete(remove_rows).execute(conn)
})
.map(|_| ())
.map_res("Error updating the cipher's collections")
}
postgresql, sqlite {
conn.transaction::<_, DieselError, _>(|conn| {
if !rows.is_empty() {
diesel::insert_into(ciphers_collections::table)
.values(&rows)
.on_conflict((ciphers_collections::cipher_uuid, ciphers_collections::collection_uuid))
.do_nothing()
.execute(conn)?;
}
diesel::delete(remove_rows).execute(conn)
})
.map(|_| ())
.map_res("Error updating the cipher's collections")
}
}
}
/// The organizations' ciphers that are in My Items collections only.
pub async fn find_my_items_only_by_orgs(org_uuids: Vec<OrganizationId>, conn: &DbConn) -> HashSet<CipherId> {
if org_uuids.is_empty() {
return HashSet::new();
}
conn.run(move |conn| {
ciphers_collections::table
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.filter(collections::org_uuid.eq_any(org_uuids))
.group_by(ciphers_collections::cipher_uuid)
.select((ciphers_collections::cipher_uuid, count(collections::default_user_uuid), count_star()))
.load::<(CipherId, i64, i64)>(conn)
.expect("Error loading My Items ciphers")
.into_iter()
.filter(|(_, in_my_items, in_total)| in_my_items == in_total)
.map(|(cipher_uuid, _, _)| cipher_uuid)
.collect()
})
.await
}
/// The organization's ciphers that are in a My Items collection, each with those My Items collections.
pub async fn find_my_items_by_org(
org_uuid: &OrganizationId,
conn: &DbConn,
) -> HashMap<CipherId, Vec<CollectionId>> {
conn.run(move |conn| {
ciphers_collections::table
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.filter(collections::org_uuid.eq(org_uuid))
.filter(collections::default_user_uuid.is_not_null())
.select(ciphers_collections::all_columns)
.load::<(CipherId, CollectionId)>(conn)
.expect("Error loading My Items ciphers")
.into_iter()
.fold(HashMap::new(), |mut map: HashMap<CipherId, Vec<CollectionId>>, (cipher, collection)| {
map.entry(cipher).or_default().push(collection);
map
})
})
.await
}
/// The My Items collections the cipher is in, whoever's they are.
pub async fn find_my_items_of_cipher(cipher_uuid: &CipherId, conn: &DbConn) -> HashSet<CollectionId> {
conn.run(move |conn| {
ciphers_collections::table
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
.filter(collections::default_user_uuid.is_not_null())
.select(ciphers_collections::collection_uuid)
.load::<CollectionId>(conn)
.expect("Error loading the cipher's My Items collections")
.into_iter()
.collect()
})
.await
}
/// Returns the owners when the cipher is assigned exclusively to My Items collections. `None` means that the
/// cipher is unassigned or has at least one shared collection assignment.
pub async fn find_my_items_owners_if_only(cipher_uuid: &CipherId, conn: &DbConn) -> Option<HashSet<UserId>> {
let owners = conn
.run(move |conn| {
ciphers_collections::table
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
.inner_join(collections::table.on(collections::uuid.eq(ciphers_collections::collection_uuid)))
.select(collections::default_user_uuid)
.load::<Option<UserId>>(conn)
.expect("Error loading My Items owners")
})
.await;
if owners.is_empty() || owners.iter().any(Option::is_none) {
None
} else {
Some(owners.into_iter().flatten().collect())
}
}
pub async fn delete_all_shared_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::delete(
ciphers_collections::table.filter(
ciphers_collections::collection_uuid.eq_any(
collections::table
.filter(collections::org_uuid.eq(org_uuid))
.filter(collections::default_user_uuid.is_null())
.select(collections::uuid),
),
),
)
.execute(conn)
.map_res("Error removing ciphers from the shared collections")
})
.await
}
} }
// Added in case we need the membership_uuid instead of the user_uuid // Added in case we need the membership_uuid instead of the user_uuid

2
src/db/models/event.rs

@ -109,7 +109,7 @@ pub enum EventType {
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517, // OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519, OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520, // OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,

2
src/db/models/mod.rs

@ -21,7 +21,7 @@ mod user;
pub use self::archive::Archive; pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherAccessScope, CipherId, RepromptType};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};

10
src/db/models/org_policy.rs

@ -381,6 +381,16 @@ impl OrgPolicy {
false false
} }
/// Whether the personal ownership (upstream: organization data ownership) policy of the member's organization
/// applies to them. Like upstream, it exempts Owners, Admins and members that are not confirmed.
pub async fn is_personal_ownership_enforced_for(member: &Membership, conn: &DbConn) -> bool {
member.has_status(MembershipStatus::Confirmed)
&& member.atype < MembershipType::Admin
&& Self::find_by_org_and_type(&member.org_uuid, OrgPolicyType::PersonalOwnership, conn)
.await
.is_some_and(|p| p.enabled)
}
pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool { pub async fn is_enabled_for_member(member_uuid: &MembershipId, policy_type: OrgPolicyType, conn: &DbConn) -> bool {
if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await if let Some(member) = Membership::find_by_uuid(member_uuid, conn).await
&& let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await && let Some(policy) = OrgPolicy::find_by_org_and_type(&member.org_uuid, policy_type, conn).await

79
src/db/models/organization.rs

@ -12,8 +12,8 @@ use crate::{
db::{ db::{
DbConn, DbConn,
schema::{ schema::{
ciphers, ciphers_collections, collections_groups, groups, groups_users, org_policies, organization_api_key, ciphers, ciphers_collections, collections, collections_groups, groups, groups_users, org_policies,
organizations, users, users_collections, users_organizations, organization_api_key, organizations, users, users_collections, users_organizations,
}, },
}, },
error::MapResult, error::MapResult,
@ -21,8 +21,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, Cipher, CipherId, Collection, CollectionCipher, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy,
TwoFactor, User, UserId, OrgPolicyType, TwoFactor, User, UserId,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -216,7 +216,7 @@ impl Organization {
"useApi": true, "useApi": true,
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads "useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported "useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported "useMyItems": true,
"useOrganizationDomains": false, // Not supported (Linked to SSO) "useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported "usePam": false, // Not supported
"usePhishingBlocker": false, "usePhishingBlocker": false,
@ -492,7 +492,7 @@ impl Membership {
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads "useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported "useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported "useMyItems": true,
"useOrganizationDomains": false, // Not supported (Linked to SSO) "useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported "usePam": false, // Not supported
"usePhishingBlocker": false, "usePhishingBlocker": false,
@ -569,9 +569,12 @@ impl Membership {
// If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self // If collections are to be included, only include them if the user does not have full access via a group or defined to the user it self
// Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint // Only the collections assigned directly are returned, the ones assigned via a group are returned via a special group endpoint
let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) { let collections: Vec<Value> = if include_collections && !(full_access_group || self.access_all) {
// Like upstream, a member's My Items collection is not part of their collection access
let my_items = Collection::find_default_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await;
CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn)
.await .await
.into_iter() .into_iter()
.filter(|cu| my_items.as_ref().is_none_or(|c| c.uuid != cu.collection_uuid))
.map(|cu| { .map(|cu| {
json!({ json!({
"id": cu.collection_uuid, "id": cu.collection_uuid,
@ -742,15 +745,60 @@ impl Membership {
pub async fn delete(self, conn: &DbConn) -> EmptyResult { pub async fn delete(self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.user_uuid, conn).await; User::update_uuid_revision(&self.user_uuid, conn).await;
CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?; // Offboarding, like upstream's `OrganizationUser_DeleteById`: the member's My Items collection and its items
GroupUser::delete_all_by_member(&self.uuid, conn).await?; // stay in the organization as a shared collection, named by the former member's email address. Converted in
// the same transaction as the removal, so a failure can't convert it while the member stays, and by the
conn.run(move |conn| { // member rather than by a collection looked up beforehand.
diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid))) let (member_uuid, user_uuid, org_uuid) = (self.uuid, self.user_uuid, self.org_uuid);
.execute(conn) let converted = conn
.run(move |conn| {
conn.transaction::<_, diesel::result::Error, _>(|conn| {
// Writes first: SQLite can't turn a transaction that started with a read into a writing one
// once another connection wrote meanwhile, and fails without waiting ("database is locked").
diesel::delete(groups_users::table.filter(groups_users::users_organizations_uuid.eq(&member_uuid)))
.execute(conn)?;
// The membership goes before the conversion: its row lock (the write lock on SQLite) makes a
// concurrent creation of the member's My Items collection either finish first, so the conversion
// below finds it, or wait and then find the member gone. See
// `Collection::create_default_user_collections()`.
diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(&member_uuid)))
.execute(conn)?;
let my_items = collections::table
.filter(collections::org_uuid.eq(&org_uuid))
.filter(collections::default_user_uuid.eq(&user_uuid));
let converted = my_items.select(collections::uuid).load::<CollectionId>(conn)?;
let email = users::table
.filter(users::uuid.eq(&user_uuid))
.select(users::email)
.first::<String>(conn)
.optional()?;
diesel::update(my_items)
.set((
collections::default_user_uuid.eq(None::<UserId>),
collections::default_user_collection_email.eq(email),
))
.execute(conn)?;
diesel::delete(
users_collections::table.filter(users_collections::user_uuid.eq(&user_uuid)).filter(
users_collections::collection_uuid.eq_any(
collections::table
.filter(collections::org_uuid.eq(&org_uuid))
.select(collections::uuid),
),
),
)
.execute(conn)?;
Ok(converted)
})
.map_res("Error removing user from organization") .map_res("Error removing user from organization")
}) })
.await .await?;
// The members that now reach the former My Items collection sync it
for collection_uuid in &converted {
CollectionCipher::update_users_revision(collection_uuid, conn).await;
}
Ok(())
} }
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
@ -1028,6 +1076,7 @@ impl Membership {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
.left_join( .left_join(
ciphers_collections::table.on(ciphers_collections::collection_uuid ciphers_collections::table.on(ciphers_collections::collection_uuid
@ -1054,6 +1103,7 @@ impl Membership {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.inner_join( .inner_join(
groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)), groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)),
) )
@ -1110,6 +1160,7 @@ impl Membership {
conn.run(move |conn| { conn.run(move |conn| {
users_organizations::table users_organizations::table
.filter(users_organizations::org_uuid.eq(org_uuid)) .filter(users_organizations::org_uuid.eq(org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid))) .left_join(users_collections::table.on(users_collections::user_uuid.eq(users_organizations::user_uuid)))
.filter(users_organizations::access_all.eq(true).or( .filter(users_organizations::access_all.eq(true).or(
// AccessAll.. // AccessAll..

28
src/db/models/user.rs

@ -19,7 +19,8 @@ use crate::{
use macros::UuidFromParam; use macros::UuidFromParam;
use super::{ use super::{
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, Cipher, Collection, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
TwoFactorIncomplete,
}; };
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
@ -361,6 +362,7 @@ impl User {
EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?; EmergencyAccess::delete_all_by_user(&self.uuid, conn).await?;
EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?; EmergencyAccess::delete_all_by_grantee_email(&self.email, conn).await?;
Membership::delete_all_by_user(&self.uuid, conn).await?; Membership::delete_all_by_user(&self.uuid, conn).await?;
Collection::release_all_by_user(&self.uuid, &self.email, conn).await?;
Cipher::delete_all_by_user(&self.uuid, conn).await?; Cipher::delete_all_by_user(&self.uuid, conn).await?;
Favorite::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?;
Folder::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?;
@ -381,6 +383,30 @@ impl User {
} }
} }
pub async fn update_uuid_revisions(uuids: Vec<UserId>, conn: &DbConn) {
if uuids.is_empty() {
return;
}
let updated_at = Utc::now().naive_utc();
if let Err(e) = conn
.run(move |conn| {
retry(
|| {
diesel::update(users::table.filter(users::uuid.eq_any(&uuids)))
.set(users::updated_at.eq(updated_at))
.execute(conn)
},
10,
)
.map(|_| ())
.map_res("Error updating user revisions")
})
.await
{
warn!("Failed to update user revisions: {e:#?}");
}
}
pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult { pub async fn update_all_revisions(conn: &DbConn) -> EmptyResult {
let updated_at = Utc::now().naive_utc(); let updated_at = Utc::now().naive_utc();

2
src/db/schema.rs

@ -40,6 +40,8 @@ table! {
org_uuid -> Text, org_uuid -> Text,
name -> Text, name -> Text,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
default_user_uuid -> Nullable<Text>,
default_user_collection_email -> Nullable<Text>,
} }
} }

42
src/util.rs

@ -540,6 +540,48 @@ pub fn is_valid_email(email: &str) -> bool {
email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none() email_url.domain().is_some() && email_url.path() == "/" && email_url.query().is_none()
} }
/// Returns whether the value has the form of an encrypted string, like upstream's `EncryptedStringAttribute`:
/// `<type>.<piece>|<piece>...` with a known encryption type, its number of base64 pieces, and the fixed decoded
/// lengths of the IV (16 bytes) and the MAC (32 bytes). Without a type it is the legacy `iv|ct[|mac]` format.
pub fn is_valid_enc_string(value: &str) -> bool {
const IV: Option<usize> = Some(16);
const MAC: Option<usize> = Some(32);
const ANY: Option<usize> = None;
let (pieces, rest): (&[Option<usize>], &str) = match value.split_once('.') {
Some((header, rest)) => match header.parse::<u8>() {
// AesCbc256_B64
Ok(0) => (&[IV, ANY], rest),
// AesCbc256_HmacSha256_B64
Ok(2) => (&[IV, ANY, MAC], rest),
// Rsa2048_OaepSha256_B64, Rsa2048_OaepSha1_B64, and type 7: a CBOR encoded Encrypt0 message
Ok(3 | 4 | 7) => (&[ANY], rest),
// Rsa2048_OaepSha256_HmacSha256_B64, Rsa2048_OaepSha1_HmacSha256_B64
Ok(5 | 6) => (&[ANY, ANY], rest),
_ => return false,
},
None if value.matches('|').count() == 2 => (&[IV, ANY, MAC], value),
None => (&[IV, ANY], value),
};
let parts: Vec<&str> = rest.split('|').collect();
parts.len() == pieces.len() && parts.iter().zip(pieces).all(|(part, len)| is_valid_base64_piece(part, *len))
}
/// Base64 with padding and, like upstream, any value for the unused bits of the last character.
fn is_valid_base64_piece(piece: &str, decoded_len: Option<usize>) -> bool {
let bytes = piece.as_bytes();
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
return false;
}
let padding = bytes.iter().rev().take_while(|&&b| b == b'=').count();
if padding > 2 {
return false;
}
bytes[..bytes.len() - padding].iter().all(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/')
&& decoded_len.is_none_or(|len| bytes.len() / 4 * 3 - padding == len)
}
// //
// Deployment environment methods // Deployment environment methods
// //

Loading…
Cancel
Save