You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

4037 lines
152 KiB

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::{
AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers, ManageGroupsHeaders,
ManagePoliciesHeaders, ManageUsersHeaders, ManageUsersOrGroupsHeaders, ManagerHeaders, ManagerHeadersLoose,
OrgMemberHeaders, OwnerHeaders, 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<Route> {
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<OrgKeyData>,
#[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<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>,
}
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<GroupId>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkMembershipIds {
ids: Vec<MembershipId>,
}
#[post("/organizations", data = "<data>")]
async fn create_organization(headers: Headers, data: Json<OrgData>, 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.access_all = true;
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/<org_id>", data = "<data>")]
async fn delete_organization(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
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/<org_id>/delete", data = "<data>")]
async fn post_delete_organization(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization(org_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/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/<org_id>")]
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/<org_id>", data = "<data>")]
async fn put_organization(
org_id: OrganizationId,
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
post_organization(org_id, headers, data, conn).await
}
#[post("/organizations/<org_id>", data = "<data>")]
async fn post_organization(
org_id: OrganizationId,
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
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<Value> {
Json(json!({
"data":
Collection::find_by_user_uuid(headers.user.uuid, &conn).await
.iter()
.map(Collection::to_json)
.collect::<Value>(),
"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/<identifier>/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/<org_id>/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 = headers.membership.has_full_access()
|| headers.membership.has_manage_users()
|| headers.membership.has_manage_groups()
|| headers.membership.has_delete_any_collection();
if !can_read_collection_list {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
"data": get_org_collections_impl(&org_id, &conn).await,
"object": "list",
"continuationToken": null,
})))
}
#[get("/organizations/<org_id>/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<MembershipId, i32> =
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();
// Delete any collection can reveal collection access metadata, matching Bitwarden's
// ReadAllWithAccess behavior, but still does not grant cipher access. Manage Users/Groups
// retain the narrower metadata-only view introduced by the base PR.
let can_read_all_collection_access = member.has_edit_any_collection() || member.has_delete_any_collection();
// 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<Value> = 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);
// If the user is a manager and is not assigned to this collection, normally skip it.
// Exception: custom users with a manage permission get a metadata-only entry (no user
// or group access details) so the web client can resolve assignment references without
// crashing. This never exposes cipher contents.
if !assigned && !can_read_all_collection_access {
if can_read_collection_list {
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
json_object["assigned"] = json!(false);
json_object["users"] = json!(Vec::<Value>::new());
json_object["groups"] = json!(Vec::<Value>::new());
json_object["object"] = json!("collectionAccessDetails");
json_object["unmanaged"] = json!(false);
data.push(json_object);
}
continue;
}
// get the users assigned directly to the given collection
let mut users: Vec<Value> = 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<Value> = 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,
})))
}
async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value {
Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::<Value>()
}
#[post("/organizations/<org_id>/collections", data = "<data>")]
async fn post_organization_collections(
org_id: OrganizationId,
headers: ManagerHeadersLoose,
data: Json<FullCollectionData>,
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's internal
// access_all representation 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?;
log_event(
EventType::CollectionCreated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
for group in data.groups {
CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.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.access_all {
continue;
}
CollectionUser::save(
&member.user_uuid,
&collection.uuid,
user.read_only,
user.hide_passwords,
user.manage,
&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<CollectionId>,
groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
}
#[post("/organizations/<org_id>/collections/bulk-access", data = "<data>", rank = 1)]
async fn post_bulk_access_collections(
org_id: OrganizationId,
headers: ManagerHeadersLoose,
data: Json<BulkCollectionAccessData>,
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 (F-1): authorization is enforced per collection below via `auth::can_edit_collection`,
// the exact same Custom-aware check the single-collection edit endpoint (`ManagerHeaders`) uses.
// Edit any collection (or Admin/Owner) may bulk-edit every collection; a legacy Manager keeps its
// broad per-collection helper; any other Custom member must hold a real per-collection Manage
// grant. In particular a Custom member's membership/group `access_all` does NOT satisfy this here
// (it did under the previous `is_manageable_by_user` check, which diverged from the single-edit
// endpoint). A custom user with only manage_users / manage_groups / manage_policies holds no such
// grant and is rejected, while a member who manages some collections keeps the ability to
// bulk-edit exactly those.
// Security (audit H-3) and atomicity (audit M-2): validate the whole request against this
// organization *before* mutating anything. Every collection must exist in the org and be
// manageable by the caller, and every referenced group and user must belong to the org. Only
// once the entire request is known-valid do we begin the destructive delete/replace of
// assignments, so a foreign-tenant group can never be linked and a later invalid element can no
// longer leave earlier collections with their assignments already wiped.
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 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 (F-1): only a caller who could delete this collection may confer a `manage` grant
// on it. Otherwise the requested `manage` is forced to false, so a caller whose access comes
// from Edit-any-collection cannot escalate into deletion by self-assigning a manage row.
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.access_all {
continue;
}
CollectionUser::save(
&member.user_uuid,
col_id,
user.read_only,
user.hide_passwords,
user.manage && may_grant_manage,
&conn,
)
.await?;
}
}
Ok(())
}
#[put("/organizations/<org_id>/collections/<col_id>", data = "<data>")]
async fn put_organization_collection_update(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
post_organization_collection_update(org_id, col_id, headers, data, conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>", data = "<data>", rank = 2)]
async fn post_organization_collection_update(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
data: Json<FullCollectionData>,
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.access_all {
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/<org_id>/collections/<col_id>")]
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/<org_id>/collections/<col_id>/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<CollectionId>,
}
#[delete("/organizations/<org_id>/collections", data = "<data>")]
async fn bulk_delete_organization_collections(
org_id: OrganizationId,
headers: ManagerHeadersLoose,
data: Json<BulkCollectionIds>,
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/<org_id>/collections/<col_id>/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<Value> = 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<MembershipId, i32> = Membership::find_confirmed_by_org(&org_id, &conn)
.await
.into_iter()
.map(|m| (m.uuid, m.atype))
.collect();
let users: Vec<Value> =
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/<org_id>/collections/<col_id>/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<Cipher>, org_id: &OrganizationId) -> Vec<Cipher> {
ciphers.into_iter().filter(|cipher| cipher.organization_uuid.as_ref() == Some(org_id)).collect()
}
// The Admin Console uses this endpoint when the acting member is not allowed to read every
// cipher in the organization. In particular, a Custom member with only DeleteAnyCollection
// needs an empty successful response so the collection list can finish loading and expose its
// collection-only delete controls.
//
// Security: start from the regular user-visible cipher query and then constrain the result to
// the requested organization. DeleteAnyCollection itself must never make cipher contents visible.
#[get("/ciphers/organization-details/assigned?<data..>")]
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
);
}
let ciphers = filter_ciphers_for_organization(
Cipher::find_by_user_visible(&headers.user.uuid, &conn).await,
&data.organization_id,
);
let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await;
let mut ciphers_json = Vec::with_capacity(ciphers.len());
for cipher in ciphers {
ciphers_json.push(
cipher
.to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn)
.await?,
);
}
Ok(Json(json!({
"data": ciphers_json,
"object": "list",
"continuationToken": null,
})))
}
#[get("/ciphers/organization-details?<data..>")]
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);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
"data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?,
"object": "list",
"continuationToken": null,
})))
}
async fn get_org_details_impl(
org_id: &OrganizationId,
host: &str,
user_id: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
let ciphers = Cipher::find_by_org(org_id, conn).await;
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<bool>,
#[field(name = "includeGroups")]
include_groups: Option<bool>,
}
#[get("/organizations/<org_id>/users?<data..>")]
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/<org_id>/keys", data = "<data>")]
async fn post_org_keys(
org_id: OrganizationId,
data: Json<OrgKeyData>,
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,
}
impl CustomRolePermissions {
fn from_request(member_type: MembershipType, permissions: &HashMap<String, Value>) -> Self {
if member_type != MembershipType::Custom {
return Self::default();
}
let enabled = |key: &str| matches!(permissions.get(key), Some(Value::Bool(true)));
Self {
manage_users: enabled("manageUsers"),
manage_groups: enabled("manageGroups"),
manage_policies: enabled("managePolicies"),
create_new_collections: enabled("createNewCollections"),
edit_any_collection: enabled("editAnyCollection"),
delete_any_collection: enabled("deleteAnyCollection"),
}
}
/// Bitwarden grants a Custom member with Edit any collection full read/edit/manage access to
/// organization ciphers. Vaultwarden's existing access_all flag is the internal data-plane
/// representation of that capability. Create and Delete remain completely independent.
fn access_all_for(self, member_type: MembershipType) -> bool {
member_type >= MembershipType::Admin || (member_type == MembershipType::Custom && self.edit_any_collection)
}
fn differs_from(self, membership: &Membership) -> bool {
self.manage_users != membership.manage_users
|| self.manage_groups != membership.manage_groups
|| self.manage_policies != membership.manage_policies
|| self.create_new_collections != membership.create_new_collections
|| self.edit_any_collection != membership.edit_any_collection
|| self.delete_any_collection != membership.delete_any_collection
}
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;
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InviteData {
emails: Vec<String>,
groups: Vec<GroupId>,
r#type: NumberOrString,
collections: Option<Vec<CollectionData>>,
#[serde(default)]
permissions: HashMap<String, Value>,
}
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/<org_id>/users/invite", data = "<data>")]
async fn send_invite(
org_id: OrganizationId,
data: Json<InviteData>,
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 new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can invite Managers, Admins or Owners")
}
// manageAllCollections is a client-only aggregate. Persist its three children independently;
// only Edit any collection maps to the existing all-cipher access representation.
let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions);
let access_all = custom_permissions.access_all_for(new_type);
let mut user_created: bool = false;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
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.access_all = access_all;
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;
// 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.
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 no accessAll, add the collections received
if !access_all && caller_can_manage_collections {
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) => {
CollectionUser::save(
&user.uuid,
&collection.uuid,
col.read_only,
col.hide_passwords,
col.manage,
&conn,
)
.await?;
}
}
}
}
// Security: assigning groups 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 assign groups when inviting.
let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin
|| match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
Some(m) => m.has_manage_groups(),
None => false,
};
if caller_can_manage_groups {
for group_id in &data.groups {
// Security: a caller who cannot manage collections must not grant collection
// access to the invitee by placing them into a collection-bearing group.
if !may_change_group_membership(
caller_can_manage_collections,
group_confers_collection_access(group_id, &org_id, &conn).await,
) {
continue;
}
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?;
}
}
}
Ok(())
}
#[post("/organizations/<org_id>/users/reinvite", data = "<data>")]
async fn bulk_reinvite_members(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
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.user.email, &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/<org_id>/users/<member_id>/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.user.email, &conn).await
}
async fn reinvite_member_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
invited_by_email: &str,
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 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(invited_by_email.to_owned())).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<String>,
}
#[post("/organizations/<org_id>/users/<member_id>/accept", data = "<data>")]
async fn accept_invite(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<AcceptData>,
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<MembershipId>,
key: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkConfirmData {
keys: Option<Vec<ConfirmData>>,
}
#[post("/organizations/<org_id>/users/confirm", data = "<data>")]
async fn bulk_confirm_invite(
org_id: OrganizationId,
data: Json<BulkConfirmData>,
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 {
let member_id = invite.id.unwrap();
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/<org_id>/users/<member_id>/confirm", data = "<data>")]
async fn confirm_invite(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<ConfirmData>,
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 member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can confirm Managers, Admins or Owners")
}
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/<org_id>/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/<org_id>/users/<member_id>?<data..>", 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<Vec<CollectionData>>,
groups: Option<Vec<GroupId>>,
#[serde(default)]
permissions: HashMap<String, Value>,
}
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn put_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
headers: ManageUsersHeaders,
conn: DbConn,
) -> EmptyResult {
edit_member(org_id, member_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn edit_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
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 custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions);
let access_all = custom_permissions.access_all_for(new_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")
};
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: only Admins and Owners may change a member's role type at all. A Custom member
// with manage_users must not change roles: raising a member to Manager/Custom grants
// collection-"manage" on every collection they can already write (see the `atype >= Manager`
// branch in `Collection`/`Membership` json), and lowering it revokes that access — both are
// collection-access changes this caller is not entitled to make, even though the custom
// permission flags and access_all are already gated below. Requests that leave the role
// unchanged are allowed, so such members can still use the regular edit dialog. The
// Admin/Owner guard above still governs Admin/Owner transitions for Owners.
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")
}
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 custom-role permissions. A Custom
// member with manage_users must not be able to grant them to themselves or others (a
// privilege escalation), nor strip flags an Admin/Owner has granted to fellow Custom members.
// Requests that leave the flags unchanged 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 actually manage collections (Admins/Owners, or users
// with full access) may change a member's collection assignments. A custom user with only
// manage_users must not be able to add/remove collection access, so we leave the existing
// assignments untouched for them.
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: `access_all` grants full access to every collection, so only callers who may
// manage collections are allowed to change it. Otherwise a custom user with only manage_users
// could enable Edit any collection on any member (including themselves) to grant full
// collection access — a privilege escalation. For everyone else we keep the member's existing
// access_all grant untouched (neither granted nor revoked).
if caller_can_manage_collections {
member_to_edit.access_all = access_all;
}
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?;
if caller_can_manage_collections {
// Delete all the odd collections
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await {
c.delete(&conn).await?;
}
// Security (F-1): a per-collection `manage` grant carries delete authority, so the caller
// may only confer it on collections they could delete themselves. A caller acting via
// Edit-any-collection thus cannot hand another member a manage/delete grant it lacks.
let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await;
// If no accessAll, add the collections received
if !access_all {
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(
&member_to_edit.user_uuid,
&collection.uuid,
col.read_only,
col.hide_passwords,
manage,
&conn,
)
.await?;
}
}
}
}
}
// 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 Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
Some(m) => m.has_manage_groups(),
None => false,
};
if caller_can_manage_groups {
// Security (audit H-2): validate that every requested group belongs to this organization
// *before* mutating any group membership. 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 any group is foreign.
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")
}
}
if caller_can_manage_collections {
// Caller may grant/revoke collection access via groups: full replace.
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
for group_id in data.groups.iter().flatten() {
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
group_entry.save(&conn).await?;
}
} else {
// Security: the caller may manage groups but NOT collections. They may only change the
// member's membership in groups that confer no collection access; collection-bearing
// memberships are preserved untouched (neither granted nor revoked), mirroring the
// restriction enforced in put_group_members and add_update_group.
// Remove the member only from non-collection-bearing groups; keep collection-bearing
// memberships so this caller cannot revoke collection access either.
for gu in GroupUser::find_by_member(&member_to_edit.uuid, &conn).await {
if may_change_group_membership(
caller_can_manage_collections,
group_confers_collection_access(&gu.groups_uuid, &org_id, &conn).await,
) {
GroupUser::delete_by_group_and_member(&gu.groups_uuid, &member_to_edit.uuid, &conn).await?;
}
}
// Add the requested groups, skipping any that would grant collection access.
for group_id in data.groups.iter().flatten() {
if !may_change_group_membership(
caller_can_manage_collections,
group_confers_collection_access(group_id, &org_id, &conn).await,
) {
continue;
}
let mut group_entry = GroupUser::new(group_id.clone(), 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/<org_id>/users", data = "<data>")]
async fn bulk_delete_member(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
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/<org_id>/users/<member_id>")]
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 member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner {
err!("Only Owners can delete Admins or Owners")
}
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/<org_id>/users/public-keys", data = "<data>")]
async fn bulk_public_keys(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
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<CipherData>,
collections: Vec<FullCollectionData>,
collection_relationships: Vec<RelationsData>,
}
#[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?<query..>", data = "<data>")]
async fn post_org_import(
query: OrgIdData,
data: Json<ImportData>,
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");
}
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/DoS (audit M-3): validate every collection<->cipher relationship index against the
// import payload *before* creating any collection or cipher. `key` indexes into `ciphers` and
// `value` into `collections`; an out-of-range index would otherwise cause an out-of-bounds panic
// when the relations are applied below — a 500 (or a process abort under panic="abort") that
// happens after rows have already been written, leaving partial state behind.
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"
)
}
}
let existing_collections: HashSet<Option<CollectionId>> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect();
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections {
let collection_uuid = if existing_collections.contains(&col.id) {
let col_id = col.id.unwrap();
// When not an Owner or Admin, check if the member is allowed to access the collection.
if headers.membership.atype < MembershipType::Admin
&& !Collection::can_access_collection(&headers.membership, &col_id, &conn).await
{
err!(Compact, "The current user isn't allowed to manage this collection")
}
col_id
} else {
// Collection creation through an organization import is governed by the same
// independent permission as the regular create endpoint. In particular,
// Edit any collection's access_all mirror must not satisfy this check.
if !headers.membership.can_create_new_collections() {
err!(Compact, "The current user isn't allowed to create new collections")
}
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id);
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<CipherId> = 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;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
// Propagate cipher-save failures instead of silently discarding them (audit M-3): a
// discarded error would still push the cipher id and let a relationship reference a cipher
// that was never persisted. This matches Bitwarden's all-or-nothing import semantics.
update_cipher_from_data(
&mut cipher,
cipher_data,
&headers,
Some(collections.clone()),
&conn,
&nt,
UpdateType::None,
)
.await?;
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!(
"Invalid collection relationship",
"A collection relationship references a non-existent cipher or collection"
)
};
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<CipherId>,
collection_ids: HashSet<CollectionId>,
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 = "<data>")]
async fn post_bulk_collections(data: Json<BulkCollectionsData>, 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<CollectionId, Collection> =
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/<org_id>/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<Value> = 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/<org_id>/policies/token?<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<Value> = 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/<org_id>/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/<org_id>/policies/<pol_type>", 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<Value>,
}
#[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/<org_id>/policies/<pol_type>", data = "<data>")]
async fn put_policy(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
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
// 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.atype < MembershipType::Admin
&& 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/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
headers: ManagePoliciesHeaders,
conn: DbConn,
) -> JsonResult {
put_policy(org_id, pol_type, data, headers, conn).await
}
#[get("/plans")]
fn get_plans() -> Json<Value> {
// 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<Value> {
// 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<Value> {
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<Value> {
// 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<Vec<MembershipId>>,
}
#[put("/organizations/<org_id>/users/<member_id>/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/<org_id>/users/revoke", data = "<data>")]
async fn bulk_revoke_members(
org_id: OrganizationId,
data: Json<BulkRevokeMembershipIds>,
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")
}
// Security: a Custom user with manage_users must not be able to revoke Admins or
// Owners. Mirrors the restriction in delete_member_impl; the Owner-specific check
// below still guards Admin-vs-Owner actions.
if member.atype != MembershipType::User && headers.membership_type < MembershipType::Admin {
err!("You don't have permission to revoke this user")
}
if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can revoke other owners")
}
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/<org_id>/users/<member_id>/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/<org_id>/users/<member_id>/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/<org_id>/users/restore", data = "<data>")]
async fn bulk_restore_members(
org_id: OrganizationId,
data: Json<BulkMembershipIds>,
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")
}
// Security: a Custom user with manage_users must not be able to restore Admins or
// Owners. Mirrors the restriction in delete_member_impl; the Owner-specific check
// below still guards Admin-vs-Owner actions.
if member.atype != MembershipType::User && headers.membership_type < MembershipType::Admin {
err!("You don't have permission to restore this user")
}
if member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only owners can restore other owners")
}
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(())
}
async fn get_groups_data(details: bool, org_id: OrganizationId, conn: DbConn) -> JsonResult {
let groups: Vec<Value> = 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) stays member-readable: the web vault needs it to
// render group names, and it exposes no access mappings.
#[get("/organizations/<org_id>/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, conn).await
}
// Security (audit M-1): group *details* expose accessAll, external IDs and collection mappings, so
// reading them requires the 'Manage Users' or 'Manage Groups' permission (or Admin/Owner), matching
// Bitwarden's ReadAll/ReadAllWithAccess authorization.
#[get("/organizations/<org_id>/groups/details", rank = 1)]
async fn get_groups_details(org_id: OrganizationId, headers: ManageUsersOrGroupsHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
get_groups_data(true, org_id, conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GroupRequest {
name: String,
#[serde(default)]
access_all: bool,
external_id: Option<String>,
collections: Vec<CollectionData>,
users: Vec<MembershipId>,
}
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/<org_id>/groups/<group_id>", data = "<data>")]
async fn post_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: ManageGroupsHeaders,
conn: DbConn,
) -> JsonResult {
put_group(org_id, group_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: OrganizationId,
headers: ManageGroupsHeaders,
data: Json<GroupRequest>,
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,
};
let mut group = group_request.to_group(&org_id);
// Security: `access_all` grants the group access to every collection, so it is a
// collection-access grant just like assigning collections. A custom user without
// collection-management rights must not be able to create an access_all group.
if !caller_can_manage_collections {
group.access_all = false;
}
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/<org_id>/groups/<group_id>", data = "<data>")]
async fn put_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
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. For them we keep the
// group's existing collection assignments untouched (neither cleared nor overwritten).
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,
};
// Preserve the current `access_all` grant for callers who can't manage collections, so a
// manage_groups-only user cannot turn a group into an access_all (all-collections) grant.
let previous_access_all = group.access_all;
let mut updated_group = group_request.update_group(group);
if !caller_can_manage_collections {
updated_group.access_all = previous_access_all;
}
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.
log_event(
EventType::GroupUpdated as i32,
&updated_group.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
// 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()
};
add_update_group(
updated_group,
collections_to_apply,
group_request.users,
org_id,
&headers,
&conn,
caller_can_manage_collections,
)
.await
}
/// 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 would indirectly grant collection access, removing would revoke it.
/// Callers who can manage collections may change any group's membership. This mirrors the
/// restriction already enforced inline in `put_group_members` and `add_update_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 a caller of `edit_member` may change a member's role type.
///
/// Only Admins and Owners may change a member's role at all. A Custom member with `manage_users`
/// must not, because the role type has collection-access side effects: a member of type
/// `Manager`/`Custom` gains collection-"manage" on every collection they can write (the
/// `atype >= Manager` branches in `Collection`/`Membership`), so promoting grants that access and
/// demoting revokes it. `manage_users` covers the user lifecycle, not the data plane, so role
/// changes are reserved for Admins/Owners. Leaving the role unchanged is always allowed so
/// `manage_users` members can still use the regular edit dialog. Admin/Owner transitions are
/// additionally governed by the dedicated Owner-only 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
}
/// 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,
}
}
/// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` /
/// `collections_groups.manage`) on `col_id`.
///
/// Security (F-1, edit-any -> delete-any escalation): a `manage` grant carries collection *delete*
/// authority — `CollectionDeleteHeaders` accepts it via `has_explicit_collection_manage_access`.
/// Without this gate a Custom member holding only `edit_any_collection` (whose `access_all` mirror
/// makes every collection "manageable") could, through the collection-access / group endpoints,
/// hand a `manage` row to a group they belong to (or to a manager-level member) and thereby gain
/// deletion — a capability `edit_any_collection` must never imply.
///
/// We therefore allow granting `manage` on a collection only to a caller who could delete that same
/// collection themselves, mirroring `collection_delete_access` exactly so it can never hand out a
/// right the caller lacks: Admin/Owner and Custom-with-`delete_any_collection` always qualify; an
/// exact legacy Manager uses its per-collection manage helper; any other Custom member must hold a
/// real explicit manage grant. This is strictly subtractive — it can only ever downgrade a requested
/// `manage` to `false`, never grant it — so it opens no new access, and delete-capable members
/// (including all Admins/Owners) are unaffected.
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,
// Manager/Custom: the answer is per-collection and must reflect a *real* manage grant.
None => match MembershipType::from_i32(caller.atype) {
// The exact legacy Manager keeps its broad per-collection manage helper (which also
// honors membership/group access_all), matching its pre-existing delete authorization.
Some(MembershipType::Manager) => {
Collection::is_coll_manageable_by_user(col_id, &caller.user_uuid, conn).await
}
// A Custom member must prove a real users_collections.manage / collections_groups.manage
// grant; edit_any_collection's access_all mirror deliberately does not count here.
Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await,
_ => false,
},
}
}
/// 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.
///
/// Kept separate so the role gating — in particular that `edit_any_collection` alone yields `None`
/// (a DB check for a genuine grant) rather than `Some(true)` — is unit-testable without a DB.
fn caller_manage_grant_role_check(caller: &Membership) -> Option<bool> {
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::Manager | MembershipType::Custom) => None,
_ => Some(false),
}
}
async fn add_update_group(
mut group: Group,
collections: Vec<CollectionData>,
members: Vec<MembershipId>,
org_id: OrganizationId,
headers: &ManageGroupsHeaders,
conn: &DbConn,
caller_can_manage_collections: bool,
) -> JsonResult {
group.save(conn).await?;
// Security (F-1): a `collections_groups.manage` grant carries collection delete authority, so a
// caller may only set it on a collection they could delete themselves. This stops a caller whose
// access derives from Edit-any-collection from creating a manage-bearing group and then joining
// it to reach Delete-any-collection. Fetched once; delete-capable callers keep `manage`.
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 uses the same guard: Manage Users OR
// Manage Groups (or Admin/Owner). Requiring Manage Groups here while the list only requires Manage
// Users-or-Groups would let a manage_users member read every group's details in bulk but be denied
// the single-group view of the same data.
#[get("/organizations/<org_id>/groups/<group_id>/details")]
async fn get_group_details(
org_id: OrganizationId,
group_id: GroupId,
headers: ManageUsersOrGroupsHeaders,
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_details(&conn).await))
}
#[post("/organizations/<org_id>/groups/<group_id>/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/<org_id>/groups/<group_id>")]
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 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. Also covers bulk_delete_groups,
// which funnels through this function.
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.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty())
{
err!("You don't have permission to delete a group that grants collection access")
}
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/<org_id>/groups", data = "<data>")]
async fn bulk_delete_groups(
org_id: OrganizationId,
data: Json<BulkGroupIds>,
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();
for group_id in data.ids {
delete_group_impl(&org_id, &group_id, &headers, &conn).await?;
}
Ok(())
}
#[get("/organizations/<org_id>/groups/<group_id>", 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/<org_id>/groups/<group_id>/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<MembershipId> = 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/<org_id>/groups/<group_id>/users", data = "<data>")]
async fn put_group_members(
org_id: OrganizationId,
group_id: GroupId,
headers: ManageGroupsHeaders,
data: Json<Vec<MembershipId>>,
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();
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/<org_id>/groups/<group_id>/delete-user/<member_id>")]
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<String>,
master_password_hash: Option<String>,
otp: Option<String>,
}
#[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/<org_id>/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/<org_id>/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/<org_id>/users/<member_id>/recover-account", data = "<data>")]
async fn put_recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
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/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
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/<org_id>/users/<member_id>/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/<org_id>/users/<user_id>/reset-password-enrollment", data = "<data>")]
async fn put_reset_password_enrollment(
org_id: OrganizationId,
user_id: UserId,
headers: OrgMemberHeaders,
data: Json<OrganizationUserResetPasswordEnrollmentRequest>,
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.
// We currently only support exports by members of the Admin or Owner status.
// Vaultwarden does not yet support exporting only managed collections!
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
#[get("/organizations/<org_id>/export")]
async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
Ok(Json(json!({
"collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await),
"ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?),
})))
}
async fn api_key(
org_id: &OrganizationId,
data: Json<PasswordOrOtpData>,
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/<org_id>/api-key", data = "<data>")]
async fn post_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, false, headers, conn).await
}
#[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
async fn rotate_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::{Value, json};
use super::{
CustomRolePermissions, caller_manage_grant_role_check, filter_ciphers_for_organization,
may_change_group_membership, may_change_member_type,
};
use crate::db::models::{Cipher, Membership, 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
}
#[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;
edit_any.access_all = true; // the internal mirror of edit_any must not shortcut to yes
assert_eq!(caller_manage_grant_role_check(&edit_any), None);
// A flagless Custom / exact Manager also defer to the per-collection DB check.
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Custom)), None);
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Manager)), 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 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 manager = MembershipType::Manager 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::Manager));
assert!(may_change_member_type(MembershipType::Admin, user, MembershipType::Custom));
// A below-Admin caller (Manager / 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));
assert!(may_change_member_type(MembershipType::Manager, manager, MembershipType::Manager));
// REGRESSION (privilege escalation, PR #7397 / finding F1): a caller below Admin must NOT
// be able to change a member's role. Promoting User -> Manager/Custom grants that member
// collection-"manage" on their writable collections (atype >= Manager), and demoting
// revokes it — collection-access changes a manage_users caller is not entitled to make.
assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Manager));
assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Custom));
assert!(!may_change_member_type(MembershipType::Custom, manager, MembershipType::User));
assert!(!may_change_member_type(MembershipType::Manager, custom, MembershipType::User));
}
#[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);
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-cipher access. Create/Delete must never do so.
assert_eq!(parsed.access_all_for(MembershipType::Custom), edit, "mask={mask:03b}");
}
}
#[test]
fn custom_permission_parser_is_strict_and_non_custom_roles_are_fail_closed() {
let permissions = HashMap::from([
("manageUsers".to_owned(), Value::String("true".to_owned())),
("manageGroups".to_owned(), json!(true)),
("managePolicies".to_owned(), json!(true)),
("createNewCollections".to_owned(), json!(true)),
("editAnyCollection".to_owned(), json!(true)),
("deleteAnyCollection".to_owned(), json!(true)),
]);
let custom = CustomRolePermissions::from_request(MembershipType::Custom, &permissions);
assert!(!custom.manage_users, "string values must not be accepted as booleans");
assert!(custom.manage_groups);
assert!(custom.manage_policies);
assert!(custom.create_new_collections);
assert!(custom.edit_any_collection);
assert!(custom.delete_any_collection);
let user = CustomRolePermissions::from_request(MembershipType::User, &permissions);
assert_eq!(user, CustomRolePermissions::default());
assert!(!user.access_all_for(MembershipType::User));
let admin = CustomRolePermissions::from_request(MembershipType::Admin, &permissions);
assert_eq!(admin, CustomRolePermissions::default());
assert!(admin.access_all_for(MembershipType::Admin));
}
#[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,
..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);
}
}