diff --git a/src/api/scim/manage.rs b/src/api/scim/manage.rs index 88d20cdd..2f08a6d2 100644 --- a/src/api/scim/manage.rs +++ b/src/api/scim/manage.rs @@ -11,12 +11,12 @@ use rocket::{Route, serde::json::Json}; use crate::{ CONFIG, - api::{EmptyResult, JsonResult, PasswordOrOtpData}, + api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event}, auth::AdminHeaders, crypto, db::{ DbConn, - models::{OrganizationId, ScimApiKey}, + models::{EventType, OrganizationId, ScimApiKey}, }, util::format_date, }; @@ -32,6 +32,44 @@ fn check_scim_enabled() -> EmptyResult { Ok(()) } +// Records a SCIM token management action in the org event log under the acting +// admin's own identity (this is an interactive, re-authenticated admin action, +// not a SCIM-driven one, so it does not use the synthetic SCIM actor). The org +// itself is the event source; OrganizationUpdated is the closest existing type, +// matching how the admin panel logs org-level configuration changes. +async fn log_scim_key_event(headers: &AdminHeaders, org_id: &OrganizationId, conn: &DbConn) { + log_event( + EventType::OrganizationUpdated as i32, + org_id, + org_id, + &headers.user.uuid, + headers.device.atype, + &headers.ip.ip, + conn, + ) + .await; +} + +// The single place a SCIM token is minted. Generates the secret, replaces any +// existing key row (rotation: the previous token stops working immediately), +// and returns the full bearer token plus the stored row. The integration +// tests call this too, so the minted format and the guard's verification +// cannot drift apart. +pub(crate) async fn mint_scim_token( + org_id: &OrganizationId, + conn: &DbConn, +) -> Result<(String, ScimApiKey), crate::Error> { + // 256-bit secret; the stored digest can only be brute-forced, not inverted. + let secret = crypto::encode_random_bytes::<32>(&data_encoding::BASE64URL_NOPAD); + let key_hash = crypto::sha256_hex(secret.as_bytes()); + + ScimApiKey::delete_all_by_organization(org_id, conn).await?; + let scim_key = ScimApiKey::new(org_id.clone(), key_hash); + scim_key.save(conn).await?; + + Ok((format!("scim_v1.{org_id}.{secret}"), scim_key)) +} + #[post("/organizations//scim/api-key", data = "")] async fn generate_scim_key( org_id: OrganizationId, @@ -45,18 +83,12 @@ async fn generate_scim_key( check_scim_enabled()?; data.into_inner().validate(&headers.user, true, &conn).await?; - // 256-bit secret; the stored digest can only be brute-forced, not inverted. - let secret = crypto::encode_random_bytes::<32>(&data_encoding::BASE64URL_NOPAD); - let key_hash = crypto::sha256_hex(secret.as_bytes()); - - // Rotation is replacement: any previous key stops working immediately. - ScimApiKey::delete_all_by_organization(&org_id, &conn).await?; - let scim_key = ScimApiKey::new(org_id.clone(), key_hash); - scim_key.save(&conn).await?; + let (token, scim_key) = mint_scim_token(&org_id, &conn).await?; + log_scim_key_event(&headers, &org_id, &conn).await; Ok(Json(json!({ "object": "scim-api-key", - "token": format!("scim_v1.{org_id}.{secret}"), + "token": token, "scimBaseUrl": format!("{}/scim/v2/{org_id}", CONFIG.domain()), "revisionDate": format_date(&scim_key.revision_date), }))) @@ -74,7 +106,9 @@ async fn delete_scim_key( } data.into_inner().validate(&headers.user, true, &conn).await?; - ScimApiKey::delete_all_by_organization(&org_id, &conn).await + ScimApiKey::delete_all_by_organization(&org_id, &conn).await?; + log_scim_key_event(&headers, &org_id, &conn).await; + Ok(()) } #[get("/organizations//scim/status")] diff --git a/src/api/scim/users.rs b/src/api/scim/users.rs index ffc4f754..9234114b 100644 --- a/src/api/scim/users.rs +++ b/src/api/scim/users.rs @@ -13,6 +13,8 @@ // and the user already has credentials). Confirmed requires an admin client // to wrap the org key for the member; no server-side path can do that. // +use std::collections::HashMap; + use rocket::Route; use serde_json::Value; @@ -22,7 +24,7 @@ use crate::{ EmptyResult, core::log_event, scim::{ - ScimJson, ScimResponse, + SCIM_ACTOR, SCIM_DEVICE_TYPE, ScimJson, ScimResponse, error::ScimError, filter::parse_eq_filter, guard::ScimToken, @@ -34,7 +36,7 @@ use crate::{ DbConn, models::{ EventType, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization, - User, + User, UserId, }, }, mail, @@ -45,12 +47,6 @@ pub fn routes() -> Vec { routes![list_users, get_user, post_user, put_user, patch_user, delete_user] } -// Synthetic acting user recorded in the org event log for SCIM-driven -// changes, following the ACTING_ADMIN_USER precedent in the admin panel. -const SCIM_ACTOR: &str = "vaultwarden-scim-00000-000000000000"; -// Device type recorded for SCIM events: 14 = UnknownBrowser, same as admin. -const SCIM_DEVICE_TYPE: i32 = 14; - // The single place the revocation encoding is interpreted: revoked statuses // are stored as status - 128 (ACTIVATE_REVOKE_DIFF), so every stored revoked // value is <= Revoked (-1). Never compare equality against Revoked. @@ -59,7 +55,7 @@ fn membership_active(member: &Membership) -> bool { } fn to_scim_user(member: &Membership, user: &User, token: &ScimToken) -> Value { - let location = format!("{}/scim/v2/{}/Users/{}", CONFIG.domain(), token.org_uuid, member.uuid); + let location = crate::api::scim::resource_location(&token.org_uuid, "Users", &member.uuid); json!({ "schemas": [crate::api::scim::discovery::USER_SCHEMA_URN], "id": member.uuid, @@ -88,16 +84,6 @@ async fn log_scim_event(event_type: EventType, member: &Membership, token: &Scim .await; } -fn list_response(total: usize, start_index: usize, resources: &[Value]) -> ScimResponse { - ScimResponse::ok(json!({ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": total, - "itemsPerPage": resources.len(), - "startIndex": start_index, - "Resources": resources, - })) -} - #[derive(FromForm)] pub struct ListParams { filter: Option, @@ -135,20 +121,24 @@ async fn list_users(params: ListParams, token: ScimToken, conn: DbConn) -> Resul }; let total = members.len(); - let start_index = usize::try_from(params.start_index.unwrap_or(1)).unwrap_or(1).max(1); - let count = usize::try_from(params.count.unwrap_or(100)).unwrap_or(0).min(200); + let (start_index, count) = crate::api::scim::page_bounds(params.start_index, params.count); - let mut resources = Vec::new(); - for member in members.into_iter().skip(start_index - 1).take(count) { + let page: Vec = members.into_iter().skip(start_index - 1).take(count).collect(); + let user_ids: Vec = page.iter().map(|member| member.user_uuid.clone()).collect(); + let users: HashMap = + User::find_by_uuids(&user_ids, &conn).await.into_iter().map(|user| (user.uuid.clone(), user)).collect(); + + let mut resources = Vec::with_capacity(page.len()); + for member in &page { // A membership always references a user; a missing row would be a // dangling foreign key, so surface it as a 500 rather than skip. - let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { + let Some(user) = users.get(&member.user_uuid) else { return Err(ScimError::internal()); }; - resources.push(to_scim_user(&member, &user, &token)); + resources.push(to_scim_user(member, user, &token)); } - Ok(list_response(total, start_index, &resources)) + Ok(crate::api::scim::list_response(total, start_index, &resources)) } #[get("/v2/<_>/Users/")] @@ -237,12 +227,7 @@ async fn post_user(data: ScimJson, token: ScimToken, conn: DbCo .await { error!("SCIM provisioning rollback, invite mail failed: {e:#?}"); - let rollback: EmptyResult = if user_created { - user.delete(&conn).await - } else { - member.delete(&conn).await - }; - drop(rollback); + rollback_provisioning(user, member, user_created, &conn).await; return Err(ScimError::internal()); } @@ -251,10 +236,30 @@ async fn post_user(data: ScimJson, token: ScimToken, conn: DbCo log_scim_event(EventType::OrganizationUserRevoked, &member, &token, &conn).await; } - let location = format!("{}/scim/v2/{}/Users/{}", CONFIG.domain(), token.org_uuid, member.uuid); + let location = crate::api::scim::resource_location(&token.org_uuid, "Users", &member.uuid); Ok(ScimResponse::created(location, to_scim_user(&member, &user, &token))) } +// Rollback for a provisioning that failed after its rows were written. The +// destructive decision lives here so it can be tested directly: a user this +// request created is removed entirely (User::delete cascades the membership), +// while a pre-existing account must survive and only the new membership goes. +pub(super) async fn rollback_provisioning(user: User, member: Membership, user_created: bool, conn: &DbConn) { + let rollback: EmptyResult = if user_created { + user.delete(conn).await + } else { + member.delete(conn).await + }; + if let Err(rollback_err) = rollback { + let orphan = if user_created { + "user" + } else { + "membership" + }; + error!("SCIM provisioning rollback failed, orphaned {orphan} row remains: {rollback_err:#?}"); + } +} + #[patch("/v2/<_>/Users/", data = "")] async fn patch_user( member_id: MembershipId, @@ -379,6 +384,29 @@ async fn restore_member(member: &mut Membership, token: &ScimToken, conn: &DbCon return Err(ScimError::bad_request("invalidValue", "Restore is blocked by an organization policy")); } member.save(conn).await.map_err(|_| ScimError::internal())?; + + // A member restored to Invited has never joined the org. The original + // invite may never have been sent (created active:false) or have expired, + // so re-send it; otherwise the person has a membership and shell account + // they were never told about and cannot register into. + if member.status == MembershipStatus::Invited as i32 + && CONFIG.mail_enabled() + && let (Some(user), Some(org)) = + (User::find_by_uuid(&member.user_uuid, conn).await, Organization::find_by_uuid(&token.org_uuid, conn).await) + && let Err(e) = mail::send_invite( + &user, + token.org_uuid.clone(), + member.uuid.clone(), + &org.name, + Some(org.billing_email.clone()), + ) + .await + { + // The membership is already restored and valid; a mail hiccup must not + // fail the deprovision-reprovision cycle. Surface it in the log. + error!("SCIM restore succeeded but re-sending the invite failed: {e:#?}"); + } + log_scim_event(EventType::OrganizationUserRestored, member, token, conn).await; Ok(()) }