Browse Source
Phase 1 of the SCIM v2 implementation - the deprovisioning core: - GET /scim/v2/<org>/Users with eq filters on userName/externalId (filter values are matched against the lowercased stored email, avoiding the Entra create-409-quarantine loop) plus startIndex/count pagination. - POST creates or links the user and invites: shell account for unknown emails, membership at Invited (Accepted when mail is off and the account already has credentials), invite mail with rollback on failure. Honors active:false on create by storing the membership pre-revoked. - PATCH handles Entra's active toggle in all observed shapes: op casing, string booleans, and the path-less value-object form. - PATCH active:false and DELETE both map to revoke, never delete: the row and akey survive so restore is lossless. Documented RFC deviation. - Last-confirmed-owner protection returns a SCIM 409 mutability error. - Restore re-runs the org policy check like restore_member_impl. - Unknown member ids and other orgs' member ids return identical 404s. - Audit events (invited/revoked/restored) recorded with a synthetic SCIM actor, following the admin panel's ACTING_ADMIN_USER precedent. - ScimJson data guard accepts application/scim+json bodies under the scim size limit; 413 catcher added. Tests: 24 new (unit: filter grammar, PatchOp quirks, status-offset table with the -126/-128 revocation encoding, regression canary on from_i32; integration: full lifecycle, duplicate 409s, last-owner 409, enumeration shape, born-disabled create). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/7443/head
8 changed files with 1254 additions and 12 deletions
@ -0,0 +1,93 @@ |
|||
//
|
|||
// Minimal SCIM filter parser, RFC 7644 section 3.4.2.2.
|
|||
//
|
|||
// Only the form Entra uses for existence checks is supported:
|
|||
// attribute eq "value"
|
|||
// Anything else (and/or, co, sw, grouping, valuePath) is rejected with
|
|||
// scimType invalidFilter. That is the correct signal for a partially
|
|||
// supported filter grammar; 501 would claim filtering is entirely absent.
|
|||
//
|
|||
use crate::api::scim::error::ScimError; |
|||
|
|||
#[derive(Debug, PartialEq, Eq)] |
|||
pub struct EqFilter { |
|||
// Lowercased: SCIM attribute names are case-insensitive (RFC 7643 s2.1).
|
|||
pub attribute: String, |
|||
pub value: String, |
|||
} |
|||
|
|||
pub fn parse_eq_filter(raw: &str) -> Result<EqFilter, ScimError> { |
|||
let unsupported = |
|||
|| ScimError::bad_request("invalidFilter", "Only filters of the form `attr eq \"value\"` are supported"); |
|||
|
|||
let trimmed = raw.trim(); |
|||
// Split "attr eq "value"" on whitespace, keeping the quoted tail intact.
|
|||
let (attribute, rest) = trimmed.split_once(char::is_whitespace).ok_or_else(unsupported)?; |
|||
let rest = rest.trim_start(); |
|||
let (operator, rest) = rest.split_once(char::is_whitespace).ok_or_else(unsupported)?; |
|||
if !operator.eq_ignore_ascii_case("eq") { |
|||
return Err(unsupported()); |
|||
} |
|||
|
|||
let quoted = rest.trim(); |
|||
let value = quoted.strip_prefix('"').and_then(|v| v.strip_suffix('"')).ok_or_else(unsupported)?; |
|||
if value.contains('"') || attribute.is_empty() || value.is_empty() { |
|||
return Err(unsupported()); |
|||
} |
|||
|
|||
Ok(EqFilter { |
|||
attribute: attribute.to_lowercase(), |
|||
value: value.to_owned(), |
|||
}) |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
use super::*; |
|||
|
|||
fn parse(raw: &str) -> Result<EqFilter, ScimError> { |
|||
parse_eq_filter(raw) |
|||
} |
|||
|
|||
#[test] |
|||
fn parses_username_eq() { |
|||
let f = parse("userName eq \"john@example.com\"").expect("valid filter"); |
|||
assert_eq!(f.attribute, "username"); |
|||
assert_eq!(f.value, "john@example.com"); |
|||
} |
|||
|
|||
#[test] |
|||
fn parses_externalid_eq_case_insensitive_attr_and_op() { |
|||
let f = parse("ExternalId EQ \"abc-123\"").expect("valid filter"); |
|||
assert_eq!(f.attribute, "externalid"); |
|||
assert_eq!(f.value, "abc-123"); |
|||
} |
|||
|
|||
#[test] |
|||
fn value_may_contain_spaces() { |
|||
let f = parse("displayName eq \"Jane van Der Berg\"").expect("valid filter"); |
|||
assert_eq!(f.value, "Jane van Der Berg"); |
|||
} |
|||
|
|||
#[test] |
|||
fn rejects_unsupported_grammar() { |
|||
for raw in [ |
|||
"", |
|||
"userName", |
|||
"userName eq", |
|||
"userName eq unquoted", |
|||
"userName co \"x\"", |
|||
"userName sw \"x\"", |
|||
"userName eq \"a\" and active eq true", |
|||
"emails[type eq \"work\"].value eq \"x\"", |
|||
"userName eq \"broken", |
|||
"userName eq \"\"", |
|||
] { |
|||
let result = parse(raw); |
|||
match result { |
|||
Err(e) => assert_eq!(e.scim_type, Some("invalidFilter"), "wrong scimType for {raw:?}"), |
|||
Ok(f) => panic!("filter {raw:?} unexpectedly parsed to {f:?}"), |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,160 @@ |
|||
//
|
|||
// Serde models for SCIM request bodies, RFC 7643.
|
|||
//
|
|||
// Deliberately NOT #[serde(deny_unknown_fields)]: RFC 7643 section 2.1
|
|||
// requires service providers to ignore attributes they do not recognize, and
|
|||
// Entra sends several (addresses, phoneNumbers, preferredLanguage, ...).
|
|||
//
|
|||
use serde::Deserialize; |
|||
use serde_json::Value; |
|||
|
|||
// Entra sometimes sends booleans as strings ("True"/"False"), particularly in
|
|||
// PATCH values. Accept both without losing strictness for other types.
|
|||
#[derive(Clone, Copy, Debug, Deserialize)] |
|||
#[serde(try_from = "Value")] |
|||
pub struct ScimBool(pub bool); |
|||
|
|||
impl TryFrom<Value> for ScimBool { |
|||
type Error = String; |
|||
|
|||
fn try_from(value: Value) -> Result<Self, Self::Error> { |
|||
match value { |
|||
Value::Bool(b) => Ok(ScimBool(b)), |
|||
Value::String(s) => match s.to_lowercase().as_str() { |
|||
"true" => Ok(ScimBool(true)), |
|||
"false" => Ok(ScimBool(false)), |
|||
_ => Err(format!("not a boolean: {s:?}")), |
|||
}, |
|||
other => Err(format!("not a boolean: {other}")), |
|||
} |
|||
} |
|||
} |
|||
|
|||
#[derive(Debug, Default, Deserialize)] |
|||
#[serde(rename_all = "camelCase")] |
|||
pub struct ScimName { |
|||
pub given_name: Option<String>, |
|||
pub family_name: Option<String>, |
|||
pub formatted: Option<String>, |
|||
} |
|||
|
|||
#[derive(Debug, Deserialize)] |
|||
#[serde(rename_all = "camelCase")] |
|||
pub struct ScimEmail { |
|||
pub value: String, |
|||
#[serde(default)] |
|||
pub primary: bool, |
|||
} |
|||
|
|||
#[derive(Debug, Deserialize)] |
|||
#[serde(rename_all = "camelCase")] |
|||
pub struct ScimUserRequest { |
|||
pub user_name: Option<String>, |
|||
pub external_id: Option<String>, |
|||
pub display_name: Option<String>, |
|||
pub name: Option<ScimName>, |
|||
#[serde(default)] |
|||
pub emails: Vec<ScimEmail>, |
|||
pub active: Option<ScimBool>, |
|||
} |
|||
|
|||
impl ScimUserRequest { |
|||
// Entra maps userName to the login identifier; some tenants map the
|
|||
// routable address only into emails[]. Prefer userName when it looks like
|
|||
// an email, else fall back to the primary (or first) email value.
|
|||
pub fn email(&self) -> Option<&str> { |
|||
let user_name = self.user_name.as_deref().filter(|v| v.contains('@')); |
|||
let from_emails = |
|||
self.emails.iter().find(|e| e.primary).or_else(|| self.emails.first()).map(|e| e.value.as_str()); |
|||
user_name.or(from_emails) |
|||
} |
|||
|
|||
// Compose a display name the way the web vault shows members.
|
|||
pub fn display_name(&self) -> Option<String> { |
|||
if let Some(display_name) = &self.display_name { |
|||
return Some(display_name.clone()); |
|||
} |
|||
if let Some(name) = &self.name { |
|||
if let Some(formatted) = &name.formatted { |
|||
return Some(formatted.clone()); |
|||
} |
|||
let composed = match (&name.given_name, &name.family_name) { |
|||
(Some(given), Some(family)) => Some(format!("{given} {family}")), |
|||
(Some(given), None) => Some(given.clone()), |
|||
(None, Some(family)) => Some(family.clone()), |
|||
(None, None) => None, |
|||
}; |
|||
return composed; |
|||
} |
|||
None |
|||
} |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
use super::*; |
|||
|
|||
#[test] |
|||
fn scim_bool_coerces_entra_strings() { |
|||
for (raw, expected) in [ |
|||
(json!(true), true), |
|||
(json!(false), false), |
|||
(json!("True"), true), |
|||
(json!("true"), true), |
|||
(json!("FALSE"), false), |
|||
(json!("false"), false), |
|||
] { |
|||
let b: ScimBool = serde_json::from_value(raw).expect("coercible"); |
|||
assert_eq!(b.0, expected); |
|||
} |
|||
for bad in [json!("yes"), json!(1), json!(null), json!({})] { |
|||
assert!(serde_json::from_value::<ScimBool>(bad).is_err()); |
|||
} |
|||
} |
|||
|
|||
#[test] |
|||
fn email_prefers_username_then_primary_email() { |
|||
let parsed: ScimUserRequest = serde_json::from_value(json!({ |
|||
"userName": "user@example.com", |
|||
"emails": [{"value": "other@example.com", "primary": true}], |
|||
})) |
|||
.expect("valid"); |
|||
assert_eq!(parsed.email(), Some("user@example.com")); |
|||
|
|||
// Non-email userName (a bare UPN-less identifier) falls back to emails.
|
|||
let parsed: ScimUserRequest = serde_json::from_value(json!({ |
|||
"userName": "just-an-id", |
|||
"emails": [{"value": "second@example.com"}, {"value": "first@example.com", "primary": true}], |
|||
})) |
|||
.expect("valid"); |
|||
assert_eq!(parsed.email(), Some("first@example.com")); |
|||
|
|||
let parsed: ScimUserRequest = serde_json::from_value(json!({})).expect("valid"); |
|||
assert_eq!(parsed.email(), None); |
|||
} |
|||
|
|||
#[test] |
|||
fn display_name_composition() { |
|||
let parsed: ScimUserRequest = serde_json::from_value(json!({ |
|||
"name": {"givenName": "Ada", "familyName": "Lovelace"}, |
|||
})) |
|||
.expect("valid"); |
|||
assert_eq!(parsed.display_name().as_deref(), Some("Ada Lovelace")); |
|||
|
|||
let parsed: ScimUserRequest = serde_json::from_value(json!({ |
|||
"displayName": "Display Wins", |
|||
"name": {"formatted": "Formatted Loses"}, |
|||
})) |
|||
.expect("valid"); |
|||
assert_eq!(parsed.display_name().as_deref(), Some("Display Wins")); |
|||
|
|||
// Unknown attributes are ignored per RFC 7643 section 2.1.
|
|||
let parsed: ScimUserRequest = serde_json::from_value(json!({ |
|||
"userName": "a@b.com", |
|||
"preferredLanguage": "en-US", |
|||
"addresses": [], |
|||
})) |
|||
.expect("unknown attrs must not fail"); |
|||
assert_eq!(parsed.email(), Some("a@b.com")); |
|||
} |
|||
} |
|||
@ -0,0 +1,159 @@ |
|||
//
|
|||
// SCIM PatchOp parsing, RFC 7644 section 3.5.2, restricted to what the User
|
|||
// endpoints support. Entra quirks handled here, all observed in real syncs:
|
|||
// - "op" arrives with any casing ("Replace", "Add").
|
|||
// - boolean values may arrive as strings ("True"/"False").
|
|||
// - an operation may have no "path", carrying a value object instead
|
|||
// ({"op":"replace","value":{"active":false}}).
|
|||
//
|
|||
use serde::Deserialize; |
|||
use serde_json::Value; |
|||
|
|||
use crate::api::scim::{error::ScimError, models::ScimBool}; |
|||
|
|||
pub const PATCH_OP_URN: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; |
|||
|
|||
#[derive(Debug, Deserialize)] |
|||
pub struct PatchOp { |
|||
#[serde(default)] |
|||
pub schemas: Vec<String>, |
|||
#[serde(rename = "Operations", default)] |
|||
pub operations: Vec<PatchOperation>, |
|||
} |
|||
|
|||
#[derive(Debug, Deserialize)] |
|||
pub struct PatchOperation { |
|||
pub op: String, |
|||
#[serde(default)] |
|||
pub path: Option<String>, |
|||
#[serde(default)] |
|||
pub value: Option<Value>, |
|||
} |
|||
|
|||
// What a User PATCH asked for, after normalization.
|
|||
#[derive(Debug, Default, PartialEq)] |
|||
pub struct UserPatch { |
|||
pub active: Option<bool>, |
|||
} |
|||
|
|||
pub fn parse_user_patch(patch: &PatchOp) -> Result<UserPatch, ScimError> { |
|||
if !patch.schemas.iter().any(|s| s == PATCH_OP_URN) { |
|||
return Err(ScimError::bad_request("invalidValue", "Missing PatchOp schema")); |
|||
} |
|||
if patch.operations.is_empty() { |
|||
return Err(ScimError::bad_request("invalidValue", "No operations provided")); |
|||
} |
|||
|
|||
let mut result = UserPatch::default(); |
|||
|
|||
for operation in &patch.operations { |
|||
let op = operation.op.to_lowercase(); |
|||
if op != "replace" && op != "add" { |
|||
return Err(ScimError::bad_request( |
|||
"invalidValue", |
|||
"Only add and replace operations are supported for Users", |
|||
)); |
|||
} |
|||
|
|||
match operation.path.as_deref() { |
|||
Some(op_path) if op_path.eq_ignore_ascii_case("active") => { |
|||
let value = operation.value.clone().unwrap_or(Value::Null); |
|||
result.active = Some(coerce_bool(value)?); |
|||
} |
|||
Some(_) => { |
|||
return Err(ScimError::bad_request("invalidPath", "Unsupported patch path")); |
|||
} |
|||
None => { |
|||
// Path-less form: the value is an object of attribute => value.
|
|||
let Some(Value::Object(map)) = operation.value.as_ref() else { |
|||
return Err(ScimError::bad_request( |
|||
"invalidValue", |
|||
"Operation without path must carry an object value", |
|||
)); |
|||
}; |
|||
for (attribute, value) in map { |
|||
if attribute.eq_ignore_ascii_case("active") { |
|||
result.active = Some(coerce_bool(value.clone())?); |
|||
} else { |
|||
return Err(ScimError::bad_request("invalidPath", "Unsupported patch attribute")); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
Ok(result) |
|||
} |
|||
|
|||
fn coerce_bool(value: Value) -> Result<bool, ScimError> { |
|||
serde_json::from_value::<ScimBool>(value) |
|||
.map(|b| b.0) |
|||
.map_err(|_| ScimError::bad_request("invalidValue", "Expected a boolean value for active")) |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
use super::*; |
|||
|
|||
fn patch(payload: Value) -> Result<UserPatch, ScimError> { |
|||
let parsed: PatchOp = serde_json::from_value(payload).expect("valid PatchOp json"); |
|||
parse_user_patch(&parsed) |
|||
} |
|||
|
|||
fn ok_active(payload: Value) -> Option<bool> { |
|||
patch(payload).expect("expected parse to succeed").active |
|||
} |
|||
|
|||
#[test] |
|||
fn replace_active_with_path() { |
|||
let active = ok_active(json!({ |
|||
"schemas": [PATCH_OP_URN], |
|||
"Operations": [{"op": "replace", "path": "active", "value": false}], |
|||
})); |
|||
assert_eq!(active, Some(false)); |
|||
} |
|||
|
|||
#[test] |
|||
fn entra_casing_and_string_bool() { |
|||
let active = ok_active(json!({ |
|||
"schemas": [PATCH_OP_URN], |
|||
"Operations": [{"op": "Replace", "path": "active", "value": "False"}], |
|||
})); |
|||
assert_eq!(active, Some(false)); |
|||
} |
|||
|
|||
#[test] |
|||
fn pathless_value_object() { |
|||
let active = ok_active(json!({ |
|||
"schemas": [PATCH_OP_URN], |
|||
"Operations": [{"op": "replace", "value": {"active": "True"}}], |
|||
})); |
|||
assert_eq!(active, Some(true)); |
|||
} |
|||
|
|||
#[test] |
|||
fn rejects_bad_patches() { |
|||
for (payload, expected_type) in [ |
|||
( |
|||
json!({"schemas": [], "Operations": [{"op": "replace", "path": "active", "value": true}]}), |
|||
"invalidValue", |
|||
), |
|||
(json!({"schemas": [PATCH_OP_URN], "Operations": []}), "invalidValue"), |
|||
(json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "remove", "path": "active"}]}), "invalidValue"), |
|||
( |
|||
json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "replace", "path": "displayName", "value": "x"}]}), |
|||
"invalidPath", |
|||
), |
|||
( |
|||
json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "replace", "path": "active", "value": "maybe"}]}), |
|||
"invalidValue", |
|||
), |
|||
(json!({"schemas": [PATCH_OP_URN], "Operations": [{"op": "replace", "value": 3}]}), "invalidValue"), |
|||
] { |
|||
match patch(payload.clone()) { |
|||
Err(e) => assert_eq!(e.scim_type, Some(expected_type), "wrong scimType for {payload}"), |
|||
Ok(p) => panic!("patch {payload} unexpectedly parsed to {p:?}"), |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,373 @@ |
|||
//
|
|||
// SCIM /Users endpoints. The SCIM User id is the org-membership uuid
|
|||
// (MembershipId), not the global user uuid: SCIM is org-scoped and one person
|
|||
// can belong to several organizations.
|
|||
//
|
|||
// Deprovisioning maps to REVOKE, never delete: the membership row and its
|
|||
// akey survive, so restore is lossless and needs no re-confirmation. This is
|
|||
// a deliberate deviation from RFC 7644 DELETE semantics, matching the
|
|||
// existing Directory Connector import endpoint, and it means a compromised
|
|||
// SCIM token cannot destroy memberships.
|
|||
//
|
|||
// Provisioning creates at most Invited (or Accepted when mail is disabled
|
|||
// 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 rocket::Route; |
|||
use serde_json::Value; |
|||
|
|||
use crate::{ |
|||
CONFIG, |
|||
api::{ |
|||
EmptyResult, |
|||
core::log_event, |
|||
scim::{ |
|||
ScimJson, ScimResponse, |
|||
error::ScimError, |
|||
filter::parse_eq_filter, |
|||
guard::ScimToken, |
|||
models::ScimUserRequest, |
|||
patch::{PatchOp, parse_user_patch}, |
|||
}, |
|||
}, |
|||
db::{ |
|||
DbConn, |
|||
models::{ |
|||
EventType, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization, |
|||
User, |
|||
}, |
|||
}, |
|||
mail, |
|||
util::is_valid_email, |
|||
}; |
|||
|
|||
pub fn routes() -> Vec<Route> { |
|||
routes![list_users, get_user, post_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.
|
|||
fn membership_active(member: &Membership) -> bool { |
|||
member.status > MembershipStatus::Revoked as i32 |
|||
} |
|||
|
|||
fn to_scim_user(member: &Membership, user: &User, token: &ScimToken) -> Value { |
|||
let location = format!("{}/scim/v2/{}/Users/{}", CONFIG.domain(), token.org_uuid, member.uuid); |
|||
json!({ |
|||
"schemas": [crate::api::scim::discovery::USER_SCHEMA_URN], |
|||
"id": member.uuid, |
|||
"externalId": member.external_id, |
|||
"userName": user.email, |
|||
"displayName": user.name, |
|||
"active": membership_active(member), |
|||
"emails": [{"value": user.email, "primary": true, "type": "work"}], |
|||
"meta": { |
|||
"resourceType": "User", |
|||
"location": location, |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
async fn log_scim_event(event_type: EventType, member: &Membership, token: &ScimToken, conn: &DbConn) { |
|||
log_event( |
|||
event_type as i32, |
|||
&member.uuid, |
|||
&token.org_uuid, |
|||
&SCIM_ACTOR.into(), |
|||
SCIM_DEVICE_TYPE, |
|||
&token.ip.ip, |
|||
conn, |
|||
) |
|||
.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>, |
|||
#[field(name = "startIndex")] |
|||
start_index: Option<i64>, |
|||
count: Option<i64>, |
|||
} |
|||
|
|||
#[get("/v2/<_>/Users?<params..>")] |
|||
async fn list_users(params: ListParams, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> { |
|||
// Resolve the matching memberships first, then paginate.
|
|||
let members: Vec<Membership> = if let Some(raw_filter) = params.filter.as_deref() { |
|||
let eq = parse_eq_filter(raw_filter)?; |
|||
match eq.attribute.as_str() { |
|||
// find_by_mail lowercases internally, so a mixed-case userName
|
|||
// from Entra still matches the lowercased stored email.
|
|||
"username" | "emails.value" => match User::find_by_mail(&eq.value, &conn).await { |
|||
Some(user) => { |
|||
Membership::find_by_user_and_org(&user.uuid, &token.org_uuid, &conn).await.into_iter().collect() |
|||
} |
|||
None => Vec::new(), |
|||
}, |
|||
"externalid" => { |
|||
Membership::find_by_external_id_and_org(&eq.value, &token.org_uuid, &conn).await.into_iter().collect() |
|||
} |
|||
_ => { |
|||
return Err(ScimError::bad_request( |
|||
"invalidFilter", |
|||
"Filterable attributes are userName and externalId", |
|||
)); |
|||
} |
|||
} |
|||
} else { |
|||
Membership::find_by_org(&token.org_uuid, &conn).await |
|||
}; |
|||
|
|||
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 mut resources = Vec::new(); |
|||
for member in members.into_iter().skip(start_index - 1).take(count) { |
|||
// 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 { |
|||
return Err(ScimError::internal()); |
|||
}; |
|||
resources.push(to_scim_user(&member, &user, &token)); |
|||
} |
|||
|
|||
Ok(list_response(total, start_index, &resources)) |
|||
} |
|||
|
|||
#[get("/v2/<_>/Users/<member_id>")] |
|||
async fn get_user(member_id: MembershipId, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> { |
|||
let Some(member) = Membership::find_by_uuid_and_org(&member_id, &token.org_uuid, &conn).await else { |
|||
return Err(ScimError::not_found()); |
|||
}; |
|||
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { |
|||
return Err(ScimError::internal()); |
|||
}; |
|||
Ok(ScimResponse::ok(to_scim_user(&member, &user, &token))) |
|||
} |
|||
|
|||
#[post("/v2/<_>/Users", data = "<data>")] |
|||
async fn post_user(data: ScimJson<ScimUserRequest>, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> { |
|||
let request = data.0; |
|||
|
|||
let Some(email) = request.email() else { |
|||
return Err(ScimError::bad_request("invalidValue", "userName (or a primary email) must be an email address")); |
|||
}; |
|||
if !is_valid_email(email) { |
|||
return Err(ScimError::bad_request("invalidValue", "userName is not a valid email address")); |
|||
} |
|||
|
|||
// Uniqueness: by externalId and by email, both scoped to the org.
|
|||
if let Some(external_id) = request.external_id.as_deref() |
|||
&& Membership::find_by_external_id_and_org(external_id, &token.org_uuid, &conn).await.is_some() |
|||
{ |
|||
return Err(ScimError::conflict("uniqueness", "A member with this externalId already exists")); |
|||
} |
|||
if Membership::find_by_email_and_org(email, &token.org_uuid, &conn).await.is_some() { |
|||
return Err(ScimError::conflict("uniqueness", "A member with this userName already exists")); |
|||
} |
|||
|
|||
let Some(org) = Organization::find_by_uuid(&token.org_uuid, &conn).await else { |
|||
return Err(ScimError::internal()); |
|||
}; |
|||
|
|||
// Mirrors the Directory Connector import: link an existing account by
|
|||
// email, or create a shell account (no keypair yet) that the invite email
|
|||
// lets the person register.
|
|||
let mut user_created = false; |
|||
let user = if let Some(user) = User::find_by_mail(email, &conn).await { |
|||
user |
|||
} else { |
|||
let mut new_user = User::new(email, request.display_name()); |
|||
new_user.save(&conn).await.map_err(|_| ScimError::internal())?; |
|||
if !CONFIG.mail_enabled() { |
|||
Invitation::new(&new_user.email).save(&conn).await.map_err(|_| ScimError::internal())?; |
|||
} |
|||
user_created = true; |
|||
new_user |
|||
}; |
|||
|
|||
// Reaching Accepted directly is only possible when no invite mail will be
|
|||
// sent and the account already has credentials to log in with.
|
|||
let member_status = if CONFIG.mail_enabled() || user.password_hash.is_empty() { |
|||
MembershipStatus::Invited as i32 |
|||
} else { |
|||
MembershipStatus::Accepted as i32 |
|||
}; |
|||
|
|||
// RFC 7644 allows creating a resource already deactivated; store the
|
|||
// membership pre-revoked and send no invite mail in that case.
|
|||
let create_active = request.active.is_none_or(|b| b.0); |
|||
|
|||
let mut member = Membership::new(user.uuid.clone(), token.org_uuid.clone(), Some(org.billing_email.clone())); |
|||
member.set_external_id(request.external_id.clone()); |
|||
member.access_all = false; |
|||
member.atype = MembershipType::User as i32; |
|||
member.status = member_status; |
|||
if !create_active { |
|||
member.revoke(); |
|||
} |
|||
member.save(&conn).await.map_err(|_| ScimError::internal())?; |
|||
|
|||
if create_active |
|||
&& CONFIG.mail_enabled() |
|||
&& let Err(e) = mail::send_invite( |
|||
&user, |
|||
token.org_uuid.clone(), |
|||
member.uuid.clone(), |
|||
&org.name, |
|||
Some(org.billing_email.clone()), |
|||
) |
|||
.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); |
|||
return Err(ScimError::internal()); |
|||
} |
|||
|
|||
log_scim_event(EventType::OrganizationUserInvited, &member, &token, &conn).await; |
|||
if !create_active { |
|||
log_scim_event(EventType::OrganizationUserRevoked, &member, &token, &conn).await; |
|||
} |
|||
|
|||
let location = format!("{}/scim/v2/{}/Users/{}", CONFIG.domain(), token.org_uuid, member.uuid); |
|||
Ok(ScimResponse::created(location, to_scim_user(&member, &user, &token))) |
|||
} |
|||
|
|||
#[patch("/v2/<_>/Users/<member_id>", data = "<data>")] |
|||
async fn patch_user( |
|||
member_id: MembershipId, |
|||
data: ScimJson<PatchOp>, |
|||
token: ScimToken, |
|||
conn: DbConn, |
|||
) -> Result<ScimResponse, ScimError> { |
|||
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &token.org_uuid, &conn).await else { |
|||
return Err(ScimError::not_found()); |
|||
}; |
|||
|
|||
let patch = parse_user_patch(&data.0)?; |
|||
let Some(desired_active) = patch.active else { |
|||
return Err(ScimError::bad_request("invalidValue", "No supported attributes in patch (supported: active)")); |
|||
}; |
|||
|
|||
if desired_active { |
|||
restore_member(&mut member, &token, &conn).await?; |
|||
} else { |
|||
revoke_member(&mut member, &token, &conn).await?; |
|||
} |
|||
|
|||
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { |
|||
return Err(ScimError::internal()); |
|||
}; |
|||
Ok(ScimResponse::ok(to_scim_user(&member, &user, &token))) |
|||
} |
|||
|
|||
// DELETE deprovisions by revoking, identically to PATCH active:false. The
|
|||
// membership row is kept: see the module comment.
|
|||
#[delete("/v2/<_>/Users/<member_id>")] |
|||
async fn delete_user(member_id: MembershipId, token: ScimToken, conn: DbConn) -> Result<ScimResponse, ScimError> { |
|||
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &token.org_uuid, &conn).await else { |
|||
return Err(ScimError::not_found()); |
|||
}; |
|||
revoke_member(&mut member, &token, &conn).await?; |
|||
Ok(ScimResponse::no_content()) |
|||
} |
|||
|
|||
async fn revoke_member(member: &mut Membership, token: &ScimToken, conn: &DbConn) -> Result<(), ScimError> { |
|||
if !membership_active(member) { |
|||
// Already revoked: deprovisioning is idempotent.
|
|||
return Ok(()); |
|||
} |
|||
|
|||
// Never leave an organization without a confirmed owner.
|
|||
if member.atype == MembershipType::Owner |
|||
&& member.status == MembershipStatus::Confirmed as i32 |
|||
&& Membership::count_confirmed_by_org_and_type(&token.org_uuid, MembershipType::Owner, conn).await <= 1 |
|||
{ |
|||
return Err(ScimError::conflict("mutability", "Cannot revoke the last confirmed owner of the organization")); |
|||
} |
|||
|
|||
member.revoke(); |
|||
member.save(conn).await.map_err(|_| ScimError::internal())?; |
|||
log_scim_event(EventType::OrganizationUserRevoked, member, token, conn).await; |
|||
Ok(()) |
|||
} |
|||
|
|||
async fn restore_member(member: &mut Membership, token: &ScimToken, conn: &DbConn) -> Result<(), ScimError> { |
|||
if membership_active(member) { |
|||
return Ok(()); |
|||
} |
|||
|
|||
member.restore(); |
|||
// Policy check runs on the restored status, mirroring restore_member_impl.
|
|||
if OrgPolicy::check_user_allowed(member, "restore", conn).await.is_err() { |
|||
return Err(ScimError::bad_request("invalidValue", "Restore is blocked by an organization policy")); |
|||
} |
|||
member.save(conn).await.map_err(|_| ScimError::internal())?; |
|||
log_scim_event(EventType::OrganizationUserRestored, member, token, conn).await; |
|||
Ok(()) |
|||
} |
|||
|
|||
#[cfg(test)] |
|||
mod tests { |
|||
use super::*; |
|||
|
|||
fn member_with_status(status: i32) -> Membership { |
|||
let mut member = Membership::new("test-user".to_owned().into(), "test-org".to_owned().into(), None); |
|||
member.status = status; |
|||
member |
|||
} |
|||
|
|||
#[test] |
|||
fn active_mapping_handles_revocation_offsets() { |
|||
// Stored revoked values are status - 128; Revoked (-1) itself is a
|
|||
// sentinel that never hits the database.
|
|||
for (status, expected_active) in [(2, true), (1, true), (0, true), (-126, false), (-127, false), (-128, false)] |
|||
{ |
|||
assert_eq!(membership_active(&member_with_status(status)), expected_active, "status {status}"); |
|||
} |
|||
} |
|||
|
|||
#[test] |
|||
fn revoke_restore_arithmetic_round_trips() { |
|||
for initial in [0, 1, 2] { |
|||
let mut member = member_with_status(initial); |
|||
assert!(member.revoke()); |
|||
assert_eq!(member.status, initial - 128); |
|||
assert!(!member.revoke(), "revoke must be idempotent"); |
|||
assert!(member.restore()); |
|||
assert_eq!(member.status, initial); |
|||
assert!(!member.restore(), "restore must be idempotent"); |
|||
} |
|||
} |
|||
|
|||
#[test] |
|||
fn from_i32_still_rejects_revoked_values() { |
|||
// Regression canary: OrgHeaders relies on revoked statuses mapping to
|
|||
// None. If upstream ever changes this, revisit membership_active().
|
|||
assert!(MembershipStatus::from_i32(-126).is_none()); |
|||
assert!(MembershipStatus::from_i32(-128).is_none()); |
|||
assert!(MembershipStatus::from_i32(-1).is_none()); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue