use std::collections::{HashMap, HashSet}; use num_traits::FromPrimitive; use rocket::{Route, serde::json::Json}; use serde_json::Value; use crate::{ CONFIG, api::admin::FAKE_ADMIN_UUID, api::{ EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor}, }, auth::{ AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite, }, db::{ DbConn, models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, }, }, mail, sso::FAKE_SSO_IDENTIFIER, util::{NumberOrString, convert_json_key_lcase_first}, }; pub fn routes() -> Vec { routes![ get_organization, create_organization, delete_organization, post_delete_organization, leave_organization, get_user_collections, get_org_collections, get_org_collections_details, get_org_collection_detail, get_collection_users, put_organization, post_organization, post_organization_collections, post_bulk_access_collections, post_organization_collection_update, put_organization_collection_update, delete_organization_collection, post_organization_collection_delete, bulk_delete_organization_collections, post_bulk_collections, get_assigned_org_details, get_org_details, get_org_domain_sso_verified, get_members, send_invite, reinvite_member, bulk_reinvite_members, confirm_invite, bulk_confirm_invite, accept_invite, get_org_user_mini_details, get_user, edit_member, put_member, delete_member, bulk_delete_member, post_org_import, list_policies, list_policies_token, get_dummy_master_password_policy, get_master_password_policy, get_policy, put_policy, put_policy_vnext, get_plans, post_org_keys, get_organization_keys, get_organization_public_key, bulk_public_keys, revoke_member, bulk_revoke_members, restore_member, restore_member_vnext, bulk_restore_members, get_groups, get_groups_details, post_groups, get_group, put_group, post_group, get_group_details, delete_group, post_delete_group, bulk_delete_groups, get_group_members, put_group_members, post_delete_group_member, put_reset_password_enrollment, get_reset_password_details, put_reset_password, put_recover_account, get_org_export, post_api_key, rotate_api_key, get_billing_metadata, get_billing_warnings, get_auto_enroll_status, get_self_host_billing_metadata, ] } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgData { billing_email: String, collection_name: String, key: String, name: String, keys: Option, #[allow(dead_code)] plan_type: NumberOrString, // Ignored, always use the same plan } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct OrganizationUpdateData { billing_email: String, name: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct FullCollectionData { name: String, groups: Vec, users: Vec, id: Option, external_id: Option, } impl FullCollectionData { pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id)) } 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(); if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) { err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id)) } Ok(()) } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CollectionGroupData { hide_passwords: bool, id: GroupId, read_only: bool, manage: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CollectionMembershipData { hide_passwords: bool, id: MembershipId, read_only: bool, manage: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrgKeyData { encrypted_private_key: String, public_key: String, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkGroupIds { ids: Vec, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkMembershipIds { ids: Vec, } #[post("/organizations", data = "")] async fn create_organization(headers: Headers, data: Json, conn: DbConn) -> JsonResult { if !CONFIG.is_org_creation_allowed(&headers.user.email) { err!("User not allowed to create organizations") } if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await { err!( "You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization." ) } let data: OrgData = data.into_inner(); let (private_key, public_key) = if let Some(keys) = data.keys { (Some(keys.encrypted_private_key), Some(keys.public_key)) } else { (None, None) }; let org = Organization::new(data.name, &data.billing_email, private_key, public_key); let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None); let collection = Collection::new(org.uuid.clone(), data.collection_name, None); member.akey = data.key; member.atype = MembershipType::Owner as i32; member.status = MembershipStatus::Confirmed as i32; org.save(&conn).await?; member.save(&conn).await?; collection.save(&conn).await?; Ok(Json(org.to_json())) } #[delete("/organizations/", data = "")] async fn delete_organization( org_id: OrganizationId, data: Json, headers: OwnerHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PasswordOrOtpData = data.into_inner(); data.validate(&headers.user, true, &conn).await?; match Organization::find_by_uuid(&org_id, &conn).await { None => err!("Organization not found"), Some(org) => org.delete(&conn).await, } } #[post("/organizations//delete", data = "")] async fn post_delete_organization( org_id: OrganizationId, data: Json, headers: OwnerHeaders, conn: DbConn, ) -> EmptyResult { delete_organization(org_id, data, headers, conn).await } #[post("/organizations//leave")] async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult { if headers.membership.status != MembershipStatus::Confirmed as i32 { err!("You need to be a Member of the Organization to call this endpoint") } let membership = headers.membership; if membership.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { err!("The last owner can't leave") } log_event( EventType::OrganizationUserLeft as i32, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; membership.delete(&conn).await } #[get("/organizations/")] async fn get_organization(org_id: OrganizationId, headers: OwnerHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if let Some(organization) = Organization::find_by_uuid(&org_id, &conn).await { Ok(Json(organization.to_json())) } else { err!("Can't find organization details") } } #[put("/organizations/", data = "")] async fn put_organization( org_id: OrganizationId, headers: OwnerHeaders, data: Json, conn: DbConn, ) -> JsonResult { post_organization(org_id, headers, data, conn).await } #[post("/organizations/", data = "")] async fn post_organization( org_id: OrganizationId, headers: OwnerHeaders, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: OrganizationUpdateData = data.into_inner(); let Some(mut org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Organization not found") }; org.name = data.name; org.billing_email = data.billing_email.to_lowercase(); org.save(&conn).await?; log_event( EventType::OrganizationUpdated as i32, org_id.as_ref(), &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(Json(org.to_json())) } // GET /api/collections?writeOnly=false #[get("/collections")] async fn get_user_collections(headers: Headers, conn: DbConn) -> Json { Json(json!({ "data": Collection::find_by_user_uuid(headers.user.uuid, &conn).await .iter() .map(Collection::to_json) .collect::(), "object": "list", "continuationToken": null, })) } // Called during the SSO enrollment // The `identifier` should be the value returned by `get_org_domain_sso_verified` // The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it #[get("/organizations//auto-enroll-status")] async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult { let org = if identifier == FAKE_SSO_IDENTIFIER { match Membership::find_main_user_org(&headers.user.uuid, &conn).await { Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await, None => None, } } else { Organization::find_by_uuid(&identifier.into(), &conn).await }; let (id, identifier, rp_auto_enroll) = match org { None => (identifier.to_owned(), identifier.to_owned(), false), Some(org) => ( org.uuid.to_string(), org.uuid.to_string(), OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &conn).await, ), }; Ok(Json(json!({ "id": id, "identifier": identifier, "resetPasswordEnabled": rp_auto_enroll, }))) } #[get("/organizations//collections")] async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } // Custom users with a user/group manage permission need to read the collection list // (metadata only) to be able to assign collections to groups/members. This does NOT // expose cipher contents. manage_policies does not need the collection list. let can_read_collection_list = may_read_complete_collection_list(&headers.membership); let all_collections = Collection::find_by_organization(&org_id, &conn).await; let collections = if can_read_collection_list { all_collections } else { let mut explicitly_managed = Vec::new(); for collection in all_collections { if headers.membership.has_explicit_collection_manage_access(&collection.uuid, &conn).await { explicitly_managed.push(collection); } } explicitly_managed }; if !can_read_collection_list && collections.is_empty() { err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); } Ok(Json(json!({ "data": collections.iter().map(Collection::to_json).collect::(), "object": "list", "continuationToken": null, }))) } #[get("/organizations//collections/details")] async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { err!("User is not part of organization") }; // get all collection memberships for the current organization let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); // check if current user has full access to the organization (either directly or via any group) let has_full_access_to_org = member.has_full_access() || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); // Custom users with a user/group manage permission need the full collection list // (metadata only) so the web client can render member/group collection assignments // without crashing on collections it can't otherwise see. This exposes names/ids // only, never cipher contents. manage_policies does not need the collection list. let can_read_collection_list = member.has_manage_users() || member.has_manage_groups() || member.has_delete_any_collection() || member.has_create_new_collections(); // Get all admins, owners and managers who can manage/access all // Those are currently not listed in the col_users but need to be listed too. let manage_all_members: Vec = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn) .await .into_iter() .map(|member| { json!({ "id": member.uuid, "readOnly": false, "hidePasswords": false, "manage": true, }) }) .collect(); let mut data = Vec::new(); for col in Collection::find_by_organization(&org_id, &conn).await { // check whether the current user has access to the given collection let assigned = has_full_access_to_org || CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await || (CONFIG.org_groups_enabled() && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); // ACL mappings require the same authority as the single-collection details endpoint. // Mere read access (`assigned`, including group `access_all`) is not Manage authority. match collection_details_response_scope( can_read_collection_access(&member, &col.uuid, &conn).await, assigned, can_read_collection_list, ) { CollectionDetailsResponseScope::MetadataOnly => { let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(Vec::::new()); json_object["groups"] = json!(Vec::::new()); json_object["object"] = json!("collectionAccessDetails"); json_object["unmanaged"] = json!(false); data.push(json_object); } CollectionDetailsResponseScope::Hidden => {} CollectionDetailsResponseScope::AccessDetails => { // get the users assigned directly to the given collection let mut users: Vec = col_users .iter() .filter(|collection_member| collection_member.collection_uuid == col.uuid) .map(|collection_member| { collection_member.to_json_details_for_member( *membership_type .get(&collection_member.membership_uuid) .unwrap_or(&(MembershipType::User as i32)), ) }) .collect(); users.extend_from_slice(&manage_all_members); // get the group details for the given collection let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&col.uuid, &conn) .await .iter() .map(CollectionGroup::to_json_details_for_group) .collect() } else { Vec::new() }; let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(users); json_object["groups"] = json!(groups); json_object["object"] = json!("collectionAccessDetails"); json_object["unmanaged"] = json!(false); data.push(json_object); } } } Ok(Json(json!({ "data": data, "object": "list", "continuationToken": null, }))) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CollectionDetailsResponseScope { AccessDetails, MetadataOnly, Hidden, } fn may_read_complete_collection_list(member: &Membership) -> bool { member.has_full_access() || member.has_manage_users() || member.has_manage_groups() || member.has_delete_any_collection() // Create new collections needs the list too: the client resolves the parent of a nested // collection against it and refreshes it after a create. || member.has_create_new_collections() } fn collection_details_response_scope( can_read_access_details: bool, has_collection_read_access: bool, can_read_collection_list: bool, ) -> CollectionDetailsResponseScope { if can_read_access_details { CollectionDetailsResponseScope::AccessDetails } else if has_collection_read_access || can_read_collection_list { CollectionDetailsResponseScope::MetadataOnly } else { CollectionDetailsResponseScope::Hidden } } #[post("/organizations//collections", data = "")] async fn post_organization_collections( org_id: OrganizationId, headers: ManagerHeadersLoose, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } // Create is independent from Edit/Delete. In particular, Edit any collection (full access to // every collection) must not implicitly grant this endpoint. if !headers.membership.can_create_new_collections() { err!("You don't have permission to create collections") } let data: FullCollectionData = data.into_inner(); data.validate(&org_id, &conn).await?; // Security (audit H-3): validate every referenced group and user against this organization // *before* creating the collection or any assignment, so a foreign-tenant group can't be // attached to the new collection and no partial state is left behind on rejection. for group in &data.groups { if Group::find_by_uuid_and_org(&group.id, &org_id, &conn).await.is_none() { err!("Group not found in this organization") } } for user in &data.users { if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() { err!("User is not part of organization") } } let collection = Collection::new(org_id.clone(), data.name, data.external_id); collection.save(&conn).await?; // Security: a `manage` grant carries collection administration authority, so only a caller who may // already administer this collection may confer it. Create is independent of Edit/Delete, so for // `create_new_collections` alone the requested `manage` is forced to false; the creator's own grant // is added separately below. Evaluated after the collection exists so the lookup sees it. let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await; let creator_needs_assignment = !headers.membership.has_full_access(); // Persist the creator's object-scoped ownership before secondary assignments. If a later // assignment write fails, the otherwise non-transactional create path still leaves the new // collection recoverably manageable by its creator. An explicit self-assignment below is // skipped so it cannot weaken this grant. if creator_needs_assignment { CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, true, &conn).await?; } for group in data.groups { CollectionGroup::new( collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage && may_grant_manage, ) .save(&org_id, &conn) .await?; } 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.grants_access_to_all_collections() { continue; } if member.user_uuid == headers.membership.user_uuid && creator_needs_assignment { continue; } CollectionUser::save( &member.user_uuid, &collection.uuid, user.read_only, user.hide_passwords, user.manage && may_grant_manage, &conn, ) .await?; } // Emit the success event only after all requested assignments and the creator's object-scoped // manage grant have been persisted. A later write failure must not leave a false audit record. log_event( EventType::CollectionCreated as i32, &collection.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BulkCollectionAccessData { collection_ids: Vec, groups: Vec, users: Vec, } #[post("/organizations//collections/bulk-access", data = "", rank = 1)] async fn post_bulk_access_collections( org_id: OrganizationId, headers: ManagerHeadersLoose, data: Json, conn: DbConn, ) -> EmptyResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let data: BulkCollectionAccessData = data.into_inner(); if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") } // Security: authorization is per collection below, via the same `auth::can_edit_collection` the // single-collection edit endpoint uses — a body-param endpoint cannot use `ManagerHeaders`, and the // two must not diverge. Group `access_all` deliberately does not satisfy it (the previous // `is_manageable_by_user` check accepted it, and disagreed with the single-edit endpoint). // Security and atomicity: validate the whole request against this organization before mutating // anything — every collection, group and user must belong to it and be manageable by the caller. // Only then does the destructive delete/replace begin, so a foreign-tenant group can never be linked // and a later invalid element cannot leave earlier collections already wiped. 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(); 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)) } for user in &data.users { if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() { err!("User is not part of organization") } } 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") }; if !crate::auth::can_edit_collection(&headers.membership, &collection.uuid, &conn).await { err!("Collection not found", "The current user isn't a manager for this collection") } collections.push(collection); } for collection in collections { let col_id = &collection.uuid; // Security: only a caller who could delete this collection may confer a `manage` grant on it; // otherwise the requested `manage` is forced to false. let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, col_id, &conn).await; // update collection modification date collection.save(&conn).await?; log_event( EventType::CollectionUpdated as i32, &collection.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; CollectionGroup::delete_all_by_collection(col_id, &org_id, &conn).await?; for group in &data.groups { CollectionGroup::new( col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage && may_grant_manage, ) .save(&org_id, &conn) .await?; } CollectionUser::delete_all_by_collection(col_id, &conn).await?; 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.grants_access_to_all_collections() { continue; } CollectionUser::save( &member.user_uuid, col_id, user.read_only, user.hide_passwords, user.manage && may_grant_manage, &conn, ) .await?; } } Ok(()) } #[put("/organizations//collections/", data = "")] async fn put_organization_collection_update( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, data: Json, conn: DbConn, ) -> JsonResult { post_organization_collection_update(org_id, col_id, headers, data, conn).await } #[post("/organizations//collections/", data = "", rank = 2)] async fn post_organization_collection_update( org_id: OrganizationId, col_id: CollectionId, headers: ManagerHeaders, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: FullCollectionData = data.into_inner(); data.validate(&org_id, &conn).await?; if Organization::find_by_uuid(&org_id, &conn).await.is_none() { err!("Can't find organization details") } let Some(mut collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { err!("Collection not found") }; collection.name = data.name; collection.external_id = match data.external_id { Some(external_id) if !external_id.trim().is_empty() => Some(external_id), _ => None, }; collection.save(&conn).await?; log_event( EventType::CollectionUpdated as i32, &collection.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; // Security (F-1): only a caller who could delete this collection may confer a `manage` grant on // it (a `manage` row carries delete authority). For everyone else the requested `manage` is // forced to false, so Edit-any-collection can rewrite access but never escalate into deletion. let may_grant_manage = match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(caller) => caller_may_grant_collection_manage(&caller, &col_id, &conn).await, None => false, }; CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?; for group in data.groups { CollectionGroup::new( col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage && may_grant_manage, ) .save(&org_id, &conn) .await?; } CollectionUser::delete_all_by_collection(&col_id, &conn).await?; 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.grants_access_to_all_collections() { continue; } CollectionUser::save( &member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage && may_grant_manage, &conn, ) .await?; } Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await)) } async fn delete_organization_collection_impl( org_id: &OrganizationId, col_id: &CollectionId, headers: &CollectionDeleteHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } 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") }; log_event( EventType::CollectionDeleted as i32, &collection.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; collection.delete(conn).await } #[delete("/organizations//collections/")] async fn delete_organization_collection( org_id: OrganizationId, col_id: CollectionId, headers: CollectionDeleteHeaders, conn: DbConn, ) -> EmptyResult { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await } #[post("/organizations//collections//delete")] async fn post_organization_collection_delete( org_id: OrganizationId, col_id: CollectionId, headers: CollectionDeleteHeaders, conn: DbConn, ) -> EmptyResult { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkCollectionIds { ids: Vec, } #[delete("/organizations//collections", data = "")] async fn bulk_delete_organization_collections( org_id: OrganizationId, headers: ManagerHeadersLoose, data: Json, conn: DbConn, ) -> EmptyResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let data: BulkCollectionIds = data.into_inner(); let collections = data.ids; let headers = CollectionDeleteHeaders::from_loose(headers, &collections, &conn).await?; for col_id in collections { delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await?; } Ok(()) } #[get("/organizations//collections//details")] async fn get_org_collection_detail( org_id: OrganizationId, col_id: CollectionId, headers: CollectionReadHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await { None => err!("Collection not found"), Some(collection) => { if collection.org_uuid != org_id { err!("Collection is not owned by organization") } let groups: Vec = if CONFIG.org_groups_enabled() { CollectionGroup::find_by_collection(&collection.uuid, &conn) .await .iter() .map(CollectionGroup::to_json_details_for_group) .collect() } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. Vec::new() }; // Generate a HashMap to get the correct MembershipType per user to determine the manage permission // We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser let membership_type: HashMap = Membership::find_confirmed_by_org(&org_id, &conn) .await .into_iter() .map(|m| (m.uuid, m.atype)) .collect(); let users: Vec = CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn) .await .iter() .map(|collection_member| { collection_member.to_json_details_for_member( *membership_type .get(&collection_member.membership_uuid) .unwrap_or(&(MembershipType::User as i32)), ) }) .collect(); let assigned = Collection::can_access_collection(&headers.membership, &collection.uuid, &conn).await; let mut json_object = collection.to_json_details(&headers.user.uuid, None, &conn).await; json_object["assigned"] = json!(assigned); json_object["users"] = json!(users); json_object["groups"] = json!(groups); json_object["object"] = json!("collectionAccessDetails"); Ok(Json(json_object)) } } } #[get("/organizations//collections//users")] async fn get_collection_users( org_id: OrganizationId, col_id: CollectionId, headers: CollectionReadHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } // Get org and collection, check that collection is from org let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { err!("Collection not found in Organization") }; let mut member_list = Vec::new(); for col_user in CollectionUser::find_by_collection(&collection.uuid, &conn).await { member_list.push( Membership::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn) .await .unwrap() .to_json_user_access_restrictions(&col_user), ); } Ok(Json(json!(member_list))) } #[derive(FromForm)] struct OrgIdData { #[field(name = "organizationId")] organization_id: OrganizationId, } fn filter_ciphers_for_organization(ciphers: Vec, org_id: &OrganizationId) -> Vec { ciphers.into_iter().filter(|cipher| cipher.organization_uuid.as_ref() == Some(org_id)).collect() } // The Admin Console calls this when the acting member may not read every cipher: DeleteAnyCollection // alone needs an empty successful response so the collection list can finish loading. // // Security: start from the regular user-visible cipher query and constrain it to the requested // organization. DeleteAnyCollection must never make cipher contents visible. #[get("/ciphers/organization-details/assigned?")] async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { err_code!( "Resource not found.", "User is not a confirmed member of the organization", rocket::http::Status::NotFound.code ); } Ok(Json(json!({ "data": assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?, "object": "list", "continuationToken": null, }))) } // Serialize exactly the organization ciphers the user is actually assigned to, directly or via a // group. `CipherSyncType::User` keeps the per-cipher access restrictions in place, so nothing outside // the caller's own collections is returned and every cipher carries its real `edit`/`viewPassword` // flags. // // NOTE: as everywhere else in Vaultwarden (and Bitwarden), `hidePasswords` is reported as // `viewPassword: false` rather than redacted server-side, so this returns exactly what the same // member already receives from `/api/sync` — never more. async fn assigned_org_ciphers_json( org_id: &OrganizationId, host: &str, user_id: &UserId, conn: &DbConn, ) -> Result { let ciphers = filter_ciphers_for_organization(Cipher::find_by_user_visible(user_id, conn).await, org_id); let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::User, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for cipher in ciphers { ciphers_json.push(cipher.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::User, conn).await?); } Ok(Value::Array(ciphers_json)) } // The organization cipher list the clients use for the admin vault view and for computing reports // locally. Admins/Owners and Custom members with `editAnyCollection` already reach every cipher. // `accessReports` alone only opens the endpoint for the caller's existing assignments: it must not // turn permission to compute reports into read access to otherwise inaccessible organization data. #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); } let ciphers_json = match organization_report_scope(&headers.membership) { OrganizationReportScope::Complete => { get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? } OrganizationReportScope::Assigned => { assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await? } OrganizationReportScope::Denied => { err_code!( "Resource not found.", "User does not have permission to read the organization ciphers", rocket::http::Status::NotFound.code ); } }; Ok(Json(json!({ "data": ciphers_json, "object": "list", "continuationToken": null, }))) } async fn get_org_details_impl( org_id: &OrganizationId, host: &str, user_id: &UserId, conn: &DbConn, ) -> Result { ciphers_to_org_json(Cipher::find_by_org(org_id, conn).await, host, user_id, conn).await } // Serialize an already-authorized set of organization ciphers. The caller decides which ciphers go // in: `CipherSyncType::Organization` skips the per-cipher access restrictions, so this must never be // handed a cipher the user is not allowed to see. async fn ciphers_to_org_json( ciphers: Vec, host: &str, user_id: &UserId, conn: &DbConn, ) -> Result { let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await; let mut ciphers_json = Vec::with_capacity(ciphers.len()); for c in ciphers { ciphers_json.push(c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?); } Ok(json!(ciphers_json)) } // Returning a Domain/Organization here allow to prefill it and prevent prompting the user // So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` #[post("/organizations/domain/sso/verified")] fn get_org_domain_sso_verified() -> JsonResult { // Always return a dummy value, no matter if SSO is enabled or not Ok(Json(json!({ "object": "list", "data": [{ "organizationIdentifier": FAKE_SSO_IDENTIFIER, // These appear to be unused "organizationName": FAKE_SSO_IDENTIFIER, "domainName": CONFIG.domain() }], "continuationToken": null }))) } #[derive(FromForm)] struct GetOrgUserData { #[field(name = "includeCollections")] include_collections: Option, #[field(name = "includeGroups")] include_groups: Option, } #[get("/organizations//users?")] async fn get_members( data: GetOrgUserData, org_id: OrganizationId, // Security (audit M-1): the full member list exposes each member's PII, 2FA/enrollment status, // permission flags and (optionally) collection/group assignments. Reading it requires the // 'Manage Users' permission (or Admin/Owner), matching Bitwarden. Members who only need to // reference other users (e.g. the collection dialog) use the member-readable mini-details. headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let mut users_json = Vec::new(); for u in Membership::find_by_org(&org_id, &conn).await { users_json.push( u.to_json_user_details( data.include_collections.unwrap_or(false), data.include_groups.unwrap_or(false), &conn, ) .await, ); } Ok(Json(json!({ "data": users_json, "object": "list", "continuationToken": null, }))) } #[post("/organizations//keys", data = "")] async fn post_org_keys( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: OrgKeyData = data.into_inner(); let mut org = if let Some(organization) = Organization::find_by_uuid(&org_id, &conn).await { if organization.private_key.is_some() && organization.public_key.is_some() { err!("Organization Keys already exist") } organization } else { err!("Can't find organization details") }; org.private_key = Some(data.encrypted_private_key); org.public_key = Some(data.public_key); org.save(&conn).await?; Ok(Json(json!({ "object": "organizationKeys", "publicKey": org.public_key, "privateKey": org.private_key, }))) } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] // This is intentionally a permission bitmap: every field represents an independent API grant. #[allow(clippy::struct_excessive_bools)] struct CustomRolePermissions { manage_users: bool, manage_groups: bool, manage_policies: bool, create_new_collections: bool, edit_any_collection: bool, delete_any_collection: bool, access_event_logs: bool, access_import_export: bool, access_reports: bool, } impl CustomRolePermissions { /// Read one known permission key. /// /// An absent key is `false`: the object is the complete set the caller wants. A key that *is* present /// must be a JSON boolean — treating `"true"`, `1` or `null` as "not `Value::Bool(true)`" turned a /// malformed request into a silent permission *removal* that still answered 200. fn read_known(permissions: &HashMap, key: &str) -> Result { match permissions.get(key) { None => Ok(false), Some(Value::Bool(value)) => Ok(*value), Some(other) => { let found = match other { Value::Null => "null", Value::String(_) => "a string", Value::Number(_) => "a number", Value::Array(_) => "an array", Value::Object(_) => "an object", Value::Bool(_) => unreachable!("booleans are handled above"), }; err!(format!("Invalid permissions: '{key}' must be true or false, but is {found}")) } } } /// Parse a permissions object. /// /// Every known key is type-checked even when the role makes the flags inert, so a malformed request /// is rejected identically whatever role it names, and always before anything is mutated. Unknown /// keys are ignored: Bitwarden sends `manageSso`, `manageScim` and `manageResetPassword`, and /// rejecting them would break clients over permissions Vaultwarden does not implement. fn from_request(member_type: MembershipType, permissions: &HashMap) -> Result { let parsed = Self { manage_users: Self::read_known(permissions, "manageUsers")?, manage_groups: Self::read_known(permissions, "manageGroups")?, manage_policies: Self::read_known(permissions, "managePolicies")?, create_new_collections: Self::read_known(permissions, "createNewCollections")?, edit_any_collection: Self::read_known(permissions, "editAnyCollection")?, delete_any_collection: Self::read_known(permissions, "deleteAnyCollection")?, access_event_logs: Self::read_known(permissions, "accessEventLogs")?, access_import_export: Self::read_known(permissions, "accessImportExport")?, access_reports: Self::read_known(permissions, "accessReports")?, }; if member_type == MembershipType::Custom { Ok(parsed) } else { Ok(Self::default()) } } /// Whether the requested role/permissions give this member access to *every* collection in the /// org: Admins/Owners implicitly, and a Custom member holding Edit any collection. Such members /// do not need (and must not be given) individual per-collection assignments. Create and Delete /// remain completely independent of this. fn grants_full_collection_access(self, member_type: MembershipType) -> bool { member_type >= MembershipType::Admin || (member_type == MembershipType::Custom && self.edit_any_collection) } /// Parse permissions for an existing member without treating an omitted permissions object as /// an instruction to clear every Custom-role grant. Older clients send legacy role value `3` /// without the modern object; that value is normalized to Custom for compatibility. fn from_edit_request( member_type: MembershipType, permissions: Option<&HashMap>, membership: &Membership, ) -> Result { Ok(match permissions { Some(permissions) => Self::from_request(member_type, permissions)?, None if member_type == MembershipType::Custom && membership.atype == MembershipType::Custom as i32 => { Self { manage_users: membership.manage_users, manage_groups: membership.manage_groups, manage_policies: membership.manage_policies, create_new_collections: membership.create_new_collections, edit_any_collection: membership.edit_any_collection, delete_any_collection: membership.delete_any_collection, access_event_logs: membership.access_event_logs, access_import_export: membership.access_import_export, access_reports: membership.access_reports, } } None => Self::default(), }) } fn differs_from(self, membership: &Membership) -> bool { let stored = if membership.atype == MembershipType::Custom as i32 { Self { manage_users: membership.manage_users, manage_groups: membership.manage_groups, manage_policies: membership.manage_policies, create_new_collections: membership.create_new_collections, edit_any_collection: membership.edit_any_collection, delete_any_collection: membership.delete_any_collection, access_event_logs: membership.access_event_logs, access_import_export: membership.access_import_export, access_reports: membership.access_reports, } } else { // Permission bits outside the Custom role are stale, inert data. Clearing them while an // ordinary member is edited is not an authority change and must not make a // ManageUsers-only caller fail the "may not change custom permissions" check. Self::default() }; self != stored } fn apply_to(self, membership: &mut Membership) { membership.manage_users = self.manage_users; membership.manage_groups = self.manage_groups; membership.manage_policies = self.manage_policies; membership.create_new_collections = self.create_new_collections; membership.edit_any_collection = self.edit_any_collection; membership.delete_any_collection = self.delete_any_collection; membership.access_event_logs = self.access_event_logs; membership.access_import_export = self.access_import_export; membership.access_reports = self.access_reports; } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InviteData { emails: Vec, groups: Vec, r#type: NumberOrString, collections: Option>, #[serde(default)] permissions: HashMap, } impl InviteData { async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { 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(); 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)) } let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) { err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id)) } Ok(()) } } #[post("/organizations//users/invite", data = "")] async fn send_invite( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: InviteData = data.into_inner(); data.validate(&org_id, &conn).await?; let raw_type = &data.r#type.into_string(); let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; if !may_provision_member_type(headers.membership_type, new_type) { err!("You don't have permission to invite this role") } // manageAllCollections is a client-only aggregate; its three children are persisted independently. // Parsed and type-checked before the loop below creates any user, invitation or membership, so a // malformed value leaves nothing behind. Reaching every collection decides whether the individual // per-collection assignments below are skipped. let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions)?; let grants_full_access = custom_permissions.grants_full_collection_access(new_type); // Security: only callers who can manage collections (Admins/Owners, or users with full access) // may assign collection access when inviting. A custom user with only manage_users can invite // members, but cannot grant them collection access. Assigning groups is gated separately, // because a collection-bearing group grants that access indirectly. let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || caller.as_ref().is_some_and(Membership::has_full_access); let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin || caller.as_ref().is_some_and(Membership::has_manage_groups); // API consistency: these fields used to be dropped silently while the invite still reported // success, so the caller believed access had been granted. Reject the request instead, and do it // before the loop below creates any user, invitation or membership row. if !grants_full_access && !caller_can_manage_collections && data.collections.iter().flatten().next().is_some() { err!("You don't have permission to assign collections to invited members") } if !caller_can_manage_groups && !data.groups.is_empty() { err!("You don't have permission to assign groups to invited members") } if !caller_can_manage_collections { for group_id in &data.groups { if group_confers_collection_access(group_id, &org_id, &conn).await { err!("You don't have permission to assign a group that grants collection access") } } } // Security: the membership does not exist yet, so every group named here is an addition — putting // the invitee into an `access_all` group is the same durable grant the other paths reserve for // Admins and Owners. `caller_can_manage_collections` above does not cover it: `editAnyCollection` // satisfies that, and could plant the grant on an account outliving the flag it was made under. if !may_grant_access_all_group(headers.membership_type) { for group_id in &data.groups { if group_grants_access_to_all_collections(group_id, &org_id, &conn).await { err!("Only Admins and Owners can invite a member into a group with access to all collections") } } } for email in &data.emails { let mut member_status = MembershipStatus::Invited as i32; // Scoped to this iteration on purpose. A single flag hoisted out of the loop stays `true` // for every later recipient once any account has been created, so a failing invite mail to // an address that already had an account would delete that *existing* global user -- their // personal ciphers, devices, 2FA, emergency access and memberships in unrelated // organizations -- instead of only the membership this request just made. let mut user_created: bool = false; let user = match User::find_by_mail(email, &conn).await { None => { if !CONFIG.invitations_allowed() { err!(format!("User does not exist: {email}")) } if !CONFIG.is_email_domain_allowed(email) { err!("Email domain not eligible for invitations") } if !CONFIG.mail_enabled() { Invitation::new(email).save(&conn).await?; } let mut new_user = User::new(email, None); new_user.save(&conn).await?; user_created = true; new_user } Some(user) => { if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { err!(format!("User already in organization: {email}")) } if !CONFIG.mail_enabled() { if user.password_hash.is_empty() { Invitation::new(email).save(&conn).await?; } else { // automatically accept existing users if mail is disabled member_status = MembershipStatus::Accepted as i32; } } user } }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); new_member.atype = new_type as i32; custom_permissions.apply_to(&mut new_member); new_member.status = member_status; new_member.save(&conn).await?; if CONFIG.mail_enabled() { let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await { org.name } else { err!("Error looking up organization") }; if let Err(e) = mail::send_invite( &user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(headers.user.email.clone()), ) .await { // Upon error delete the user, invite and org member records when needed if user_created { user.delete(&conn).await?; } else { new_member.delete(&conn).await?; } err!(format!("Error sending invite: {e:?} ")); } } log_event( EventType::OrganizationUserInvited as i32, &new_member.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; // If the member does not already reach every collection, add the collections received if !grants_full_access && caller_can_manage_collections { // Security (F-1): a per-collection `manage` grant carries delete authority, so the // caller may only confer it on collections they could delete themselves. Otherwise a // caller acting via Edit-any-collection could invite an account they control with a // `manage` row and reach Delete-any-collection through it. for col in data.collections.iter().flatten() { match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await { None => err!("Collection not found in Organization"), Some(collection) => { let manage = col.manage && match &caller { Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, None => false, }; CollectionUser::save( &user.uuid, &collection.uuid, col.read_only, col.hide_passwords, manage, &conn, ) .await?; } } } } // NOTE: every requested group was already validated against this organization in // `InviteData::validate`, and both the manage_groups permission and the collection-bearing // group restriction were rejected up front, before any record was created. if caller_can_manage_groups { for group_id in &data.groups { let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); group_entry.save(&conn).await?; } } } Ok(()) } #[post("/organizations//users/reinvite", data = "")] async fn bulk_reinvite_members( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: BulkMembershipIds = data.into_inner(); let mut bulk_response = Vec::new(); for member_id in data.ids { let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": member_id, "error": err_msg } )); } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } #[post("/organizations//users//reinvite")] async fn reinvite_member( org_id: OrganizationId, member_id: MembershipId, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } reinvite_member_impl(&org_id, &member_id, &headers, &conn).await } async fn reinvite_member_impl( org_id: &OrganizationId, member_id: &MembershipId, headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The user hasn't been invited to the organization.") }; if !may_manage_stored_member_type(headers.membership_type, member.atype) { err!("You don't have permission to reinvite this user") } if member.status != MembershipStatus::Invited as i32 { err!("The user is already accepted or confirmed to the organization") } let Some(user) = User::find_by_uuid(&member.user_uuid, conn).await else { err!("User not found.") }; if !CONFIG.invitations_allowed() && user.password_hash.is_empty() { err!("Invitations are not allowed.") } let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { org.name } else { err!("Error looking up organization.") }; if CONFIG.mail_enabled() { mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(headers.user.email.clone())).await?; } else if user.password_hash.is_empty() { let invitation = Invitation::new(&user.email); invitation.save(conn).await?; } else { Invitation::take(&user.email, conn).await; let mut member = member; member.status = MembershipStatus::Accepted as i32; member.save(conn).await?; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AcceptData { token: String, reset_password_key: Option, } #[post("/organizations//users//accept", data = "")] async fn accept_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: Headers, conn: DbConn, ) -> EmptyResult { // The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner(); let claims = decode_invite(&data.token)?; // Don't allow other users from accepting an invitation. if !claims.email.eq(&headers.user.email) { err!("Invitation was issued to a different account", "Claim does not match user_id") } // If a claim org_id does not match the one in from the URI, something is wrong. if !claims.org_id.eq(&org_id) { err!("Error accepting the invitation", "Claim does not match the org_id") } // If a claim does not have a member_id or it does not match the one in from the URI, something is wrong. if !claims.member_id.eq(&member_id) { err!("Error accepting the invitation", "Claim does not match the member_id") } let member_id = &claims.member_id; Invitation::take(&claims.email, &conn).await; // skip invitation logic when we were invited via the /admin panel if **member_id != FAKE_ADMIN_UUID { let Some(mut membership) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else { err!("Error accepting the invitation") }; let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&membership.org_uuid, &conn).await { true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), true => data.reset_password_key, false => None, }; // In case the user was invited before the mail was saved in db. membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email); accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?; } else if CONFIG.mail_enabled() { // User was invited from /admin, so they are automatically confirmed let org_name = CONFIG.invitation_org_name(); mail::send_invite_confirmed(&claims.email, &org_name).await?; } Ok(()) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ConfirmData { id: Option, key: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BulkConfirmData { keys: Option>, } #[post("/organizations//users/confirm", data = "")] async fn bulk_confirm_invite( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); let mut bulk_response = Vec::new(); match data.keys { Some(keys) => { for invite in keys { // The id is request-controlled and optional. Unwrapping it aborted the worker with a 500 and, because // the panic unwound mid-loop, discarded the response for every entry already confirmed in the same // batch. Report it as a per-entry error, like an id that is present but empty. let Some(member_id) = invite.id else { bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": null, "error": "Key or UserId is not set, unable to process request" } )); continue; }; 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 { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": member_id, "error": err_msg } )); } } None => error!("No keys to confirm"), } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } #[post("/organizations//users//confirm", data = "")] async fn confirm_invite( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let data = data.into_inner(); let user_key = data.key.unwrap_or_default(); confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await } async fn confirm_invite_impl( org_id: &OrganizationId, member_id: &MembershipId, key: &str, headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } if key.is_empty() || member_id.is_empty() { err!("Key or UserId is not set, unable to process request"); } let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("The specified user isn't a member of the organization") }; if !may_provision_stored_member_type(headers.membership_type, member_to_confirm.atype) { err!("You don't have permission to confirm this user") } if member_to_confirm.status != MembershipStatus::Accepted as i32 { err!("User in invalid state") } member_to_confirm.status = MembershipStatus::Confirmed as i32; member_to_confirm.akey = key.to_owned(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?; log_event( EventType::OrganizationUserConfirmed as i32, &member_to_confirm.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; if CONFIG.mail_enabled() { let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await { org.name } else { err!("Error looking up organization.") }; let address = if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { user.email } else { err!("Error looking up user.") }; mail::send_invite_confirmed(&address, &org_name).await?; } let save_result = member_to_confirm.save(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; } save_result } #[get("/organizations//users/mini-details", rank = 1)] async fn get_org_user_mini_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let mut members_json = Vec::new(); for m in Membership::find_by_org(&org_id, &conn).await { members_json.push(m.to_json_mini_details(&conn).await); } Ok(Json(json!({ "data": members_json, "object": "list", "continuationToken": null, }))) } #[get("/organizations//users/?", rank = 2)] async fn get_user( org_id: OrganizationId, member_id: MembershipId, data: GetOrgUserData, headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(user) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("The specified user isn't a member of the organization") }; // In this case, when groups are requested we also need to include collections. // Else these will not be shown in the interface, and could lead to missing collections when saved. let include_groups = data.include_groups.unwrap_or(false); Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await)) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EditUserData { r#type: NumberOrString, collections: Option>, groups: Option>, permissions: Option>, } #[put("/organizations//users/", data = "", rank = 1)] async fn put_member( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { edit_member(org_id, member_id, data, headers, conn).await } #[post("/organizations//users/", data = "", rank = 1)] async fn edit_member( org_id: OrganizationId, member_id: MembershipId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: EditUserData = data.into_inner(); let raw_type = &data.r#type.into_string(); let Some(new_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("The specified user isn't member of the organization") }; // Parsed (and type-checked) here, long before the write phase further down, so a malformed // permission value leaves the role, the permission flags, the collection assignments and the // group memberships exactly as they were. let custom_permissions = CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit)?; let grants_full_access = custom_permissions.grants_full_collection_access(new_type); if new_type != member_to_edit.atype && (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin) && headers.membership_type != MembershipType::Owner { err!("Only Owners can grant and remove Admin or Owner privileges") } // Security: raising a member to Custom activates existing explicit collection-Manage assignments and // other Custom-only paths, and lowering it revokes them — authority changes outside Manage Users. An // unchanged role is still allowed, so such members can use the regular edit dialog. if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) { err!("Only Admins or Owners can change a member's role") } if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner { err!("Only Owners can edit Owner users") } // Security: the same actor/target role matrix as every other member endpoint. Without it // `edit_member` was the only path on which Custom+manage_users could aim at an Admin or a peer // Custom membership, as long as the role stayed unchanged. // // Deliberate narrowing of upstream: Bitwarden lets Custom+ManageUsers administer peer Custom members // and delegate a subset of its own permissions. That hands permission delegation to a non-Admin and // makes correctness rest on a subset comparison being right on every path, so Vaultwarden keeps role // and permission changes with Admins/Owners instead. if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) { err!("You don't have permission to edit this member") } if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner && member_to_edit.status == MembershipStatus::Confirmed as i32 { // Removing owner permission, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { err!("Can't delete the last owner") } } // Security: only Admins and Owners may change the granular permissions — manage_users must not grant // them to itself or others, nor strip flags an Admin/Owner granted. Unchanged flags are allowed, so // such members can still use the regular edit dialog. if headers.membership_type < MembershipType::Admin && custom_permissions.differs_from(&member_to_edit) { err!("Only Admins or Owners can change custom permissions") } // Security: only callers who can manage collections (Admin/Owner, or full access) may change a // member's collection assignments; manage_users alone leaves them untouched. // // Narrowing of upstream, which resolves ModifyUserAccess per collection. Requiring blanket authority // is coarser, but it keeps a stored `manage` grant from becoming a lever for handing out access — the // same boundary `caller_may_grant_collection_manage` draws, and the group paths below follow it. // Widening this needs the per-collection check to cover *current* assignments too, or removal becomes // the hole. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(m) => m.has_full_access(), None => false, }; // API consistency: dropping these fields while answering 200 let client and server drift apart after // an apparently saved change. Reject instead — but only for an actual add or removal, since the edit // dialog echoes the current assignments back. Flag-only differences stay ignored. if !caller_can_manage_collections && !grants_full_access { let requested: HashSet = data.collections.iter().flatten().map(|c| c.id.clone()).collect(); let current: HashSet = CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn) .await .into_iter() .map(|c| c.collection_uuid) .collect(); if requested != current { err!("You don't have permission to change this member's collection assignments") } } // Edit any collection (the successor of the removed access_all flag) grants full access to // every collection. It is part of the granular custom permissions applied here, and the // differs_from guard above already prevents a non-Admin caller from changing it — so a Custom // member with only manage_users can never grant themselves or others full collection access. custom_permissions.apply_to(&mut member_to_edit); member_to_edit.atype = new_type as i32; // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; // --------------------------------------------------------------------------------------------- // Validation phase. Nothing may be written until every id, tenant binding and caller right has // been checked: this endpoint replaces both collection assignments and group memberships, and with // no database transactions an error between the two replaces used to leave the request // half-applied while answering 4xx. This cannot make them atomic, but a *rejected* request now // changes nothing. // --------------------------------------------------------------------------------------------- // Security: a per-collection `manage` grant is durable administration authority, so it may only be // conferred where the caller already holds it. let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await; // Resolve the requested assignments: every collection has to exist in *this* organization, and // the effective `manage` bit is decided here rather than while writing. let mut collection_assignments: Vec<(CollectionId, bool, bool, bool)> = Vec::new(); if caller_can_manage_collections && !grants_full_access { for col in data.collections.iter().flatten() { let Some(collection) = Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await else { err!("Collection not found in Organization") }; let manage = col.manage && match &caller { Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await, None => false, }; collection_assignments.push((collection.uuid, col.read_only, col.hide_passwords, manage)); } } // Security: changing a member's group membership can indirectly grant collection access // (via the groups' collections). Only callers who may manage groups (Admins/Owners or users // with manage_groups) are allowed to change it. For others we leave group membership untouched. let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin || match &caller { Some(m) => m.has_manage_groups(), None => false, }; // API consistency, as for the collection assignments above: reject group changes this caller may // not make instead of silently dropping them. let requested_groups: HashSet = data.groups.iter().flatten().cloned().collect(); let current_groups: HashSet = GroupUser::find_by_member(&member_to_edit.uuid, &conn).await.into_iter().map(|gu| gu.groups_uuid).collect(); if !caller_can_manage_groups && requested_groups != current_groups { err!("You don't have permission to change this member's group assignments") } if caller_can_manage_groups && !caller_can_manage_collections { let mut collection_bearing: HashSet = HashSet::new(); for group_id in requested_groups.union(¤t_groups) { if group_confers_collection_access(group_id, &org_id, &conn).await { collection_bearing.insert(group_id.clone()); } } if !collection_bearing_membership_unchanged(&requested_groups, ¤t_groups, &collection_bearing) { err!("You don't have permission to change memberships in groups that grant collection access") } } // Security: adding this member to an `access_all` group grants durable organization-wide access, so // a caller whose own reach comes from `editAnyCollection` must not hand it out. Removals are // unrestricted, so only the groups this request adds are examined. if caller_can_manage_groups && !may_grant_access_all_group(headers.membership_type) { for group_id in requested_groups.difference(¤t_groups) { if group_grants_access_to_all_collections(group_id, &org_id, &conn).await { err!("Only Admins and Owners can add a member to a group with access to all collections") } } } // Security (audit H-2): every requested group has to belong to this organization. Otherwise a // caller could link the member to a group of a foreign tenant (e.g. an access-all group), which // the direct cipher-access checks would then honor. Fail closed on the whole request. if caller_can_manage_groups { for group_id in data.groups.iter().flatten() { if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() { err!("Group not found in this organization") } } } // Decide the group changes while still not writing. A caller who may manage groups but *not* // collections may only touch memberships in groups that confer no collection access; the others // are preserved untouched (neither granted nor revoked), mirroring put_group_members and // add_update_group. let mut groups_to_remove: Vec = Vec::new(); let mut groups_to_add: Vec = Vec::new(); if caller_can_manage_groups { for group_id in ¤t_groups { if caller_can_manage_collections || may_change_group_membership( caller_can_manage_collections, group_confers_collection_access(group_id, &org_id, &conn).await, ) { groups_to_remove.push(group_id.clone()); } } for group_id in data.groups.iter().flatten() { if caller_can_manage_collections || may_change_group_membership( caller_can_manage_collections, group_confers_collection_access(group_id, &org_id, &conn).await, ) { groups_to_add.push(group_id.clone()); } } } // --------------------------------------------------------------------------------------------- // Write phase. // --------------------------------------------------------------------------------------------- if caller_can_manage_collections { for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await { c.delete(&conn).await?; } for (collection_uuid, read_only, hide_passwords, manage) in collection_assignments { CollectionUser::save(&member_to_edit.user_uuid, &collection_uuid, read_only, hide_passwords, manage, &conn) .await?; } } for group_id in groups_to_remove { GroupUser::delete_by_group_and_member(&group_id, &member_to_edit.uuid, &conn).await?; } for group_id in groups_to_add { let mut group_entry = GroupUser::new(group_id, member_to_edit.uuid.clone()); group_entry.save(&conn).await?; } log_event( EventType::OrganizationUserUpdated as i32, &member_to_edit.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; member_to_edit.save(&conn).await } #[delete("/organizations//users", data = "")] async fn bulk_delete_member( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: BulkMembershipIds = data.into_inner(); let mut bulk_response = Vec::new(); for member_id in data.ids { let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationBulkConfirmResponseModel", "id": member_id, "error": err_msg } )); } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } #[delete("/organizations//users/")] async fn delete_member( org_id: OrganizationId, member_id: MembershipId, headers: ManageUsersHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await } async fn delete_member_impl( org_id: &OrganizationId, member_id: &MembershipId, headers: &ManageUsersHeaders, conn: &DbConn, nt: &Notify<'_>, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(member_to_delete) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("User to delete isn't member of the organization") }; if !may_delete_stored_member_type(headers.membership_type, member_to_delete.atype) { err!("You don't have permission to delete this user") } if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32 { // Removing owner, check that there is at least one other confirmed owner if Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { err!("Can't delete the last owner") } } log_event( EventType::OrganizationUserRemoved as i32, &member_to_delete.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; if !CONFIG.mail_enabled() && !Membership::find_invited_by_user(&user.uuid, conn) .await .into_iter() .any(|m| m.uuid != member_to_delete.uuid) { Invitation::take(&user.email, conn).await; } } member_to_delete.delete(conn).await } #[post("/organizations//users/public-keys", data = "")] async fn bulk_public_keys( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: BulkMembershipIds = data.into_inner(); let mut bulk_response = Vec::new(); // Check all received Membership UUID's and find the matching User to retrieve the public-key. // If the user does not exists, just ignore it, and do not return any information regarding that Membership UUID. // The web-vault will then ignore that user for the following steps. for member_id in data.ids { match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await { Some(member) => match User::find_by_uuid(&member.user_uuid, &conn).await { Some(user) => bulk_response.push(json!( { "object": "organizationUserPublicKeyResponseModel", "id": member_id, "userId": user.uuid, "key": user.public_key } )), None => debug!("User doesn't exist"), }, None => debug!("Membership doesn't exist"), } } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } use super::ciphers::CipherData; use super::ciphers::update_cipher_from_data; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { ciphers: Vec, collections: Vec, collection_relationships: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RelationsData { // Cipher index key: usize, // Collection index value: usize, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/ImportCiphersController.cs#L62 #[post("/ciphers/import-organization?", data = "")] async fn post_org_import( query: OrgIdData, data: Json, headers: OrgMemberHeaders, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let org_id = query.organization_id; if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } // Organization imports are authorized per target collection. `accessImportExport` gates export, // but does not replace Write authority on an existing collection or Create authority for a new // one. Require confirmation independently so a membership with no target collection cannot create // an unreachable organization cipher. if !headers.membership.has_status(MembershipStatus::Confirmed) { err!("You need to be a confirmed member of this organization to import into it") } let data: ImportData = data.into_inner(); // Validate the import before continuing // Bitwarden does not process the import if there is one item invalid. // Since we check for the size of the encrypted note length, we need to do that here to pre-validate it. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. Cipher::validate_cipher_data(&data.ciphers)?; // Robustness: validate every collection<->cipher relationship index against the payload *before* // creating anything. `key` indexes into `ciphers` and `value` into `collections`, and an out-of-range // index would otherwise panic when the relations are applied — after rows have already been written. let import_cipher_count = data.ciphers.len(); let import_collection_count = data.collections.len(); for relation in &data.collection_relationships { if relation.key >= import_cipher_count || relation.value >= import_collection_count { err!( "Invalid collection relationship", "A collection relationship references a non-existent cipher or collection" ) } } // Security (audit F8/upstream): index the existing collections by id so the per-collection // authorization below can use the *write* predicate `is_writable_by_user`. A read-only // assignment must not let an importer plant ciphers into a shared collection. let existing_collections: HashMap = Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect(); // Finish every request-controlled collection authorization check before the first new collection // is written. This matters for the PR's create-only Custom role: a payload may name a new // collection first and an existing, non-writable collection later. Rejecting the latter only in // the write loop left the former behind even though the request failed. for col in &data.collections { if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) { let writable = collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await; if !may_import_to_collection( &headers.membership, OrganizationImportTarget::Existing { writable, }, ) { err!(Compact, "The current user isn't allowed to manage this collection") } } else if !may_import_to_collection(&headers.membership, OrganizationImportTarget::New) { err!(Compact, "The current user isn't allowed to create new collections") } } let mut collections: Vec = 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 { // Collection creation through an organization import is governed by the same // independent permission as the regular create endpoint. In particular, // Edit any collection (full access to every collection) must not satisfy this check. let new_collection = Collection::new(org_id.clone(), col.name, col.external_id); new_collection.save(&conn).await?; // Import-created collections do not carry the regular create endpoint's user access // selections. Give a create-only importer Manage access to the collection they just // created, matching Bitwarden's organization-import behavior. if !headers.membership.has_full_access() { CollectionUser::save(&headers.membership.user_uuid, &new_collection.uuid, false, false, true, &conn) .await?; } new_collection.uuid }; collections.push(collection_uuid); } // Read the relations between collections and ciphers // Ciphers can be in multiple collections at the same time let mut relations = Vec::with_capacity(data.collection_relationships.len()); for relation in data.collection_relationships { relations.push((relation.key, relation.value)); } let headers: Headers = headers.into(); let mut ciphers: Vec = Vec::with_capacity(data.ciphers.len()); for mut cipher_data in data.ciphers { // Always clear folder_id's via an organization import cipher_data.folder_id = None; // Replace the client-provided, unvalidated organizationId with the real target org cipher_data.organization_id = Some(org_id.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data( &mut cipher, cipher_data, &headers, Some(collections.clone()), &conn, &nt, UpdateType::None, ) .await .ok(); ciphers.push(cipher.uuid); } // Assign the collections. Indices were bounds-validated above, but use `.get()` here as well so // any future drift fails closed with an error instead of panicking. for (cipher_index, col_index) in relations { let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else { err!(Compact, "Invalid collection relationship") }; CollectionCipher::save(cipher_id, col_id, &conn).await?; } let mut user = headers.user; user.update_revision(&conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct BulkCollectionsData { organization_id: OrganizationId, cipher_ids: Vec, collection_ids: HashSet, remove_collections: bool, } // This endpoint is only reachable via the organization view, therefore this endpoint is located here // Also Bitwarden does not send out Notifications for these changes, it only does this for individual cipher collection updates #[post("/ciphers/bulk-collections", data = "")] async fn post_bulk_collections(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let data: BulkCollectionsData = data.into_inner(); if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { err!("You need to be a Member of the Organization to call this endpoint") } // Get all the collection available to the user in one query // Also filter based upon the provided collections let user_collections: HashMap = Collection::find_by_organization_and_user_uuid(&data.organization_id, &headers.user.uuid, &conn) .await .into_iter() .filter_map(|c| { if data.collection_ids.contains(&c.uuid) { Some((c.uuid.clone(), c)) } else { None } }) .collect(); // Verify if all the collections requested exists and are writable for the user, else abort for collection_uuid in &data.collection_ids { match user_collections.get(collection_uuid) { Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), _ => err_code!("Resource not found", "User does not have access to a collection", 404), } } for cipher_id in &data.cipher_ids { // 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 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 { // 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 { for collection in &data.collection_ids { CollectionCipher::save(&cipher.uuid, collection, &conn).await?; } } } } Ok(()) } #[get("/organizations//policies")] async fn list_policies(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } // Security: only Admins/Owners, or Custom members holding the manage_policies permission, // may see the actual policy configuration. Other Managers/Custom members (e.g. manage_users // or manage_groups only) are still allowed to call this endpoint so the Admin Console can // load, but they receive an empty list instead of the policy contents. let can_view_policies = headers.membership.atype >= MembershipType::Admin || headers.membership.has_manage_policies(); let policies_json: Vec = if can_view_policies { OrgPolicy::find_by_org(&org_id, &conn).await.iter().map(OrgPolicy::to_json).collect() } else { Vec::new() }; Ok(Json(json!({ "data": policies_json, "object": "list", "continuationToken": null }))) } #[get("/organizations//policies/token?")] async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) -> JsonResult { let invite = decode_invite(token)?; if invite.org_id != org_id { err!("Token doesn't match request organization"); } // exit early when we have been invited via /admin panel if org_id.as_ref() == FAKE_ADMIN_UUID { return Ok(Json(json!({}))); } // TODO: We receive the invite token as ?token=<>, validate it contains the org id let policies = OrgPolicy::find_by_org(&org_id, &conn).await; let policies_json: Vec = policies.iter().map(OrgPolicy::to_json).collect(); Ok(Json(json!({ "data": policies_json, "object": "list", "continuationToken": null }))) } // Called during the SSO enrollment return the default policy #[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)] fn get_dummy_master_password_policy() -> JsonResult { let (enabled, data) = match CONFIG.sso_master_password_policy_value() { Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), _ => (false, "null".to_owned()), }; let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data); Ok(Json(policy.to_json())) } // Called during the SSO enrollment return the org policy if it exists #[get("/organizations//policies/master-password", rank = 2)] async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { let policy = OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| { let (enabled, data) = match CONFIG.sso_master_password_policy_value() { Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), _ => (false, "null".to_owned()), }; OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, enabled, data) }); Ok(Json(policy.to_json())) } #[get("/organizations//policies/", rank = 3)] async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: ManagePoliciesHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; let policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "null".to_owned()), }; Ok(Json(policy.to_json())) } #[derive(Deserialize)] struct PolicyData { enabled: bool, data: Option, } #[derive(Deserialize)] struct PutPolicy { policy: PolicyData, // Ignore metadata for now as we do not yet support this // "metadata": { // "defaultUserCollectionName": "2.xx|xx==|xx=" // } } #[put("/organizations//policies/", data = "")] async fn put_policy( org_id: OrganizationId, pol_type: i32, data: Json, headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PolicyData = data.into_inner().policy; let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") }; // 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 // Now that groups are available we can enforce this option when wanted. // We put this behind a config option to prevent breaking current installation. // Maybe we want to enable this by default in the future, but currently it is disabled by default. if CONFIG.enforce_single_org_with_reset_pw_policy() { if pol_type_enum == OrgPolicyType::ResetPassword && data.enabled { let single_org_policy_enabled = match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await { Some(p) => p.enabled, None => false, }; if !single_org_policy_enabled { err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.") } } // Also prevent the Single Org Policy to be disabled if the Reset Password policy is enabled if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled { let reset_pw_policy_enabled = match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::ResetPassword, &conn).await { Some(p) => p.enabled, None => false, }; if reset_pw_policy_enabled { err!("Account recovery policy is enabled. It is not allowed to disable this policy.") } } } // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await?; } // When enabling the SingleOrg policy, remove this org's members that are members of other orgs if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled { for mut member in Membership::find_by_org(&org_id, &conn).await { // Policy only applies to non-Owner/non-Admin members who have accepted joining the org, // and never to the member enabling it -- see `Membership::is_policy_enforcement_target`. // Exclude invited and revoked users when checking for this policy. // Those users will not be allowed to accept or be activated because of the policy checks done there. if member.is_policy_enforcement_target(&headers.user.uuid) && member.status != MembershipStatus::Invited as i32 && Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await > 0 { if CONFIG.mail_enabled() { let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap(); let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap(); mail::send_single_org_removed_from_org(&user.email, &org.name).await?; } log_event( EventType::OrganizationUserRemoved as i32, &member.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; member.revoke(); member.save(&conn).await?; } } } let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), }; policy.enabled = data.enabled; policy.data = serde_json::to_string(&data.data)?; policy.save(&conn).await?; log_event( EventType::PolicyUpdated as i32, policy.uuid.as_ref(), &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(Json(policy.to_json())) } // Deprecated with client v2026.5.0 #[put("/organizations//policies//vnext", data = "")] async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, data: Json, headers: ManagePoliciesHeaders, conn: DbConn, ) -> JsonResult { put_policy(org_id, pol_type, data, headers, conn).await } #[get("/plans")] fn get_plans() -> Json { // Respond with a minimal json just enough to allow the creation of an new organization. Json(json!({ "object": "list", "data": [{ "object": "plan", "type": 0, "product": 0, "name": "Free", "nameLocalizationKey": "planNameFree", "bitwardenProduct": 0, "maxUsers": 0, "descriptionLocalizationKey": "planDescFree" },{ "object": "plan", "type": 0, "product": 1, "name": "Free", "nameLocalizationKey": "planNameFree", "bitwardenProduct": 1, "maxUsers": 0, "descriptionLocalizationKey": "planDescFree" }], "continuationToken": null })) } #[get("/organizations/<_org_id>/billing/metadata")] fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { // Prevent a 404 error, which also causes Javascript errors. Json(empty_data_json()) } #[get("/organizations/<_org_id>/billing/vnext/warnings")] fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { Json(json!({ "freeTrial":null, "inactiveSubscription":null, "resellerRenewal":null, "taxId":null, })) } #[get("/organizations/<_org_id>/billing/vnext/self-host/metadata")] fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json { // Prevent a 404 error, which also causes Javascript errors. Json(json!({ "isOnSecretsManagerStandalone": false, // Secrets Manager is not supported by Vaultwarden "organizationOccupiedSeats": 0 // Vaultwarden does not count seats })) } fn empty_data_json() -> Value { json!({ "object": "list", "data": [], "continuationToken": null }) } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct BulkRevokeMembershipIds { ids: Option>, } #[put("/organizations//users//revoke")] async fn revoke_member( org_id: OrganizationId, member_id: MembershipId, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { revoke_member_impl(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users/revoke", data = "")] async fn bulk_revoke_members( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); let mut bulk_response = Vec::new(); match data.ids { Some(members) => { for member_id in members { let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationUserBulkResponseModel", "id": member_id, "error": err_msg } )); } } None => error!("No users to revoke"), } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } async fn revoke_member_impl( org_id: &OrganizationId, member_id: &MembershipId, headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { Some(mut member) if member.status > MembershipStatus::Revoked as i32 => { if member.user_uuid == headers.user.uuid { err!("You cannot revoke yourself") } if !may_revoke_stored_member_type(headers.membership_type, member.atype) { err!("You don't have permission to revoke this user") } if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 { err!("Organization must have at least one confirmed owner") } member.revoke(); member.save(conn).await?; log_event( EventType::OrganizationUserRevoked as i32, &member.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } Some(_) => err!("User is already revoked"), None => err!("User not found in organization"), } Ok(()) } #[put("/organizations//users//restore/vnext")] async fn restore_member_vnext( org_id: OrganizationId, member_id: MembershipId, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { // Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy. // Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member restore_member_impl(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users//restore")] async fn restore_member( org_id: OrganizationId, member_id: MembershipId, headers: ManageUsersHeaders, conn: DbConn, ) -> EmptyResult { restore_member_impl(&org_id, &member_id, &headers, &conn).await } #[put("/organizations//users/restore", data = "")] async fn bulk_restore_members( org_id: OrganizationId, data: Json, headers: ManageUsersHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data = data.into_inner(); let mut bulk_response = Vec::new(); for member_id in data.ids { let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), Err(e) => format!("{e:?}"), }; bulk_response.push(json!( { "object": "OrganizationUserBulkResponseModel", "id": member_id, "error": err_msg } )); } Ok(Json(json!({ "data": bulk_response, "object": "list", "continuationToken": null }))) } async fn restore_member_impl( org_id: &OrganizationId, member_id: &MembershipId, headers: &ManageUsersHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } match Membership::find_by_uuid_and_org(member_id, org_id, conn).await { Some(mut member) if member.status < MembershipStatus::Accepted as i32 => { if member.user_uuid == headers.user.uuid { err!("You cannot restore yourself") } if !may_manage_stored_member_type(headers.membership_type, member.atype) { err!("You don't have permission to restore this user") } member.restore(); // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // This check need to be done after restoring to work with the correct status OrgPolicy::check_user_allowed(&member, "restore", conn).await?; member.save(conn).await?; log_event( EventType::OrganizationUserRestored as i32, &member.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } Some(_) => err!("User is already active"), None => err!("User not found in organization"), } Ok(()) } /// Whether `membership` may read group→collection/user mappings. /// /// Two independent routes to the same data: the Manage Users / Manage Groups permissions, which is /// what Bitwarden gates ReadAll on, and organization-wide collection reach, which is what released /// Vaultwarden gated it on. Both are kept so a legacy Manager with "Manage all collections" still /// reads these mappings after the migration converts them. The single-group view returns exactly this /// data and so asks exactly this question. async fn can_read_group_details(org_id: &OrganizationId, membership: &Membership, conn: &DbConn) -> bool { membership.has_manage_users() || membership.has_manage_groups() || membership.has_full_access() || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(org_id, &membership.uuid, conn).await) } async fn get_groups_data(details: bool, org_id: OrganizationId, membership: &Membership, conn: DbConn) -> JsonResult { let can_read_details = can_read_group_details(&org_id, membership, &conn).await; // The plain list (id, name, externalId) carries no access mappings, so it additionally opens to // anyone who manages a single collection: they need the group names to assign groups to it. let allowed = if details { can_read_details } else { can_read_details || Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await }; if !allowed { err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); } let groups: Vec = if CONFIG.org_groups_enabled() { let groups = Group::find_by_organization(&org_id, &conn).await; let mut groups_json = Vec::with_capacity(groups.len()); if details { for g in groups { groups_json.push(g.to_json_details(&conn).await); } } else { for g in groups { groups_json.push(g.to_json()); } } groups_json } else { // The Bitwarden clients seem to call this API regardless of whether groups are enabled, // so just act as if there are no groups. Vec::new() }; Ok(Json(json!({ "data": groups, "object": "list", "continuationToken": null, }))) } // The plain group list (id, name, externalId) exposes no access mappings, so it stays readable for // members who have a reason to see it — the web vault needs it to render group names. The exact // condition is enforced in `get_groups_data`. #[get("/organizations//groups")] async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } get_groups_data(false, org_id, &headers.membership, conn).await } // Group *details* expose accessAll, external IDs and collection mappings. The condition is // `can_read_group_details`, enforced in `get_groups_data`; keeping the guard loose and the condition // in one place is what stops the list and single-group views from drifting apart, as they had. #[get("/organizations//groups/details", rank = 1)] async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } get_groups_data(true, org_id, &headers.membership, conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GroupRequest { name: String, #[serde(default)] access_all: bool, external_id: Option, collections: Vec, users: Vec, } impl GroupRequest { pub fn to_group(&self, org_uuid: &OrganizationId) -> Group { Group::new(org_uuid.clone(), self.name.clone(), self.access_all, self.external_id.clone()) } pub fn update_group(&self, mut group: Group) -> Group { group.name.clone_from(&self.name); group.access_all = self.access_all; // Group Updates do not support changing the external_id // These input fields are in a disabled state, and can only be updated/added via ldap_import group } /// Validate if all the collections and members belong to the provided organization pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { 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(); 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)) } 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(); if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) { err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) } Ok(()) } } #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct CollectionData { id: CollectionId, read_only: bool, hide_passwords: bool, manage: bool, } impl CollectionData { pub fn to_collection_group(&self, groups_uuid: GroupId) -> CollectionGroup { CollectionGroup::new(self.id.clone(), groups_uuid, self.read_only, self.hide_passwords, self.manage) } } #[post("/organizations//groups/", data = "")] async fn post_group( org_id: OrganizationId, group_id: GroupId, data: Json, headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { put_group(org_id, group_id, data, headers, conn).await } #[post("/organizations//groups", data = "")] async fn post_groups( org_id: OrganizationId, headers: ManageGroupsHeaders, data: Json, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let group_request = data.into_inner(); group_request.validate(&org_id, &conn).await?; // Security: only callers who can manage collections may assign collections to a new group. // A custom user with only manage_groups can create the group, but without collection access. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(m) => m.has_full_access(), None => false, }; // Security: creating an `access_all` group is reserved for Admins and Owners; `has_full_access()`, // which `editAnyCollection` satisfies, is deliberately not enough. See `may_grant_access_all_group`. if group_request.access_all && !may_grant_access_all_group(headers.membership_type) { err!("Only Admins and Owners can create a group with access to all collections") } // Assigning collections to a group is a collection-access grant. Rejected rather than silently // created without it, so a caller never believes it granted something the server dropped; a request // that grants nothing is still accepted, which is what the plain "new group" dialog sends. if !caller_can_manage_collections && !group_request.collections.is_empty() { err!("You don't have permission to assign collections to a group") } let group = group_request.to_group(&org_id); log_event( EventType::GroupCreated as i32, &group.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; let collections_to_apply = if caller_can_manage_collections { group_request.collections } else { Vec::new() }; add_update_group( group, collections_to_apply, group_request.users, org_id, &headers, &conn, caller_can_manage_collections, ) .await } #[put("/organizations//groups/", data = "")] async fn put_group( org_id: OrganizationId, group_id: GroupId, data: Json, headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; let group_request = data.into_inner(); group_request.validate(&org_id, &conn).await?; // Security: only callers who can actually manage collections (Admins/Owners, or users with // full access) may change a group's collection assignments. A custom user with only // manage_groups must not be able to add/remove collection access. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(m) => m.has_full_access(), None => false, }; // Security: turning an ordinary group into an organization-wide one is the same durable grant as // creating one. Clearing the flag is a reduction and keeps the rule below. if group_request.access_all && !group.access_all && !may_grant_access_all_group(headers.membership_type) { err!("Only Admins and Owners can give a group access to all collections") } // API consistency: reject a collection-access change this caller may not make rather than answering // 200 and keeping the old value, as `edit_member` and `send_invite` do. Only an actual difference is // rejected, since the group dialog echoes the current assignments back; flag differences stay ignored. if !caller_can_manage_collections { if group_request.access_all != group.access_all { err!("You don't have permission to change a group's access to all collections") } let requested: HashSet = group_request.collections.iter().map(|c| c.id.clone()).collect(); let current: HashSet = CollectionGroup::find_by_group(&group_id, &org_id, &conn) .await .into_iter() .map(|cg| cg.collections_uuid) .collect(); if requested != current { err!("You don't have permission to change this group's collection assignments") } } let updated_group = group_request.update_group(group); // Security (audit F-1): `add_update_group` asks this too, but it runs *after* the destructive // `CollectionGroup::delete_all_by_group` below -- a refusal there answered 400 with the group's // collection assignments already gone, revoking them for every member of the group. Ask it here, // while nothing has been written. `updated_group.access_all` is the value this request leaves // behind, which is what actually grants, and it is the same value the later check reads. if !may_grant_access_all_group(headers.membership_type) && adds_member_to_access_all_group(&updated_group, &group_request.users, &org_id, &conn).await { err!("Only Admins and Owners can add a member to a group with access to all collections") } if caller_can_manage_collections { CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?; } // NOTE: group membership is replaced (and access-gated) inside add_update_group. // Only pass collection changes through if the caller is allowed to manage collections. let collections_to_apply = if caller_can_manage_collections { group_request.collections } else { Vec::new() }; let response = add_update_group( updated_group, collections_to_apply, group_request.users, org_id.clone(), &headers, &conn, caller_can_manage_collections, ) .await?; // Logged once the update has actually been applied. `add_update_group` still refuses a membership // change the caller may not make, and an event written before it recorded an update that never // happened. log_event( EventType::GroupUpdated as i32, &group_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(response) } /// Whether a caller may change (add OR remove) a member's membership in a group. /// /// A caller who cannot manage collections must never touch a collection-bearing group's membership: /// adding grants those collections, removing revokes them. Organization-wide (`access_all`) groups /// are a separate, stricter question — see [`may_grant_access_all_group`]. fn may_change_group_membership(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool { caller_can_manage_collections || !group_confers_collection_access } /// Whether `caller_type` may hand out organization-wide group access: create a group carrying /// `groups.access_all`, turn one into it, or add a member to one. /// /// Security: `access_all` reaches every collection and is not bound to the grantee's role, so it /// survives their Custom permissions being cleared — a durable grant like a `users_collections.manage` /// row, which `caller_may_grant_collection_manage` already reserves. Gating on `has_full_access()` /// instead would let `manageGroups` + `editAnyCollection` mint one and outlive the flag it came from, /// so no Custom permission satisfies this, `deleteAnyCollection` included. /// /// Only granting is restricted; removals, clearing the flag and deleting the group all reduce access. /// Asked by `post_groups`, `put_group`, `add_update_group`, `put_group_members`, `edit_member` and /// `send_invite` — the last needs it because a new membership has no current groups to diff against, /// so every group in an invite is an addition. /// /// A missing call site is invisible to `cargo test`, so that list is the thing to check when a new /// group path is added. So is *where* the call sits: it has to precede every write the request makes, /// or a refusal leaves the endpoint half-applied (audit F-1). fn may_grant_access_all_group(caller_type: MembershipType) -> bool { caller_type >= MembershipType::Admin } /// Whether `requested` would add at least one member that `current` does not already contain. /// /// Separates an addition to an access-all group (restricted) from a pure removal (always allowed) on /// the endpoints that replace a whole member list. fn adds_group_member(requested: &HashSet<&MembershipId>, current: &HashSet<&MembershipId>) -> bool { !requested.is_subset(current) } /// Whether replacing `group`'s member list with `members` adds someone to an organization-wide group. /// /// The question `may_grant_access_all_group` gates, resolved against the database. Shared by /// `put_group` and `add_update_group` so the pre-write check and the one next to the write can never /// answer differently. Reads `group.access_all` first, so an ordinary group costs no query. async fn adds_member_to_access_all_group( group: &Group, members: &[MembershipId], org_id: &OrganizationId, conn: &DbConn, ) -> bool { if !group.access_all { return false; } let current_members = GroupUser::find_by_group(&group.uuid, org_id, conn).await; let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); adds_group_member(&members.iter().collect(), ¤t) } /// Whether `requested` and `current` agree on every group that confers collection access. /// /// Such a change has to be rejected rather than skipped silently, so a save that appears to succeed /// never means something different on the server. fn collection_bearing_membership_unchanged( requested: &HashSet, current: &HashSet, collection_bearing: &HashSet, ) -> bool { let restrict = |set: &HashSet| -> HashSet { set.intersection(collection_bearing).cloned().collect() }; restrict(requested) == restrict(current) } /// Whether a caller of `edit_member` may change a member's role type. /// /// The role type changes organization-wide collection reach and which granular permissions are /// effective — the data plane, not the user lifecycle `manage_users` covers — so only Admins and /// Owners may change it. An unchanged role is always allowed, so `manage_users` members can still use /// the regular edit dialog; Admin/Owner transitions have their own guard in `edit_member`. fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool { caller_type >= MembershipType::Admin || new_type == current_atype } /// Whether a caller with user-management access may perform lifecycle actions on a target role. /// /// Owners may manage every role. Admins may manage Admin, Custom, and User memberships, but never /// Owners. Custom members holding `manage_users` are limited to ordinary Users. fn may_manage_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { match caller_type { MembershipType::Owner => true, MembershipType::Admin => target_type != MembershipType::Owner, MembershipType::Custom => target_type == MembershipType::User, MembershipType::User => false, } } fn may_manage_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { MembershipType::from_i32(target_atype).is_some_and(|target_type| may_manage_member_type(caller_type, target_type)) } /// Whether a caller may *provision* a membership of `target_type` — create it (invite), activate it /// (confirm) or remove it (delete). /// /// Stricter than [`may_manage_member_type`], preserving the rule that only Owners bring Admin/Owner /// memberships into or out of existence — `edit_member` guards the same boundary on role transitions, /// so an Admin must not route around it by inviting a fresh Admin. State changes that leave the /// membership in place keep using [`may_manage_member_type`]. fn may_provision_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool { match caller_type { MembershipType::Owner => true, MembershipType::Admin => target_type < MembershipType::Admin, MembershipType::Custom => target_type == MembershipType::User, MembershipType::User => false, } } fn may_provision_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { MembershipType::from_i32(target_atype) .is_some_and(|target_type| may_provision_member_type(caller_type, target_type)) } /// Whether a caller may act on a membership whose stored `atype` this build cannot interpret. /// /// Such a row (a future build, a partial rollback, a hand edit) holds no authority — `OrgHeaders` /// refuses it and every permission flag on it is inert — but the helpers above fail closed on the /// unknown value, which left nobody able to remove it either, unlike Vaultwarden. So: an Owner only, /// and only for the two actions that reduce what the row can become. Editing, confirming, restoring /// and reinviting keep refusing, because they preserve or reactivate a role the server cannot reason /// about. fn may_act_on_unknown_stored_member_type(caller_type: MembershipType) -> bool { caller_type == MembershipType::Owner } /// Whether a caller may delete `target_atype`. Provisioning rules for a role this build knows; /// Owner-only for one it does not (see [`may_act_on_unknown_stored_member_type`]). fn may_delete_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { match MembershipType::from_i32(target_atype) { Some(role) => may_provision_member_type(caller_type, role), None => may_act_on_unknown_stored_member_type(caller_type), } } /// Whether a caller may revoke `target_atype`. Management rules for a role this build knows; /// Owner-only for one it does not. Kept separate from [`may_delete_stored_member_type`] so revoking /// keeps the looser actor/target matrix it has always had (an Admin may revoke a peer Admin, which /// provisioning does not allow). fn may_revoke_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool { match MembershipType::from_i32(target_atype) { Some(role) => may_manage_member_type(caller_type, role), None => may_act_on_unknown_stored_member_type(caller_type), } } /// Returns true if being a member of `group_id` confers collection access — either because the /// group has `access_all` set, or because it has collections assigned. async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { match Group::find_by_uuid_and_org(group_id, org_id, conn).await { Some(group) => group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty(), None => false, } } /// Returns true if `group_id` carries `groups.access_all`, i.e. membership of it reaches every /// collection of the organization. A group that does not exist in this organization confers /// nothing. See [`may_grant_access_all_group`] for why this is asked separately from /// [`group_confers_collection_access`]. async fn group_grants_access_to_all_collections(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool { Group::find_by_uuid_and_org(group_id, org_id, conn).await.is_some_and(|group| group.access_all) } /// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` / /// `collections_groups.manage`) on `col_id`. /// /// Security: a `manage` grant is collection administration authority (`ManagerHeaders` accepts it via /// `has_explicit_collection_manage_access`) and survives every later change to the grantee's role, so /// a caller acting through `edit_any_collection` — revocable by clearing one flag — must not be able /// to write one. Only blanket authority (Admin/Owner, or Custom with `delete_any_collection`) or a /// real stored grant on that same collection qualifies. Strictly subtractive: it can only downgrade a /// requested `manage` to false. async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool { match caller_manage_grant_role_check(caller) { // Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no). Some(decision) => decision, // Custom without delete_any: the answer is per-collection and must reflect a *real* stored // manage grant. Edit any collection deliberately does not count here — it is revocable by // clearing a flag, while a `manage` row written here outlives it. Accepting it would let // temporary authority be laundered into a permanent grant, which is exactly the escalation // this clamp exists to prevent. None => match MembershipType::from_i32(caller.atype) { Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await, _ => false, }, } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum OrganizationImportTarget { Existing { writable: bool, }, New, } /// Organization imports retain the pre-existing per-target authorization model. The /// `accessImportExport` permission opens export, but it is deliberately not an organization-wide /// Create/Write shortcut for imports. fn may_import_to_collection(caller: &Membership, target: OrganizationImportTarget) -> bool { if !caller.has_status(MembershipStatus::Confirmed) { return false; } match target { OrganizationImportTarget::Existing { writable, } => caller.atype >= MembershipType::Admin || writable, OrganizationImportTarget::New => caller.can_create_new_collections(), } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum OrganizationReportScope { Complete, Assigned, Denied, } /// Full-access members receive the complete organization view. `accessReports` alone receives the /// same assignment-scoped, restriction-bearing cipher representation as the caller's normal sync. fn organization_report_scope(caller: &Membership) -> OrganizationReportScope { if caller.has_full_access() { OrganizationReportScope::Complete } else if caller.has_status(MembershipStatus::Confirmed) && caller.has_access_reports() { OrganizationReportScope::Assigned } else { OrganizationReportScope::Denied } } /// Whether `caller` may export the *entire* organization instead of only their own assignments. /// /// Security: the `AccessImportExportHeaders` guard decides whether a member may export at all, not /// *what* they get. Only members who already reach every collection get the full dump; for anyone /// else it is built from their own assignments, so the permission never becomes a full vault read. fn may_export_entire_organization(caller: &Membership) -> bool { caller.has_full_access() } /// Pure, collection-independent part of `caller_may_grant_collection_manage`. /// /// `Some(true)` -> the caller may grant `manage` on *any* collection (Admin/Owner, or a Custom /// member holding `delete_any_collection`). /// `Some(false)` -> the caller may never grant `manage` (unconfirmed, plain User, or unknown type). /// `None` -> depends on a real per-collection manage grant, resolved against the database. /// /// Separate so the role gating — in particular `edit_any_collection` alone yielding `None` rather /// than `Some(true)` — is unit-testable without a DB. fn caller_manage_grant_role_check(caller: &Membership) -> Option { if caller.can_delete_any_collection() { return Some(true); // Admin/Owner, or a Custom member holding delete_any_collection } if !caller.has_status(MembershipStatus::Confirmed) { return Some(false); } match MembershipType::from_i32(caller.atype) { Some(MembershipType::Custom) => None, _ => Some(false), } } async fn add_update_group( mut group: Group, collections: Vec, members: Vec, org_id: OrganizationId, headers: &ManageGroupsHeaders, conn: &DbConn, caller_can_manage_collections: bool, ) -> JsonResult { // Security: membership of a collection-bearing group grants (or revokes) access to those // collections' contents, so only a caller who can manage collections may change it. Rejected rather // than silently ignored, and checked before the first write so a refused request leaves nothing // behind. On create the group is brand new and this never triggers. // // `group.access_all` is the value this request leaves behind, which is what actually grants: adding a // member to it is Admin/Owner authority even for a caller who can manage collections. Removals stay // under the collection rule below. `put_group` asks the same question before its own destructive // delete (audit F-1); this call covers `post_groups`, where nothing has been written yet either. if !may_grant_access_all_group(headers.membership_type) && adds_member_to_access_all_group(&group, &members, &org_id, conn).await { err!("Only Admins and Owners can add a member to a group with access to all collections") } if !caller_can_manage_collections && (group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty()) { let requested: HashSet<&MembershipId> = members.iter().collect(); let current_members = GroupUser::find_by_group(&group.uuid, &org_id, conn).await; let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); if requested != current { err!("You don't have permission to change the membership of a group that grants collection access") } } group.save(conn).await?; // Security: a `collections_groups.manage` grant carries collection delete authority, so it may only // be set on a collection the caller could delete themselves — otherwise Edit-any-collection reaches // Delete-any-collection through a manage-bearing group the caller then joins. let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await; for col_selection in collections { let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); if collection_group.manage { let may_grant_manage = match &caller { Some(c) => caller_may_grant_collection_manage(c, &collection_group.collections_uuid, conn).await, None => false, }; collection_group.manage = may_grant_manage; } collection_group.save(&org_id, conn).await?; } // Security: assigning members to a group that grants collection access (via `access_all` // or assigned collections) would indirectly grant those members access to the collections' // contents. Only callers who can manage collections may change the membership of such a // group; for others we leave the group's membership untouched. let group_grants_collection_access = group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty(); if caller_can_manage_collections || !group_grants_collection_access { GroupUser::delete_all_by_group(&group.uuid, &org_id, conn).await?; for assigned_member in members { let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_member.clone()); user_entry.save(conn).await?; log_event( EventType::OrganizationUserUpdatedGroups as i32, &assigned_member, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; } } Ok(Json(json!({ "id": group.uuid, "organizationId": group.organizations_uuid, "name": group.name, "accessAll": group.access_all, "externalId": group.external_id, "object": "group" }))) } // Reads a single group's details (accessAll, externalId, collection mappings). This is the same data // the `/groups/details` list endpoint returns, so it asks the same question — `can_read_group_details`. // Any divergence would let a member read every group's details in bulk but be denied the single-group // view of the same data, or the reverse. #[get("/organizations//groups//details")] async fn get_group_details( org_id: OrganizationId, group_id: GroupId, headers: ManagerHeadersLoose, conn: DbConn, ) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } if !can_read_group_details(&org_id, &headers.membership, &conn).await { err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; Ok(Json(group.to_json_details(&conn).await)) } #[post("/organizations//groups//delete")] async fn post_delete_group( org_id: OrganizationId, group_id: GroupId, headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await } #[delete("/organizations//groups/")] async fn delete_group( org_id: OrganizationId, group_id: GroupId, headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { delete_group_impl(&org_id, &group_id, &headers, &conn).await } async fn delete_group_impl( org_id: &OrganizationId, group_id: &GroupId, headers: &ManageGroupsHeaders, conn: &DbConn, ) -> EmptyResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || headers.membership.has_full_access(); let group = authorize_group_deletion(group_id, org_id, caller_can_manage_collections, conn).await?; delete_authorized_group(&group, org_id, headers, conn).await } async fn authorize_group_deletion( group_id: &GroupId, org_id: &OrganizationId, caller_can_manage_collections: bool, conn: &DbConn, ) -> Result { let Some(group) = Group::find_by_uuid_and_org(group_id, org_id, conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; // Security: deleting a group that grants collection access (via `access_all` or assigned // collections) revokes that access for all its members. A custom user with only manage_groups // must not be able to affect collection access, so only callers who can actually manage // collections (Admins/Owners or users with full access) may delete such a group. Mirrors the // restriction in put_group_members / post_delete_group_member. let group_confers_collection_access = group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty(); if !may_delete_group(caller_can_manage_collections, group_confers_collection_access) { err!("You don't have permission to delete a group that grants collection access") } Ok(group) } fn may_delete_group(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool { caller_can_manage_collections || !group_confers_collection_access } async fn delete_authorized_group( group: &Group, org_id: &OrganizationId, headers: &ManageGroupsHeaders, conn: &DbConn, ) -> EmptyResult { log_event( EventType::GroupDeleted as i32, &group.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn, ) .await; group.delete(org_id, conn).await } #[delete("/organizations//groups", data = "")] async fn bulk_delete_groups( org_id: OrganizationId, data: Json, headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let data: BulkGroupIds = data.into_inner(); // Authorize the complete request before the first event or deletion. In particular, a // manageGroups-only caller may delete ordinary groups but not collection-bearing groups; a mixed // batch must not delete an authorized prefix and then fail on a later item. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || headers.membership.has_full_access(); let mut groups = Vec::with_capacity(data.ids.len()); let mut seen_group_ids = HashSet::with_capacity(data.ids.len()); for group_id in data.ids { if !seen_group_ids.insert(group_id.clone()) { err!("Duplicate group id in bulk delete request") } groups.push(authorize_group_deletion(&group_id, &org_id, caller_can_manage_collections, &conn).await?); } for group in &groups { delete_authorized_group(group, &org_id, &headers, &conn).await?; } Ok(()) } #[get("/organizations//groups/", rank = 2)] async fn get_group( org_id: OrganizationId, group_id: GroupId, headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group not found", "Group uuid is invalid or does not belong to the organization") }; Ok(Json(group.to_json())) } #[get("/organizations//groups//users")] async fn get_group_members( org_id: OrganizationId, group_id: GroupId, headers: ManageGroupsHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") } let group_members: Vec = GroupUser::find_by_group(&group_id, &org_id, &conn) .await .iter() .map(|entry| entry.users_organizations_uuid.clone()) .collect(); Ok(Json(json!(group_members))) } #[put("/organizations//groups//users", data = "")] async fn put_group_members( org_id: OrganizationId, group_id: GroupId, headers: ManageGroupsHeaders, data: Json>, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") }; // Security: changing the membership of a group that grants collection access (via // `access_all` or assigned collections) indirectly grants those members access to the // collections' contents. Only callers who can actually manage collections (Admins/Owners // or users with full access) may do this. A custom user with only manage_groups may manage // the membership of groups that grant no collection access, but not of collection-bearing ones. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(m) => m.has_full_access(), None => false, }; let group_grants_collection_access = group.access_all || !CollectionGroup::find_by_group(&group_id, &org_id, &conn).await.is_empty(); if !caller_can_manage_collections && group_grants_collection_access { err!("You don't have permission to change the membership of a group that grants collection access") } let assigned_members = data.into_inner(); // Security: adding a member to an `access_all` group hands out durable organization-wide access, so // it is Admin/Owner authority even for a caller who can manage collections. Removals stay allowed. if group.access_all && !may_grant_access_all_group(headers.membership_type) { let current_members = GroupUser::find_by_group(&group_id, &org_id, &conn).await; let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect(); let requested: HashSet<&MembershipId> = assigned_members.iter().collect(); if adds_group_member(&requested, ¤t) { err!("Only Admins and Owners can add a member to a group with access to all collections") } } 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(); if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) { err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) } GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; for assigned_member in assigned_members { let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone()); user_entry.save(&conn).await?; log_event( EventType::OrganizationUserUpdatedGroups as i32, &assigned_member, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; } Ok(()) } #[post("/organizations//groups//delete-user/")] async fn post_delete_group_member( org_id: OrganizationId, group_id: GroupId, member_id: MembershipId, headers: ManageGroupsHeaders, conn: DbConn, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } if !CONFIG.org_groups_enabled() { err!("Group support is disabled"); } if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() { err!("User could not be found or does not belong to the organization."); } if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() { err!("Group could not be found or does not belong to the organization."); } // Security: removing a member from a group that grants collection access (via `access_all` // or assigned collections) revokes that member's collection access. A custom user with only // manage_groups must not be able to affect collection access, so only callers who can actually // manage collections (Admins/Owners or users with full access) may do this. Mirrors the // restriction enforced in put_group_members. let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin || match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { Some(m) => m.has_full_access(), None => false, }; if !caller_can_manage_collections && group_confers_collection_access(&group_id, &org_id, &conn).await { err!("You don't have permission to change the membership of a group that grants collection access") } log_event( EventType::OrganizationUserUpdatedGroups as i32, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserResetPasswordEnrollmentRequest { reset_password_key: Option, master_password_hash: Option, otp: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserRecoverAccountRequest { new_master_password_hash: String, key: String, #[serde(default)] reset_master_password: bool, #[serde(default)] reset_two_factor: bool, } // Upstream reports this is the renamed endpoint instead of `/keys` // But the clients do not seem to use this at all // Just add it here in case they will #[get("/organizations//public-key")] async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { if org_id != headers.membership.org_uuid { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Organization not found") }; Ok(Json(json!({ "object": "organizationPublicKey", "publicKey": org.public_key, }))) } // Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492 #[get("/organizations//keys")] async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { get_organization_public_key(org_id, headers, conn).await } // Will allow to reset 2FA too // https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts #[put("/organizations//users//recover-account", data = "")] async fn put_recover_account( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, data: Json, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { let req = data.into_inner(); if req.reset_master_password && !req.reset_two_factor { recover_account(org_id, member_id, headers, req, conn, nt).await } else { err!("Unsupported operation") } } // Deprecated since `v2026.4.2` #[put("/organizations//users//reset-password", data = "")] async fn put_reset_password( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, data: Json, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await } async fn recover_account( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, reset_request: OrganizationUserRecoverAccountRequest, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Required organization not found") }; let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org.uuid, &conn).await else { err!("User to reset isn't member of required organization") }; let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { err!("User not found") }; check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?; if member.reset_password_key.is_none() { err!("Password reset not or not correctly enrolled"); } if member.status != (MembershipStatus::Confirmed as i32) { err!("Organization user must be confirmed for password reset functionality"); } // Sending email before resetting password to ensure working email configuration and the resulting // user notification. Also this might add some protection against security flaws and misuse if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await { err!(format!("Error sending user reset password email: {e:#?}")); } let mut user = user; user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) .await?; user.save(&conn).await?; nt.send_logout(&user, None, &conn).await; log_event( EventType::OrganizationUserAdminResetPassword as i32, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn, ) .await; Ok(()) } #[get("/organizations//users//reset-password-details")] async fn get_reset_password_details( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { err!("Required organization not found") }; let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { err!("User to reset isn't member of required organization") }; let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { err!("User not found") }; check_reset_password_applicable_and_permissions(&org_id, &member_id, &headers, &conn).await?; // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs#L190 Ok(Json(json!({ "object": "organizationUserResetPasswordDetails", "organizationUserId": member_id, "kdf": user.client_kdf_type, "kdfIterations": user.client_kdf_iter, "kdfMemory": user.client_kdf_memory, "kdfParallelism": user.client_kdf_parallelism, "resetPasswordKey": member.reset_password_key, "encryptedPrivateKey": org.private_key, }))) } async fn check_reset_password_applicable_and_permissions( org_id: &OrganizationId, member_id: &MembershipId, headers: &AdminHeaders, conn: &DbConn, ) -> EmptyResult { check_reset_password_applicable(org_id, conn).await?; let Some(target_user) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else { err!("Reset target user not found") }; // Resetting user must be higher/equal to user to reset match headers.membership_type { MembershipType::Owner => Ok(()), MembershipType::Admin if target_user.atype <= MembershipType::Admin => Ok(()), _ => err!("No permission to reset this user's password"), } } async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { if !CONFIG.mail_enabled() { err!("Password reset is not supported on an email-disabled instance."); } let Some(policy) = OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, conn).await else { err!("Policy not found") }; if !policy.enabled { err!("Reset password policy not enabled"); } Ok(()) } #[put("/organizations//users//reset-password-enrollment", data = "")] async fn put_reset_password_enrollment( org_id: OrganizationId, user_id: UserId, headers: OrgMemberHeaders, data: Json, conn: DbConn, ) -> EmptyResult { if user_id != headers.user.uuid { err!("User to enroll isn't member of required organization", "The user_id and acting user do not match"); } let mut membership = headers.membership; check_reset_password_applicable(&org_id, &conn).await?; let reset_request = data.into_inner(); let reset_password_key = match reset_request.reset_password_key { None => None, Some(ref key) if key.is_empty() => None, Some(key) => Some(key), }; if reset_password_key.is_none() && OrgPolicy::org_is_reset_password_auto_enroll(&org_id, &conn).await { err!("Reset password can't be withdrawn due to an enterprise policy"); } if reset_password_key.is_some() { PasswordOrOtpData { master_password_hash: reset_request.master_password_hash, otp: reset_request.otp, } .validate(&headers.user, true, &conn) .await?; } membership.reset_password_key = reset_password_key; membership.save(&conn).await?; let event_type = if membership.reset_password_key.is_some() { EventType::OrganizationUserResetPasswordEnroll as i32 } else { EventType::OrganizationUserResetPasswordWithdraw as i32 }; log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; Ok(()) } // NOTE: It seems clients can't handle uppercase-first keys!! // We need to convert all keys so they have the first character to be a lowercase. // Else the export will be just an empty JSON file. // Members with full access to the organization (Admin/Owner, or a Custom member with // 'Edit any collection') export the whole organization; everyone else exports only what they can // actually reach, like Bitwarden's export controller does. // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52 #[get("/organizations//export")] async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } // Security (audit F1): 'Access Import/Export' decides *whether* a member may export, it must not // widen *what* they may read. Without this scoping a Custom member holding only this permission // — assigned to no collection at all — would receive every cipher in the organization, because // the organization sync type deliberately skips the per-cipher access restrictions. let (collections, ciphers) = if may_export_entire_organization(&headers.membership) { (Collection::find_by_organization(&org_id, &conn).await, Cipher::find_by_org(&org_id, &conn).await) } else { ( Collection::find_by_organization_and_user_uuid(&org_id, &headers.user.uuid, &conn).await, filter_ciphers_for_organization(Cipher::find_by_user_visible(&headers.user.uuid, &conn).await, &org_id), ) }; let collections_json: Value = collections.iter().map(Collection::to_json).collect(); Ok(Json(json!({ "collections": convert_json_key_lcase_first(collections_json), "ciphers": convert_json_key_lcase_first(ciphers_to_org_json(ciphers, &headers.host, &headers.user.uuid, &conn).await?), }))) } async fn api_key( org_id: &OrganizationId, data: Json, rotate: bool, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != &headers.org_id { err!("Organization not found", "Organization id's do not match"); } let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; // Validate the admin users password/otp data.validate(&user, true, &conn).await?; let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await { if rotate { org_api_key.api_key = crate::crypto::generate_api_key(); org_api_key.revision_date = chrono::Utc::now().naive_utc(); org_api_key.save(&conn).await.expect("Error rotating organization API Key"); } org_api_key } else { let api_key = crate::crypto::generate_api_key(); let new_org_api_key = OrganizationApiKey::new(org_id.clone(), api_key); new_org_api_key.save(&conn).await.expect("Error creating organization API Key"); new_org_api_key }; Ok(Json(json!({ "apiKey": org_api_key.api_key, "revisionDate": crate::util::format_date(&org_api_key.revision_date), "object": "apiKey", }))) } #[post("/organizations//api-key", data = "")] async fn post_api_key( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { api_key(&org_id, data, false, headers, conn).await } #[post("/organizations//rotate-api-key", data = "")] async fn rotate_api_key( org_id: OrganizationId, data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { api_key(&org_id, data, true, headers, conn).await } #[cfg(test)] mod tests { use std::collections::{HashMap, HashSet}; use serde_json::{Value, json}; use super::{ CollectionDetailsResponseScope, CustomRolePermissions, OrganizationImportTarget, OrganizationReportScope, adds_group_member, caller_manage_grant_role_check, collection_bearing_membership_unchanged, collection_details_response_scope, filter_ciphers_for_organization, may_change_group_membership, may_change_member_type, may_delete_group, may_delete_stored_member_type, may_export_entire_organization, may_grant_access_all_group, may_import_to_collection, may_manage_member_type, may_manage_stored_member_type, may_provision_member_type, may_provision_stored_member_type, may_read_complete_collection_list, may_revoke_stored_member_type, organization_report_scope, }; use crate::db::models::{ Cipher, GroupId, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, }; fn confirmed_member(member_type: MembershipType) -> Membership { let mut m = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); m.atype = member_type as i32; m.status = MembershipStatus::Confirmed as i32; m } /// Handing out `groups.access_all` is Admin/Owner authority: the grant keeps working after the /// grantee's Custom permissions are cleared. #[test] fn only_admins_and_owners_may_hand_out_access_all_group_authority() { assert!(may_grant_access_all_group(MembershipType::Owner)); assert!(may_grant_access_all_group(MembershipType::Admin)); assert!(!may_grant_access_all_group(MembershipType::Custom)); assert!(!may_grant_access_all_group(MembershipType::User)); } /// No Custom permission opens this, and in particular not the two that come closest: /// `editAnyCollection` satisfies `has_full_access()` (which gates every other group operation) and /// `deleteAnyCollection` is accepted for a per-collection `manage` row. #[test] fn no_custom_permission_grants_access_all_group_authority() { let mut edit_any = confirmed_member(MembershipType::Custom); edit_any.edit_any_collection = true; assert!(edit_any.has_full_access(), "the escalation starts from a member who has full access"); let mut delete_any = confirmed_member(MembershipType::Custom); delete_any.delete_any_collection = true; assert_eq!( caller_manage_grant_role_check(&delete_any), Some(true), "delete-any may still confer a per-collection manage grant" ); // Neither, nor any combination, is organization-wide group authority. assert!(!may_grant_access_all_group(MembershipType::Custom)); } /// The invite path's group gate is `caller_can_manage_collections`, which `editAnyCollection` /// satisfies, so this caller clears every precondition checked before the access-all rule. #[test] fn inviting_into_an_access_all_group_is_admin_only() { let mut inviter = confirmed_member(MembershipType::Custom); inviter.manage_users = true; inviter.manage_groups = true; inviter.edit_any_collection = true; // Everything `send_invite` checks before the access-all rule passes for this caller ... assert!(inviter.has_manage_users(), "reaches send_invite at all"); assert!(inviter.has_manage_groups(), "satisfies caller_can_manage_groups"); assert!(inviter.has_full_access(), "satisfies caller_can_manage_collections"); assert!( may_provision_member_type(MembershipType::Custom, MembershipType::User), "and may invite the one role a Custom member can provision" ); // ... and the organization-wide group is still out of reach. assert!(!may_grant_access_all_group(MembershipType::Custom)); assert!(may_grant_access_all_group(MembershipType::Admin)); assert!(may_grant_access_all_group(MembershipType::Owner)); } /// Only *adding* to an access-all group is restricted; removals and an unchanged set are allowed. #[test] fn only_additions_to_an_access_all_group_are_restricted() { let a: MembershipId = "member-a".to_owned().into(); let b: MembershipId = "member-b".to_owned().into(); let c: MembershipId = "member-c".to_owned().into(); let current: HashSet<&MembershipId> = HashSet::from([&a, &b]); // unchanged, and pure removals assert!(!adds_group_member(&HashSet::from([&a, &b]), ¤t)); assert!(!adds_group_member(&HashSet::from([&a]), ¤t)); assert!(!adds_group_member(&HashSet::new(), ¤t)); // any new member, including alongside a removal assert!(adds_group_member(&HashSet::from([&a, &b, &c]), ¤t)); assert!(adds_group_member(&HashSet::from([&c]), ¤t)); assert!(adds_group_member(&HashSet::from([&a, &c]), ¤t)); } /// The collection-bearing rule still admits any caller who can manage collections. #[test] fn the_collection_bearing_group_rule_is_unchanged() { assert!(may_change_group_membership(true, true)); assert!(!may_change_group_membership(false, true)); assert!(may_change_group_membership(false, false)); } /// An unparsable stored role holds no authority but still has to be removable: an Owner may delete /// or revoke it, nobody else may touch it, and no reactivating action opens up for anyone. #[test] fn an_owner_may_remove_a_membership_with_an_unknown_stored_role() { // 3 is the retired Manager wire value, which this build never persists; the rest are values // no Vaultwarden release writes at all. for unknown in [3, 5, -1, i32::MAX, i32::MIN] { // 0, 1, 2 and 4 are the only values `MembershipType::from_i32` accepts. assert!(![0, 1, 2, 4].contains(&unknown), "{unknown} must not be a known role"); assert!(may_delete_stored_member_type(MembershipType::Owner, unknown), "{unknown}"); assert!(may_revoke_stored_member_type(MembershipType::Owner, unknown), "{unknown}"); for caller in [MembershipType::Admin, MembershipType::Custom, MembershipType::User] { assert!(!may_delete_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); assert!(!may_revoke_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); } // Editing, confirming, restoring and reinviting all still refuse, for every caller. for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { assert!(!may_manage_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32); assert!( !may_provision_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32 ); } } } /// For a role this build knows, delete keeps the provisioning matrix and revoke the looser /// management one. #[test] fn known_roles_keep_their_existing_delete_and_revoke_matrices() { for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { assert_eq!( may_delete_stored_member_type(caller, target as i32), may_provision_member_type(caller, target), "delete: caller={} target={}", caller as i32, target as i32 ); assert_eq!( may_revoke_stored_member_type(caller, target as i32), may_manage_member_type(caller, target), "revoke: caller={} target={}", caller as i32, target as i32 ); } } // The place the two matrices differ, kept intact: an Admin may revoke a peer Admin but may // not delete one, while an Owner may do both. assert!(may_revoke_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); assert!(!may_delete_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); assert!(may_revoke_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32)); assert!(may_delete_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32)); } #[test] fn bulk_collection_details_only_include_acls_for_manage_authority() { // Ordinary collection assignment, including group access_all, keeps the collection metadata // visible but must never reveal user/group ACL mappings. assert_eq!(collection_details_response_scope(false, false, false), CollectionDetailsResponseScope::Hidden); assert_eq!(collection_details_response_scope(false, true, false), CollectionDetailsResponseScope::MetadataOnly); assert_eq!(collection_details_response_scope(false, false, true), CollectionDetailsResponseScope::MetadataOnly); // Admin/Owner, Edit-any/Delete-any, and explicit per-collection Manage all arrive here as // `can_read_access_details = true`, matching CollectionReadHeaders on the single endpoint. assert_eq!( collection_details_response_scope(true, false, false), CollectionDetailsResponseScope::AccessDetails ); assert_eq!(collection_details_response_scope(true, true, true), CollectionDetailsResponseScope::AccessDetails); } #[test] fn flagless_custom_uses_only_its_explicit_manage_collections_in_the_list() { // `false` selects the route's per-collection explicit-Manage filtering path. Permissions that // need metadata for every collection select the complete list instead. assert!(!may_read_complete_collection_list(&confirmed_member(MembershipType::Custom))); let mut manage_users = confirmed_member(MembershipType::Custom); manage_users.manage_users = true; assert!(may_read_complete_collection_list(&manage_users)); let mut create = confirmed_member(MembershipType::Custom); create.create_new_collections = true; assert!(may_read_complete_collection_list(&create)); assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Admin))); assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Owner))); } #[test] fn only_delete_capable_callers_may_grant_collection_manage() { // Admin/Owner may always confer a per-collection `manage` (delete) grant. assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Owner)), Some(true)); assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Admin)), Some(true)); // A Custom member with `delete_any_collection` may also always grant it. let mut delete_any = confirmed_member(MembershipType::Custom); delete_any.delete_any_collection = true; assert_eq!(caller_manage_grant_role_check(&delete_any), Some(true)); // REGRESSION (F-1): a Custom member with ONLY `edit_any_collection` must NOT get a blanket // yes. The role check returns None so the decision falls through to a real per-collection // manage grant in the DB — which a self-assigned group/user manage row is prevented from // manufacturing. This is what stops edit-any from escalating into delete-any. let mut edit_any = confirmed_member(MembershipType::Custom); edit_any.edit_any_collection = true; assert_eq!(caller_manage_grant_role_check(&edit_any), None); // A flagless Custom member (this is what a migrated legacy Manager becomes) also defers to // the per-collection DB check. assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Custom)), None); // Plain User never qualifies. assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::User)), Some(false)); // An unconfirmed caller never qualifies, even with delete_any set. let mut unconfirmed = confirmed_member(MembershipType::Custom); unconfirmed.status = MembershipStatus::Accepted as i32; unconfirmed.delete_any_collection = true; assert_eq!(caller_manage_grant_role_check(&unconfirmed), Some(false)); } #[test] fn access_import_export_alone_does_not_widen_the_export() { // REGRESSION (audit F1): 'Access Import/Export' opens the export endpoint, but a Custom // member holding only that permission reaches no collection of their own, so the export // must be built from their assignments — never from the whole organization. let mut import_export_only = confirmed_member(MembershipType::Custom); import_export_only.access_import_export = true; assert!(!may_export_entire_organization(&import_export_only)); // Custom members who already reach every collection keep the full dump. let mut edit_any = confirmed_member(MembershipType::Custom); edit_any.edit_any_collection = true; edit_any.access_import_export = true; assert!(may_export_entire_organization(&edit_any)); // Admins and Owners are unaffected. assert!(may_export_entire_organization(&confirmed_member(MembershipType::Admin))); assert!(may_export_entire_organization(&confirmed_member(MembershipType::Owner))); // An unconfirmed membership never qualifies, whatever its flags say. let mut unconfirmed = confirmed_member(MembershipType::Custom); unconfirmed.edit_any_collection = true; unconfirmed.status = MembershipStatus::Accepted as i32; assert!(!may_export_entire_organization(&unconfirmed)); } #[test] fn access_import_export_does_not_replace_import_collection_authority() { let mut import_export = confirmed_member(MembershipType::Custom); import_export.access_import_export = true; assert!(!may_import_to_collection( &import_export, OrganizationImportTarget::Existing { writable: false } )); assert!(!may_import_to_collection(&import_export, OrganizationImportTarget::New)); assert!(may_import_to_collection( &import_export, OrganizationImportTarget::Existing { writable: true } )); let mut create = confirmed_member(MembershipType::Custom); create.create_new_collections = true; assert!(may_import_to_collection(&create, OrganizationImportTarget::New)); let mut edit_any = confirmed_member(MembershipType::Custom); edit_any.edit_any_collection = true; assert!(!may_import_to_collection(&edit_any, OrganizationImportTarget::New)); assert!(may_import_to_collection( &confirmed_member(MembershipType::User), OrganizationImportTarget::Existing { writable: true } )); assert!(may_import_to_collection( &confirmed_member(MembershipType::Admin), OrganizationImportTarget::Existing { writable: false } )); assert!(may_import_to_collection(&confirmed_member(MembershipType::Owner), OrganizationImportTarget::New)); import_export.status = MembershipStatus::Accepted as i32; assert!(!may_import_to_collection( &import_export, OrganizationImportTarget::Existing { writable: true } )); } #[test] fn access_reports_is_assignment_scoped_without_full_access() { let mut reports = confirmed_member(MembershipType::Custom); reports.access_reports = true; assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Assigned); assert_eq!( organization_report_scope(&confirmed_member(MembershipType::Custom)), OrganizationReportScope::Denied ); assert_eq!( organization_report_scope(&confirmed_member(MembershipType::Admin)), OrganizationReportScope::Complete ); assert_eq!( organization_report_scope(&confirmed_member(MembershipType::Owner)), OrganizationReportScope::Complete ); reports.edit_any_collection = true; assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Complete); reports.edit_any_collection = false; reports.status = MembershipStatus::Accepted as i32; assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Denied); let mut stale_user = confirmed_member(MembershipType::User); stale_user.access_reports = true; assert_eq!(organization_report_scope(&stale_user), OrganizationReportScope::Denied); } #[test] fn collection_bearing_group_deletion_requires_collection_authority() { assert!(may_delete_group(false, false)); assert!(!may_delete_group(false, true)); assert!(may_delete_group(true, false)); assert!(may_delete_group(true, true)); } #[test] fn assigned_cipher_response_is_scoped_to_requested_organization() { let requested_org: OrganizationId = "requested-org".to_owned().into(); let other_org: OrganizationId = "other-org".to_owned().into(); let mut requested_cipher = Cipher::new(1, "requested".to_owned()); requested_cipher.organization_uuid = Some(requested_org.clone()); let requested_cipher_id = requested_cipher.uuid.clone(); let mut other_cipher = Cipher::new(1, "other".to_owned()); other_cipher.organization_uuid = Some(other_org); let personal_cipher = Cipher::new(1, "personal".to_owned()); let filtered = filter_ciphers_for_organization(vec![other_cipher, personal_cipher, requested_cipher], &requested_org); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].uuid, requested_cipher_id); assert_eq!(filtered[0].organization_uuid.as_ref(), Some(&requested_org)); } #[test] fn manage_users_caller_cannot_change_member_role() { let user = MembershipType::User as i32; let custom = MembershipType::Custom as i32; // Admins and Owners may change a member's role. assert!(may_change_member_type(MembershipType::Owner, user, MembershipType::Custom)); assert!(may_change_member_type(MembershipType::Admin, user, MembershipType::Custom)); // A below-Admin caller (Custom-with-manage_users) may only submit an unchanged role, so the // regular edit dialog keeps working. assert!(may_change_member_type(MembershipType::Custom, user, MembershipType::User)); assert!(may_change_member_type(MembershipType::Custom, custom, MembershipType::Custom)); // REGRESSION (privilege escalation, PR #7397 / finding F1): a caller below Admin must NOT // be able to change a member's role. Promoting User -> Custom can activate explicit // collection-Manage assignments and Custom-only authorization paths; demoting revokes // them. A manage_users caller is not entitled to either authority change. assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Custom)); assert!(!may_change_member_type(MembershipType::Custom, custom, MembershipType::User)); } #[test] fn member_lifecycle_permissions_follow_the_role_hierarchy() { let roles = [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User]; for target in roles { assert!(may_manage_member_type(MembershipType::Owner, target)); } assert!(!may_manage_member_type(MembershipType::Admin, MembershipType::Owner)); assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin)); assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Custom)); assert!(may_manage_member_type(MembershipType::Admin, MembershipType::User)); assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Owner)); assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Admin)); assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Custom)); assert!(may_manage_member_type(MembershipType::Custom, MembershipType::User)); for target in roles { assert!(!may_manage_member_type(MembershipType::User, target)); } assert!(may_manage_stored_member_type(MembershipType::Admin, MembershipType::Custom as i32)); assert!(!may_manage_stored_member_type(MembershipType::Owner, i32::MAX)); // edit_member applies the same matrix as reinvite/confirm/revoke/restore/delete, so a // Custom caller cannot target an Admin or a fellow Custom member even when the requested // role equals the stored one. for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom] { assert!(may_change_member_type(MembershipType::Custom, target as i32, target)); assert!(!may_manage_stored_member_type(MembershipType::Custom, target as i32)); } assert!(may_manage_stored_member_type(MembershipType::Custom, MembershipType::User as i32)); } #[test] fn only_owners_provision_admin_memberships() { // REGRESSION: bringing an Admin (or Owner) membership into or out of existence stays // Owner-only, exactly as before this feature ("Only Owners can invite Managers, Admins or // Owners" / "Only Owners can delete Admins or Owners"). Otherwise an Admin could route around // the Owner-only role-change guard in `edit_member` by inviting a fresh Admin instead. for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { assert!(may_provision_member_type(MembershipType::Owner, target)); } assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Owner)); assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Admin)); assert!(may_provision_member_type(MembershipType::Admin, MembershipType::Custom)); assert!(may_provision_member_type(MembershipType::Admin, MembershipType::User)); // A Custom member with manage_users stays limited to ordinary Users, as for every other // lifecycle action. assert!(may_provision_member_type(MembershipType::Custom, MembershipType::User)); for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom] { assert!(!may_provision_member_type(MembershipType::Custom, target)); } for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] { assert!(!may_provision_member_type(MembershipType::User, target)); } // Provisioning is strictly narrower than the state-change matrix: an Admin may still revoke, // restore or edit a peer Admin (which Vaultwarden allowed before), but no longer create, // confirm or delete one. assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin)); assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Admin)); assert!(may_provision_stored_member_type(MembershipType::Admin, MembershipType::User as i32)); assert!(!may_provision_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32)); // An unknown stored role never qualifies. assert!(!may_provision_stored_member_type(MembershipType::Owner, i32::MAX)); } #[test] fn only_collection_bearing_group_changes_are_rejected() { let plain: GroupId = "plain".to_owned().into(); let bearing: GroupId = "bearing".to_owned().into(); let collection_bearing = HashSet::from([bearing.clone()]); let set = |ids: &[&GroupId]| -> HashSet { ids.iter().map(|id| (*id).clone()).collect() }; // Adding, removing or keeping a group without collections is fine. for (requested, current) in [(set(&[&plain]), set(&[])), (set(&[]), set(&[&plain])), (set(&[&plain, &bearing]), set(&[&bearing]))] { assert!(collection_bearing_membership_unchanged(&requested, ¤t, &collection_bearing)); } // Adding or removing a collection-bearing group is not. for (requested, current) in [(set(&[&bearing]), set(&[])), (set(&[&plain]), set(&[&plain, &bearing]))] { assert!(!collection_bearing_membership_unchanged(&requested, ¤t, &collection_bearing)); } } #[test] fn manage_groups_caller_cannot_grant_collection_access_via_groups() { // A caller who can manage collections may change membership of any group. assert!(may_change_group_membership(true, true)); assert!(may_change_group_membership(true, false)); // A caller who cannot manage collections may change membership of groups that confer no // collection access (plain groups). assert!(may_change_group_membership(false, false)); // REGRESSION (privilege escalation, PR #7397): a caller who cannot manage collections must // NOT be able to change membership of a collection-bearing / access_all group. This is the // vector that let a Custom user with manage_users + manage_groups add themselves to an // access_all group and read all collection contents via edit_member / send_invite. Adding // AND removing such memberships must be denied. assert!(!may_change_group_membership(false, true)); } #[test] fn collection_permission_request_combinations_remain_independent() { for mask in 0_u8..8 { let create = mask & 0b001 != 0; let edit = mask & 0b010 != 0; let delete = mask & 0b100 != 0; let permissions = HashMap::from([ ("createNewCollections".to_owned(), json!(create)), ("editAnyCollection".to_owned(), json!(edit)), ("deleteAnyCollection".to_owned(), json!(delete)), ]); let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions).unwrap(); assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}"); assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}"); assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}"); // Only Edit any collection maps to all-collection access. Create/Delete must never do so. assert_eq!(parsed.grants_full_collection_access(MembershipType::Custom), edit, "mask={mask:03b}"); } } const KNOWN_PERMISSION_KEYS: [&str; 9] = [ "manageUsers", "manageGroups", "managePolicies", "createNewCollections", "editAnyCollection", "deleteAnyCollection", "accessEventLogs", "accessImportExport", "accessReports", ]; #[test] fn custom_permission_parser_accepts_only_booleans_and_non_custom_roles_are_fail_closed() { let all_true: HashMap = KNOWN_PERMISSION_KEYS.iter().map(|key| ((*key).to_owned(), json!(true))).collect(); let custom = CustomRolePermissions::from_request(MembershipType::Custom, &all_true).unwrap(); assert!(custom.manage_users); assert!(custom.manage_groups); assert!(custom.manage_policies); assert!(custom.create_new_collections); assert!(custom.edit_any_collection); assert!(custom.delete_any_collection); assert!(custom.access_event_logs); assert!(custom.access_import_export); assert!(custom.access_reports); let user = CustomRolePermissions::from_request(MembershipType::User, &all_true).unwrap(); assert_eq!(user, CustomRolePermissions::default()); assert!(!user.grants_full_collection_access(MembershipType::User)); let admin = CustomRolePermissions::from_request(MembershipType::Admin, &all_true).unwrap(); assert_eq!(admin, CustomRolePermissions::default()); assert!(admin.grants_full_collection_access(MembershipType::Admin)); } /// A known key carrying anything other than a JSON boolean is a malformed request. It used to be /// read as `false`, which turned a client bug into a silent permission removal. #[test] fn a_known_permission_with_a_non_boolean_value_is_rejected() { let bad_values = [ json!("true"), json!("false"), json!(""), json!(1), json!(0), json!(1.5), Value::Null, json!({}), json!([]), json!(["manageUsers"]), ]; for key in KNOWN_PERMISSION_KEYS { for value in &bad_values { let permissions = HashMap::from([(key.to_owned(), value.clone())]); for member_type in [MembershipType::Custom, MembershipType::User, MembershipType::Admin, MembershipType::Owner] { assert!( CustomRolePermissions::from_request(member_type, &permissions).is_err(), "{key} = {value} must be rejected for {}", member_type as i32 ); } let membership = confirmed_member(MembershipType::Custom); assert!( CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&permissions), &membership) .is_err(), "{key} = {value} must be rejected on the edit path" ); } // ... while both booleans stay valid for the same key. for value in [true, false] { let permissions = HashMap::from([(key.to_owned(), json!(value))]); let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions) .expect("a boolean is always valid"); assert_eq!(parsed != CustomRolePermissions::default(), value, "{key} = {value}"); } } } /// Bitwarden already sends permission keys Vaultwarden does not implement (`manageSso`, /// `manageScim`, `manageResetPassword`) and may add more. Unknown keys stay ignored, whatever /// they contain, so the strictness above cannot break a newer client. #[test] fn unknown_permission_keys_are_ignored_whatever_they_contain() { let permissions = HashMap::from([ ("manageUsers".to_owned(), json!(true)), ("manageSso".to_owned(), Value::String("yes".to_owned())), ("manageScim".to_owned(), Value::Null), ("manageResetPassword".to_owned(), json!(0)), ("someFuturePermission".to_owned(), json!({"nested": true})), ]); let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions) .expect("unknown keys must not make a request invalid"); assert!(parsed.manage_users); assert!(!parsed.manage_groups); assert!(!parsed.edit_any_collection); } #[test] fn custom_permission_change_detection_covers_collection_flags() { let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); membership.atype = MembershipType::Custom as i32; membership.status = MembershipStatus::Confirmed as i32; let requested = CustomRolePermissions { create_new_collections: true, edit_any_collection: true, delete_any_collection: true, access_event_logs: true, access_import_export: true, access_reports: true, ..CustomRolePermissions::default() }; assert!(requested.differs_from(&membership)); requested.apply_to(&mut membership); assert!(!requested.differs_from(&membership)); assert!(membership.create_new_collections); assert!(membership.edit_any_collection); assert!(membership.delete_any_collection); assert!(membership.access_event_logs); assert!(membership.access_import_export); assert!(membership.access_reports); } #[test] fn omitted_edit_permissions_preserve_supported_custom_grants() { let mut membership = confirmed_member(MembershipType::Custom); membership.manage_users = true; membership.manage_groups = true; membership.manage_policies = true; membership.create_new_collections = true; membership.edit_any_collection = true; membership.delete_any_collection = true; membership.access_event_logs = true; membership.access_import_export = true; membership.access_reports = true; let preserved = CustomRolePermissions::from_edit_request(MembershipType::Custom, None, &membership).unwrap(); assert!(preserved.manage_users); assert!(preserved.manage_groups); assert!(preserved.manage_policies); assert!(preserved.create_new_collections); assert!(preserved.edit_any_collection); assert!(preserved.delete_any_collection); assert!(preserved.access_event_logs); assert!(preserved.access_import_export); assert!(preserved.access_reports); assert!(!preserved.differs_from(&membership)); let explicit_reset = HashMap::new(); assert_eq!( CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&explicit_reset), &membership) .unwrap(), CustomRolePermissions::default() ); assert_eq!( CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap(), CustomRolePermissions::default() ); } #[test] fn stale_permission_bits_on_non_custom_members_are_not_authority_changes() { let mut membership = confirmed_member(MembershipType::User); membership.manage_users = true; membership.manage_groups = true; membership.manage_policies = true; membership.create_new_collections = true; membership.edit_any_collection = true; membership.delete_any_collection = true; membership.access_event_logs = true; membership.access_import_export = true; membership.access_reports = true; let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap(); assert_eq!(requested, CustomRolePermissions::default()); assert!(!requested.differs_from(&membership)); // Applying the effective request opportunistically clears the inert historical data. requested.apply_to(&mut membership); assert!(!membership.manage_users); assert!(!membership.manage_groups); assert!(!membership.manage_policies); assert!(!membership.create_new_collections); assert!(!membership.edit_any_collection); assert!(!membership.delete_any_collection); assert!(!membership.access_event_logs); assert!(!membership.access_import_export); assert!(!membership.access_reports); } }