mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-23 09:48:09 +03:00
- Attachments: reject a declared v2 attachment size above the upload limit, keep display sizes within the largest unit, and make download links valid for 1 minute like upstream. - Ciphers: moving a personal cipher into an organization needs collection write access or full access, like upstream. - Events: /events/collect only accepts the client event types upstream accepts. - Client IP: for list headers like X-Forwarded-For, use the rightmost address that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence. - Prelogin: unknown emails get stable KDF settings from upstream's list. - Login with device: an auth request can be used for one login, like upstream. - Directory Connector import: never revokes, restores or removes Owners, like upstream. - SSO: check the PKCE verifier again when the code exchange is reused during 2FA, and accept the same redirect URIs as upstream (desktop localhost callback for AppImage and dev builds, CLI on ports 8065-8070). - Identity: send the merged master password policy in the 2FA response and use PascalCase policy keys in identity responses, like upstream. Organization API key login no longer needs device fields. Recovery code login normalises the code, uses upstream's error message, logs a failed 2FA event and sends a "two-step login recovered" email. - Add GET /api/accounts/keys. - Return errors instead of panicking on malformed event dates, cipher data, key rotation cipher ids, bulk confirm entries and YubiKey OTPs.
3089 lines
105 KiB
Rust
3089 lines
105 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
|
|
use num_traits::FromPrimitive;
|
|
use rocket::{Route, http::Status, serde::json::Json};
|
|
use serde_json::Value;
|
|
|
|
use crate::{
|
|
CONFIG,
|
|
api::admin::FAKE_ADMIN_UUID,
|
|
api::{
|
|
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
|
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
|
},
|
|
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
|
db::{
|
|
DbConn,
|
|
models::{
|
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
|
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
|
|
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
|
|
TwoFactorType, 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,
|
|
leave_organization,
|
|
get_user_collections,
|
|
get_org_collections,
|
|
get_org_collections_details,
|
|
get_org_collection_detail,
|
|
get_collection_users,
|
|
put_organization,
|
|
post_organization_collections,
|
|
post_bulk_access_collections,
|
|
put_organization_collection_update,
|
|
delete_organization_collection,
|
|
bulk_delete_organization_collections,
|
|
post_bulk_collections,
|
|
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,
|
|
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,
|
|
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,
|
|
get_group_details,
|
|
delete_group,
|
|
bulk_delete_groups,
|
|
get_group_members,
|
|
put_reset_password_enrollment,
|
|
get_reset_password_details,
|
|
put_recover_account,
|
|
get_org_export,
|
|
post_api_key,
|
|
rotate_api_key,
|
|
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>,
|
|
external_id: Option<String>,
|
|
}
|
|
|
|
impl FullCollectionData {
|
|
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
|
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
|
|
}
|
|
|
|
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CollectionGroupData {
|
|
hide_passwords: bool,
|
|
id: GroupId,
|
|
read_only: bool,
|
|
manage: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CollectionMembershipData {
|
|
hide_passwords: bool,
|
|
id: MembershipId,
|
|
read_only: bool,
|
|
manage: bool,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct OrgKeyData {
|
|
encrypted_private_key: String,
|
|
public_key: String,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BulkGroupIds {
|
|
ids: Vec<GroupId>,
|
|
}
|
|
|
|
#[derive(Deserialize, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BulkMembershipIds {
|
|
ids: Vec<MembershipId>,
|
|
}
|
|
|
|
#[post("/organizations", data = "<data>")]
|
|
async fn create_organization(headers: Headers, data: Json<OrgData>, conn: DbConn) -> JsonResult {
|
|
if !CONFIG.is_org_creation_allowed(&headers.user.email) {
|
|
err!("User not allowed to create organizations")
|
|
}
|
|
if OrgPolicy::is_applicable_to_user(&headers.user.uuid, OrgPolicyType::SingleOrg, None, &conn).await {
|
|
err!(
|
|
"You may not create an organization. You belong to an organization which has a policy that prohibits you from being a member of any other organization."
|
|
)
|
|
}
|
|
|
|
let data: OrgData = data.into_inner();
|
|
let (private_key, public_key) = if let Some(keys) = data.keys {
|
|
(Some(keys.encrypted_private_key), Some(keys.public_key))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let org = Organization::new(data.name, &data.billing_email, private_key, public_key);
|
|
let mut member = Membership::new(headers.user.uuid, org.uuid.clone(), None);
|
|
let collection = Collection::new(org.uuid.clone(), data.collection_name, None);
|
|
|
|
member.akey = data.key;
|
|
member.access_all = true;
|
|
member.atype = MembershipType::Owner as i32;
|
|
member.status = MembershipStatus::Confirmed as i32;
|
|
|
|
org.save(&conn).await?;
|
|
member.save(&conn).await?;
|
|
collection.save(&conn).await?;
|
|
|
|
Ok(Json(org.to_json()))
|
|
}
|
|
|
|
#[delete("/organizations/<org_id>", data = "<data>")]
|
|
async fn delete_organization(
|
|
org_id: OrganizationId,
|
|
data: Json<PasswordOrOtpData>,
|
|
headers: OwnerHeaders,
|
|
conn: DbConn,
|
|
) -> EmptyResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let data: PasswordOrOtpData = data.into_inner();
|
|
|
|
data.validate(&headers.user, true, &conn).await?;
|
|
|
|
match Organization::find_by_uuid(&org_id, &conn).await {
|
|
None => err!("Organization not found"),
|
|
Some(org) => org.delete(&conn).await,
|
|
}
|
|
}
|
|
|
|
#[post("/organizations/<org_id>/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,
|
|
&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 {
|
|
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,
|
|
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");
|
|
}
|
|
|
|
if !headers.membership.has_full_access() {
|
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"data": get_org_collections_impl(&org_id, &conn).await,
|
|
"object": "list",
|
|
"continuationToken": null,
|
|
})))
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/collections/details")]
|
|
async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
|
|
if org_id != headers.membership.org_uuid {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
|
|
let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else {
|
|
err!("User is not part of organization")
|
|
};
|
|
|
|
// get all collection memberships for the current organization
|
|
let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await;
|
|
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
|
|
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
|
|
// This lists other members for admins, so it must not depend on the membership status
|
|
let membership_type: HashMap<MembershipId, i32> =
|
|
Membership::find_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);
|
|
|
|
// Get all admins, owners and managers who can manage/access all
|
|
// Those are currently not listed in the col_users but need to be listed too.
|
|
let manage_all_members: Vec<Value> = Membership::find_confirmed_and_manage_all_by_org(&org_id, &conn)
|
|
.await
|
|
.into_iter()
|
|
.map(|member| {
|
|
json!({
|
|
"id": member.uuid,
|
|
"readOnly": false,
|
|
"hidePasswords": false,
|
|
"manage": true,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let mut data = Vec::new();
|
|
for col in Collection::find_by_organization(&org_id, &conn).await {
|
|
// check whether the current user has access to the given collection
|
|
let assigned = has_full_access_to_org
|
|
|| CollectionUser::has_access_to_collection_by_user(&col.uuid, &member.user_uuid, &conn).await
|
|
|| (CONFIG.org_groups_enabled()
|
|
&& GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await);
|
|
|
|
// If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection
|
|
if !assigned {
|
|
continue;
|
|
}
|
|
|
|
// get the users assigned directly to the given collection
|
|
let mut users: Vec<Value> = col_users
|
|
.iter()
|
|
.filter(|collection_member| collection_member.collection_uuid == col.uuid)
|
|
.map(|collection_member| {
|
|
collection_member.to_json_details_for_member(
|
|
*membership_type.get(&collection_member.membership_uuid).unwrap_or(&(MembershipType::User as i32)),
|
|
)
|
|
})
|
|
.collect();
|
|
users.extend_from_slice(&manage_all_members);
|
|
|
|
// get the group details for the given collection
|
|
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
|
|
CollectionGroup::find_by_collection(&col.uuid, &conn)
|
|
.await
|
|
.iter()
|
|
.map(CollectionGroup::to_json_details_for_group)
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
|
|
json_object["assigned"] = json!(assigned);
|
|
json_object["users"] = json!(users);
|
|
json_object["groups"] = json!(groups);
|
|
json_object["object"] = json!("collectionAccessDetails");
|
|
json_object["unmanaged"] = json!(false);
|
|
data.push(json_object);
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"data": data,
|
|
"object": "list",
|
|
"continuationToken": null,
|
|
})))
|
|
}
|
|
|
|
async fn get_org_collections_impl(org_id: &OrganizationId, conn: &DbConn) -> Value {
|
|
Collection::find_by_organization(org_id, conn).await.iter().map(Collection::to_json).collect::<Value>()
|
|
}
|
|
|
|
#[post("/organizations/<org_id>/collections", data = "<data>")]
|
|
async fn post_organization_collections(
|
|
org_id: OrganizationId,
|
|
headers: ManagerHeadersLoose,
|
|
data: Json<FullCollectionData>,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.membership.org_uuid {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let data: FullCollectionData = data.into_inner();
|
|
data.validate(&org_id, &conn).await?;
|
|
|
|
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
|
|
err!("You don't have permission to create collections")
|
|
}
|
|
|
|
let collection = Collection::new(org_id.clone(), data.name, data.external_id);
|
|
collection.save(&conn).await?;
|
|
|
|
log_event(
|
|
EventType::CollectionCreated,
|
|
&collection.uuid,
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
|
|
for group in data.groups {
|
|
CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
|
|
.save(&org_id, &conn)
|
|
.await?;
|
|
}
|
|
|
|
for user in data.users {
|
|
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
|
|
err!("User is not part of organization")
|
|
};
|
|
|
|
if member.access_all {
|
|
continue;
|
|
}
|
|
|
|
CollectionUser::save(
|
|
&member.user_uuid,
|
|
&collection.uuid,
|
|
user.read_only,
|
|
user.hide_passwords,
|
|
user.manage,
|
|
&conn,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BulkCollectionAccessData {
|
|
collection_ids: Vec<CollectionId>,
|
|
groups: Vec<CollectionGroupData>,
|
|
users: Vec<CollectionMembershipData>,
|
|
}
|
|
|
|
#[post("/organizations/<org_id>/collections/bulk-access", data = "<data>", rank = 1)]
|
|
async fn post_bulk_access_collections(
|
|
org_id: OrganizationId,
|
|
headers: ManagerHeadersLoose,
|
|
data: Json<BulkCollectionAccessData>,
|
|
conn: DbConn,
|
|
) -> EmptyResult {
|
|
if org_id != headers.membership.org_uuid {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let data: BulkCollectionAccessData = data.into_inner();
|
|
|
|
if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
|
|
err!("Can't find organization details")
|
|
}
|
|
|
|
// The collections and members are checked below, the groups only here.
|
|
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 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 !collection.is_manageable_by_user(&headers.membership.user_uuid, &conn).await {
|
|
err!("Collection not found", "The current user isn't a manager for this collection")
|
|
}
|
|
|
|
// update collection modification date
|
|
collection.save(&conn).await?;
|
|
|
|
log_event(
|
|
EventType::CollectionUpdated,
|
|
&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)
|
|
.save(&org_id, &conn)
|
|
.await?;
|
|
}
|
|
|
|
CollectionUser::delete_all_by_collection(&col_id, &conn).await?;
|
|
for user in &data.users {
|
|
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
|
|
err!("User is not part of organization")
|
|
};
|
|
|
|
if member.access_all {
|
|
continue;
|
|
}
|
|
|
|
CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &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 {
|
|
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,
|
|
&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, group.read_only, group.hide_passwords, group.manage)
|
|
.save(&org_id, &conn)
|
|
.await?;
|
|
}
|
|
|
|
CollectionUser::delete_all_by_collection(&col_id, &conn).await?;
|
|
|
|
for user in data.users {
|
|
let Some(member) = Membership::find_by_uuid_and_org(&user.id, &org_id, &conn).await else {
|
|
err!("User is not part of organization")
|
|
};
|
|
|
|
if member.access_all {
|
|
continue;
|
|
}
|
|
|
|
CollectionUser::save(&member.user_uuid, &col_id, user.read_only, user.hide_passwords, user.manage, &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: &ManagerHeaders,
|
|
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,
|
|
&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: ManagerHeaders,
|
|
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 = ManagerHeaders::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: ManagerHeaders,
|
|
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_user(&col_id, headers.user.uuid.clone(), &conn).await {
|
|
None => err!("Collection not found"),
|
|
Some(collection) => {
|
|
if collection.org_uuid != org_id {
|
|
err!("Collection is not owned by organization")
|
|
}
|
|
|
|
let Some(member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else {
|
|
err!("User is not part of 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
|
|
// This lists other members for admins, so it must not depend on the membership status
|
|
let membership_type: HashMap<MembershipId, i32> =
|
|
Membership::find_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(&member, &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: ManagerHeaders,
|
|
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,
|
|
}
|
|
|
|
#[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", Status::NotFound.code);
|
|
}
|
|
|
|
if !headers.membership.has_full_access() {
|
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"data": get_org_details_impl(&data.organization_id, &headers.host, &headers.user.uuid, &conn).await?,
|
|
"object": "list",
|
|
"continuationToken": null,
|
|
})))
|
|
}
|
|
|
|
async fn get_org_details_impl(
|
|
org_id: &OrganizationId,
|
|
host: &str,
|
|
user_id: &UserId,
|
|
conn: &DbConn,
|
|
) -> Result<Value, crate::Error> {
|
|
let ciphers = Cipher::find_by_org(org_id, conn).await;
|
|
let cipher_sync_data = CipherSyncData::new(user_id, CipherSyncType::Organization, conn).await;
|
|
|
|
let mut ciphers_json = Vec::with_capacity(ciphers.len());
|
|
for c in ciphers {
|
|
ciphers_json.push(c.to_json(host, user_id, Some(&cipher_sync_data), CipherSyncType::Organization, conn).await?);
|
|
}
|
|
Ok(json!(ciphers_json))
|
|
}
|
|
|
|
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
|
// So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
|
|
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
|
|
#[post("/organizations/domain/sso/verified")]
|
|
fn get_org_domain_sso_verified() -> JsonResult {
|
|
// Always return a dummy value, no matter if SSO is enabled or not
|
|
Ok(Json(json!({
|
|
"object": "list",
|
|
"data": [{
|
|
"organizationIdentifier": FAKE_SSO_IDENTIFIER,
|
|
// These appear to be unused
|
|
"organizationName": FAKE_SSO_IDENTIFIER,
|
|
"domainName": CONFIG.domain()
|
|
}],
|
|
"continuationToken": null
|
|
})))
|
|
}
|
|
|
|
#[derive(FromForm)]
|
|
struct GetOrgUserData {
|
|
#[field(name = "includeCollections")]
|
|
include_collections: Option<bool>,
|
|
#[field(name = "includeGroups")]
|
|
include_groups: Option<bool>,
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/users?<data..>")]
|
|
async fn get_members(
|
|
data: GetOrgUserData,
|
|
org_id: OrganizationId,
|
|
headers: ManagerHeadersLoose,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.membership.org_uuid {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
|
|
if !headers.membership.has_full_access() {
|
|
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
|
|
}
|
|
|
|
let mut users_json = Vec::new();
|
|
for u in Membership::find_by_org(&org_id, &conn).await {
|
|
// The user can be a manager instead of an admin, but we've checked above that they have full access
|
|
users_json.push(
|
|
u.to_json_details_for_admin(
|
|
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(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: AdminHeaders,
|
|
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?;
|
|
|
|
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
|
// The from_str() will convert the custom role type into a manager role type
|
|
let raw_type = &data.r#type.into_string();
|
|
// Membership::from_str will convert custom (4) to manager (3)
|
|
let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) {
|
|
new_type as i32
|
|
} else {
|
|
err!("Invalid type")
|
|
};
|
|
|
|
if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
|
err!("Only Owners can invite Managers, Admins or Owners")
|
|
}
|
|
|
|
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
|
|
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
|
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
|
let access_all = new_type >= MembershipType::Admin
|
|
|| (raw_type.eq("4")
|
|
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
|
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
|
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
|
|
|
let mut user_created: bool;
|
|
for email in &data.emails {
|
|
let mut member_status = MembershipStatus::Invited as i32;
|
|
let user = match User::find_by_mail(email, &conn).await {
|
|
None => {
|
|
if !CONFIG.invitations_allowed() {
|
|
err!(format!("User does not exist: {}", email.escape_debug()))
|
|
}
|
|
|
|
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.escape_debug()))
|
|
}
|
|
|
|
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_created = false;
|
|
user
|
|
}
|
|
};
|
|
|
|
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone()));
|
|
new_member.access_all = access_all;
|
|
new_member.atype = new_type;
|
|
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,
|
|
&new_member.uuid,
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
|
|
// If no accessAll, add the collections received
|
|
if !access_all {
|
|
for col in data.collections.iter().flatten() {
|
|
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await {
|
|
None => err!("Collection not found in Organization"),
|
|
Some(collection) => {
|
|
CollectionUser::save(
|
|
&user.uuid,
|
|
&collection.uuid,
|
|
col.read_only,
|
|
col.hide_passwords,
|
|
col.manage,
|
|
&conn,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for group_id in &data.groups {
|
|
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
|
|
err!("Group not found in Organization")
|
|
}
|
|
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: AdminHeaders,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let data: BulkMembershipIds = data.into_inner();
|
|
|
|
let mut bulk_response = Vec::new();
|
|
for member_id in data.ids {
|
|
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
|
Ok(()) => String::new(),
|
|
Err(e) => format!("{e:?}"),
|
|
};
|
|
|
|
bulk_response.push(json!(
|
|
{
|
|
"object": "OrganizationBulkConfirmResponseModel",
|
|
"id": member_id,
|
|
"error": err_msg
|
|
}
|
|
));
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"data": bulk_response,
|
|
"object": "list",
|
|
"continuationToken": null
|
|
})))
|
|
}
|
|
|
|
#[post("/organizations/<org_id>/users/<member_id>/reinvite")]
|
|
async fn reinvite_member(
|
|
org_id: OrganizationId,
|
|
member_id: MembershipId,
|
|
headers: AdminHeaders,
|
|
conn: DbConn,
|
|
) -> EmptyResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await
|
|
}
|
|
|
|
async fn reinvite_member_impl(
|
|
org_id: &OrganizationId,
|
|
member_id: &MembershipId,
|
|
invited_by_email: &str,
|
|
conn: &DbConn,
|
|
) -> EmptyResult {
|
|
let Some(member) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
|
|
err!("The user hasn't been invited to the organization.")
|
|
};
|
|
|
|
if member.status != MembershipStatus::Invited as i32 {
|
|
err!("The user is already accepted or confirmed to the organization")
|
|
}
|
|
|
|
let Some(user) = User::find_by_uuid(&member.user_uuid, conn).await else {
|
|
err!("User not found.")
|
|
};
|
|
|
|
if !CONFIG.invitations_allowed() && user.password_hash.is_empty() {
|
|
err!("Invitations are not allowed.")
|
|
}
|
|
|
|
let org_name = if let Some(org) = Organization::find_by_uuid(org_id, conn).await {
|
|
org.name
|
|
} else {
|
|
err!("Error looking up organization.")
|
|
};
|
|
|
|
if CONFIG.mail_enabled() {
|
|
mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(invited_by_email.to_owned())).await?;
|
|
} else if user.password_hash.is_empty() {
|
|
let invitation = Invitation::new(&user.email);
|
|
invitation.save(conn).await?;
|
|
} else {
|
|
Invitation::take(&user.email, conn).await;
|
|
let mut member = member;
|
|
member.status = MembershipStatus::Accepted as i32;
|
|
member.save(conn).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct AcceptData {
|
|
token: String,
|
|
reset_password_key: Option<String>,
|
|
}
|
|
|
|
#[post("/organizations/<org_id>/users/<member_id>/accept", data = "<data>")]
|
|
async fn accept_invite(
|
|
org_id: OrganizationId,
|
|
member_id: MembershipId,
|
|
data: Json<AcceptData>,
|
|
headers: Headers,
|
|
conn: DbConn,
|
|
) -> EmptyResult {
|
|
// The web-vault passes org_id and member_id in the URL, but we are just reading them from the JWT instead
|
|
let data: AcceptData = data.into_inner();
|
|
let claims = decode_invite(&data.token)?;
|
|
|
|
// Don't allow other users from accepting an invitation.
|
|
if !claims.email.eq(&headers.user.email) {
|
|
err!("Invitation was issued to a different account", "Claim does not match user_id")
|
|
}
|
|
|
|
// If a claim org_id does not match the one in from the URI, something is wrong.
|
|
if !claims.org_id.eq(&org_id) {
|
|
err!("Error accepting the invitation", "Claim does not match the org_id")
|
|
}
|
|
|
|
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
|
|
if !claims.member_id.eq(&member_id) {
|
|
err!("Error accepting the invitation", "Claim does not match the member_id")
|
|
}
|
|
|
|
let member_id = &claims.member_id;
|
|
Invitation::take(&claims.email, &conn).await;
|
|
|
|
// skip invitation logic when we were invited via the /admin panel
|
|
if **member_id != FAKE_ADMIN_UUID {
|
|
let Some(mut membership) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else {
|
|
err!("Error accepting the invitation")
|
|
};
|
|
|
|
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&membership.org_uuid, &conn).await {
|
|
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
|
|
true => data.reset_password_key,
|
|
false => None,
|
|
};
|
|
|
|
// In case the user was invited before the mail was saved in db.
|
|
membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email);
|
|
|
|
accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?;
|
|
} else if CONFIG.mail_enabled() {
|
|
// User was invited from /admin, so they are automatically confirmed
|
|
let org_name = CONFIG.invitation_org_name();
|
|
mail::send_invite_confirmed(&claims.email, &org_name).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ConfirmData {
|
|
id: Option<MembershipId>,
|
|
key: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct BulkConfirmData {
|
|
keys: Option<Vec<ConfirmData>>,
|
|
}
|
|
|
|
#[post("/organizations/<org_id>/users/confirm", data = "<data>")]
|
|
async fn bulk_confirm_invite(
|
|
org_id: OrganizationId,
|
|
data: Json<BulkConfirmData>,
|
|
headers: AdminHeaders,
|
|
conn: DbConn,
|
|
nt: Notify<'_>,
|
|
) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let data = data.into_inner();
|
|
|
|
let mut bulk_response = Vec::new();
|
|
match data.keys {
|
|
Some(keys) => {
|
|
for invite in keys {
|
|
let Some(member_id) = invite.id else {
|
|
err!("Member id is required")
|
|
};
|
|
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: AdminHeaders,
|
|
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: &AdminHeaders,
|
|
conn: &DbConn,
|
|
nt: &Notify<'_>,
|
|
) -> EmptyResult {
|
|
if org_id != &headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
if key.is_empty() || member_id.is_empty() {
|
|
err!("Key or UserId is not set, unable to process request");
|
|
}
|
|
|
|
let Some(mut member_to_confirm) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
|
|
err!("The specified user isn't a member of the organization")
|
|
};
|
|
|
|
if member_to_confirm.atype != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
|
err!("Only Owners can confirm Managers, Admins or Owners")
|
|
}
|
|
|
|
if member_to_confirm.status != MembershipStatus::Accepted as i32 {
|
|
err!("User in invalid state")
|
|
}
|
|
|
|
member_to_confirm.status = MembershipStatus::Confirmed as i32;
|
|
member_to_confirm.akey = key.to_owned();
|
|
|
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
|
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
|
|
|
|
log_event(
|
|
EventType::OrganizationUserConfirmed,
|
|
&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: AdminHeaders,
|
|
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_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await,
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct EditUserData {
|
|
r#type: NumberOrString,
|
|
collections: Option<Vec<CollectionData>>,
|
|
groups: Option<Vec<GroupId>>,
|
|
#[serde(default)]
|
|
permissions: HashMap<String, Value>,
|
|
}
|
|
|
|
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
|
|
async fn edit_member(
|
|
org_id: OrganizationId,
|
|
member_id: MembershipId,
|
|
data: Json<EditUserData>,
|
|
headers: AdminHeaders,
|
|
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();
|
|
|
|
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
|
|
// The from_str() will convert the custom role type into a manager role type
|
|
let raw_type = &data.r#type.into_string();
|
|
// MembershipType::from_str will convert custom (4) to manager (3)
|
|
let Some(new_type) = MembershipType::from_str(raw_type) else {
|
|
err!("Invalid type")
|
|
};
|
|
|
|
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
|
|
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
|
|
// If the box is not checked, the user will still be a manager, but not with the access_all permission
|
|
let access_all = new_type >= MembershipType::Admin
|
|
|| (raw_type.eq("4")
|
|
&& data.permissions.get("editAnyCollection") == Some(&json!(true))
|
|
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
|
|
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
|
|
|
|
let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
|
|
err!("The specified user isn't member of the organization")
|
|
};
|
|
|
|
if new_type != member_to_edit.atype
|
|
&& (member_to_edit.atype >= MembershipType::Admin || new_type >= MembershipType::Admin)
|
|
&& headers.membership_type != MembershipType::Owner
|
|
{
|
|
err!("Only Owners can grant and remove Admin or Owner privileges")
|
|
}
|
|
|
|
if member_to_edit.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
|
|
err!("Only Owners can edit Owner users")
|
|
}
|
|
|
|
if member_to_edit.atype == MembershipType::Owner
|
|
&& new_type != MembershipType::Owner
|
|
&& member_to_edit.status == MembershipStatus::Confirmed as i32
|
|
{
|
|
// Removing owner permission, check that there is at least one other confirmed owner
|
|
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
|
|
err!("Can't delete the last owner")
|
|
}
|
|
}
|
|
|
|
member_to_edit.access_all = access_all;
|
|
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?;
|
|
|
|
// Delete all the odd collections
|
|
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &member_to_edit.user_uuid, &conn).await {
|
|
c.delete(&conn).await?;
|
|
}
|
|
|
|
// If no accessAll, add the collections received
|
|
if !access_all {
|
|
for col in data.collections.iter().flatten() {
|
|
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn).await {
|
|
None => err!("Collection not found in Organization"),
|
|
Some(collection) => {
|
|
CollectionUser::save(
|
|
&member_to_edit.user_uuid,
|
|
&collection.uuid,
|
|
col.read_only,
|
|
col.hide_passwords,
|
|
col.manage,
|
|
&conn,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
|
|
|
|
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 Organization")
|
|
}
|
|
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
|
|
group_entry.save(&conn).await?;
|
|
}
|
|
|
|
log_event(
|
|
EventType::OrganizationUserUpdated,
|
|
&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: AdminHeaders,
|
|
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: AdminHeaders,
|
|
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: &AdminHeaders,
|
|
conn: &DbConn,
|
|
nt: &Notify<'_>,
|
|
) -> EmptyResult {
|
|
if org_id != &headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let Some(member_to_delete) = Membership::find_by_uuid_and_org(member_id, org_id, conn).await else {
|
|
err!("User to delete isn't member of the organization")
|
|
};
|
|
|
|
if member_to_delete.atype != MembershipType::User && headers.membership_type != MembershipType::Owner {
|
|
err!("Only Owners can delete Admins or Owners")
|
|
}
|
|
|
|
if member_to_delete.atype == MembershipType::Owner && member_to_delete.status == MembershipStatus::Confirmed as i32
|
|
{
|
|
// Removing owner, check that there is at least one other confirmed owner
|
|
if Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1 {
|
|
err!("Can't delete the last owner")
|
|
}
|
|
}
|
|
|
|
log_event(
|
|
EventType::OrganizationUserRemoved,
|
|
&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: AdminHeaders,
|
|
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;
|
|
|
|
// The import endpoint only ever uses the name/id/external_id of a collection.
|
|
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
|
|
// mandatory: clients are free to leave them out.
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ImportCollectionData {
|
|
name: String,
|
|
id: Option<CollectionId>,
|
|
external_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ImportData {
|
|
ciphers: Vec<CipherData>,
|
|
collections: Vec<ImportCollectionData>,
|
|
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");
|
|
}
|
|
// OrgMemberHeaders also allows invited and accepted members, which are not allowed to import
|
|
if headers.membership.status != MembershipStatus::Confirmed as i32 {
|
|
err!("You need to be a Member of the Organization to call this endpoint")
|
|
}
|
|
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)?;
|
|
|
|
let existing_collections: HashMap<CollectionId, Collection> =
|
|
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
|
|
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
|
|
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 {
|
|
// When not an Owner or Admin, check if the member is allowed to write to the collection.
|
|
if headers.membership.atype < MembershipType::Admin
|
|
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
|
|
{
|
|
err!(Compact, "The current user isn't allowed to manage this collection")
|
|
}
|
|
collection.uuid.clone()
|
|
} else {
|
|
// We do not allow users or managers which can not manage all collections to create new collections
|
|
// If there is any collection other than an existing import collection, abort the import.
|
|
if headers.membership.atype <= MembershipType::Manager && !headers.membership.has_full_access() {
|
|
err!(Compact, "The current user isn't allowed to create new collections")
|
|
}
|
|
let new_collection = Collection::new(org_id.clone(), col.name, col.external_id);
|
|
new_collection.save(&conn).await?;
|
|
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
|
|
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: HashSet<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: AdminHeaders, conn: DbConn) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
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
|
|
})))
|
|
}
|
|
|
|
#[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: AdminHeaders, 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: AdminHeaders,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
let data: PolicyData = data.into_inner().policy;
|
|
|
|
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
|
|
err!("Invalid or unsupported policy type")
|
|
};
|
|
|
|
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
|
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
|
// Now that groups are available we can enforce this option when wanted.
|
|
// We put this behind a config option to prevent breaking current installation.
|
|
// Maybe we want to enable this by default in the future, but currently it is disabled by default.
|
|
if CONFIG.enforce_single_org_with_reset_pw_policy() {
|
|
if pol_type_enum == OrgPolicyType::ResetPassword && data.enabled {
|
|
let single_org_policy_enabled =
|
|
match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await {
|
|
Some(p) => p.enabled,
|
|
None => false,
|
|
};
|
|
|
|
if !single_org_policy_enabled {
|
|
err!("Single Organization policy is not enabled. It is mandatory for this policy to be enabled.")
|
|
}
|
|
}
|
|
|
|
// Also prevent the Single Org Policy to be disabled if the Reset Password policy is enabled
|
|
if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled {
|
|
let reset_pw_policy_enabled =
|
|
match OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::ResetPassword, &conn).await {
|
|
Some(p) => p.enabled,
|
|
None => false,
|
|
};
|
|
|
|
if reset_pw_policy_enabled {
|
|
err!("Account recovery policy is enabled. It is not allowed to disable this policy.")
|
|
}
|
|
}
|
|
}
|
|
|
|
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
|
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
|
two_factor::enforce_2fa_policy_for_org(
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// When enabling the SingleOrg policy, remove this org's members that are members of other orgs
|
|
if pol_type_enum == OrgPolicyType::SingleOrg && data.enabled {
|
|
for mut member in Membership::find_by_org(&org_id, &conn).await {
|
|
// Policy only applies to non-Owner/non-Admin members who have accepted joining the org
|
|
// Exclude invited and revoked users when checking for this policy.
|
|
// Those users will not be allowed to accept or be activated because of the policy checks done there.
|
|
if member.atype < MembershipType::Admin
|
|
&& member.status != MembershipStatus::Invited as i32
|
|
&& Membership::count_accepted_and_confirmed_by_user(&member.user_uuid, &member.org_uuid, &conn).await
|
|
> 0
|
|
{
|
|
if CONFIG.mail_enabled() {
|
|
let org = Organization::find_by_uuid(&member.org_uuid, &conn).await.unwrap();
|
|
let user = User::find_by_uuid(&member.user_uuid, &conn).await.unwrap();
|
|
|
|
mail::send_single_org_removed_from_org(&user.email, &org.name).await?;
|
|
}
|
|
|
|
log_event(
|
|
EventType::OrganizationUserRemoved,
|
|
&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,
|
|
policy.uuid.as_ref(),
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
|
|
Ok(Json(policy.to_json()))
|
|
}
|
|
|
|
#[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/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
|
|
}))
|
|
}
|
|
|
|
#[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: AdminHeaders,
|
|
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: AdminHeaders,
|
|
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: &AdminHeaders,
|
|
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 member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
|
|
err!("Only owners can revoke other owners")
|
|
}
|
|
if member.atype == MembershipType::Owner
|
|
&& Membership::count_confirmed_by_org_and_type(org_id, MembershipType::Owner, conn).await <= 1
|
|
{
|
|
err!("Organization must have at least one confirmed owner")
|
|
}
|
|
|
|
member.revoke();
|
|
member.save(conn).await?;
|
|
|
|
log_event(
|
|
EventType::OrganizationUserRevoked,
|
|
&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: AdminHeaders,
|
|
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: AdminHeaders,
|
|
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: AdminHeaders,
|
|
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: &AdminHeaders,
|
|
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 member.atype == MembershipType::Owner && headers.membership_type != MembershipType::Owner {
|
|
err!("Only owners can restore other owners")
|
|
}
|
|
|
|
member.restore();
|
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
|
// This check need to be done after restoring to work with the correct status
|
|
OrgPolicy::check_user_allowed(&member, "restore", conn).await?;
|
|
member.save(conn).await?;
|
|
|
|
log_event(
|
|
EventType::OrganizationUserRestored,
|
|
&member.uuid,
|
|
org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
conn,
|
|
)
|
|
.await;
|
|
}
|
|
Some(_) => err!("User is already active"),
|
|
None => err!("User not found in organization"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_groups_data(
|
|
details: bool,
|
|
org_id: OrganizationId,
|
|
headers: ManagerHeadersLoose,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.membership.org_uuid {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
|
|
// The details view (group→collection/user mappings) needs full org access; the plain list only
|
|
// needs manage access to a collection, so a manager of a collection (directly or via a group)
|
|
// can load it to assign groups.
|
|
let has_full_access = headers.membership.has_full_access()
|
|
|| (CONFIG.org_groups_enabled()
|
|
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
|
|
let allowed = if details {
|
|
has_full_access
|
|
} else {
|
|
has_full_access
|
|
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
|
|
};
|
|
if !allowed {
|
|
err_code!("Resource not found.", "User does not have access", 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,
|
|
})))
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/groups")]
|
|
async fn get_groups(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
|
|
get_groups_data(false, org_id, headers, conn).await
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/groups/details", rank = 1)]
|
|
async fn get_groups_details(org_id: OrganizationId, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
|
|
get_groups_data(true, org_id, headers, 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", data = "<data>")]
|
|
async fn post_groups(
|
|
org_id: OrganizationId,
|
|
headers: AdminHeaders,
|
|
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?;
|
|
|
|
let group = group_request.to_group(&org_id);
|
|
|
|
log_event(
|
|
EventType::GroupCreated,
|
|
&group.uuid,
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
|
|
add_update_group(group, group_request.collections, group_request.users, org_id, &headers, &conn).await
|
|
}
|
|
|
|
#[put("/organizations/<org_id>/groups/<group_id>", data = "<data>")]
|
|
async fn put_group(
|
|
org_id: OrganizationId,
|
|
group_id: GroupId,
|
|
data: Json<GroupRequest>,
|
|
headers: AdminHeaders,
|
|
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?;
|
|
|
|
let updated_group = group_request.update_group(group);
|
|
|
|
CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
|
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
|
|
|
|
log_event(
|
|
EventType::GroupUpdated,
|
|
&updated_group.uuid,
|
|
&org_id,
|
|
&headers.user.uuid,
|
|
headers.device.atype,
|
|
&headers.ip.ip,
|
|
&conn,
|
|
)
|
|
.await;
|
|
|
|
add_update_group(updated_group, group_request.collections, group_request.users, org_id, &headers, &conn).await
|
|
}
|
|
|
|
async fn add_update_group(
|
|
mut group: Group,
|
|
collections: Vec<CollectionData>,
|
|
members: Vec<MembershipId>,
|
|
org_id: OrganizationId,
|
|
headers: &AdminHeaders,
|
|
conn: &DbConn,
|
|
) -> JsonResult {
|
|
group.save(conn).await?;
|
|
|
|
for col_selection in collections {
|
|
let mut collection_group = col_selection.to_collection_group(group.uuid.clone());
|
|
collection_group.save(&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,
|
|
&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,
|
|
"externalId": group.external_id,
|
|
"object": "group"
|
|
})))
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/groups/<group_id>/details")]
|
|
async fn get_group_details(
|
|
org_id: OrganizationId,
|
|
group_id: GroupId,
|
|
headers: AdminHeaders,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
if !CONFIG.org_groups_enabled() {
|
|
err!("Group support is disabled");
|
|
}
|
|
|
|
let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else {
|
|
err!("Group not found", "Group uuid is invalid or does not belong to the organization")
|
|
};
|
|
|
|
Ok(Json(group.to_json_details(&conn).await))
|
|
}
|
|
|
|
#[delete("/organizations/<org_id>/groups/<group_id>")]
|
|
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, 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: &AdminHeaders,
|
|
conn: &DbConn,
|
|
) -> EmptyResult {
|
|
if org_id != &headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
if !CONFIG.org_groups_enabled() {
|
|
err!("Group support is disabled");
|
|
}
|
|
|
|
let Some(group) = Group::find_by_uuid_and_org(group_id, org_id, conn).await else {
|
|
err!("Group not found", "Group uuid is invalid or does not belong to the organization")
|
|
};
|
|
|
|
log_event(
|
|
EventType::GroupDeleted,
|
|
&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: AdminHeaders,
|
|
conn: DbConn,
|
|
) -> EmptyResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
if !CONFIG.org_groups_enabled() {
|
|
err!("Group support is disabled");
|
|
}
|
|
|
|
let data: BulkGroupIds = data.into_inner();
|
|
|
|
for group_id in data.ids {
|
|
delete_group_impl(&org_id, &group_id, &headers, &conn).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[get("/organizations/<org_id>/groups/<group_id>", rank = 2)]
|
|
async fn get_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, 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: AdminHeaders,
|
|
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)))
|
|
}
|
|
|
|
#[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: Option<String>,
|
|
key: Option<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 {
|
|
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,
|
|
req: 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(mut 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");
|
|
}
|
|
|
|
let fallback_2fa_email =
|
|
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
|
|
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Sending email first 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_account_recovery(
|
|
&user.email,
|
|
user.display_name(),
|
|
&org.name,
|
|
req.reset_master_password,
|
|
req.reset_two_factor,
|
|
fallback_2fa_email,
|
|
)
|
|
.await
|
|
{
|
|
err!(format!("Error sending user reset password email: {e:#?}"));
|
|
}
|
|
|
|
if req.reset_master_password {
|
|
if let Some(key) = req.key
|
|
&& let Some(hash) = req.new_master_password_hash
|
|
{
|
|
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
|
|
} else {
|
|
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
|
|
}
|
|
}
|
|
|
|
if req.reset_two_factor {
|
|
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
|
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
|
|
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
|
|
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
user.save(&conn).await?;
|
|
|
|
nt.send_logout(&user, None, &conn).await;
|
|
|
|
if req.reset_master_password {
|
|
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
|
|
}
|
|
|
|
if req.reset_two_factor {
|
|
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &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
|
|
} else {
|
|
EventType::OrganizationUserResetPasswordWithdraw
|
|
};
|
|
|
|
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// NOTE: It seems clients can't handle uppercase-first keys!!
|
|
// We need to convert all keys so they have the first character to be a lowercase.
|
|
// Else the export will be just an empty JSON file.
|
|
// We currently only support exports by members of the Admin or Owner status.
|
|
// Vaultwarden does not yet support exporting only managed collections!
|
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/OrganizationExportController.cs#L52
|
|
#[get("/organizations/<org_id>/export")]
|
|
async fn get_org_export(org_id: OrganizationId, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
|
if org_id != headers.org_id {
|
|
err!("Organization not found", "Organization id's do not match");
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"collections": convert_json_key_lcase_first(get_org_collections_impl(&org_id, &conn).await),
|
|
"ciphers": convert_json_key_lcase_first(get_org_details_impl(&org_id, &headers.host, &headers.user.uuid, &conn).await?),
|
|
})))
|
|
}
|
|
|
|
async fn api_key(
|
|
org_id: &OrganizationId,
|
|
data: Json<PasswordOrOtpData>,
|
|
rotate: bool,
|
|
headers: OwnerHeaders,
|
|
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 owner 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: OwnerHeaders,
|
|
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: OwnerHeaders,
|
|
conn: DbConn,
|
|
) -> JsonResult {
|
|
api_key(&org_id, data, true, headers, conn).await
|
|
}
|