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