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.
 
 
 
 
 
 

5194 lines
209 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::{
AccessImportExportHeaders, AdminHeaders, CollectionDeleteHeaders, CollectionReadHeaders, Headers,
ManageGroupsHeaders, ManagePoliciesHeaders, ManageUsersHeaders, ManagerHeaders, ManagerHeadersLoose,
OrgMemberHeaders, OwnerHeaders, can_read_collection_access, decode_invite,
},
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
},
},
mail,
sso::FAKE_SSO_IDENTIFIER,
util::{NumberOrString, convert_json_key_lcase_first},
};
pub fn routes() -> Vec<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.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 = may_read_complete_collection_list(&headers.membership);
let all_collections = Collection::find_by_organization(&org_id, &conn).await;
let collections = if can_read_collection_list {
all_collections
} else {
let mut explicitly_managed = Vec::new();
for collection in all_collections {
if headers.membership.has_explicit_collection_manage_access(&collection.uuid, &conn).await {
explicitly_managed.push(collection);
}
}
explicitly_managed
};
if !can_read_collection_list && collections.is_empty() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
Ok(Json(json!({
"data": collections.iter().map(Collection::to_json).collect::<Value>(),
"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()
|| member.has_create_new_collections();
// Get all admins, owners and managers who can manage/access all
// Those are currently not listed in the col_users but need to be listed too.
let manage_all_members: Vec<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);
// ACL mappings require the same authority as the single-collection details endpoint.
// Mere read access (`assigned`, including group `access_all`) is not Manage authority.
match collection_details_response_scope(
can_read_collection_access(&member, &col.uuid, &conn).await,
assigned,
can_read_collection_list,
) {
CollectionDetailsResponseScope::MetadataOnly => {
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
json_object["assigned"] = json!(assigned);
json_object["users"] = json!(Vec::<Value>::new());
json_object["groups"] = json!(Vec::<Value>::new());
json_object["object"] = json!("collectionAccessDetails");
json_object["unmanaged"] = json!(false);
data.push(json_object);
}
CollectionDetailsResponseScope::Hidden => {}
CollectionDetailsResponseScope::AccessDetails => {
// get the users assigned directly to the given collection
let mut users: Vec<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,
})))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CollectionDetailsResponseScope {
AccessDetails,
MetadataOnly,
Hidden,
}
fn may_read_complete_collection_list(member: &Membership) -> bool {
member.has_full_access()
|| member.has_manage_users()
|| member.has_manage_groups()
|| member.has_delete_any_collection()
// Create new collections needs the list too: the client resolves the parent of a nested
// collection against it and refreshes it after a create.
|| member.has_create_new_collections()
}
fn collection_details_response_scope(
can_read_access_details: bool,
has_collection_read_access: bool,
can_read_collection_list: bool,
) -> CollectionDetailsResponseScope {
if can_read_access_details {
CollectionDetailsResponseScope::AccessDetails
} else if has_collection_read_access || can_read_collection_list {
CollectionDetailsResponseScope::MetadataOnly
} else {
CollectionDetailsResponseScope::Hidden
}
}
#[post("/organizations/<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 (full access to
// every collection) must not implicitly grant this endpoint.
if !headers.membership.can_create_new_collections() {
err!("You don't have permission to create collections")
}
let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?;
// Security (audit H-3): validate every referenced group and user against this organization
// *before* creating the collection or any assignment, so a foreign-tenant group can't be
// attached to the new collection and no partial state is left behind on rejection.
for group in &data.groups {
if Group::find_by_uuid_and_org(&group.id, &org_id, &conn).await.is_none() {
err!("Group not found in this organization")
}
}
for user in &data.users {
if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() {
err!("User is not part of organization")
}
}
let collection = Collection::new(org_id.clone(), data.name, data.external_id);
collection.save(&conn).await?;
// Security: a `manage` grant carries collection administration authority, so only a caller who may
// already administer this collection may confer it. Create is independent of Edit/Delete, so for
// `create_new_collections` alone the requested `manage` is forced to false; the creator's own grant
// is added separately below. Evaluated after the collection exists so the lookup sees it.
let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, &collection.uuid, &conn).await;
let creator_needs_assignment = !headers.membership.has_full_access();
// Persist the creator's object-scoped ownership before secondary assignments. If a later
// assignment write fails, the otherwise non-transactional create path still leaves the new
// collection recoverably manageable by its creator. An explicit self-assignment below is
// skipped so it cannot weaken this grant.
if creator_needs_assignment {
CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, true, &conn).await?;
}
for group in data.groups {
CollectionGroup::new(
collection.uuid.clone(),
group.id,
group.read_only,
group.hide_passwords,
group.manage && may_grant_manage,
)
.save(&org_id, &conn)
.await?;
}
for user in data.users {
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
err!("User is not part of organization")
};
if member.grants_access_to_all_collections() {
continue;
}
if member.user_uuid == headers.membership.user_uuid && creator_needs_assignment {
continue;
}
CollectionUser::save(
&member.user_uuid,
&collection.uuid,
user.read_only,
user.hide_passwords,
user.manage && may_grant_manage,
&conn,
)
.await?;
}
// Emit the success event only after all requested assignments and the creator's object-scoped
// manage grant have been persisted. A later write failure must not leave a false audit record.
log_event(
EventType::CollectionCreated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkCollectionAccessData {
collection_ids: Vec<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: authorization is per collection below, via the same `auth::can_edit_collection` the
// single-collection edit endpoint uses — a body-param endpoint cannot use `ManagerHeaders`, and the
// two must not diverge. Group `access_all` deliberately does not satisfy it (the previous
// `is_manageable_by_user` check accepted it, and disagreed with the single-edit endpoint).
// Security and atomicity: validate the whole request against this organization before mutating
// anything — every collection, group and user must belong to it and be manageable by the caller.
// Only then does the destructive delete/replace begin, so a foreign-tenant group can never be linked
// and a later invalid element cannot leave earlier collections already wiped.
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
}
for user in &data.users {
if Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await.is_none() {
err!("User is not part of organization")
}
}
let mut collections = Vec::with_capacity(data.collection_ids.len());
for col_id in &data.collection_ids {
let Some(collection) = Collection::find_by_uuid_and_org(col_id, &org_id, &conn).await else {
err!("Collection not found")
};
if !crate::auth::can_edit_collection(&headers.membership, &collection.uuid, &conn).await {
err!("Collection not found", "The current user isn't a manager for this collection")
}
collections.push(collection);
}
for collection in collections {
let col_id = &collection.uuid;
// Security: only a caller who could delete this collection may confer a `manage` grant on it;
// otherwise the requested `manage` is forced to false.
let may_grant_manage = caller_may_grant_collection_manage(&headers.membership, col_id, &conn).await;
// update collection modification date
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated as i32,
&collection.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
CollectionGroup::delete_all_by_collection(col_id, &org_id, &conn).await?;
for group in &data.groups {
CollectionGroup::new(
col_id.clone(),
group.id.clone(),
group.read_only,
group.hide_passwords,
group.manage && may_grant_manage,
)
.save(&org_id, &conn)
.await?;
}
CollectionUser::delete_all_by_collection(col_id, &conn).await?;
for user in &data.users {
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
err!("User is not part of organization")
};
if member.grants_access_to_all_collections() {
continue;
}
CollectionUser::save(
&member.user_uuid,
col_id,
user.read_only,
user.hide_passwords,
user.manage && may_grant_manage,
&conn,
)
.await?;
}
}
Ok(())
}
#[put("/organizations/<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.grants_access_to_all_collections() {
continue;
}
CollectionUser::save(
&member.user_uuid,
&col_id,
user.read_only,
user.hide_passwords,
user.manage && may_grant_manage,
&conn,
)
.await?;
}
Ok(Json(collection.to_json_details(&headers.user.uuid, None, &conn).await))
}
async fn delete_organization_collection_impl(
org_id: &OrganizationId,
col_id: &CollectionId,
headers: &CollectionDeleteHeaders,
conn: &DbConn,
) -> EmptyResult {
if org_id != &headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let Some(collection) = Collection::find_by_uuid_and_org(col_id, org_id, conn).await else {
err!("Collection not found", "Collection does not exist or does not belong to this organization")
};
log_event(
EventType::CollectionDeleted as i32,
&collection.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
collection.delete(conn).await
}
#[delete("/organizations/<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 calls this when the acting member may not read every cipher: DeleteAnyCollection
// alone needs an empty successful response so the collection list can finish loading.
//
// Security: start from the regular user-visible cipher query and constrain it to the requested
// organization. DeleteAnyCollection must never make cipher contents visible.
#[get("/ciphers/organization-details/assigned?<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
);
}
Ok(Json(json!({
"data": assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?,
"object": "list",
"continuationToken": null,
})))
}
// Serialize exactly the organization ciphers the user is actually assigned to, directly or via a
// group. `CipherSyncType::User` keeps the per-cipher access restrictions in place, so nothing outside
// the caller's own collections is returned and every cipher carries its real `edit`/`viewPassword`
// flags.
//
// NOTE: as everywhere else in Vaultwarden (and Bitwarden), `hidePasswords` is reported as
// `viewPassword: false` rather than redacted server-side, so this returns exactly what the same
// member already receives from `/api/sync` — never more.
async fn assigned_org_ciphers_json(
org_id: &OrganizationId,
host: &str,
user_id: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
let ciphers = filter_ciphers_for_organization(Cipher::find_by_user_visible(user_id, conn).await, org_id);
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::User, conn).await;
let mut ciphers_json = Vec::with_capacity(ciphers.len());
for cipher in ciphers {
ciphers_json.push(cipher.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::User, conn).await?);
}
Ok(Value::Array(ciphers_json))
}
// The organization cipher list the clients use for the admin vault view and for computing reports
// locally. Admins/Owners and Custom members with `editAnyCollection` already reach every cipher.
// `accessReports` alone only opens the endpoint for the caller's existing assignments: it must not
// turn permission to compute reports into read access to otherwise inaccessible organization data.
#[get("/ciphers/organization-details?<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);
}
let ciphers_json = match organization_report_scope(&headers.membership) {
OrganizationReportScope::Complete => {
get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?
}
OrganizationReportScope::Assigned => {
assigned_org_ciphers_json(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?
}
OrganizationReportScope::Denied => {
err_code!(
"Resource not found.",
"User does not have permission to read the organization ciphers",
rocket::http::Status::NotFound.code
);
}
};
Ok(Json(json!({
"data": ciphers_json,
"object": "list",
"continuationToken": null,
})))
}
async fn get_org_details_impl(
org_id: &OrganizationId,
host: &str,
user_id: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
ciphers_to_org_json(Cipher::find_by_org(org_id, conn).await, host, user_id, conn).await
}
// Serialize an already-authorized set of organization ciphers. The caller decides which ciphers go
// in: `CipherSyncType::Organization` skips the per-cipher access restrictions, so this must never be
// handed a cipher the user is not allowed to see.
async fn ciphers_to_org_json(
ciphers: Vec<Cipher>,
host: &str,
user_id: &UserId,
conn: &DbConn,
) -> Result<Value, crate::Error> {
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,
access_event_logs: bool,
access_import_export: bool,
access_reports: bool,
}
impl CustomRolePermissions {
/// Read one known permission key.
///
/// An absent key is `false`: the object is the complete set the caller wants. A key that *is* present
/// must be a JSON boolean — treating `"true"`, `1` or `null` as "not `Value::Bool(true)`" turned a
/// malformed request into a silent permission *removal* that still answered 200.
fn read_known(permissions: &HashMap<String, Value>, key: &str) -> Result<bool, crate::Error> {
match permissions.get(key) {
None => Ok(false),
Some(Value::Bool(value)) => Ok(*value),
Some(other) => {
let found = match other {
Value::Null => "null",
Value::String(_) => "a string",
Value::Number(_) => "a number",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
Value::Bool(_) => unreachable!("booleans are handled above"),
};
err!(format!("Invalid permissions: '{key}' must be true or false, but is {found}"))
}
}
}
/// Parse a permissions object.
///
/// Every known key is type-checked even when the role makes the flags inert, so a malformed request
/// is rejected identically whatever role it names, and always before anything is mutated. Unknown
/// keys are ignored: Bitwarden sends `manageSso`, `manageScim` and `manageResetPassword`, and
/// rejecting them would break clients over permissions Vaultwarden does not implement.
fn from_request(member_type: MembershipType, permissions: &HashMap<String, Value>) -> Result<Self, crate::Error> {
let parsed = Self {
manage_users: Self::read_known(permissions, "manageUsers")?,
manage_groups: Self::read_known(permissions, "manageGroups")?,
manage_policies: Self::read_known(permissions, "managePolicies")?,
create_new_collections: Self::read_known(permissions, "createNewCollections")?,
edit_any_collection: Self::read_known(permissions, "editAnyCollection")?,
delete_any_collection: Self::read_known(permissions, "deleteAnyCollection")?,
access_event_logs: Self::read_known(permissions, "accessEventLogs")?,
access_import_export: Self::read_known(permissions, "accessImportExport")?,
access_reports: Self::read_known(permissions, "accessReports")?,
};
if member_type == MembershipType::Custom {
Ok(parsed)
} else {
Ok(Self::default())
}
}
/// Whether the requested role/permissions give this member access to *every* collection in the
/// org: Admins/Owners implicitly, and a Custom member holding Edit any collection. Such members
/// do not need (and must not be given) individual per-collection assignments. Create and Delete
/// remain completely independent of this.
fn grants_full_collection_access(self, member_type: MembershipType) -> bool {
member_type >= MembershipType::Admin || (member_type == MembershipType::Custom && self.edit_any_collection)
}
/// Parse permissions for an existing member without treating an omitted permissions object as
/// an instruction to clear every Custom-role grant. Older clients send legacy role value `3`
/// without the modern object; that value is normalized to Custom for compatibility.
fn from_edit_request(
member_type: MembershipType,
permissions: Option<&HashMap<String, Value>>,
membership: &Membership,
) -> Result<Self, crate::Error> {
Ok(match permissions {
Some(permissions) => Self::from_request(member_type, permissions)?,
None if member_type == MembershipType::Custom && membership.atype == MembershipType::Custom as i32 => {
Self {
manage_users: membership.manage_users,
manage_groups: membership.manage_groups,
manage_policies: membership.manage_policies,
create_new_collections: membership.create_new_collections,
edit_any_collection: membership.edit_any_collection,
delete_any_collection: membership.delete_any_collection,
access_event_logs: membership.access_event_logs,
access_import_export: membership.access_import_export,
access_reports: membership.access_reports,
}
}
None => Self::default(),
})
}
fn differs_from(self, membership: &Membership) -> bool {
let stored = if membership.atype == MembershipType::Custom as i32 {
Self {
manage_users: membership.manage_users,
manage_groups: membership.manage_groups,
manage_policies: membership.manage_policies,
create_new_collections: membership.create_new_collections,
edit_any_collection: membership.edit_any_collection,
delete_any_collection: membership.delete_any_collection,
access_event_logs: membership.access_event_logs,
access_import_export: membership.access_import_export,
access_reports: membership.access_reports,
}
} else {
// Permission bits outside the Custom role are stale, inert data. Clearing them while an
// ordinary member is edited is not an authority change and must not make a
// ManageUsers-only caller fail the "may not change custom permissions" check.
Self::default()
};
self != stored
}
fn apply_to(self, membership: &mut Membership) {
membership.manage_users = self.manage_users;
membership.manage_groups = self.manage_groups;
membership.manage_policies = self.manage_policies;
membership.create_new_collections = self.create_new_collections;
membership.edit_any_collection = self.edit_any_collection;
membership.delete_any_collection = self.delete_any_collection;
membership.access_event_logs = self.access_event_logs;
membership.access_import_export = self.access_import_export;
membership.access_reports = self.access_reports;
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InviteData {
emails: Vec<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 !may_provision_member_type(headers.membership_type, new_type) {
err!("You don't have permission to invite this role")
}
// manageAllCollections is a client-only aggregate; its three children are persisted independently.
// Parsed and type-checked before the loop below creates any user, invitation or membership, so a
// malformed value leaves nothing behind. Reaching every collection decides whether the individual
// per-collection assignments below are skipped.
let custom_permissions = CustomRolePermissions::from_request(new_type, &data.permissions)?;
let grants_full_access = custom_permissions.grants_full_collection_access(new_type);
// Security: only callers who can manage collections (Admins/Owners, or users with full access)
// may assign collection access when inviting. A custom user with only manage_users can invite
// members, but cannot grant them collection access. Assigning groups is gated separately,
// because a collection-bearing group grants that access indirectly.
let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await;
let caller_can_manage_collections =
headers.membership_type >= MembershipType::Admin || caller.as_ref().is_some_and(Membership::has_full_access);
let caller_can_manage_groups =
headers.membership_type >= MembershipType::Admin || caller.as_ref().is_some_and(Membership::has_manage_groups);
// API consistency: these fields used to be dropped silently while the invite still reported
// success, so the caller believed access had been granted. Reject the request instead, and do it
// before the loop below creates any user, invitation or membership row.
if !grants_full_access && !caller_can_manage_collections && data.collections.iter().flatten().next().is_some() {
err!("You don't have permission to assign collections to invited members")
}
if !caller_can_manage_groups && !data.groups.is_empty() {
err!("You don't have permission to assign groups to invited members")
}
if !caller_can_manage_collections {
for group_id in &data.groups {
if group_confers_collection_access(group_id, &org_id, &conn).await {
err!("You don't have permission to assign a group that grants collection access")
}
}
}
// Security: the membership does not exist yet, so every group named here is an addition — putting
// the invitee into an `access_all` group is the same durable grant the other paths reserve for
// Admins and Owners. `caller_can_manage_collections` above does not cover it: `editAnyCollection`
// satisfies that, and could plant the grant on an account outliving the flag it was made under.
if !may_grant_access_all_group(headers.membership_type) {
for group_id in &data.groups {
if group_grants_access_to_all_collections(group_id, &org_id, &conn).await {
err!("Only Admins and Owners can invite a member into a group with access to all collections")
}
}
}
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
// Scoped to this iteration on purpose. A single flag hoisted out of the loop stays `true`
// for every later recipient once any account has been created, so a failing invite mail to
// an address that already had an account would delete that *existing* global user -- their
// personal ciphers, devices, 2FA, emergency access and memberships in unrelated
// organizations -- instead of only the membership this request just made.
let mut user_created: bool = false;
let user = match User::find_by_mail(email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {email}"))
}
if !CONFIG.is_email_domain_allowed(email) {
err!("Email domain not eligible for invitations")
}
if !CONFIG.mail_enabled() {
Invitation::new(email).save(&conn).await?;
}
let mut new_user = User::new(email, None);
new_user.save(&conn).await?;
user_created = true;
new_user
}
Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
err!(format!("User already in organization: {email}"))
}
if !CONFIG.mail_enabled() {
if user.password_hash.is_empty() {
Invitation::new(email).save(&conn).await?;
} else {
// automatically accept existing users if mail is disabled
member_status = MembershipStatus::Accepted as i32;
}
}
user
}
};
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
new_member.atype = new_type as i32;
custom_permissions.apply_to(&mut new_member);
new_member.status = member_status;
new_member.save(&conn).await?;
if CONFIG.mail_enabled() {
let org_name = if let Some(org) = Organization::find_by_uuid(&org_id, &conn).await {
org.name
} else {
err!("Error looking up organization")
};
if let Err(e) = mail::send_invite(
&user,
org_id.clone(),
new_member.uuid.clone(),
&org_name,
Some(headers.user.email.clone()),
)
.await
{
// Upon error delete the user, invite and org member records when needed
if user_created {
user.delete(&conn).await?;
} else {
new_member.delete(&conn).await?;
}
err!(format!("Error sending invite: {e:?} "));
}
}
log_event(
EventType::OrganizationUserInvited as i32,
&new_member.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
// If the member does not already reach every collection, add the collections received
if !grants_full_access && caller_can_manage_collections {
// Security (F-1): a per-collection `manage` grant carries delete authority, so the
// caller may only confer it on collections they could delete themselves. Otherwise a
// caller acting via Edit-any-collection could invite an account they control with a
// `manage` row and reach Delete-any-collection through it.
for col in data.collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await {
None => err!("Collection not found in Organization"),
Some(collection) => {
let manage = col.manage
&& match &caller {
Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await,
None => false,
};
CollectionUser::save(
&user.uuid,
&collection.uuid,
col.read_only,
col.hide_passwords,
manage,
&conn,
)
.await?;
}
}
}
}
// NOTE: every requested group was already validated against this organization in
// `InviteData::validate`, and both the manage_groups permission and the collection-bearing
// group restriction were rejected up front, before any record was created.
if caller_can_manage_groups {
for group_id in &data.groups {
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?;
}
}
}
Ok(())
}
#[post("/organizations/<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, &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, &conn).await
}
async fn reinvite_member_impl(
org_id: &OrganizationId,
member_id: &MembershipId,
headers: &ManageUsersHeaders,
conn: &DbConn,
) -> EmptyResult {
let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
err!("The user hasn't been invited to the organization.")
};
if !may_manage_stored_member_type(headers.membership_type, member.atype) {
err!("You don't have permission to reinvite this user")
}
if member.status != MembershipStatus::Invited as i32 {
err!("The user is already accepted or confirmed to the organization")
}
let Some(user) = User::find_by_uuid(&member.user_uuid, conn).await else {
err!("User not found.")
};
if !CONFIG.invitations_allowed() && user.password_hash.is_empty() {
err!("Invitations are not allowed.")
}
let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await {
org.name
} else {
err!("Error looking up organization.")
};
if CONFIG.mail_enabled() {
mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(headers.user.email.clone())).await?;
} else if user.password_hash.is_empty() {
let invitation = Invitation::new(&user.email);
invitation.save(conn).await?;
} else {
Invitation::take(&user.email, conn).await;
let mut member = member;
member.status = MembershipStatus::Accepted as i32;
member.save(conn).await?;
}
Ok(())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AcceptData {
token: String,
reset_password_key: Option<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 {
// The id is request-controlled and optional. Unwrapping it aborted the worker with a 500 and, because
// the panic unwound mid-loop, discarded the response for every entry already confirmed in the same
// batch. Report it as a per-entry error, like an id that is present but empty.
let Some(member_id) = invite.id else {
bulk_response.push(json!(
{
"object": "OrganizationBulkConfirmResponseModel",
"id": null,
"error": "Key or UserId is not set, unable to process request"
}
));
continue;
};
let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(),
Err(e) => format!("{e:?}"),
};
bulk_response.push(json!(
{
"object": "OrganizationBulkConfirmResponseModel",
"id": member_id,
"error": err_msg
}
));
}
}
None => error!("No keys to confirm"),
}
Ok(Json(json!({
"data": bulk_response,
"object": "list",
"continuationToken": null
})))
}
#[post("/organizations/<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 !may_provision_stored_member_type(headers.membership_type, member_to_confirm.atype) {
err!("You don't have permission to confirm this user")
}
if member_to_confirm.status != MembershipStatus::Accepted as i32 {
err!("User in invalid state")
}
member_to_confirm.status = MembershipStatus::Confirmed as i32;
member_to_confirm.akey = key.to_owned();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
log_event(
EventType::OrganizationUserConfirmed as i32,
&member_to_confirm.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
if CONFIG.mail_enabled() {
let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await {
org.name
} else {
err!("Error looking up organization.")
};
let address = if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
user.email
} else {
err!("Error looking up user.")
};
mail::send_invite_confirmed(&address, &org_name).await?;
}
let save_result = member_to_confirm.save(conn).await;
if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
}
save_result
}
#[get("/organizations/<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>>,
permissions: Option<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 Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err!("The specified user isn't member of the organization")
};
// Parsed (and type-checked) here, long before the write phase further down, so a malformed
// permission value leaves the role, the permission flags, the collection assignments and the
// group memberships exactly as they were.
let custom_permissions =
CustomRolePermissions::from_edit_request(new_type, data.permissions.as_ref(), &member_to_edit)?;
let grants_full_access = custom_permissions.grants_full_collection_access(new_type);
if new_type != member_to_edit.atype
&& (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin)
&& headers.membership_type != MembershipType::Owner
{
err!("Only Owners can grant and remove Admin or Owner privileges")
}
// Security: raising a member to Custom activates existing explicit collection-Manage assignments and
// other Custom-only paths, and lowering it revokes them — authority changes outside Manage Users. An
// unchanged role is still allowed, so such members can use the regular edit dialog.
if !may_change_member_type(headers.membership_type, member_to_edit.atype, new_type) {
err!("Only Admins or Owners can change a member's role")
}
if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
err!("Only Owners can edit Owner users")
}
// Security: the same actor/target role matrix as every other member endpoint. Without it
// `edit_member` was the only path on which Custom+manage_users could aim at an Admin or a peer
// Custom membership, as long as the role stayed unchanged.
//
// Deliberate narrowing of upstream: Bitwarden lets Custom+ManageUsers administer peer Custom members
// and delegate a subset of its own permissions. That hands permission delegation to a non-Admin and
// makes correctness rest on a subset comparison being right on every path, so Vaultwarden keeps role
// and permission changes with Admins/Owners instead.
if !may_manage_stored_member_type(headers.membership_type, member_to_edit.atype) {
err!("You don't have permission to edit this member")
}
if member_to_edit.atype == MembershipType::Owner
&& new_type != MembershipType::Owner
&& member_to_edit.status == MembershipStatus::Confirmed as i32
{
// Removing owner permission, check that there is at least one other confirmed owner
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
err!("Can't delete the last owner")
}
}
// Security: only Admins and Owners may change the granular permissions — manage_users must not grant
// them to itself or others, nor strip flags an Admin/Owner granted. Unchanged flags are allowed, so
// such members can still use the regular edit dialog.
if headers.membership_type < MembershipType::Admin && custom_permissions.differs_from(&member_to_edit) {
err!("Only Admins or Owners can change custom permissions")
}
// Security: only callers who can manage collections (Admin/Owner, or full access) may change a
// member's collection assignments; manage_users alone leaves them untouched.
//
// Narrowing of upstream, which resolves ModifyUserAccess per collection. Requiring blanket authority
// is coarser, but it keeps a stored `manage` grant from becoming a lever for handing out access — the
// same boundary `caller_may_grant_collection_manage` draws, and the group paths below follow it.
// Widening this needs the per-collection check to cover *current* assignments too, or removal becomes
// the hole.
let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin
|| match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
Some(m) => m.has_full_access(),
None => false,
};
// API consistency: dropping these fields while answering 200 let client and server drift apart after
// an apparently saved change. Reject instead — but only for an actual add or removal, since the edit
// dialog echoes the current assignments back. Flag-only differences stay ignored.
if !caller_can_manage_collections && !grants_full_access {
let requested: HashSet<CollectionId> = data.collections.iter().flatten().map(|c| c.id.clone()).collect();
let current: HashSet<CollectionId> =
CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn)
.await
.into_iter()
.map(|c| c.collection_uuid)
.collect();
if requested != current {
err!("You don't have permission to change this member's collection assignments")
}
}
// Edit any collection (the successor of the removed access_all flag) grants full access to
// every collection. It is part of the granular custom permissions applied here, and the
// differs_from guard above already prevents a non-Admin caller from changing it — so a Custom
// member with only manage_users can never grant themselves or others full collection access.
custom_permissions.apply_to(&mut member_to_edit);
member_to_edit.atype = new_type as i32;
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// We need to perform the check after changing the type since `admin` is exempt.
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
// ---------------------------------------------------------------------------------------------
// Validation phase. Nothing may be written until every id, tenant binding and caller right has
// been checked: this endpoint replaces both collection assignments and group memberships, and with
// no database transactions an error between the two replaces used to leave the request
// half-applied while answering 4xx. This cannot make them atomic, but a *rejected* request now
// changes nothing.
// ---------------------------------------------------------------------------------------------
// Security: a per-collection `manage` grant is durable administration authority, so it may only be
// conferred where the caller already holds it.
let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await;
// Resolve the requested assignments: every collection has to exist in *this* organization, and
// the effective `manage` bit is decided here rather than while writing.
let mut collection_assignments: Vec<(CollectionId, bool, bool, bool)> = Vec::new();
if caller_can_manage_collections && !grants_full_access {
for col in data.collections.iter().flatten() {
let Some(collection) = Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await else {
err!("Collection not found in Organization")
};
let manage = col.manage
&& match &caller {
Some(c) => caller_may_grant_collection_manage(c, &collection.uuid, &conn).await,
None => false,
};
collection_assignments.push((collection.uuid, col.read_only, col.hide_passwords, manage));
}
}
// Security: changing a member's group membership can indirectly grant collection access
// (via the groups' collections). Only callers who may manage groups (Admins/Owners or users
// with manage_groups) are allowed to change it. For others we leave group membership untouched.
let caller_can_manage_groups = headers.membership_type >= MembershipType::Admin
|| match &caller {
Some(m) => m.has_manage_groups(),
None => false,
};
// API consistency, as for the collection assignments above: reject group changes this caller may
// not make instead of silently dropping them.
let requested_groups: HashSet<GroupId> = data.groups.iter().flatten().cloned().collect();
let current_groups: HashSet<GroupId> =
GroupUser::find_by_member(&member_to_edit.uuid, &conn).await.into_iter().map(|gu| gu.groups_uuid).collect();
if !caller_can_manage_groups && requested_groups != current_groups {
err!("You don't have permission to change this member's group assignments")
}
if caller_can_manage_groups && !caller_can_manage_collections {
let mut collection_bearing: HashSet<GroupId> = HashSet::new();
for group_id in requested_groups.union(&current_groups) {
if group_confers_collection_access(group_id, &org_id, &conn).await {
collection_bearing.insert(group_id.clone());
}
}
if !collection_bearing_membership_unchanged(&requested_groups, &current_groups, &collection_bearing) {
err!("You don't have permission to change memberships in groups that grant collection access")
}
}
// Security: adding this member to an `access_all` group grants durable organization-wide access, so
// a caller whose own reach comes from `editAnyCollection` must not hand it out. Removals are
// unrestricted, so only the groups this request adds are examined.
if caller_can_manage_groups && !may_grant_access_all_group(headers.membership_type) {
for group_id in requested_groups.difference(&current_groups) {
if group_grants_access_to_all_collections(group_id, &org_id, &conn).await {
err!("Only Admins and Owners can add a member to a group with access to all collections")
}
}
}
// Security (audit H-2): every requested group has to belong to this organization. Otherwise a
// caller could link the member to a group of a foreign tenant (e.g. an access-all group), which
// the direct cipher-access checks would then honor. Fail closed on the whole request.
if caller_can_manage_groups {
for group_id in data.groups.iter().flatten() {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in this organization")
}
}
}
// Decide the group changes while still not writing. A caller who may manage groups but *not*
// collections may only touch memberships in groups that confer no collection access; the others
// are preserved untouched (neither granted nor revoked), mirroring put_group_members and
// add_update_group.
let mut groups_to_remove: Vec<GroupId> = Vec::new();
let mut groups_to_add: Vec<GroupId> = Vec::new();
if caller_can_manage_groups {
for group_id in &current_groups {
if caller_can_manage_collections
|| may_change_group_membership(
caller_can_manage_collections,
group_confers_collection_access(group_id, &org_id, &conn).await,
)
{
groups_to_remove.push(group_id.clone());
}
}
for group_id in data.groups.iter().flatten() {
if caller_can_manage_collections
|| may_change_group_membership(
caller_can_manage_collections,
group_confers_collection_access(group_id, &org_id, &conn).await,
)
{
groups_to_add.push(group_id.clone());
}
}
}
// ---------------------------------------------------------------------------------------------
// Write phase.
// ---------------------------------------------------------------------------------------------
if caller_can_manage_collections {
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await {
c.delete(&conn).await?;
}
for (collection_uuid, read_only, hide_passwords, manage) in collection_assignments {
CollectionUser::save(&member_to_edit.user_uuid, &collection_uuid, read_only, hide_passwords, manage, &conn)
.await?;
}
}
for group_id in groups_to_remove {
GroupUser::delete_by_group_and_member(&group_id, &member_to_edit.uuid, &conn).await?;
}
for group_id in groups_to_add {
let mut group_entry = GroupUser::new(group_id, member_to_edit.uuid.clone());
group_entry.save(&conn).await?;
}
log_event(
EventType::OrganizationUserUpdated as i32,
&member_to_edit.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
member_to_edit.save(&conn).await
}
#[delete("/organizations/<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 !may_delete_stored_member_type(headers.membership_type, member_to_delete.atype) {
err!("You don't have permission to delete this user")
}
if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32
{
// Removing owner, check that there is at least one other confirmed owner
if Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 {
err!("Can't delete the last owner")
}
}
log_event(
EventType::OrganizationUserRemoved as i32,
&member_to_delete.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
if !CONFIG.mail_enabled()
&& !Membership::find_invited_by_user(&user.uuid, conn)
.await
.into_iter()
.any(|m| m.uuid != member_to_delete.uuid)
{
Invitation::take(&user.email, conn).await;
}
}
member_to_delete.delete(conn).await
}
#[post("/organizations/<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");
}
// Organization imports are authorized per target collection. `accessImportExport` gates export,
// but does not replace Write authority on an existing collection or Create authority for a new
// one. Require confirmation independently so a membership with no target collection cannot create
// an unreachable organization cipher.
if !headers.membership.has_status(MembershipStatus::Confirmed) {
err!("You need to be a confirmed member of this organization to import into it")
}
let data: ImportData = data.into_inner();
// Validate the import before continuing
// Bitwarden does not process the import if there is one item invalid.
// Since we check for the size of the encrypted note length, we need to do that here to pre-validate it.
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?;
// Robustness: validate every collection<->cipher relationship index against the payload *before*
// creating anything. `key` indexes into `ciphers` and `value` into `collections`, and an out-of-range
// index would otherwise panic when the relations are applied — after rows have already been written.
let import_cipher_count = data.ciphers.len();
let import_collection_count = data.collections.len();
for relation in &data.collection_relationships {
if relation.key >= import_cipher_count || relation.value >= import_collection_count {
err!(
"Invalid collection relationship",
"A collection relationship references a non-existent cipher or collection"
)
}
}
// Security (audit F8/upstream): index the existing collections by id so the per-collection
// authorization below can use the *write* predicate `is_writable_by_user`. A read-only
// assignment must not let an importer plant ciphers into a shared collection.
let existing_collections: HashMap<CollectionId, Collection> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
// Finish every request-controlled collection authorization check before the first new collection
// is written. This matters for the PR's create-only Custom role: a payload may name a new
// collection first and an existing, non-writable collection later. Rejecting the latter only in
// the write loop left the former behind even though the request failed.
for col in &data.collections {
if let Some(collection) = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id)) {
let writable = collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await;
if !may_import_to_collection(
&headers.membership,
OrganizationImportTarget::Existing {
writable,
},
) {
err!(Compact, "The current user isn't allowed to manage this collection")
}
} else if !may_import_to_collection(&headers.membership, OrganizationImportTarget::New) {
err!(Compact, "The current user isn't allowed to create new collections")
}
}
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections {
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
let collection_uuid = if let Some(collection) = existing {
collection.uuid.clone()
} else {
// Collection creation through an organization import is governed by the same
// independent permission as the regular create endpoint. In particular,
// Edit any collection (full access to every collection) must not satisfy this check.
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id);
new_collection.save(&conn).await?;
// Import-created collections do not carry the regular create endpoint's user access
// selections. Give a create-only importer Manage access to the collection they just
// created, matching Bitwarden's organization-import behavior.
if !headers.membership.has_full_access() {
CollectionUser::save(&headers.membership.user_uuid, &new_collection.uuid, false, false, true, &conn)
.await?;
}
new_collection.uuid
};
collections.push(collection_uuid);
}
// Read the relations between collections and ciphers
// Ciphers can be in multiple collections at the same time
let mut relations = Vec::with_capacity(data.collection_relationships.len());
for relation in data.collection_relationships {
relations.push((relation.key, relation.value));
}
let headers: Headers = headers.into();
let mut ciphers: Vec<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;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(
&mut cipher,
cipher_data,
&headers,
Some(collections.clone()),
&conn,
&nt,
UpdateType::None,
)
.await
.ok();
ciphers.push(cipher.uuid);
}
// Assign the collections. Indices were bounds-validated above, but use `.get()` here as well so
// any future drift fails closed with an error instead of panicking.
for (cipher_index, col_index) in relations {
let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
err!(Compact, "Invalid collection relationship")
};
CollectionCipher::save(cipher_id, col_id, &conn).await?;
}
let mut user = headers.user;
user.update_revision(&conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BulkCollectionsData {
organization_id: OrganizationId,
cipher_ids: Vec<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,
// and never to the member enabling it -- see `Membership::is_policy_enforcement_target`.
// Exclude invited and revoked users when checking for this policy.
// Those users will not be allowed to accept or be activated because of the policy checks done there.
if member.is_policy_enforcement_target(&headers.user.uuid)
&& member.status != MembershipStatus::Invited as i32
&& Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await
> 0
{
if CONFIG.mail_enabled() {
let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap();
let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap();
mail::send_single_org_removed_from_org(&user.email, &org.name).await?;
}
log_event(
EventType::OrganizationUserRemoved as i32,
&member.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
member.revoke();
member.save(&conn).await?;
}
}
}
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
Some(p) => p,
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
};
policy.enabled = data.enabled;
policy.data = serde_json::to_string(&data.data)?;
policy.save(&conn).await?;
log_event(
EventType::PolicyUpdated as i32,
policy.uuid.as_ref(),
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(Json(policy.to_json()))
}
// Deprecated with client v2026.5.0
#[put("/organizations/<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")
}
if !may_revoke_stored_member_type(headers.membership_type, member.atype) {
err!("You don't have permission to revoke this user")
}
if member.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1
{
err!("Organization must have at least one confirmed owner")
}
member.revoke();
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
}
Some(_) => err!("User is already revoked"),
None => err!("User not found in organization"),
}
Ok(())
}
#[put("/organizations/<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")
}
if !may_manage_stored_member_type(headers.membership_type, member.atype) {
err!("You don't have permission to restore this user")
}
member.restore();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
// This check need to be done after restoring to work with the correct status
OrgPolicy::check_user_allowed(&member, "restore", conn).await?;
member.save(conn).await?;
log_event(
EventType::OrganizationUserRestored as i32,
&member.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
}
Some(_) => err!("User is already active"),
None => err!("User not found in organization"),
}
Ok(())
}
/// Whether `membership` may read group→collection/user mappings.
///
/// Two independent routes to the same data: the Manage Users / Manage Groups permissions, which is
/// what Bitwarden gates ReadAll on, and organization-wide collection reach, which is what released
/// Vaultwarden gated it on. Both are kept so a legacy Manager with "Manage all collections" still
/// reads these mappings after the migration converts them. The single-group view returns exactly this
/// data and so asks exactly this question.
async fn can_read_group_details(org_id: &OrganizationId, membership: &Membership, conn: &DbConn) -> bool {
membership.has_manage_users()
|| membership.has_manage_groups()
|| membership.has_full_access()
|| (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(org_id, &membership.uuid, conn).await)
}
async fn get_groups_data(details: bool, org_id: OrganizationId, membership: &Membership, conn: DbConn) -> JsonResult {
let can_read_details = can_read_group_details(&org_id, membership, &conn).await;
// The plain list (id, name, externalId) carries no access mappings, so it additionally opens to
// anyone who manages a single collection: they need the group names to assign groups to it.
let allowed = if details {
can_read_details
} else {
can_read_details || Collection::has_manageable_collection_by_user(&org_id, &membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<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) exposes no access mappings, so it stays readable for
// members who have a reason to see it — the web vault needs it to render group names. The exact
// condition is enforced in `get_groups_data`.
#[get("/organizations/<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, &headers.membership, conn).await
}
// Group *details* expose accessAll, external IDs and collection mappings. The condition is
// `can_read_group_details`, enforced in `get_groups_data`; keeping the guard loose and the condition
// in one place is what stops the list and single-group views from drifting apart, as they had.
#[get("/organizations/<org_id>/groups/details", rank = 1)]
async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
get_groups_data(true, org_id, &headers.membership, conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GroupRequest {
name: String,
#[serde(default)]
access_all: bool,
external_id: Option<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,
};
// Security: creating an `access_all` group is reserved for Admins and Owners; `has_full_access()`,
// which `editAnyCollection` satisfies, is deliberately not enough. See `may_grant_access_all_group`.
if group_request.access_all && !may_grant_access_all_group(headers.membership_type) {
err!("Only Admins and Owners can create a group with access to all collections")
}
// Assigning collections to a group is a collection-access grant. Rejected rather than silently
// created without it, so a caller never believes it granted something the server dropped; a request
// that grants nothing is still accepted, which is what the plain "new group" dialog sends.
if !caller_can_manage_collections && !group_request.collections.is_empty() {
err!("You don't have permission to assign collections to a group")
}
let group = group_request.to_group(&org_id);
log_event(
EventType::GroupCreated as i32,
&group.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
let collections_to_apply = if caller_can_manage_collections {
group_request.collections
} else {
Vec::new()
};
add_update_group(
group,
collections_to_apply,
group_request.users,
org_id,
&headers,
&conn,
caller_can_manage_collections,
)
.await
}
#[put("/organizations/<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.
let caller_can_manage_collections = headers.membership_type >= MembershipType::Admin
|| match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await {
Some(m) => m.has_full_access(),
None => false,
};
// Security: turning an ordinary group into an organization-wide one is the same durable grant as
// creating one. Clearing the flag is a reduction and keeps the rule below.
if group_request.access_all && !group.access_all && !may_grant_access_all_group(headers.membership_type) {
err!("Only Admins and Owners can give a group access to all collections")
}
// API consistency: reject a collection-access change this caller may not make rather than answering
// 200 and keeping the old value, as `edit_member` and `send_invite` do. Only an actual difference is
// rejected, since the group dialog echoes the current assignments back; flag differences stay ignored.
if !caller_can_manage_collections {
if group_request.access_all != group.access_all {
err!("You don't have permission to change a group's access to all collections")
}
let requested: HashSet<CollectionId> = group_request.collections.iter().map(|c| c.id.clone()).collect();
let current: HashSet<CollectionId> = CollectionGroup::find_by_group(&group_id, &org_id, &conn)
.await
.into_iter()
.map(|cg| cg.collections_uuid)
.collect();
if requested != current {
err!("You don't have permission to change this group's collection assignments")
}
}
let updated_group = group_request.update_group(group);
// Security (audit F-1): `add_update_group` asks this too, but it runs *after* the destructive
// `CollectionGroup::delete_all_by_group` below -- a refusal there answered 400 with the group's
// collection assignments already gone, revoking them for every member of the group. Ask it here,
// while nothing has been written. `updated_group.access_all` is the value this request leaves
// behind, which is what actually grants, and it is the same value the later check reads.
if !may_grant_access_all_group(headers.membership_type)
&& adds_member_to_access_all_group(&updated_group, &group_request.users, &org_id, &conn).await
{
err!("Only Admins and Owners can add a member to a group with access to all collections")
}
if caller_can_manage_collections {
CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?;
}
// NOTE: group membership is replaced (and access-gated) inside add_update_group.
// Only pass collection changes through if the caller is allowed to manage collections.
let collections_to_apply = if caller_can_manage_collections {
group_request.collections
} else {
Vec::new()
};
let response = add_update_group(
updated_group,
collections_to_apply,
group_request.users,
org_id.clone(),
&headers,
&conn,
caller_can_manage_collections,
)
.await?;
// Logged once the update has actually been applied. `add_update_group` still refuses a membership
// change the caller may not make, and an event written before it recorded an update that never
// happened.
log_event(
EventType::GroupUpdated as i32,
&group_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(response)
}
/// Whether a caller may change (add OR remove) a member's membership in a group.
///
/// A caller who cannot manage collections must never touch a collection-bearing group's membership:
/// adding grants those collections, removing revokes them. Organization-wide (`access_all`) groups
/// are a separate, stricter question — see [`may_grant_access_all_group`].
fn may_change_group_membership(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool {
caller_can_manage_collections || !group_confers_collection_access
}
/// Whether `caller_type` may hand out organization-wide group access: create a group carrying
/// `groups.access_all`, turn one into it, or add a member to one.
///
/// Security: `access_all` reaches every collection and is not bound to the grantee's role, so it
/// survives their Custom permissions being cleared — a durable grant like a `users_collections.manage`
/// row, which `caller_may_grant_collection_manage` already reserves. Gating on `has_full_access()`
/// instead would let `manageGroups` + `editAnyCollection` mint one and outlive the flag it came from,
/// so no Custom permission satisfies this, `deleteAnyCollection` included.
///
/// Only granting is restricted; removals, clearing the flag and deleting the group all reduce access.
/// Asked by `post_groups`, `put_group`, `add_update_group`, `put_group_members`, `edit_member` and
/// `send_invite` — the last needs it because a new membership has no current groups to diff against,
/// so every group in an invite is an addition.
///
/// A missing call site is invisible to `cargo test`, so that list is the thing to check when a new
/// group path is added. So is *where* the call sits: it has to precede every write the request makes,
/// or a refusal leaves the endpoint half-applied (audit F-1).
fn may_grant_access_all_group(caller_type: MembershipType) -> bool {
caller_type >= MembershipType::Admin
}
/// Whether `requested` would add at least one member that `current` does not already contain.
///
/// Separates an addition to an access-all group (restricted) from a pure removal (always allowed) on
/// the endpoints that replace a whole member list.
fn adds_group_member(requested: &HashSet<&MembershipId>, current: &HashSet<&MembershipId>) -> bool {
!requested.is_subset(current)
}
/// Whether replacing `group`'s member list with `members` adds someone to an organization-wide group.
///
/// The question `may_grant_access_all_group` gates, resolved against the database. Shared by
/// `put_group` and `add_update_group` so the pre-write check and the one next to the write can never
/// answer differently. Reads `group.access_all` first, so an ordinary group costs no query.
async fn adds_member_to_access_all_group(
group: &Group,
members: &[MembershipId],
org_id: &OrganizationId,
conn: &DbConn,
) -> bool {
if !group.access_all {
return false;
}
let current_members = GroupUser::find_by_group(&group.uuid, org_id, conn).await;
let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect();
adds_group_member(&members.iter().collect(), &current)
}
/// Whether `requested` and `current` agree on every group that confers collection access.
///
/// Such a change has to be rejected rather than skipped silently, so a save that appears to succeed
/// never means something different on the server.
fn collection_bearing_membership_unchanged(
requested: &HashSet<GroupId>,
current: &HashSet<GroupId>,
collection_bearing: &HashSet<GroupId>,
) -> bool {
let restrict =
|set: &HashSet<GroupId>| -> HashSet<GroupId> { set.intersection(collection_bearing).cloned().collect() };
restrict(requested) == restrict(current)
}
/// Whether a caller of `edit_member` may change a member's role type.
///
/// The role type changes organization-wide collection reach and which granular permissions are
/// effective — the data plane, not the user lifecycle `manage_users` covers — so only Admins and
/// Owners may change it. An unchanged role is always allowed, so `manage_users` members can still use
/// the regular edit dialog; Admin/Owner transitions have their own guard in `edit_member`.
fn may_change_member_type(caller_type: MembershipType, current_atype: i32, new_type: MembershipType) -> bool {
caller_type >= MembershipType::Admin || new_type == current_atype
}
/// Whether a caller with user-management access may perform lifecycle actions on a target role.
///
/// Owners may manage every role. Admins may manage Admin, Custom, and User memberships, but never
/// Owners. Custom members holding `manage_users` are limited to ordinary Users.
fn may_manage_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool {
match caller_type {
MembershipType::Owner => true,
MembershipType::Admin => target_type != MembershipType::Owner,
MembershipType::Custom => target_type == MembershipType::User,
MembershipType::User => false,
}
}
fn may_manage_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool {
MembershipType::from_i32(target_atype).is_some_and(|target_type| may_manage_member_type(caller_type, target_type))
}
/// Whether a caller may *provision* a membership of `target_type` — create it (invite), activate it
/// (confirm) or remove it (delete).
///
/// Stricter than [`may_manage_member_type`], preserving the rule that only Owners bring Admin/Owner
/// memberships into or out of existence — `edit_member` guards the same boundary on role transitions,
/// so an Admin must not route around it by inviting a fresh Admin. State changes that leave the
/// membership in place keep using [`may_manage_member_type`].
fn may_provision_member_type(caller_type: MembershipType, target_type: MembershipType) -> bool {
match caller_type {
MembershipType::Owner => true,
MembershipType::Admin => target_type < MembershipType::Admin,
MembershipType::Custom => target_type == MembershipType::User,
MembershipType::User => false,
}
}
fn may_provision_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool {
MembershipType::from_i32(target_atype)
.is_some_and(|target_type| may_provision_member_type(caller_type, target_type))
}
/// Whether a caller may act on a membership whose stored `atype` this build cannot interpret.
///
/// Such a row (a future build, a partial rollback, a hand edit) holds no authority — `OrgHeaders`
/// refuses it and every permission flag on it is inert — but the helpers above fail closed on the
/// unknown value, which left nobody able to remove it either, unlike Vaultwarden. So: an Owner only,
/// and only for the two actions that reduce what the row can become. Editing, confirming, restoring
/// and reinviting keep refusing, because they preserve or reactivate a role the server cannot reason
/// about.
fn may_act_on_unknown_stored_member_type(caller_type: MembershipType) -> bool {
caller_type == MembershipType::Owner
}
/// Whether a caller may delete `target_atype`. Provisioning rules for a role this build knows;
/// Owner-only for one it does not (see [`may_act_on_unknown_stored_member_type`]).
fn may_delete_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool {
match MembershipType::from_i32(target_atype) {
Some(role) => may_provision_member_type(caller_type, role),
None => may_act_on_unknown_stored_member_type(caller_type),
}
}
/// Whether a caller may revoke `target_atype`. Management rules for a role this build knows;
/// Owner-only for one it does not. Kept separate from [`may_delete_stored_member_type`] so revoking
/// keeps the looser actor/target matrix it has always had (an Admin may revoke a peer Admin, which
/// provisioning does not allow).
fn may_revoke_stored_member_type(caller_type: MembershipType, target_atype: i32) -> bool {
match MembershipType::from_i32(target_atype) {
Some(role) => may_manage_member_type(caller_type, role),
None => may_act_on_unknown_stored_member_type(caller_type),
}
}
/// Returns true if being a member of `group_id` confers collection access — either because the
/// group has `access_all` set, or because it has collections assigned.
async fn group_confers_collection_access(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool {
match Group::find_by_uuid_and_org(group_id, org_id, conn).await {
Some(group) => group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty(),
None => false,
}
}
/// Returns true if `group_id` carries `groups.access_all`, i.e. membership of it reaches every
/// collection of the organization. A group that does not exist in this organization confers
/// nothing. See [`may_grant_access_all_group`] for why this is asked separately from
/// [`group_confers_collection_access`].
async fn group_grants_access_to_all_collections(group_id: &GroupId, org_id: &OrganizationId, conn: &DbConn) -> bool {
Group::find_by_uuid_and_org(group_id, org_id, conn).await.is_some_and(|group| group.access_all)
}
/// Whether `caller` may set a per-collection `manage` grant (`users_collections.manage` /
/// `collections_groups.manage`) on `col_id`.
///
/// Security: a `manage` grant is collection administration authority (`ManagerHeaders` accepts it via
/// `has_explicit_collection_manage_access`) and survives every later change to the grantee's role, so
/// a caller acting through `edit_any_collection` — revocable by clearing one flag — must not be able
/// to write one. Only blanket authority (Admin/Owner, or Custom with `delete_any_collection`) or a
/// real stored grant on that same collection qualifies. Strictly subtractive: it can only downgrade a
/// requested `manage` to false.
async fn caller_may_grant_collection_manage(caller: &Membership, col_id: &CollectionId, conn: &DbConn) -> bool {
match caller_manage_grant_role_check(caller) {
// Role alone decides it (Admin/Owner or delete_any -> yes; User/unknown/unconfirmed -> no).
Some(decision) => decision,
// Custom without delete_any: the answer is per-collection and must reflect a *real* stored
// manage grant. Edit any collection deliberately does not count here — it is revocable by
// clearing a flag, while a `manage` row written here outlives it. Accepting it would let
// temporary authority be laundered into a permanent grant, which is exactly the escalation
// this clamp exists to prevent.
None => match MembershipType::from_i32(caller.atype) {
Some(MembershipType::Custom) => caller.has_explicit_collection_manage_access(col_id, conn).await,
_ => false,
},
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum OrganizationImportTarget {
Existing {
writable: bool,
},
New,
}
/// Organization imports retain the pre-existing per-target authorization model. The
/// `accessImportExport` permission opens export, but it is deliberately not an organization-wide
/// Create/Write shortcut for imports.
fn may_import_to_collection(caller: &Membership, target: OrganizationImportTarget) -> bool {
if !caller.has_status(MembershipStatus::Confirmed) {
return false;
}
match target {
OrganizationImportTarget::Existing {
writable,
} => caller.atype >= MembershipType::Admin || writable,
OrganizationImportTarget::New => caller.can_create_new_collections(),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum OrganizationReportScope {
Complete,
Assigned,
Denied,
}
/// Full-access members receive the complete organization view. `accessReports` alone receives the
/// same assignment-scoped, restriction-bearing cipher representation as the caller's normal sync.
fn organization_report_scope(caller: &Membership) -> OrganizationReportScope {
if caller.has_full_access() {
OrganizationReportScope::Complete
} else if caller.has_status(MembershipStatus::Confirmed) && caller.has_access_reports() {
OrganizationReportScope::Assigned
} else {
OrganizationReportScope::Denied
}
}
/// Whether `caller` may export the *entire* organization instead of only their own assignments.
///
/// Security: the `AccessImportExportHeaders` guard decides whether a member may export at all, not
/// *what* they get. Only members who already reach every collection get the full dump; for anyone
/// else it is built from their own assignments, so the permission never becomes a full vault read.
fn may_export_entire_organization(caller: &Membership) -> bool {
caller.has_full_access()
}
/// Pure, collection-independent part of `caller_may_grant_collection_manage`.
///
/// `Some(true)` -> the caller may grant `manage` on *any* collection (Admin/Owner, or a Custom
/// member holding `delete_any_collection`).
/// `Some(false)` -> the caller may never grant `manage` (unconfirmed, plain User, or unknown type).
/// `None` -> depends on a real per-collection manage grant, resolved against the database.
///
/// Separate so the role gating — in particular `edit_any_collection` alone yielding `None` rather
/// than `Some(true)` — is unit-testable without a DB.
fn caller_manage_grant_role_check(caller: &Membership) -> Option<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::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 {
// Security: membership of a collection-bearing group grants (or revokes) access to those
// collections' contents, so only a caller who can manage collections may change it. Rejected rather
// than silently ignored, and checked before the first write so a refused request leaves nothing
// behind. On create the group is brand new and this never triggers.
//
// `group.access_all` is the value this request leaves behind, which is what actually grants: adding a
// member to it is Admin/Owner authority even for a caller who can manage collections. Removals stay
// under the collection rule below. `put_group` asks the same question before its own destructive
// delete (audit F-1); this call covers `post_groups`, where nothing has been written yet either.
if !may_grant_access_all_group(headers.membership_type)
&& adds_member_to_access_all_group(&group, &members, &org_id, conn).await
{
err!("Only Admins and Owners can add a member to a group with access to all collections")
}
if !caller_can_manage_collections
&& (group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty())
{
let requested: HashSet<&MembershipId> = members.iter().collect();
let current_members = GroupUser::find_by_group(&group.uuid, &org_id, conn).await;
let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect();
if requested != current {
err!("You don't have permission to change the membership of a group that grants collection access")
}
}
group.save(conn).await?;
// Security: a `collections_groups.manage` grant carries collection delete authority, so it may only
// be set on a collection the caller could delete themselves — otherwise Edit-any-collection reaches
// Delete-any-collection through a manage-bearing group the caller then joins.
let caller = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await;
for col_selection in collections {
let mut collection_group = col_selection.to_collection_group(group.uuid.clone());
if collection_group.manage {
let may_grant_manage = match &caller {
Some(c) => caller_may_grant_collection_manage(c, &collection_group.collections_uuid, conn).await,
None => false,
};
collection_group.manage = may_grant_manage;
}
collection_group.save(&org_id, conn).await?;
}
// Security: assigning members to a group that grants collection access (via `access_all`
// or assigned collections) would indirectly grant those members access to the collections'
// contents. Only callers who can manage collections may change the membership of such a
// group; for others we leave the group's membership untouched.
let group_grants_collection_access =
group.access_all || !CollectionGroup::find_by_group(&group.uuid, &org_id, conn).await.is_empty();
if caller_can_manage_collections || !group_grants_collection_access {
GroupUser::delete_all_by_group(&group.uuid, &org_id, conn).await?;
for assigned_member in members {
let mut user_entry = GroupUser::new(group.uuid.clone(), assigned_member.clone());
user_entry.save(conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
}
}
Ok(Json(json!({
"id": group.uuid,
"organizationId": group.organizations_uuid,
"name": group.name,
"accessAll": group.access_all,
"externalId": group.external_id,
"object": "group"
})))
}
// Reads a single group's details (accessAll, externalId, collection mappings). This is the same data
// the `/groups/details` list endpoint returns, so it asks the same question — `can_read_group_details`.
// Any divergence would let a member read every group's details in bulk but be denied the single-group
// view of the same data, or the reverse.
#[get("/organizations/<org_id>/groups/<group_id>/details")]
async fn get_group_details(
org_id: OrganizationId,
group_id: GroupId,
headers: ManagerHeadersLoose,
conn: DbConn,
) -> JsonResult {
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if !can_read_group_details(&org_id, &headers.membership, &conn).await {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else {
err!("Group not found", "Group uuid is invalid or does not belong to the organization")
};
Ok(Json(group.to_json_details(&conn).await))
}
#[post("/organizations/<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 caller_can_manage_collections =
headers.membership_type >= MembershipType::Admin || headers.membership.has_full_access();
let group = authorize_group_deletion(group_id, org_id, caller_can_manage_collections, conn).await?;
delete_authorized_group(&group, org_id, headers, conn).await
}
async fn authorize_group_deletion(
group_id: &GroupId,
org_id: &OrganizationId,
caller_can_manage_collections: bool,
conn: &DbConn,
) -> Result<Group, crate::Error> {
let Some(group) = Group::find_by_uuid_and_org(group_id, org_id, conn).await else {
err!("Group not found", "Group uuid is invalid or does not belong to the organization")
};
// Security: deleting a group that grants collection access (via `access_all` or assigned
// collections) revokes that access for all its members. A custom user with only manage_groups
// must not be able to affect collection access, so only callers who can actually manage
// collections (Admins/Owners or users with full access) may delete such a group. Mirrors the
// restriction in put_group_members / post_delete_group_member.
let group_confers_collection_access =
group.access_all || !CollectionGroup::find_by_group(group_id, org_id, conn).await.is_empty();
if !may_delete_group(caller_can_manage_collections, group_confers_collection_access) {
err!("You don't have permission to delete a group that grants collection access")
}
Ok(group)
}
fn may_delete_group(caller_can_manage_collections: bool, group_confers_collection_access: bool) -> bool {
caller_can_manage_collections || !group_confers_collection_access
}
async fn delete_authorized_group(
group: &Group,
org_id: &OrganizationId,
headers: &ManageGroupsHeaders,
conn: &DbConn,
) -> EmptyResult {
log_event(
EventType::GroupDeleted as i32,
&group.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
group.delete(org_id, conn).await
}
#[delete("/organizations/<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();
// Authorize the complete request before the first event or deletion. In particular, a
// manageGroups-only caller may delete ordinary groups but not collection-bearing groups; a mixed
// batch must not delete an authorized prefix and then fail on a later item.
let caller_can_manage_collections =
headers.membership_type >= MembershipType::Admin || headers.membership.has_full_access();
let mut groups = Vec::with_capacity(data.ids.len());
let mut seen_group_ids = HashSet::with_capacity(data.ids.len());
for group_id in data.ids {
if !seen_group_ids.insert(group_id.clone()) {
err!("Duplicate group id in bulk delete request")
}
groups.push(authorize_group_deletion(&group_id, &org_id, caller_can_manage_collections, &conn).await?);
}
for group in &groups {
delete_authorized_group(group, &org_id, &headers, &conn).await?;
}
Ok(())
}
#[get("/organizations/<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();
// Security: adding a member to an `access_all` group hands out durable organization-wide access, so
// it is Admin/Owner authority even for a caller who can manage collections. Removals stay allowed.
if group.access_all && !may_grant_access_all_group(headers.membership_type) {
let current_members = GroupUser::find_by_group(&group_id, &org_id, &conn).await;
let current: HashSet<&MembershipId> = current_members.iter().map(|gu| &gu.users_organizations_uuid).collect();
let requested: HashSet<&MembershipId> = assigned_members.iter().collect();
if adds_group_member(&requested, &current) {
err!("Only Admins and Owners can add a member to a group with access to all collections")
}
}
let org_memberships = Membership::find_by_org(&org_id, &conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for assigned_member in assigned_members {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
user_entry.save(&conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
}
Ok(())
}
#[post("/organizations/<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.
// Members with full access to the organization (Admin/Owner, or a Custom member with
// 'Edit any collection') export the whole organization; everyone else exports only what they can
// actually reach, like Bitwarden's export controller does.
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
#[get("/organizations/<org_id>/export")]
async fn get_org_export(org_id: OrganizationId, headers: AccessImportExportHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
// Security (audit F1): 'Access Import/Export' decides *whether* a member may export, it must not
// widen *what* they may read. Without this scoping a Custom member holding only this permission
// — assigned to no collection at all — would receive every cipher in the organization, because
// the organization sync type deliberately skips the per-cipher access restrictions.
let (collections, ciphers) = if may_export_entire_organization(&headers.membership) {
(Collection::find_by_organization(&org_id, &conn).await, Cipher::find_by_org(&org_id, &conn).await)
} else {
(
Collection::find_by_organization_and_user_uuid(&org_id, &headers.user.uuid, &conn).await,
filter_ciphers_for_organization(Cipher::find_by_user_visible(&headers.user.uuid, &conn).await, &org_id),
)
};
let collections_json: Value = collections.iter().map(Collection::to_json).collect();
Ok(Json(json!({
"collections": convert_json_key_lcase_first(collections_json),
"ciphers": convert_json_key_lcase_first(ciphers_to_org_json(ciphers, &headers.host, &headers.user.uuid, &conn).await?),
})))
}
async fn api_key(
org_id: &OrganizationId,
data: Json<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, HashSet};
use serde_json::{Value, json};
use super::{
CollectionDetailsResponseScope, CustomRolePermissions, OrganizationImportTarget, OrganizationReportScope,
adds_group_member, caller_manage_grant_role_check, collection_bearing_membership_unchanged,
collection_details_response_scope, filter_ciphers_for_organization, may_change_group_membership,
may_change_member_type, may_delete_group, may_delete_stored_member_type, may_export_entire_organization,
may_grant_access_all_group, may_import_to_collection, may_manage_member_type, may_manage_stored_member_type,
may_provision_member_type, may_provision_stored_member_type, may_read_complete_collection_list,
may_revoke_stored_member_type, organization_report_scope,
};
use crate::db::models::{
Cipher, GroupId, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId,
};
fn confirmed_member(member_type: MembershipType) -> Membership {
let mut m = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
m.atype = member_type as i32;
m.status = MembershipStatus::Confirmed as i32;
m
}
/// Handing out `groups.access_all` is Admin/Owner authority: the grant keeps working after the
/// grantee's Custom permissions are cleared.
#[test]
fn only_admins_and_owners_may_hand_out_access_all_group_authority() {
assert!(may_grant_access_all_group(MembershipType::Owner));
assert!(may_grant_access_all_group(MembershipType::Admin));
assert!(!may_grant_access_all_group(MembershipType::Custom));
assert!(!may_grant_access_all_group(MembershipType::User));
}
/// No Custom permission opens this, and in particular not the two that come closest:
/// `editAnyCollection` satisfies `has_full_access()` (which gates every other group operation) and
/// `deleteAnyCollection` is accepted for a per-collection `manage` row.
#[test]
fn no_custom_permission_grants_access_all_group_authority() {
let mut edit_any = confirmed_member(MembershipType::Custom);
edit_any.edit_any_collection = true;
assert!(edit_any.has_full_access(), "the escalation starts from a member who has full access");
let mut delete_any = confirmed_member(MembershipType::Custom);
delete_any.delete_any_collection = true;
assert_eq!(
caller_manage_grant_role_check(&delete_any),
Some(true),
"delete-any may still confer a per-collection manage grant"
);
// Neither, nor any combination, is organization-wide group authority.
assert!(!may_grant_access_all_group(MembershipType::Custom));
}
/// The invite path's group gate is `caller_can_manage_collections`, which `editAnyCollection`
/// satisfies, so this caller clears every precondition checked before the access-all rule.
#[test]
fn inviting_into_an_access_all_group_is_admin_only() {
let mut inviter = confirmed_member(MembershipType::Custom);
inviter.manage_users = true;
inviter.manage_groups = true;
inviter.edit_any_collection = true;
// Everything `send_invite` checks before the access-all rule passes for this caller ...
assert!(inviter.has_manage_users(), "reaches send_invite at all");
assert!(inviter.has_manage_groups(), "satisfies caller_can_manage_groups");
assert!(inviter.has_full_access(), "satisfies caller_can_manage_collections");
assert!(
may_provision_member_type(MembershipType::Custom, MembershipType::User),
"and may invite the one role a Custom member can provision"
);
// ... and the organization-wide group is still out of reach.
assert!(!may_grant_access_all_group(MembershipType::Custom));
assert!(may_grant_access_all_group(MembershipType::Admin));
assert!(may_grant_access_all_group(MembershipType::Owner));
}
/// Only *adding* to an access-all group is restricted; removals and an unchanged set are allowed.
#[test]
fn only_additions_to_an_access_all_group_are_restricted() {
let a: MembershipId = "member-a".to_owned().into();
let b: MembershipId = "member-b".to_owned().into();
let c: MembershipId = "member-c".to_owned().into();
let current: HashSet<&MembershipId> = HashSet::from([&a, &b]);
// unchanged, and pure removals
assert!(!adds_group_member(&HashSet::from([&a, &b]), &current));
assert!(!adds_group_member(&HashSet::from([&a]), &current));
assert!(!adds_group_member(&HashSet::new(), &current));
// any new member, including alongside a removal
assert!(adds_group_member(&HashSet::from([&a, &b, &c]), &current));
assert!(adds_group_member(&HashSet::from([&c]), &current));
assert!(adds_group_member(&HashSet::from([&a, &c]), &current));
}
/// The collection-bearing rule still admits any caller who can manage collections.
#[test]
fn the_collection_bearing_group_rule_is_unchanged() {
assert!(may_change_group_membership(true, true));
assert!(!may_change_group_membership(false, true));
assert!(may_change_group_membership(false, false));
}
/// An unparsable stored role holds no authority but still has to be removable: an Owner may delete
/// or revoke it, nobody else may touch it, and no reactivating action opens up for anyone.
#[test]
fn an_owner_may_remove_a_membership_with_an_unknown_stored_role() {
// 3 is the retired Manager wire value, which this build never persists; the rest are values
// no Vaultwarden release writes at all.
for unknown in [3, 5, -1, i32::MAX, i32::MIN] {
// 0, 1, 2 and 4 are the only values `MembershipType::from_i32` accepts.
assert!(![0, 1, 2, 4].contains(&unknown), "{unknown} must not be a known role");
assert!(may_delete_stored_member_type(MembershipType::Owner, unknown), "{unknown}");
assert!(may_revoke_stored_member_type(MembershipType::Owner, unknown), "{unknown}");
for caller in [MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
assert!(!may_delete_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32);
assert!(!may_revoke_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32);
}
// Editing, confirming, restoring and reinviting all still refuse, for every caller.
for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
assert!(!may_manage_stored_member_type(caller, unknown), "caller={} target={unknown}", caller as i32);
assert!(
!may_provision_stored_member_type(caller, unknown),
"caller={} target={unknown}",
caller as i32
);
}
}
}
/// For a role this build knows, delete keeps the provisioning matrix and revoke the looser
/// management one.
#[test]
fn known_roles_keep_their_existing_delete_and_revoke_matrices() {
for caller in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
assert_eq!(
may_delete_stored_member_type(caller, target as i32),
may_provision_member_type(caller, target),
"delete: caller={} target={}",
caller as i32,
target as i32
);
assert_eq!(
may_revoke_stored_member_type(caller, target as i32),
may_manage_member_type(caller, target),
"revoke: caller={} target={}",
caller as i32,
target as i32
);
}
}
// The place the two matrices differ, kept intact: an Admin may revoke a peer Admin but may
// not delete one, while an Owner may do both.
assert!(may_revoke_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32));
assert!(!may_delete_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32));
assert!(may_revoke_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32));
assert!(may_delete_stored_member_type(MembershipType::Owner, MembershipType::Admin as i32));
}
#[test]
fn bulk_collection_details_only_include_acls_for_manage_authority() {
// Ordinary collection assignment, including group access_all, keeps the collection metadata
// visible but must never reveal user/group ACL mappings.
assert_eq!(collection_details_response_scope(false, false, false), CollectionDetailsResponseScope::Hidden);
assert_eq!(collection_details_response_scope(false, true, false), CollectionDetailsResponseScope::MetadataOnly);
assert_eq!(collection_details_response_scope(false, false, true), CollectionDetailsResponseScope::MetadataOnly);
// Admin/Owner, Edit-any/Delete-any, and explicit per-collection Manage all arrive here as
// `can_read_access_details = true`, matching CollectionReadHeaders on the single endpoint.
assert_eq!(
collection_details_response_scope(true, false, false),
CollectionDetailsResponseScope::AccessDetails
);
assert_eq!(collection_details_response_scope(true, true, true), CollectionDetailsResponseScope::AccessDetails);
}
#[test]
fn flagless_custom_uses_only_its_explicit_manage_collections_in_the_list() {
// `false` selects the route's per-collection explicit-Manage filtering path. Permissions that
// need metadata for every collection select the complete list instead.
assert!(!may_read_complete_collection_list(&confirmed_member(MembershipType::Custom)));
let mut manage_users = confirmed_member(MembershipType::Custom);
manage_users.manage_users = true;
assert!(may_read_complete_collection_list(&manage_users));
let mut create = confirmed_member(MembershipType::Custom);
create.create_new_collections = true;
assert!(may_read_complete_collection_list(&create));
assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Admin)));
assert!(may_read_complete_collection_list(&confirmed_member(MembershipType::Owner)));
}
#[test]
fn only_delete_capable_callers_may_grant_collection_manage() {
// Admin/Owner may always confer a per-collection `manage` (delete) grant.
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Owner)), Some(true));
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Admin)), Some(true));
// A Custom member with `delete_any_collection` may also always grant it.
let mut delete_any = confirmed_member(MembershipType::Custom);
delete_any.delete_any_collection = true;
assert_eq!(caller_manage_grant_role_check(&delete_any), Some(true));
// REGRESSION (F-1): a Custom member with ONLY `edit_any_collection` must NOT get a blanket
// yes. The role check returns None so the decision falls through to a real per-collection
// manage grant in the DB — which a self-assigned group/user manage row is prevented from
// manufacturing. This is what stops edit-any from escalating into delete-any.
let mut edit_any = confirmed_member(MembershipType::Custom);
edit_any.edit_any_collection = true;
assert_eq!(caller_manage_grant_role_check(&edit_any), None);
// A flagless Custom member (this is what a migrated legacy Manager becomes) also defers to
// the per-collection DB check.
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::Custom)), None);
// Plain User never qualifies.
assert_eq!(caller_manage_grant_role_check(&confirmed_member(MembershipType::User)), Some(false));
// An unconfirmed caller never qualifies, even with delete_any set.
let mut unconfirmed = confirmed_member(MembershipType::Custom);
unconfirmed.status = MembershipStatus::Accepted as i32;
unconfirmed.delete_any_collection = true;
assert_eq!(caller_manage_grant_role_check(&unconfirmed), Some(false));
}
#[test]
fn access_import_export_alone_does_not_widen_the_export() {
// REGRESSION (audit F1): 'Access Import/Export' opens the export endpoint, but a Custom
// member holding only that permission reaches no collection of their own, so the export
// must be built from their assignments — never from the whole organization.
let mut import_export_only = confirmed_member(MembershipType::Custom);
import_export_only.access_import_export = true;
assert!(!may_export_entire_organization(&import_export_only));
// Custom members who already reach every collection keep the full dump.
let mut edit_any = confirmed_member(MembershipType::Custom);
edit_any.edit_any_collection = true;
edit_any.access_import_export = true;
assert!(may_export_entire_organization(&edit_any));
// Admins and Owners are unaffected.
assert!(may_export_entire_organization(&confirmed_member(MembershipType::Admin)));
assert!(may_export_entire_organization(&confirmed_member(MembershipType::Owner)));
// An unconfirmed membership never qualifies, whatever its flags say.
let mut unconfirmed = confirmed_member(MembershipType::Custom);
unconfirmed.edit_any_collection = true;
unconfirmed.status = MembershipStatus::Accepted as i32;
assert!(!may_export_entire_organization(&unconfirmed));
}
#[test]
fn access_import_export_does_not_replace_import_collection_authority() {
let mut import_export = confirmed_member(MembershipType::Custom);
import_export.access_import_export = true;
assert!(!may_import_to_collection(
&import_export,
OrganizationImportTarget::Existing {
writable: false
}
));
assert!(!may_import_to_collection(&import_export, OrganizationImportTarget::New));
assert!(may_import_to_collection(
&import_export,
OrganizationImportTarget::Existing {
writable: true
}
));
let mut create = confirmed_member(MembershipType::Custom);
create.create_new_collections = true;
assert!(may_import_to_collection(&create, OrganizationImportTarget::New));
let mut edit_any = confirmed_member(MembershipType::Custom);
edit_any.edit_any_collection = true;
assert!(!may_import_to_collection(&edit_any, OrganizationImportTarget::New));
assert!(may_import_to_collection(
&confirmed_member(MembershipType::User),
OrganizationImportTarget::Existing {
writable: true
}
));
assert!(may_import_to_collection(
&confirmed_member(MembershipType::Admin),
OrganizationImportTarget::Existing {
writable: false
}
));
assert!(may_import_to_collection(&confirmed_member(MembershipType::Owner), OrganizationImportTarget::New));
import_export.status = MembershipStatus::Accepted as i32;
assert!(!may_import_to_collection(
&import_export,
OrganizationImportTarget::Existing {
writable: true
}
));
}
#[test]
fn access_reports_is_assignment_scoped_without_full_access() {
let mut reports = confirmed_member(MembershipType::Custom);
reports.access_reports = true;
assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Assigned);
assert_eq!(
organization_report_scope(&confirmed_member(MembershipType::Custom)),
OrganizationReportScope::Denied
);
assert_eq!(
organization_report_scope(&confirmed_member(MembershipType::Admin)),
OrganizationReportScope::Complete
);
assert_eq!(
organization_report_scope(&confirmed_member(MembershipType::Owner)),
OrganizationReportScope::Complete
);
reports.edit_any_collection = true;
assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Complete);
reports.edit_any_collection = false;
reports.status = MembershipStatus::Accepted as i32;
assert_eq!(organization_report_scope(&reports), OrganizationReportScope::Denied);
let mut stale_user = confirmed_member(MembershipType::User);
stale_user.access_reports = true;
assert_eq!(organization_report_scope(&stale_user), OrganizationReportScope::Denied);
}
#[test]
fn collection_bearing_group_deletion_requires_collection_authority() {
assert!(may_delete_group(false, false));
assert!(!may_delete_group(false, true));
assert!(may_delete_group(true, false));
assert!(may_delete_group(true, true));
}
#[test]
fn assigned_cipher_response_is_scoped_to_requested_organization() {
let requested_org: OrganizationId = "requested-org".to_owned().into();
let other_org: OrganizationId = "other-org".to_owned().into();
let mut requested_cipher = Cipher::new(1, "requested".to_owned());
requested_cipher.organization_uuid = Some(requested_org.clone());
let requested_cipher_id = requested_cipher.uuid.clone();
let mut other_cipher = Cipher::new(1, "other".to_owned());
other_cipher.organization_uuid = Some(other_org);
let personal_cipher = Cipher::new(1, "personal".to_owned());
let filtered =
filter_ciphers_for_organization(vec![other_cipher, personal_cipher, requested_cipher], &requested_org);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].uuid, requested_cipher_id);
assert_eq!(filtered[0].organization_uuid.as_ref(), Some(&requested_org));
}
#[test]
fn manage_users_caller_cannot_change_member_role() {
let user = MembershipType::User as i32;
let custom = MembershipType::Custom as i32;
// Admins and Owners may change a member's role.
assert!(may_change_member_type(MembershipType::Owner, user, MembershipType::Custom));
assert!(may_change_member_type(MembershipType::Admin, user, MembershipType::Custom));
// A below-Admin caller (Custom-with-manage_users) may only submit an unchanged role, so the
// regular edit dialog keeps working.
assert!(may_change_member_type(MembershipType::Custom, user, MembershipType::User));
assert!(may_change_member_type(MembershipType::Custom, custom, MembershipType::Custom));
// REGRESSION (privilege escalation, PR #7397 / finding F1): a caller below Admin must NOT
// be able to change a member's role. Promoting User -> Custom can activate explicit
// collection-Manage assignments and Custom-only authorization paths; demoting revokes
// them. A manage_users caller is not entitled to either authority change.
assert!(!may_change_member_type(MembershipType::Custom, user, MembershipType::Custom));
assert!(!may_change_member_type(MembershipType::Custom, custom, MembershipType::User));
}
#[test]
fn member_lifecycle_permissions_follow_the_role_hierarchy() {
let roles = [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User];
for target in roles {
assert!(may_manage_member_type(MembershipType::Owner, target));
}
assert!(!may_manage_member_type(MembershipType::Admin, MembershipType::Owner));
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin));
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Custom));
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::User));
assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Owner));
assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Admin));
assert!(!may_manage_member_type(MembershipType::Custom, MembershipType::Custom));
assert!(may_manage_member_type(MembershipType::Custom, MembershipType::User));
for target in roles {
assert!(!may_manage_member_type(MembershipType::User, target));
}
assert!(may_manage_stored_member_type(MembershipType::Admin, MembershipType::Custom as i32));
assert!(!may_manage_stored_member_type(MembershipType::Owner, i32::MAX));
// edit_member applies the same matrix as reinvite/confirm/revoke/restore/delete, so a
// Custom caller cannot target an Admin or a fellow Custom member even when the requested
// role equals the stored one.
for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom] {
assert!(may_change_member_type(MembershipType::Custom, target as i32, target));
assert!(!may_manage_stored_member_type(MembershipType::Custom, target as i32));
}
assert!(may_manage_stored_member_type(MembershipType::Custom, MembershipType::User as i32));
}
#[test]
fn only_owners_provision_admin_memberships() {
// REGRESSION: bringing an Admin (or Owner) membership into or out of existence stays
// Owner-only, exactly as before this feature ("Only Owners can invite Managers, Admins or
// Owners" / "Only Owners can delete Admins or Owners"). Otherwise an Admin could route around
// the Owner-only role-change guard in `edit_member` by inviting a fresh Admin instead.
for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
assert!(may_provision_member_type(MembershipType::Owner, target));
}
assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Owner));
assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Admin));
assert!(may_provision_member_type(MembershipType::Admin, MembershipType::Custom));
assert!(may_provision_member_type(MembershipType::Admin, MembershipType::User));
// A Custom member with manage_users stays limited to ordinary Users, as for every other
// lifecycle action.
assert!(may_provision_member_type(MembershipType::Custom, MembershipType::User));
for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom] {
assert!(!may_provision_member_type(MembershipType::Custom, target));
}
for target in [MembershipType::Owner, MembershipType::Admin, MembershipType::Custom, MembershipType::User] {
assert!(!may_provision_member_type(MembershipType::User, target));
}
// Provisioning is strictly narrower than the state-change matrix: an Admin may still revoke,
// restore or edit a peer Admin (which Vaultwarden allowed before), but no longer create,
// confirm or delete one.
assert!(may_manage_member_type(MembershipType::Admin, MembershipType::Admin));
assert!(!may_provision_member_type(MembershipType::Admin, MembershipType::Admin));
assert!(may_provision_stored_member_type(MembershipType::Admin, MembershipType::User as i32));
assert!(!may_provision_stored_member_type(MembershipType::Admin, MembershipType::Admin as i32));
// An unknown stored role never qualifies.
assert!(!may_provision_stored_member_type(MembershipType::Owner, i32::MAX));
}
#[test]
fn only_collection_bearing_group_changes_are_rejected() {
let plain: GroupId = "plain".to_owned().into();
let bearing: GroupId = "bearing".to_owned().into();
let collection_bearing = HashSet::from([bearing.clone()]);
let set = |ids: &[&GroupId]| -> HashSet<GroupId> { ids.iter().map(|id| (*id).clone()).collect() };
// Adding, removing or keeping a group without collections is fine.
for (requested, current) in
[(set(&[&plain]), set(&[])), (set(&[]), set(&[&plain])), (set(&[&plain, &bearing]), set(&[&bearing]))]
{
assert!(collection_bearing_membership_unchanged(&requested, &current, &collection_bearing));
}
// Adding or removing a collection-bearing group is not.
for (requested, current) in [(set(&[&bearing]), set(&[])), (set(&[&plain]), set(&[&plain, &bearing]))] {
assert!(!collection_bearing_membership_unchanged(&requested, &current, &collection_bearing));
}
}
#[test]
fn manage_groups_caller_cannot_grant_collection_access_via_groups() {
// A caller who can manage collections may change membership of any group.
assert!(may_change_group_membership(true, true));
assert!(may_change_group_membership(true, false));
// A caller who cannot manage collections may change membership of groups that confer no
// collection access (plain groups).
assert!(may_change_group_membership(false, false));
// REGRESSION (privilege escalation, PR #7397): a caller who cannot manage collections must
// NOT be able to change membership of a collection-bearing / access_all group. This is the
// vector that let a Custom user with manage_users + manage_groups add themselves to an
// access_all group and read all collection contents via edit_member / send_invite. Adding
// AND removing such memberships must be denied.
assert!(!may_change_group_membership(false, true));
}
#[test]
fn collection_permission_request_combinations_remain_independent() {
for mask in 0_u8..8 {
let create = mask & 0b001 != 0;
let edit = mask & 0b010 != 0;
let delete = mask & 0b100 != 0;
let permissions = HashMap::from([
("createNewCollections".to_owned(), json!(create)),
("editAnyCollection".to_owned(), json!(edit)),
("deleteAnyCollection".to_owned(), json!(delete)),
]);
let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions).unwrap();
assert_eq!(parsed.create_new_collections, create, "mask={mask:03b}");
assert_eq!(parsed.edit_any_collection, edit, "mask={mask:03b}");
assert_eq!(parsed.delete_any_collection, delete, "mask={mask:03b}");
// Only Edit any collection maps to all-collection access. Create/Delete must never do so.
assert_eq!(parsed.grants_full_collection_access(MembershipType::Custom), edit, "mask={mask:03b}");
}
}
const KNOWN_PERMISSION_KEYS: [&str; 9] = [
"manageUsers",
"manageGroups",
"managePolicies",
"createNewCollections",
"editAnyCollection",
"deleteAnyCollection",
"accessEventLogs",
"accessImportExport",
"accessReports",
];
#[test]
fn custom_permission_parser_accepts_only_booleans_and_non_custom_roles_are_fail_closed() {
let all_true: HashMap<String, Value> =
KNOWN_PERMISSION_KEYS.iter().map(|key| ((*key).to_owned(), json!(true))).collect();
let custom = CustomRolePermissions::from_request(MembershipType::Custom, &all_true).unwrap();
assert!(custom.manage_users);
assert!(custom.manage_groups);
assert!(custom.manage_policies);
assert!(custom.create_new_collections);
assert!(custom.edit_any_collection);
assert!(custom.delete_any_collection);
assert!(custom.access_event_logs);
assert!(custom.access_import_export);
assert!(custom.access_reports);
let user = CustomRolePermissions::from_request(MembershipType::User, &all_true).unwrap();
assert_eq!(user, CustomRolePermissions::default());
assert!(!user.grants_full_collection_access(MembershipType::User));
let admin = CustomRolePermissions::from_request(MembershipType::Admin, &all_true).unwrap();
assert_eq!(admin, CustomRolePermissions::default());
assert!(admin.grants_full_collection_access(MembershipType::Admin));
}
/// A known key carrying anything other than a JSON boolean is a malformed request. It used to be
/// read as `false`, which turned a client bug into a silent permission removal.
#[test]
fn a_known_permission_with_a_non_boolean_value_is_rejected() {
let bad_values = [
json!("true"),
json!("false"),
json!(""),
json!(1),
json!(0),
json!(1.5),
Value::Null,
json!({}),
json!([]),
json!(["manageUsers"]),
];
for key in KNOWN_PERMISSION_KEYS {
for value in &bad_values {
let permissions = HashMap::from([(key.to_owned(), value.clone())]);
for member_type in
[MembershipType::Custom, MembershipType::User, MembershipType::Admin, MembershipType::Owner]
{
assert!(
CustomRolePermissions::from_request(member_type, &permissions).is_err(),
"{key} = {value} must be rejected for {}",
member_type as i32
);
}
let membership = confirmed_member(MembershipType::Custom);
assert!(
CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&permissions), &membership)
.is_err(),
"{key} = {value} must be rejected on the edit path"
);
}
// ... while both booleans stay valid for the same key.
for value in [true, false] {
let permissions = HashMap::from([(key.to_owned(), json!(value))]);
let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions)
.expect("a boolean is always valid");
assert_eq!(parsed != CustomRolePermissions::default(), value, "{key} = {value}");
}
}
}
/// Bitwarden already sends permission keys Vaultwarden does not implement (`manageSso`,
/// `manageScim`, `manageResetPassword`) and may add more. Unknown keys stay ignored, whatever
/// they contain, so the strictness above cannot break a newer client.
#[test]
fn unknown_permission_keys_are_ignored_whatever_they_contain() {
let permissions = HashMap::from([
("manageUsers".to_owned(), json!(true)),
("manageSso".to_owned(), Value::String("yes".to_owned())),
("manageScim".to_owned(), Value::Null),
("manageResetPassword".to_owned(), json!(0)),
("someFuturePermission".to_owned(), json!({"nested": true})),
]);
let parsed = CustomRolePermissions::from_request(MembershipType::Custom, &permissions)
.expect("unknown keys must not make a request invalid");
assert!(parsed.manage_users);
assert!(!parsed.manage_groups);
assert!(!parsed.edit_any_collection);
}
#[test]
fn custom_permission_change_detection_covers_collection_flags() {
let mut membership = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None);
membership.atype = MembershipType::Custom as i32;
membership.status = MembershipStatus::Confirmed as i32;
let requested = CustomRolePermissions {
create_new_collections: true,
edit_any_collection: true,
delete_any_collection: true,
access_event_logs: true,
access_import_export: true,
access_reports: true,
..CustomRolePermissions::default()
};
assert!(requested.differs_from(&membership));
requested.apply_to(&mut membership);
assert!(!requested.differs_from(&membership));
assert!(membership.create_new_collections);
assert!(membership.edit_any_collection);
assert!(membership.delete_any_collection);
assert!(membership.access_event_logs);
assert!(membership.access_import_export);
assert!(membership.access_reports);
}
#[test]
fn omitted_edit_permissions_preserve_supported_custom_grants() {
let mut membership = confirmed_member(MembershipType::Custom);
membership.manage_users = true;
membership.manage_groups = true;
membership.manage_policies = true;
membership.create_new_collections = true;
membership.edit_any_collection = true;
membership.delete_any_collection = true;
membership.access_event_logs = true;
membership.access_import_export = true;
membership.access_reports = true;
let preserved = CustomRolePermissions::from_edit_request(MembershipType::Custom, None, &membership).unwrap();
assert!(preserved.manage_users);
assert!(preserved.manage_groups);
assert!(preserved.manage_policies);
assert!(preserved.create_new_collections);
assert!(preserved.edit_any_collection);
assert!(preserved.delete_any_collection);
assert!(preserved.access_event_logs);
assert!(preserved.access_import_export);
assert!(preserved.access_reports);
assert!(!preserved.differs_from(&membership));
let explicit_reset = HashMap::new();
assert_eq!(
CustomRolePermissions::from_edit_request(MembershipType::Custom, Some(&explicit_reset), &membership)
.unwrap(),
CustomRolePermissions::default()
);
assert_eq!(
CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap(),
CustomRolePermissions::default()
);
}
#[test]
fn stale_permission_bits_on_non_custom_members_are_not_authority_changes() {
let mut membership = confirmed_member(MembershipType::User);
membership.manage_users = true;
membership.manage_groups = true;
membership.manage_policies = true;
membership.create_new_collections = true;
membership.edit_any_collection = true;
membership.delete_any_collection = true;
membership.access_event_logs = true;
membership.access_import_export = true;
membership.access_reports = true;
let requested = CustomRolePermissions::from_edit_request(MembershipType::User, None, &membership).unwrap();
assert_eq!(requested, CustomRolePermissions::default());
assert!(!requested.differs_from(&membership));
// Applying the effective request opportunistically clears the inert historical data.
requested.apply_to(&mut membership);
assert!(!membership.manage_users);
assert!(!membership.manage_groups);
assert!(!membership.manage_policies);
assert!(!membership.create_new_collections);
assert!(!membership.edit_any_collection);
assert!(!membership.delete_any_collection);
assert!(!membership.access_event_logs);
assert!(!membership.access_import_export);
assert!(!membership.access_reports);
}
}