Browse Source

fix(scim): audit token management, log rollback, resend invite on restore

Log an org event under the acting admin's identity when a SCIM token is
generated, rotated, or deleted (previously unaudited). Extract the mint path
into manage::mint_scim_token so the token format and the guard's verification
share one definition. Extract rollback_provisioning and log when the rollback
delete itself fails instead of silently dropping the result. Re-send the invite
when a member is restored to Invited (an omitted or expired original invite
otherwise leaves a reactivated member with a membership they cannot join).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/7443/head
David Croft 3 days ago
parent
commit
d7e94fa4cd
  1. 58
      src/api/scim/manage.rs
  2. 94
      src/api/scim/users.rs

58
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/<org_id>/scim/api-key", data = "<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/<org_id>/scim/status")]

94
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<Route> {
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<String>,
@ -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<Membership> = members.into_iter().skip(start_index - 1).take(count).collect();
let user_ids: Vec<UserId> = page.iter().map(|member| member.user_uuid.clone()).collect();
let users: HashMap<UserId, User> =
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/<member_id>")]
@ -237,12 +227,7 @@ async fn post_user(data: ScimJson<ScimUserRequest>, 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<ScimUserRequest>, 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/<member_id>", data = "<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(())
}

Loading…
Cancel
Save