diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 5518fa3c..d2b19d13 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -190,8 +190,8 @@ async fn post_events_collect(data: Json>, headers: Headers, event.r#type, org_id, org_id, - &headers.user.uuid, - headers.device.atype, + Some(&headers.user.uuid), + Some(headers.device.atype), Some(event_date), &headers.ip.ip, &conn, @@ -211,8 +211,8 @@ async fn post_events_collect(data: Json>, headers: Headers, event.r#type, cipher_uuid, &org_id, - &headers.user.uuid, - headers.device.atype, + Some(&headers.user.uuid), + Some(headers.device.atype), Some(event_date), &headers.ip.ip, &conn, @@ -278,7 +278,17 @@ pub async fn log_event( if !CONFIG.org_events_enabled() { return; } - log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; + log_event_impl(event_type, source_uuid, org_id, Some(act_user_id), Some(device_type), None, ip, conn).await; +} + +/// Log an organization event that was triggered through the Public API. +/// These are performed by an organization API client instead of a user, so there is +/// no acting user or device to record and both are stored as null. +pub async fn log_public_event(event_type: i32, source_uuid: &str, org_id: &OrganizationId, ip: &IpAddr, conn: &DbConn) { + if !CONFIG.org_events_enabled() { + return; + } + log_event_impl(event_type, source_uuid, org_id, None, None, None, ip, conn).await; } #[expect(clippy::too_many_arguments)] @@ -286,8 +296,8 @@ async fn log_event_impl( event_type: i32, source_uuid: &str, org_id: &OrganizationId, - act_user_id: &UserId, - device_type: i32, + act_user_id: Option<&UserId>, + device_type: Option, event_date: Option, ip: &IpAddr, conn: &DbConn, @@ -322,8 +332,8 @@ async fn log_event_impl( } event.org_uuid = Some(org_id.clone()); - event.act_user_uuid = Some(act_user_id.clone()); - event.device_type = Some(device_type); + event.act_user_uuid = act_user_id.cloned(); + event.device_type = device_type; event.ip_address = Some(ip.to_string()); event.save(conn).await.unwrap_or(()); } diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 7d9bd05a..b6f091f1 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use chrono::Utc; use rocket::{ @@ -10,19 +10,22 @@ use serde_json::Value; use crate::{ CONFIG, - api::{EmptyResult, JsonResult}, + api::{EmptyResult, JsonResult, Notify, UpdateType}, auth, db::{ DbConn, models::{ - Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, Invitation, - Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization, OrganizationApiKey, - OrganizationId, User, + Collection, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, + Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization, + OrganizationApiKey, OrganizationId, User, }, }, mail, + util::NumberOrString, }; +use super::events::log_public_event; + pub fn routes() -> Vec { routes![ ldap_import, @@ -34,6 +37,17 @@ pub fn routes() -> Vec { get_group_member_ids, get_collections, get_collection, + post_member, + put_member, + delete_member, + put_member_group_ids, + post_member_reinvite, + post_member_revoke, + post_member_restore, + post_group, + put_group, + delete_group, + put_group_member_ids, ] } @@ -415,6 +429,585 @@ async fn get_collection(collection_id: CollectionId, token: PublicToken, conn: D Ok(Json(collection_json)) } +// These endpoints implement the write side of the organization Public API. Together +// with the read endpoints above they replace the need to drive every change through +// "/public/organization/import", which is a full directory snapshot and revokes any +// member missing from the payload when overwriteExisting is set. +// +// The write paths mirror the equivalent internal endpoints in `organizations.rs`, but +// are guarded by PublicToken instead of AdminHeaders. A PublicToken carries only an +// organization, so the per-actor permission checks of the internal API do not apply; +// the guards that protect organization integrity (last confirmed owner, org policies, +// group support being enabled) are kept. + +// Bitwarden models an assignment to a collection as its id plus the permissions granted. +// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssociationData { + id: CollectionId, + #[serde(default)] + read_only: bool, + #[serde(default)] + hide_passwords: bool, + #[serde(default)] + manage: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MemberCreateData { + email: String, + r#type: NumberOrString, + external_id: Option, + #[serde(default)] + collections: Vec, + #[serde(default)] + groups: Vec, + #[serde(default)] + permissions: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MemberUpdateData { + r#type: NumberOrString, + external_id: Option, + #[serde(default)] + collections: Vec, + #[serde(default)] + groups: Vec, + #[serde(default)] + permissions: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GroupCreateUpdateData { + name: String, + // Upstream dropped accessAll from its group model, but the Vaultwarden group still + // carries the flag, so it is accepted here and defaults to false when omitted. + #[serde(default)] + access_all: bool, + external_id: Option, + #[serde(default)] + collections: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GroupIdsData { + group_ids: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MemberIdsData { + member_ids: Vec, +} + +// 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 +fn member_type_and_access_all( + r#type: NumberOrString, + permissions: &HashMap, +) -> Option<(MembershipType, bool)> { + let raw_type = &r#type.into_string(); + // MembershipType::from_str will convert custom (4) to manager (3) + let new_type = MembershipType::from_str(raw_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") + && permissions.get("editAnyCollection") == Some(&json!(true)) + && permissions.get("deleteAnyCollection") == Some(&json!(true)) + && permissions.get("createNewCollections") == Some(&json!(true))); + + Some((new_type, access_all)) +} + +async fn validate_collections(collections: &[AssociationData], 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) = collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { + err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) + } + Ok(()) +} + +async fn validate_groups(group_ids: &[GroupId], 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(|g| &g.uuid).collect(); + if let Some(e) = group_ids.iter().find(|g| !org_group_ids.contains(g)) { + err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id)) + } + Ok(()) +} + +async fn validate_members(member_ids: &[MembershipId], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + 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) = member_ids.iter().find(|m| !org_membership_ids.contains(m)) { + err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) + } + Ok(()) +} + +// Replace a member's collection assignments. Members of type Admin or Owner reach every +// collection through their type, so no explicit assignments are stored for them. +async fn set_member_collections( + member: &Membership, + collections: &[AssociationData], + org_id: &OrganizationId, + conn: &DbConn, +) -> EmptyResult { + for c in CollectionUser::find_by_organization_and_user_uuid(org_id, &member.user_uuid, conn).await { + c.delete(conn).await?; + } + + if !member.access_all { + for col in collections { + CollectionUser::save(&member.user_uuid, &col.id, col.read_only, col.hide_passwords, col.manage, conn) + .await?; + } + } + + Ok(()) +} + +async fn set_member_groups(member: &Membership, group_ids: &[GroupId], conn: &DbConn) -> EmptyResult { + GroupUser::delete_all_by_member(&member.uuid, conn).await?; + for group_id in group_ids { + let mut group_entry = GroupUser::new(group_id.clone(), member.uuid.clone()); + group_entry.save(conn).await?; + } + + Ok(()) +} + +async fn set_group_collections( + group: &Group, + collections: &[AssociationData], + org_id: &OrganizationId, + conn: &DbConn, +) -> EmptyResult { + CollectionGroup::delete_all_by_group(&group.uuid, org_id, conn).await?; + for col in collections { + let mut collection_group = + CollectionGroup::new(col.id.clone(), group.uuid.clone(), col.read_only, col.hide_passwords, col.manage); + collection_group.save(org_id, conn).await?; + } + + Ok(()) +} + +// A Public API client is not a member of the organization, so invites are recorded as +// coming from the organization itself, the same way "/public/organization/import" does. +async fn org_name_and_email(org_id: &OrganizationId, conn: &DbConn) -> Result<(String, String), crate::error::Error> { + let Some(org) = Organization::find_by_uuid(org_id, conn).await else { + err!("Error looking up organization") + }; + + Ok((org.name, org.billing_email)) +} + +#[post("/public/members", data = "")] +async fn post_member(data: Json, token: PublicToken, ip: auth::ClientIp, conn: DbConn) -> JsonResult { + let org_id = token.0; + let data = data.into_inner(); + + let Some((new_type, access_all)) = member_type_and_access_all(data.r#type, &data.permissions) else { + err!("Invalid type") + }; + + validate_collections(&data.collections, &org_id, &conn).await?; + validate_groups(&data.groups, &org_id, &conn).await?; + + let mut user_created = false; + let mut member_status = MembershipStatus::Invited as i32; + let user = match User::find_by_mail(&data.email, &conn).await { + None => { + if !CONFIG.invitations_allowed() { + err!(format!("User does not exist: {}", data.email)) + } + + if !CONFIG.is_email_domain_allowed(&data.email) { + err!("Email domain not eligible for invitations") + } + + if !CONFIG.mail_enabled() { + Invitation::new(&data.email).save(&conn).await?; + } + + let mut new_user = User::new(&data.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: {}", data.email)) + } + + if !CONFIG.mail_enabled() { + if user.password_hash.is_empty() { + Invitation::new(&data.email).save(&conn).await?; + } else { + // automatically accept existing users if mail is disabled + member_status = MembershipStatus::Accepted as i32; + } + } + user + } + }; + + let (org_name, org_email) = org_name_and_email(&org_id, &conn).await?; + + let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); + new_member.access_all = access_all; + new_member.atype = new_type as i32; + new_member.status = member_status; + new_member.set_external_id(data.external_id.clone()); + new_member.save(&conn).await?; + + if CONFIG.mail_enabled() + && let Err(e) = + mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).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_public_event(EventType::OrganizationUserInvited as i32, &new_member.uuid, &org_id, &ip.ip, &conn).await; + + set_member_collections(&new_member, &data.collections, &org_id, &conn).await?; + set_member_groups(&new_member, &data.groups, &conn).await?; + + Ok(Json(member_to_json(&new_member, &conn).await)) +} + +#[put("/public/members/", data = "")] +async fn put_member( + member_id: MembershipId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> JsonResult { + let org_id = token.0; + let data = data.into_inner(); + + let Some((new_type, access_all)) = member_type_and_access_all(data.r#type, &data.permissions) else { + err!("Invalid type") + }; + + let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err_code!(format!("Member {member_id} not found in organization"), 404); + }; + + validate_collections(&data.collections, &org_id, &conn).await?; + validate_groups(&data.groups, &org_id, &conn).await?; + + if member.atype == MembershipType::Owner + && new_type != MembershipType::Owner + && member.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.access_all = access_all; + member.atype = new_type as i32; + member.set_external_id(data.external_id.clone()); + + // 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. + OrgPolicy::check_user_allowed(&member, "modify", &conn).await?; + + set_member_collections(&member, &data.collections, &org_id, &conn).await?; + set_member_groups(&member, &data.groups, &conn).await?; + + member.save(&conn).await?; + + log_public_event(EventType::OrganizationUserUpdated as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(Json(member_to_json(&member, &conn).await)) +} + +#[delete("/public/members/")] +async fn delete_member( + member_id: MembershipId, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let org_id = token.0; + let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err_code!(format!("Member {member_id} not found in organization"), 404); + }; + + if member.atype == MembershipType::Owner && member.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_public_event(EventType::OrganizationUserRemoved as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + if let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await { + // There is no device behind a Public API request, so no push device to exclude. + nt.send_user_update(UpdateType::SyncOrgKeys, &user, None, &conn).await; + + if !CONFIG.mail_enabled() + && !Membership::find_invited_by_user(&user.uuid, &conn).await.into_iter().any(|m| m.uuid != member.uuid) + { + Invitation::take(&user.email, &conn).await; + } + } + + member.delete(&conn).await +} + +#[put("/public/members//group-ids", data = "")] +async fn put_member_group_ids( + member_id: MembershipId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err_code!(format!("Member {member_id} not found in organization"), 404); + }; + + let data = data.into_inner(); + validate_groups(&data.group_ids, &org_id, &conn).await?; + + set_member_groups(&member, &data.group_ids, &conn).await?; + + log_public_event(EventType::OrganizationUserUpdatedGroups as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(()) +} + +#[post("/public/members//reinvite")] +async fn post_member_reinvite(member_id: MembershipId, token: PublicToken, conn: DbConn) -> EmptyResult { + let org_id = token.0; + let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err_code!(format!("Member {member_id} not found in organization"), 404); + }; + + 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, org_email) = org_name_and_email(&org_id, &conn).await?; + + if CONFIG.mail_enabled() { + mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(org_email)).await?; + } else if user.password_hash.is_empty() { + Invitation::new(&user.email).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(()) +} + +#[post("/public/members//revoke")] +async fn post_member_revoke( + member_id: MembershipId, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err_code!(format!("Member {member_id} not found in organization"), 404); + }; + + if member.status <= MembershipStatus::Revoked as i32 { + err!("User is already revoked") + } + + 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_public_event(EventType::OrganizationUserRevoked as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(()) +} + +#[post("/public/members//restore")] +async fn post_member_restore( + member_id: MembershipId, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err_code!(format!("Member {member_id} not found in organization"), 404); + }; + + if member.status >= MembershipStatus::Accepted as i32 { + err!("User is already active") + } + + member.restore(); + // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, + // admin::update_membership_type. It needs to happen after restoring to see the correct status. + OrgPolicy::check_user_allowed(&member, "restore", &conn).await?; + member.save(&conn).await?; + + log_public_event(EventType::OrganizationUserRestored as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(()) +} + +#[post("/public/groups", data = "")] +async fn post_group( + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> JsonResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + let data = data.into_inner(); + validate_collections(&data.collections, &org_id, &conn).await?; + + let mut group = Group::new(org_id.clone(), data.name.clone(), data.access_all, data.external_id.clone()); + group.save(&conn).await?; + + set_group_collections(&group, &data.collections, &org_id, &conn).await?; + + log_public_event(EventType::GroupCreated as i32, &group.uuid, &org_id, &ip.ip, &conn).await; + + Ok(Json(group_to_json(&group))) +} + +#[put("/public/groups/", data = "")] +async fn put_group( + group_id: GroupId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> JsonResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + let Some(mut group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else { + err_code!(format!("Group {group_id} not found in organization"), 404); + }; + + let data = data.into_inner(); + validate_collections(&data.collections, &org_id, &conn).await?; + + group.name.clone_from(&data.name); + group.access_all = data.access_all; + // Unlike the internal endpoint, the external_id is updatable here. The Public API is + // the directory integration surface, the same one "/public/organization/import" uses + // to assign external ids in the first place. + group.set_external_id(data.external_id.clone()); + group.save(&conn).await?; + + // Member assignments are owned by "/public/groups//member-ids" and are + // deliberately left untouched here. + set_group_collections(&group, &data.collections, &org_id, &conn).await?; + + log_public_event(EventType::GroupUpdated as i32, &group.uuid, &org_id, &ip.ip, &conn).await; + + Ok(Json(group_to_json(&group))) +} + +#[delete("/public/groups/")] +async fn delete_group(group_id: GroupId, token: PublicToken, ip: auth::ClientIp, conn: DbConn) -> EmptyResult { + let org_id = token.0; + 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_code!(format!("Group {group_id} not found in organization"), 404); + }; + + log_public_event(EventType::GroupDeleted as i32, &group.uuid, &org_id, &ip.ip, &conn).await; + + group.delete(&org_id, &conn).await +} + +#[put("/public/groups//member-ids", data = "")] +async fn put_group_member_ids( + group_id: GroupId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + 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_code!(format!("Group {group_id} not found in organization"), 404); + } + + let data = data.into_inner(); + validate_members(&data.member_ids, &org_id, &conn).await?; + + GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; + for member_id in &data.member_ids { + let mut user_entry = GroupUser::new(group_id.clone(), member_id.clone()); + user_entry.save(&conn).await?; + + log_public_event(EventType::OrganizationUserUpdatedGroups as i32, member_id, &org_id, &ip.ip, &conn).await; + } + + Ok(()) +} + pub struct PublicToken(OrganizationId); #[rocket::async_trait]