From a4d1d870b120e64f2f1e56fc96ace1c1cbf863f6 Mon Sep 17 00:00:00 2001 From: David Croft Date: Sun, 19 Jul 2026 01:31:42 +1000 Subject: [PATCH] Add SCIM Users endpoints: provision, deprovision, restore Phase 1 of the SCIM v2 implementation - the deprovisioning core: - GET /scim/v2//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 --- src/api/scim/error.rs | 16 ++ src/api/scim/filter.rs | 93 ++++++++++ src/api/scim/guard.rs | 4 +- src/api/scim/mod.rs | 93 +++++++++- src/api/scim/models.rs | 160 ++++++++++++++++ src/api/scim/patch.rs | 159 ++++++++++++++++ src/api/scim/tests/mod.rs | 368 ++++++++++++++++++++++++++++++++++++- src/api/scim/users.rs | 373 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 1254 insertions(+), 12 deletions(-) create mode 100644 src/api/scim/filter.rs create mode 100644 src/api/scim/models.rs create mode 100644 src/api/scim/patch.rs create mode 100644 src/api/scim/users.rs diff --git a/src/api/scim/error.rs b/src/api/scim/error.rs index 17c70f5b..a5ad2209 100644 --- a/src/api/scim/error.rs +++ b/src/api/scim/error.rs @@ -40,6 +40,22 @@ impl ScimError { } } + pub fn conflict(scim_type: &'static str, detail: &str) -> Self { + Self { + status: Status::Conflict, + scim_type: Some(scim_type), + detail: String::from(detail), + } + } + + pub fn payload_too_large() -> Self { + Self { + status: Status::PayloadTooLarge, + scim_type: None, + detail: String::from("Payload too large"), + } + } + pub fn not_found() -> Self { Self { status: Status::NotFound, diff --git a/src/api/scim/filter.rs b/src/api/scim/filter.rs new file mode 100644 index 00000000..df872e70 --- /dev/null +++ b/src/api/scim/filter.rs @@ -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 { + 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 { + 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:?}"), + } + } + } +} diff --git a/src/api/scim/guard.rs b/src/api/scim/guard.rs index 373f1279..93cddf35 100644 --- a/src/api/scim/guard.rs +++ b/src/api/scim/guard.rs @@ -34,9 +34,10 @@ use crate::{ const DUMMY_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; // Handlers must scope every query to token.org_uuid, never to a path or body -// value. +// value. The ip is carried for audit logging. pub struct ScimToken { pub org_uuid: OrganizationId, + pub ip: ClientIp, } impl ScimToken { @@ -108,6 +109,7 @@ impl<'r> FromRequest<'r> for ScimToken { Outcome::Success(ScimToken { org_uuid, + ip, }) } } diff --git a/src/api/scim/mod.rs b/src/api/scim/mod.rs index 763d895c..e5b09b30 100644 --- a/src/api/scim/mod.rs +++ b/src/api/scim/mod.rs @@ -9,48 +9,109 @@ pub mod guard; mod discovery; mod error; +mod filter; mod manage; +mod models; +mod patch; +mod users; use std::io::Cursor; use rocket::{ Catcher, Route, + data::{Data, FromData, Outcome as DataOutcome}, http::Status, request::Request, response::{self, Responder, Response}, }; +use serde::de::DeserializeOwned; use serde_json::Value; pub use error::ScimError; pub use manage::routes as manage_routes; pub fn routes() -> Vec { - discovery::routes() + let mut routes = discovery::routes(); + routes.append(&mut users::routes()); + routes +} + +// A JSON body limited by the "scim" size limit. Accepts both +// application/scim+json (what Entra sends) and application/json; rocket's +// own Json would reject the former. +pub struct ScimJson(pub T); + +#[rocket::async_trait] +impl<'r, T: DeserializeOwned> FromData<'r> for ScimJson { + type Error = ScimError; + + async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> DataOutcome<'r, Self> { + let limit = request.limits().get("scim").unwrap_or_else(|| rocket::data::ByteUnit::Kibibyte(512)); + let bytes = match data.open(limit).into_bytes().await { + Ok(bytes) if bytes.is_complete() => bytes.into_inner(), + Ok(_) => return DataOutcome::Error((Status::PayloadTooLarge, ScimError::payload_too_large())), + Err(_) => { + return DataOutcome::Error(( + Status::BadRequest, + ScimError::bad_request("invalidSyntax", "Unreadable body"), + )); + } + }; + match serde_json::from_slice::(&bytes) { + Ok(value) => DataOutcome::Success(ScimJson(value)), + Err(e) => { + warn!(target: "scim", "Rejecting unparseable SCIM body: {e}"); + DataOutcome::Error((Status::BadRequest, ScimError::bad_request("invalidSyntax", "Malformed SCIM body"))) + } + } + } } // A JSON body with Content-Type application/scim+json, RFC 7644 section 3.1. pub struct ScimResponse { status: Status, - body: Value, + location: Option, + body: Option, } impl ScimResponse { pub fn ok(body: Value) -> Self { Self { status: Status::Ok, - body, + location: None, + body: Some(body), + } + } + + pub fn created(location: String, body: Value) -> Self { + Self { + status: Status::Created, + location: Some(location), + body: Some(body), + } + } + + pub fn no_content() -> Self { + Self { + status: Status::NoContent, + location: None, + body: None, } } } impl Responder<'_, 'static> for ScimResponse { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { - let body = self.body.to_string(); - Response::build() - .status(self.status) - .header(error::scim_content_type()) - .sized_body(Some(body.len()), Cursor::new(body)) - .ok() + let mut builder = Response::build(); + builder.status(self.status); + if let Some(location) = self.location { + builder.raw_header("Location", location); + } + if let Some(body) = self.body { + let body = body.to_string(); + builder.header(error::scim_content_type()).sized_body(Some(body.len()), Cursor::new(body)); + } + builder.ok() } } @@ -58,7 +119,14 @@ impl Responder<'_, 'static> for ScimResponse { // guard rejections (which carry only a status). The 401 body is a constant: // all auth failure causes look identical to the caller. pub fn catchers() -> Vec { - catchers![scim_bad_request, scim_unauthorized, scim_not_found, scim_too_many_requests, scim_internal] + catchers![ + scim_bad_request, + scim_unauthorized, + scim_not_found, + scim_payload_too_large, + scim_too_many_requests, + scim_internal + ] } #[catch(400)] @@ -76,6 +144,11 @@ fn scim_not_found() -> ScimError { ScimError::not_found() } +#[catch(413)] +fn scim_payload_too_large() -> ScimError { + ScimError::payload_too_large() +} + #[catch(429)] fn scim_too_many_requests() -> ScimError { ScimError::too_many_requests() diff --git a/src/api/scim/models.rs b/src/api/scim/models.rs new file mode 100644 index 00000000..81ca8780 --- /dev/null +++ b/src/api/scim/models.rs @@ -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 for ScimBool { + type Error = String; + + fn try_from(value: Value) -> Result { + 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, + pub family_name: Option, + pub formatted: Option, +} + +#[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, + pub external_id: Option, + pub display_name: Option, + pub name: Option, + #[serde(default)] + pub emails: Vec, + pub active: Option, +} + +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 { + 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::(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")); + } +} diff --git a/src/api/scim/patch.rs b/src/api/scim/patch.rs new file mode 100644 index 00000000..45d03a54 --- /dev/null +++ b/src/api/scim/patch.rs @@ -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, + #[serde(rename = "Operations", default)] + pub operations: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct PatchOperation { + pub op: String, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub value: Option, +} + +// What a User PATCH asked for, after normalization. +#[derive(Debug, Default, PartialEq)] +pub struct UserPatch { + pub active: Option, +} + +pub fn parse_user_patch(patch: &PatchOp) -> Result { + 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 { + serde_json::from_value::(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 { + let parsed: PatchOp = serde_json::from_value(payload).expect("valid PatchOp json"); + parse_user_patch(&parsed) + } + + fn ok_active(payload: Value) -> Option { + 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:?}"), + } + } + } +} diff --git a/src/api/scim/tests/mod.rs b/src/api/scim/tests/mod.rs index 767805ca..ebb96084 100644 --- a/src/api/scim/tests/mod.rs +++ b/src/api/scim/tests/mod.rs @@ -18,12 +18,14 @@ use rocket::{ local::asynchronous::{Client, LocalResponse}, }; +use serde_json::Value; + use crate::{ api::scim, crypto, db::{ DbConn, DbPool, - models::{Organization, OrganizationId, ScimApiKey}, + models::{Membership, MembershipId, MembershipType, Organization, OrganizationId, ScimApiKey, User}, }, }; @@ -190,3 +192,367 @@ fn rate_limiter_returns_429_when_drained() { } assert!(limited, "limiter never tripped after {burst} + 1 requests"); } + +// --------------------------------------------------------------------------- +// Users lifecycle (Phase 1: provision, deprovision, restore) +// --------------------------------------------------------------------------- + +fn scim_body(header_token: &str, body: &Value) -> (Header<'static>, Header<'static>, String) { + (bearer(header_token), Header::new("Content-Type", "application/scim+json"), body.to_string()) +} + +async fn seed_user(conn: &DbConn, email: &str, with_password: bool) -> User { + let mut user = User::new(email, None); + if with_password { + user.password_hash = vec![1, 2, 3]; + } + user.save(conn).await.expect("saving test user"); + user +} + +async fn seed_member( + conn: &DbConn, + org: &OrganizationId, + email: &str, + status: i32, + atype: MembershipType, +) -> MembershipId { + let user = seed_user(conn, email, true).await; + let mut member = Membership::new(user.uuid, org.clone(), None); + member.status = status; + member.atype = atype as i32; + member.save(conn).await.expect("saving test member"); + member.uuid +} + +async fn member_status(conn: &DbConn, member_id: &MembershipId, org: &OrganizationId) -> i32 { + Membership::find_by_uuid_and_org(member_id, org, conn).await.expect("membership row must exist").status +} + +fn parse_json(body: &str) -> Value { + serde_json::from_str(body).expect("valid json body") +} + +#[rocket::async_test] +async fn post_creates_invited_user() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-post-org").await; + let token = seed_scim_key(&conn, &org).await; + + let payload = json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "Provision.Me@Example.com", + "externalId": "entra-obj-001", + "name": {"givenName": "Provision", "familyName": "Me"}, + "emails": [{"value": "Provision.Me@Example.com", "primary": true}], + "active": true, + }); + let (auth, content_type, body) = scim_body(&token, &payload); + let response = + client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await; + + assert_eq!(response.status(), Status::Created); + let location = response.headers().get_one("Location").expect("Location header").to_string(); + let parsed = parse_json(&body_of(response).await); + + // Mail is disabled and the user is new (no password): Invited (0). + assert_eq!(parsed["active"], json!(true)); + assert_eq!(parsed["userName"], json!("provision.me@example.com"), "email must be stored lowercased"); + assert_eq!(parsed["externalId"], json!("entra-obj-001")); + let member_id: MembershipId = parsed["id"].as_str().expect("id").to_owned().into(); + assert!(location.ends_with(&format!("/scim/v2/{org}/Users/{member_id}"))); + + assert_eq!(member_status(&conn, &member_id, &org).await, 0, "new shell user must land at Invited"); + assert!(User::find_by_mail("provision.me@example.com", &conn).await.is_some()); +} + +#[rocket::async_test] +async fn post_existing_credentialed_user_becomes_accepted() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-post-accepted-org").await; + let token = seed_scim_key(&conn, &org).await; + seed_user(&conn, "has.password@example.com", true).await; + + let payload = json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "has.password@example.com", + "externalId": "entra-obj-002", + }); + let (auth, content_type, body) = scim_body(&token, &payload); + let response = + client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await; + + assert_eq!(response.status(), Status::Created); + let parsed = parse_json(&body_of(response).await); + let member_id: MembershipId = parsed["id"].as_str().expect("id").to_owned().into(); + // Mail disabled + existing credentials: straight to Accepted (1). + assert_eq!(member_status(&conn, &member_id, &org).await, 1); +} + +#[rocket::async_test] +async fn post_duplicate_is_409_uniqueness() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-dup-org").await; + let token = seed_scim_key(&conn, &org).await; + + let payload = json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "dup@example.com", + "externalId": "entra-dup-1", + }); + let (auth, content_type, body) = scim_body(&token, &payload); + let response = + client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await; + assert_eq!(response.status(), Status::Created); + + // Same externalId again. + let (auth, content_type, body) = scim_body(&token, &payload); + let response = + client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await; + assert_eq!(response.status(), Status::Conflict); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["scimType"], json!("uniqueness")); + + // Same email, different externalId. + let payload = json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "dup@example.com", + "externalId": "entra-dup-2", + }); + let (auth, content_type, body) = scim_body(&token, &payload); + let response = + client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await; + assert_eq!(response.status(), Status::Conflict); +} + +#[rocket::async_test] +async fn patch_active_lifecycle_hits_correct_status_offsets() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-patch-org").await; + let token = seed_scim_key(&conn, &org).await; + + let invited = seed_member(&conn, &org, "patch.invited@example.com", 0, MembershipType::User).await; + let confirmed = seed_member(&conn, &org, "patch.confirmed@example.com", 2, MembershipType::User).await; + + let deactivate = json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "Replace", "path": "active", "value": "False"}], + }); + let activate = json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "value": {"active": true}}], + }); + + // Invited (0) revokes to -128, never -1. + let (auth, ct, body) = scim_body(&token, &deactivate); + let response = + client.patch(format!("/scim/v2/{org}/Users/{invited}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(parse_json(&body_of(response).await)["active"], json!(false)); + assert_eq!(member_status(&conn, &invited, &org).await, -128); + + // Deprovisioning is idempotent. + let (auth, ct, body) = scim_body(&token, &deactivate); + let response = + client.patch(format!("/scim/v2/{org}/Users/{invited}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(member_status(&conn, &invited, &org).await, -128); + + // Restore is lossless: straight back to Invited (0). + let (auth, ct, body) = scim_body(&token, &activate); + let response = + client.patch(format!("/scim/v2/{org}/Users/{invited}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(member_status(&conn, &invited, &org).await, 0); + + // Confirmed (2) revokes to -126 and restores to 2 with akey intact. + let (auth, ct, body) = scim_body(&token, &deactivate); + let response = + client.patch(format!("/scim/v2/{org}/Users/{confirmed}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(member_status(&conn, &confirmed, &org).await, -126); + + let response = client.get(format!("/scim/v2/{org}/Users/{confirmed}")).header(bearer(&token)).dispatch().await; + assert_eq!(parse_json(&body_of(response).await)["active"], json!(false)); + + let (auth, ct, body) = scim_body(&token, &activate); + let response = + client.patch(format!("/scim/v2/{org}/Users/{confirmed}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Ok); + assert_eq!(member_status(&conn, &confirmed, &org).await, 2); +} + +#[rocket::async_test] +async fn delete_revokes_and_keeps_the_row() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-delete-org").await; + let token = seed_scim_key(&conn, &org).await; + let member = seed_member(&conn, &org, "delete.me@example.com", 2, MembershipType::User).await; + + let response = client.delete(format!("/scim/v2/{org}/Users/{member}")).header(bearer(&token)).dispatch().await; + assert_eq!(response.status(), Status::NoContent); + // The row survives (revoked), so restore stays lossless. + assert_eq!(member_status(&conn, &member, &org).await, -126); + + // Idempotent. + let response = client.delete(format!("/scim/v2/{org}/Users/{member}")).header(bearer(&token)).dispatch().await; + assert_eq!(response.status(), Status::NoContent); + assert_eq!(member_status(&conn, &member, &org).await, -126); +} + +#[rocket::async_test] +async fn last_confirmed_owner_cannot_be_revoked() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-owner-org").await; + let token = seed_scim_key(&conn, &org).await; + let owner = seed_member(&conn, &org, "sole.owner@example.com", 2, MembershipType::Owner).await; + + let deactivate = json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "active", "value": false}], + }); + let (auth, ct, body) = scim_body(&token, &deactivate); + let response = + client.patch(format!("/scim/v2/{org}/Users/{owner}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Conflict); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["scimType"], json!("mutability")); + assert_eq!(member_status(&conn, &owner, &org).await, 2, "owner must remain untouched"); + + // DELETE takes the same path. + let response = client.delete(format!("/scim/v2/{org}/Users/{owner}")).header(bearer(&token)).dispatch().await; + assert_eq!(response.status(), Status::Conflict); + assert_eq!(member_status(&conn, &owner, &org).await, 2); +} + +#[rocket::async_test] +async fn filter_round_trip_and_enumeration_shape() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-filter-org").await; + let token = seed_scim_key(&conn, &org).await; + seed_member(&conn, &org, "filter.target@example.com", 1, MembershipType::User).await; + + // Mixed-case filter value must match the lowercased stored email. + let filter = "userName eq \"Filter.Target@Example.COM\""; + let response = client + .get(format!("/scim/v2/{org}/Users?filter={}", url_escape(filter))) + .header(bearer(&token)) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["totalResults"], json!(1)); + assert_eq!(parsed["Resources"][0]["userName"], json!("filter.target@example.com")); + + // A miss is an empty 200 list, not a 404: Entra's Test Connection probes + // exactly this, and a distinguishable miss would enable enumeration. + let filter = "userName eq \"nobody-here@example.com\""; + let response = client + .get(format!("/scim/v2/{org}/Users?filter={}", url_escape(filter))) + .header(bearer(&token)) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["totalResults"], json!(0)); + + // Unsupported grammar is a 400 invalidFilter. + let filter = "userName co \"partial\""; + let response = client + .get(format!("/scim/v2/{org}/Users?filter={}", url_escape(filter))) + .header(bearer(&token)) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["scimType"], json!("invalidFilter")); +} + +#[rocket::async_test] +async fn unknown_member_and_foreign_member_are_identical_404s() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org_a = seed_org(&conn, "scim-404-a").await; + let token_a = seed_scim_key(&conn, &org_a).await; + let org_b = seed_org(&conn, "scim-404-b").await; + let _token_b = seed_scim_key(&conn, &org_b).await; + let foreign = seed_member(&conn, &org_b, "foreign.member@example.com", 2, MembershipType::User).await; + + let response = client + .get(format!("/scim/v2/{org_a}/Users/00000000-dead-beef-0000-000000000000")) + .header(bearer(&token_a)) + .dispatch() + .await; + assert_eq!(response.status(), Status::NotFound); + let unknown_body = body_of(response).await; + + // Another org's member id must be indistinguishable from a nonexistent one. + let response = client.get(format!("/scim/v2/{org_a}/Users/{foreign}")).header(bearer(&token_a)).dispatch().await; + assert_eq!(response.status(), Status::NotFound); + let foreign_body = body_of(response).await; + assert_eq!(unknown_body, foreign_body); +} + +#[rocket::async_test] +async fn post_inactive_creates_revoked_membership() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-inactive-org").await; + let token = seed_scim_key(&conn, &org).await; + + let payload = json!({ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName": "born.disabled@example.com", + "externalId": "entra-inactive-1", + "active": "False", + }); + let (auth, ct, body) = scim_body(&token, &payload); + let response = client.post(format!("/scim/v2/{org}/Users")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::Created); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["active"], json!(false)); + let member_id: MembershipId = parsed["id"].as_str().expect("id").to_owned().into(); + assert_eq!(member_status(&conn, &member_id, &org).await, -128, "invited-then-revoked offset"); +} + +#[rocket::async_test] +async fn patch_unsupported_path_is_invalid_path() { + let _guard = TEST_LOCK.lock().await; + let (client, pool) = scim_client().await; + let conn = pool.get().await.expect("conn"); + let org = seed_org(&conn, "scim-badpatch-org").await; + let token = seed_scim_key(&conn, &org).await; + let member = seed_member(&conn, &org, "bad.patch@example.com", 1, MembershipType::User).await; + + let payload = json!({ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "displayName", "value": "New Name"}], + }); + let (auth, ct, body) = scim_body(&token, &payload); + let response = + client.patch(format!("/scim/v2/{org}/Users/{member}")).header(auth).header(ct).body(body).dispatch().await; + assert_eq!(response.status(), Status::BadRequest); + let parsed = parse_json(&body_of(response).await); + assert_eq!(parsed["scimType"], json!("invalidPath")); +} + +fn url_escape(raw: &str) -> String { + // Percent-encode just enough for filter values in test URLs. + raw.replace('%', "%25").replace(' ', "%20").replace('"', "%22") +} diff --git a/src/api/scim/users.rs b/src/api/scim/users.rs new file mode 100644 index 00000000..664158c2 --- /dev/null +++ b/src/api/scim/users.rs @@ -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 { + 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, + #[field(name = "startIndex")] + start_index: Option, + count: Option, +} + +#[get("/v2/<_>/Users?")] +async fn list_users(params: ListParams, token: ScimToken, conn: DbConn) -> Result { + // Resolve the matching memberships first, then paginate. + let members: Vec = 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/")] +async fn get_user(member_id: MembershipId, token: ScimToken, conn: DbConn) -> Result { + 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 = "")] +async fn post_user(data: ScimJson, token: ScimToken, conn: DbConn) -> Result { + 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/", data = "")] +async fn patch_user( + member_id: MembershipId, + data: ScimJson, + token: ScimToken, + conn: DbConn, +) -> Result { + 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/")] +async fn delete_user(member_id: MembershipId, token: ScimToken, conn: DbConn) -> Result { + 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()); + } +}