diff --git a/Cargo.lock b/Cargo.lock index 20cc7c3..8f702d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3337,6 +3337,7 @@ dependencies = [ "registry", "rkyv", "scim", + "scim-proto", "serde", "serde_json", "services", @@ -7417,8 +7418,11 @@ dependencies = [ name = "scim" version = "0.16.22" dependencies = [ + "ahash", + "base64 0.23.1", "common", "directory", + "hmac 0.13.0", "http_proto", "hyper", "icu_locale", @@ -7428,6 +7432,7 @@ dependencies = [ "scim-proto", "serde", "serde_json", + "sha2 0.11.0", "store", "trc", "types", diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 4c6ce2d..c775cf1 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -796,6 +796,14 @@ impl AccessToken { } impl AccessTokenInner { + /// inbuxa: SCIM-27: the account's own effective permission, from its + /// roles, its own settings and its tenant, before a credential narrows it + pub fn account_has_permission(&self, permission: Permission) -> bool { + self.scopes + .first() + .is_some_and(|scope| scope.permissions.get(permission as usize)) + } + pub fn from_id(account_id: u32) -> Self { Self { account_id, diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index c4e9870..9b849a5 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -69,7 +69,8 @@ pub struct DomainCache { pub const DOMAIN_FLAG_RELAY: u8 = 1; pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1; - +// inbuxa: SCIM-15, SCIM-58 +pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2; #[derive(Debug, Clone, Default)] pub struct AccountCache { @@ -329,4 +330,8 @@ impl DomainCache { self.names.first().map(|s| s.as_ref()).unwrap_or_default() } + // inbuxa: SCIM-15, SCIM-58 + pub fn allows_scim(&self) -> bool { + self.flags & DOMAIN_FLAG_SCIM != 0 + } } diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index c97c4d2..cf1bbdb 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -158,7 +158,11 @@ impl Server { if domain.allow_relaying { flags |= DOMAIN_FLAG_RELAY; } - + // inbuxa: SCIM-15, SCIM-58: the domain is open to SCIM, and SCIM is + // authoritative for its accounts + if domain.allow_scim_provisioning { + flags |= crate::auth::DOMAIN_FLAG_SCIM; + } let sub_addressing_custom = match domain.sub_addressing { SubAddressing::Enabled => { diff --git a/crates/http/Cargo.toml b/crates/http/Cargo.toml index 7f88d28..ee84554 100644 --- a/crates/http/Cargo.toml +++ b/crates/http/Cargo.toml @@ -13,6 +13,7 @@ smtp = { path = "../smtp" } jmap = { path = "../jmap" } dav = { path = "../dav" } scim = { path = "../scim" } +scim-proto = { path = "../scim-proto" } groupware = { path = "../groupware" } http_proto = { path = "../http-proto" } jmap_proto = { path = "../jmap-proto" } diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs index 7696b7d..de1c5d4 100644 --- a/crates/http/src/lib.rs +++ b/crates/http/src/lib.rs @@ -12,6 +12,7 @@ pub mod branding; // inbuxa: branding pub mod form; pub mod live; // inbuxa: monitoring (MON-20 to MON-24) pub mod request; +pub mod scim; // inbuxa: SCIM 2.0 provisioning use common::Inner; use std::sync::Arc; diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 34695f4..11f9d7c 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -478,6 +478,12 @@ impl ParseHttp for Server { return crate::branding::rsvp_page(self, RSVP_PAGE).await; } } + // inbuxa: SCIM 2.0 provisioning (feature 7) + "scim" => { + if path.next() == Some("v2") { + return Ok(crate::scim::handle(self, &mut req, &session).await); + } + } // inbuxa: BT-5: the logo that applies, anonymous "logo" if req.method() == Method::GET => { self.is_http_anonymous_request_allowed(session.remote_ip) diff --git a/crates/http/src/scim.rs b/crates/http/src/scim.rs new file mode 100644 index 0000000..d3a107c --- /dev/null +++ b/crates/http/src/scim.rs @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `/scim/v2` (SCIM spec): routes, authenticates with an API key only, +//! enforces the rate limits and the body cap, and hands the request to the +//! `scim` crate. + +use crate::auth::authenticate::{Authenticator, HttpHeaders}; +use common::Server; +use http_proto::{HttpRequest, HttpResponse, HttpSessionData, request::fetch_body}; +use percent_encoding::percent_decode_str; +use scim::{Route, ScimRequest, ScimResponse}; +use scim_proto::ScimError; + +/// Seconds until a rate limit resets, from the error the limiter gave. +fn retry_after(err: &trc::Error) -> u64 { + let now = store::write::now(); + match err.value(trc::Key::Expires).and_then(|v| v.to_uint()) { + Some(at) if at > now => at - now, + Some(seconds) if seconds > 0 => seconds, + _ => 1, + } +} + +/// A limiter refusal as `429` with `Retry-After` (SCIM-14), anything else +/// as `401` (SCIM-7). +fn refusal(err: trc::Error) -> ScimResponse { + match err.event_type() { + trc::EventType::Limit( + trc::LimitEvent::TooManyRequests | trc::LimitEvent::ConcurrentRequest, + ) => ScimResponse::error(ScimError::new(429, "Too many requests")) + .with_header("Retry-After", retry_after(&err).to_string()), + _ => ScimResponse::error(ScimError::unauthorized( + "The API key is missing, invalid, expired, revoked, or not allowed from this address", + )), + } +} + +pub async fn handle( + server: &Server, + req: &mut HttpRequest, + session: &HttpSessionData, +) -> HttpResponse { + respond(server, req, session).await.into_http_response() +} + +async fn respond( + server: &Server, + req: &mut HttpRequest, + session: &HttpSessionData, +) -> ScimResponse { + let segments = req + .uri() + .path() + .split('/') + .skip(3) + .map(|segment| percent_decode_str(segment).decode_utf8_lossy().into_owned()) + .collect::>(); + let route = match Route::parse(req.method(), &segments) { + Ok(route) => route, + Err(response) => return response, + }; + let query = req.uri().query().map(str::to_string); + + // SCIM-3: discovery is anonymous, under the anonymous rate limit + if route.is_anonymous() { + if let Err(err) = server + .is_http_anonymous_request_allowed(session.remote_ip) + .await + { + return refusal(err); + } + return scim::handle_anonymous(server, &route, query.as_deref()); + } + + // SCIM-7: an API key as a bearer token, and nothing else + match req.authorization() { + None => { + return ScimResponse::error(ScimError::unauthorized( + "An API key is required, as an Authorization: Bearer token", + )); + } + Some((mechanism, _)) if mechanism.eq_ignore_ascii_case("basic") => { + return ScimResponse::error(ScimError::unauthorized( + "Basic authentication isn't accepted: send an API key as an Authorization: Bearer token", + )); + } + Some((mechanism, token)) + if !mechanism.eq_ignore_ascii_case("bearer") || !token.starts_with("API_") => + { + return ScimResponse::error(ScimError::unauthorized( + "Only API keys are accepted, as an Authorization: Bearer token", + )); + } + Some(_) => {} + } + let (_in_flight, access_token) = match server.authenticate_headers(req, session).await { + Ok(result) => result, + Err(err) => { + trc::error!(err.clone().span_id(session.session_id)); + return refusal(err); + } + }; + + // SCIM-51: 1 MiB for every body, /Bulk included + let Some(body) = fetch_body(req, scim::MAX_PAYLOAD, session.session_id).await else { + return ScimResponse::error(ScimError::new( + 413, + format!("The body is larger than {} bytes", scim::MAX_PAYLOAD), + )); + }; + + scim::handle( + server, + &access_token, + session, + ScimRequest { + route, + query: query.as_deref(), + headers: req.headers(), + body, + }, + ) + .await +} diff --git a/crates/scim-proto/src/filter.rs b/crates/scim-proto/src/filter.rs index f844dea..7a1be68 100644 --- a/crates/scim-proto/src/filter.rs +++ b/crates/scim-proto/src/filter.rs @@ -133,7 +133,10 @@ pub enum Filter { Or(Box, Box), Not(Box), /// `attr[filter]`, with the inner filter's paths relative to `attr`. - ValuePath { path: AttrPath, filter: Box }, + ValuePath { + path: AttrPath, + filter: Box, + }, } impl Filter { @@ -280,18 +283,18 @@ impl Parser { let inner = self.or()?; match self.next() { Some(Token::Close) => Ok(inner), - _ => Err(ScimError::invalid_filter("A '(' without its ')' in the filter")), + _ => Err(ScimError::invalid_filter( + "A '(' without its ')' in the filter", + )), } } fn unary(&mut self) -> Result { match self.next() { - Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => { - match self.next() { - Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))), - _ => Err(ScimError::invalid_filter("'not' must be followed by '('")), - } - } + Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => match self.next() { + Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))), + _ => Err(ScimError::invalid_filter("'not' must be followed by '('")), + }, Some(Token::Open) => self.group(), Some(Token::Word(word)) => { let path = AttrPath::parse(&word).ok_or_else(|| { @@ -385,11 +388,14 @@ mod tests { Filter::parse("emails.value eq \"a\\\"b\"").unwrap(), eq("emails.value", json!("a\"b")) ); - let urn = Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"") - .unwrap(); + let urn = + Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"").unwrap(); match urn { Filter::Compare { path, .. } => { - assert_eq!(path.urn.as_deref(), Some("urn:ietf:params:scim:schemas:core:2.0:User")); + assert_eq!( + path.urn.as_deref(), + Some("urn:ietf:params:scim:schemas:core:2.0:User") + ); assert!(path.is("username", None)); } other => panic!("{other:?}"), @@ -426,7 +432,11 @@ mod tests { "1abc eq \"a\"", ] { let err = Filter::parse(text).unwrap_err(); - assert_eq!(err.scim_type, Some(crate::ScimType::InvalidFilter), "{text}"); + assert_eq!( + err.scim_type, + Some(crate::ScimType::InvalidFilter), + "{text}" + ); } } } diff --git a/crates/scim-proto/src/path.rs b/crates/scim-proto/src/path.rs index 5f36828..99f7936 100644 --- a/crates/scim-proto/src/path.rs +++ b/crates/scim-proto/src/path.rs @@ -25,11 +25,13 @@ impl PatchPath { let text = text.trim(); let (head, filter, after) = match text.find('[') { Some(open) => { - let close = text.rfind(']').filter(|close| *close > open).ok_or_else(invalid)?; + let close = text + .rfind(']') + .filter(|close| *close > open) + .ok_or_else(invalid)?; let inner = &text[open + 1..close]; - let filter = Filter::parse(inner).map_err(|err| { - ScimError::invalid_path(format!("'{text}': {}", err.detail)) - })?; + let filter = Filter::parse(inner) + .map_err(|err| ScimError::invalid_path(format!("'{text}': {}", err.detail)))?; let after = &text[close + 1..]; let after = if after.is_empty() { None @@ -85,7 +87,14 @@ mod tests { #[test] fn refuses_bad_paths() { - for text in ["", "members[", "members[value eq]", "a[b eq 1]x", "1a", "a.b[c eq 1]"] { + for text in [ + "", + "members[", + "members[value eq]", + "a[b eq 1]x", + "1a", + "a.b[c eq 1]", + ] { assert!(PatchPath::parse(text).is_err(), "{text}"); } } diff --git a/crates/scim/Cargo.toml b/crates/scim/Cargo.toml index 91cc716..cfd84e3 100644 --- a/crates/scim/Cargo.toml +++ b/crates/scim/Cargo.toml @@ -20,6 +20,10 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" xxhash-rust = { version = "0.8.18", features = ["xxh3"] } icu_locale = "2.3.1" +ahash = { version = "0.8.12", features = ["serde"] } +base64 = "0.23" +hmac = "0.13" +sha2 = "0.11" [dev-dependencies] diff --git a/crates/scim/src/bulk.rs b/crates/scim/src/bulk.rs new file mode 100644 index 0000000..e98a4de --- /dev/null +++ b/crates/scim/src/bulk.rs @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `POST /Bulk` (SCIM-51). Operations run in the order sent, each +//! authorized and scoped as it would be alone. A later operation may name +//! a resource created earlier as `bulkId:`. Not atomic. + +use crate::{ + MAX_OPERATIONS, ResourceKind, Route, ScimResponse, + context::Ctx, + resource::{dispatch, get, parse_body}, +}; +use ahash::AHashMap; +use hyper::{HeaderMap, Method, header::HeaderValue}; +use scim_proto::{MESSAGE_BULK_REQUEST, MESSAGE_BULK_RESPONSE, ScimError}; +use serde_json::{Map, Value, json}; + +/// Replaces `bulkId:` references; `Err` names the first unresolved. +fn resolve(value: &mut Value, created: &AHashMap) -> Result<(), String> { + match value { + Value::String(text) => { + if let Some(reference) = text.strip_prefix("bulkId:") { + *text = created + .get(reference) + .cloned() + .ok_or_else(|| reference.to_string())?; + } + Ok(()) + } + Value::Array(items) => items.iter_mut().try_for_each(|item| resolve(item, created)), + Value::Object(map) => map.values_mut().try_for_each(|item| resolve(item, created)), + _ => Ok(()), + } +} + +fn resolve_path(path: &str, created: &AHashMap) -> Result { + path.split('/') + .map(|segment| match segment.strip_prefix("bulkId:") { + Some(reference) => created + .get(reference) + .cloned() + .ok_or_else(|| reference.to_string()), + None => Ok(segment.to_string()), + }) + .collect::, _>>() + .map(|segments| segments.join("/")) +} + +pub async fn bulk(ctx: &Ctx<'_>, body: &[u8]) -> Result { + let body = parse_body(body)?; + let schemas = get(&body, "schemas") + .and_then(Value::as_array) + .ok_or_else(|| ScimError::invalid_syntax("The 'schemas' attribute is missing"))?; + if !schemas.iter().any(|s| { + s.as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(MESSAGE_BULK_REQUEST)) + }) { + return Err(ScimError::invalid_syntax(format!( + "'schemas' must include '{MESSAGE_BULK_REQUEST}'" + ))); + } + let operations = get(&body, "Operations") + .and_then(Value::as_array) + .ok_or_else(|| ScimError::invalid_syntax("'Operations' must be a list"))?; + if operations.len() > MAX_OPERATIONS { + return Err(ScimError::new( + 413, + format!("A bulk request takes at most {MAX_OPERATIONS} operations"), + )); + } + let fail_on_errors = + match get(&body, "failOnErrors") { + Some(value) => Some(value.as_u64().filter(|n| *n > 0).ok_or_else(|| { + ScimError::invalid_value("'failOnErrors' must be a positive number") + })? as usize), + None => None, + }; + + let mut created: AHashMap = AHashMap::new(); + let mut results = Vec::with_capacity(operations.len()); + let mut errors = 0; + for operation in operations { + if fail_on_errors.is_some_and(|limit| errors >= limit) { + break; + } + let Some(operation) = operation.as_object() else { + return Err(ScimError::invalid_syntax( + "Each operation must be an object", + )); + }; + let (result, created_id) = run_one(ctx, operation, &created).await; + let bulk_id = get(operation, "bulkId").and_then(Value::as_str); + if let (Some(bulk_id), Some(id)) = (bulk_id, created_id) { + created.insert(bulk_id.to_string(), id); + } + if result + .get("status") + .and_then(Value::as_str) + .is_some_and(|s| !s.starts_with('2')) + { + errors += 1; + } + results.push(result); + } + + Ok(ScimResponse::json( + 200, + json!({ + "schemas": [MESSAGE_BULK_RESPONSE], + "Operations": results, + }), + )) +} + +/// One operation's result, and the id it created, if any. +async fn run_one( + ctx: &Ctx<'_>, + operation: &Map, + created: &AHashMap, +) -> (Value, Option) { + let method_text = get(operation, "method") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_uppercase(); + let bulk_id = get(operation, "bulkId").and_then(Value::as_str); + let mut result = Map::new(); + result.insert("method".into(), json!(method_text)); + if let Some(bulk_id) = bulk_id { + result.insert("bulkId".into(), json!(bulk_id)); + } + let fail = |mut result: Map, error: ScimError| { + result.insert("status".into(), json!(error.status.to_string())); + result.insert("response".into(), error.to_json()); + (Value::Object(result), None) + }; + + let method = match method_text.as_str() { + "POST" => Method::POST, + "PUT" => Method::PUT, + "PATCH" => Method::PATCH, + "DELETE" => Method::DELETE, + _ => { + return fail( + result, + ScimError::invalid_syntax(format!("'{method_text}' isn't a bulk method")), + ); + } + }; + if method == Method::POST && bulk_id.is_none() { + return fail(result, ScimError::invalid_syntax("A POST needs a 'bulkId'")); + } + let Some(path) = get(operation, "path").and_then(Value::as_str) else { + return fail( + result, + ScimError::invalid_syntax("Each operation needs a 'path'"), + ); + }; + let unresolved = |reference: String| ScimError { + status: 409, + scim_type: Some(scim_proto::ScimType::InvalidValue), + detail: format!("The reference 'bulkId:{reference}' can't be resolved"), + }; + let path = match resolve_path(path, created) { + Ok(path) => path, + Err(reference) => return fail(result, unresolved(reference)), + }; + let mut data = get(operation, "data").cloned().unwrap_or(Value::Null); + if let Err(reference) = resolve(&mut data, created) { + return fail(result, unresolved(reference)); + } + + let segments = path + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect::>(); + let route = match Route::parse(&method, &segments) { + Ok( + route @ (Route::Create(_) | Route::Replace(..) | Route::Modify(..) | Route::Delete(..)), + ) => route, + _ => { + return fail( + result, + ScimError::invalid_value(format!("'{method_text} {path}' isn't a bulk operation")), + ); + } + }; + let kind = match &route { + Route::Create(kind) + | Route::Replace(kind, _) + | Route::Modify(kind, _) + | Route::Delete(kind, _) => *kind, + _ => ResourceKind::User, + }; + let mut headers = HeaderMap::new(); + if let Some(version) = get(operation, "version").and_then(Value::as_str) + && let Ok(value) = HeaderValue::from_str(version) + { + headers.insert("if-match", value); + } + let body = if data.is_null() { + Vec::new() + } else { + data.to_string().into_bytes() + }; + + match dispatch(ctx, kind, &route, None, &headers, &body).await { + Ok(response) => { + let location = response + .body + .as_ref() + .and_then(|b| b.pointer("/meta/location")) + .and_then(Value::as_str) + .map(str::to_string); + let id = response + .body + .as_ref() + .and_then(|b| b.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + let version = response + .body + .as_ref() + .and_then(|b| b.pointer("/meta/version")) + .and_then(Value::as_str) + .map(str::to_string); + result.insert("status".into(), json!(response.status.to_string())); + if let Some(location) = location { + result.insert("location".into(), json!(location)); + } + if let Some(version) = version { + result.insert("version".into(), json!(version)); + } + let created_id = if matches!(route, Route::Create(_)) { + id + } else { + None + }; + (Value::Object(result), created_id) + } + Err(error) => fail(result, error), + } +} diff --git a/crates/scim/src/context.rs b/crates/scim/src/context.rs new file mode 100644 index 0000000..42e232f --- /dev/null +++ b/crates/scim/src/context.rs @@ -0,0 +1,334 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The caller and its scope (SCIM-11 to SCIM-20), and the `x:Account` +//! reads and writes every resource operation goes through. + +use crate::{ResourceKind, server_error}; +use common::{ + Server, + auth::{AccessToken, DomainCache}, +}; +use http_proto::HttpSessionData; +use jmap::registry::set::RegistrySet; +use jmap_proto::{method::set::SetRequest, object::registry::Registry}; +use registry::{ + schema::{ + enums::Permission, + prelude::{ObjectType, Property}, + structs::Account, + }, + types::EnumImpl, +}; +use scim_proto::ScimError; +use serde_json::{Value, json}; +use std::{str::FromStr, sync::Arc}; +use store::registry::RegistryQuery; +use types::id::Id; + +/// The server's public address with `/scim/v2` (SCIM-30). +pub fn base_url(server: &Server) -> String { + format!( + "{}/scim/v2", + server.core.network.http.url_https.trim_end_matches('/') + ) +} + +pub struct Ctx<'x> { + pub server: &'x Server, + pub token: &'x AccessToken, + pub session: &'x HttpSessionData, + pub base: String, +} + +impl<'x> Ctx<'x> { + /// Checks the two gates every non-discovery request passes (SCIM-11). + pub async fn new( + server: &'x Server, + token: &'x AccessToken, + session: &'x HttpSessionData, + ) -> Result, ScimError> { + let ctx = Ctx { + server, + token, + session, + base: base_url(server), + }; + ctx.require(Permission::Authenticate)?; + ctx.require(Permission::ScimAccess)?; + Ok(ctx) + } + + /// A `403` naming the missing permission (SCIM-11). + pub fn require(&self, permission: Permission) -> Result<(), ScimError> { + if self.token.has_permission(permission) { + Ok(()) + } else { + Err(ScimError::forbidden(format!( + "The credential lacks the '{}' permission", + permission.as_str() + ))) + } + } + + pub fn tenant_id(&self) -> Option { + self.token.tenant_id() + } + + /// The service principal's own id (SCIM-13). + pub fn principal_id(&self) -> u32 { + self.token.account_id() + } + + pub fn location(&self, kind: ResourceKind, id: Id) -> String { + format!("{}/{}/{id}", self.base, kind.endpoint()) + } + + /// A domain a write may put an address on (SCIM-15, SCIM-17). + pub async fn writable_domain(&self, name: &str) -> Result, ScimError> { + let not_open = || { + ScimError::invalid_value(format!( + "The domain '{name}' isn't open to SCIM provisioning" + )) + }; + let domain = self + .server + .domain(name) + .await + .map_err(server_error)? + .ok_or_else(not_open)?; + if let Some(tenant_id) = self.tenant_id() + && domain.id_tenant != Some(tenant_id) + { + return Err(ScimError::not_found(format!( + "The domain '{name}' isn't in your tenant" + ))); + } + if !domain.allows_scim() { + return Err(not_open()); + } + Ok(domain) + } + + /// The domain, when it's in the caller's SCIM scope (SCIM-16). + pub async fn scoped_domain( + &self, + domain_id: u32, + ) -> Result>, ScimError> { + Ok(self + .server + .domain_by_id(domain_id) + .await + .map_err(server_error)? + .filter(|domain| { + domain.allows_scim() + && self + .tenant_id() + .is_none_or(|tenant_id| domain.id_tenant == Some(tenant_id)) + })) + } + + /// Every domain in the caller's SCIM scope (SCIM-16). + pub async fn scoped_domains(&self) -> Result>, ScimError> { + let ids = self + .server + .registry() + .query::>(RegistryQuery::new(ObjectType::Domain).with_tenant(self.tenant_id())) + .await + .map_err(server_error)?; + let mut domains = Vec::new(); + for id in ids { + if let Some(domain) = self.scoped_domain(id.document_id()).await? { + domains.push(domain); + } + } + Ok(domains) + } + + /// Whether an account is in the caller's scope (SCIM-16, SCIM-17). + pub async fn in_scope(&self, account: &Account) -> Result { + let (domain_id, tenant_id) = match account { + Account::User(user) => (user.domain_id, user.member_tenant_id), + Account::Group(group) => (group.domain_id, group.member_tenant_id), + }; + if let Some(caller) = self.tenant_id() + && tenant_id.map(|id| id.document_id()) != Some(caller) + { + return Ok(false); + } + Ok(self.scoped_domain(domain_id.document_id()).await?.is_some()) + } + + /// Reads an account of that kind in scope. Anything else, another + /// tenant's included, is `404` (SCIM-17, SCIM-40). + pub async fn load(&self, kind: ResourceKind, id: &str) -> Result<(Id, Account), ScimError> { + let not_found = || ScimError::not_found(format!("{} {id} not found", kind.name())); + let id = Id::from_str(id).map_err(|_| not_found())?; + let account = self.load_id(id).await?.ok_or_else(not_found)?; + let matches = matches!( + (&account, kind), + (Account::User(_), ResourceKind::User) | (Account::Group(_), ResourceKind::Group) + ); + if matches && self.in_scope(&account).await? { + Ok((id, account)) + } else { + Err(not_found()) + } + } + + /// Reads any account, in scope or not. + pub async fn load_id(&self, id: Id) -> Result, ScimError> { + self.server + .registry() + .object::(id) + .await + .map_err(server_error) + } + + /// Account ids matching `query`, within the caller's tenant. + pub async fn query_ids(&self, query: RegistryQuery) -> Result, ScimError> { + self.server + .registry() + .query::>(query.with_tenant(self.tenant_id())) + .await + .map_err(server_error) + } + + /// Accounts of one kind on one domain. + pub fn accounts_query(kind: ResourceKind) -> RegistryQuery { + RegistryQuery::new(ObjectType::Account).equal( + Property::Type, + match kind { + ResourceKind::User => registry::schema::enums::AccountType::User, + ResourceKind::Group => registry::schema::enums::AccountType::Group, + } + .to_id(), + ) + } + + /// An `x:Account/set` as the caller, through the same path JMAP takes, + /// so every registry check applies. Returns the response as JSON. + async fn account_set(&self, request: Value) -> Result { + let text = request.to_string(); + let request = serde_json::from_str::>(&text) + .map_err(|err| server_error(trc::JmapEvent::InvalidArguments.into_err().reason(err)))?; + let response = self + .server + .registry_set(ObjectType::Account, request, self.token, self.session) + .await + .map_err(|err| { + if matches!( + err.event_type(), + trc::EventType::Jmap(trc::JmapEvent::Forbidden) + | trc::EventType::Security(trc::SecurityEvent::Unauthorized) + ) { + ScimError::forbidden( + err.value_as_str(trc::Key::Details) + .unwrap_or("The request isn't allowed") + .to_string(), + ) + } else { + server_error(err) + } + })?; + serde_json::to_value(&response) + .map_err(|err| server_error(trc::JmapEvent::InvalidArguments.into_err().reason(err))) + } + + fn account_id(&self) -> String { + Id::from(self.token.account_id()).to_string() + } + + /// Creates an account; the new id, or the registry's refusal. + pub async fn create(&self, object: Value) -> Result { + let response = self + .account_set(json!({ + "accountId": self.account_id(), + "create": {"scim": object}, + })) + .await?; + if let Some(error) = response.pointer("/notCreated/scim") { + return Err(set_error(error)); + } + response + .pointer("/created/scim/id") + .and_then(Value::as_str) + .and_then(|id| Id::from_str(id).ok()) + .ok_or_else(|| ScimError::new(500, "The account wasn't created")) + } + + /// Updates an account with a JMAP patch object. + pub async fn update(&self, id: Id, patch: Value) -> Result<(), ScimError> { + let key = id.to_string(); + let response = self + .account_set(json!({ + "accountId": self.account_id(), + "update": {key.clone(): patch}, + })) + .await?; + match response.get("notUpdated").and_then(|v| v.get(&key)) { + Some(error) => Err(set_error(error)), + None => Ok(()), + } + } + + /// Destroys an account, the way an administrator's destroy does + /// (SCIM-52, SCIM-53). + pub async fn destroy(&self, id: Id) -> Result<(), ScimError> { + let key = id.to_string(); + let response = self + .account_set(json!({ + "accountId": self.account_id(), + "destroy": [key.clone()], + })) + .await?; + match response.get("notDestroyed").and_then(|v| v.get(&key)) { + Some(error) => Err(set_error(error)), + None => Ok(()), + } + } +} + +/// A registry refusal as a SCIM error. Only the description is passed on, +/// never another object's id. +pub fn set_error(error: &Value) -> ScimError { + let description = error + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let with = |fallback: &str| { + if description.is_empty() { + fallback.to_string() + } else { + description.clone() + } + }; + match error + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + { + "primaryKeyViolation" | "alreadyExists" => { + ScimError::conflict(with("The address or name is already in use")) + } + "forbidden" => ScimError::forbidden(with("The change isn't allowed")), + // SCIM-20: a tenant limit, named in the description + "overQuota" => ScimError::forbidden(with("A tenant limit is reached")), + "notFound" => ScimError::not_found(with("The resource wasn't found")), + "invalidForeignKey" => ScimError::invalid_value(with( + "A referenced resource is in a different tenant or doesn't exist", + )), + "objectIsLinked" => ScimError::invalid_value(with("Other objects still refer to it")), + _ => { + let mut detail = with("The value isn't valid"); + if let Some(errors) = error.get("validationErrors") { + detail = format!("{detail}: {errors}"); + } + ScimError::invalid_value(detail) + } + } +} diff --git a/crates/scim/src/cursor.rs b/crates/scim/src/cursor.rs new file mode 100644 index 0000000..99dee17 --- /dev/null +++ b/crates/scim/src/cursor.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Cursor pagination (RFC 9865, SCIM-49). A cursor carries its own state, +//! sealed with an HMAC under the server's key: the position, the page size, +//! when it expires, and a hash of what produced it (principal, query and +//! sort). Nothing is kept on the server. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use scim_proto::{ScimError, ScimType}; +use sha2::Sha256; + +const VERSION: u8 = 1; +const TAG_LEN: usize = 16; +const BODY_LEN: usize = 1 + 8 + 8 + 8 + 8; + +/// What a cursor is bound to. +pub fn binding(parts: &[&str]) -> u64 { + let mut text = String::new(); + for part in parts { + text.push_str(part); + text.push('\u{0}'); + } + xxhash_rust::xxh3::xxh3_64(text.as_bytes()) +} + +fn tag(key: &[u8], body: &[u8]) -> Vec { + let mut mac = as KeyInit>::new_from_slice(key).expect("HMAC takes any key"); + mac.update(b"inbuxa-scim-cursor"); + mac.update(body); + mac.finalize().into_bytes()[..TAG_LEN].to_vec() +} + +pub fn encode(key: &[u8], offset: u64, count: u64, expires: u64, binding: u64) -> String { + let mut body = Vec::with_capacity(BODY_LEN + TAG_LEN); + body.push(VERSION); + body.extend_from_slice(&offset.to_be_bytes()); + body.extend_from_slice(&count.to_be_bytes()); + body.extend_from_slice(&expires.to_be_bytes()); + body.extend_from_slice(&binding.to_be_bytes()); + let tag = tag(key, &body); + body.extend_from_slice(&tag); + URL_SAFE_NO_PAD.encode(body) +} + +/// The position a cursor points at, if it's genuine, unexpired, and was +/// issued for this binding and page size. +pub fn decode( + key: &[u8], + cursor: &str, + count: u64, + now: u64, + binding: u64, +) -> Result { + let invalid = || ScimError::bad_request(ScimType::InvalidCursor, "The cursor isn't valid"); + let bytes = URL_SAFE_NO_PAD + .decode(cursor.trim()) + .map_err(|_| invalid())?; + if bytes.len() != BODY_LEN + TAG_LEN || bytes[0] != VERSION { + return Err(invalid()); + } + let (body, sent) = bytes.split_at(BODY_LEN); + let expected = tag(key, body); + // Constant-time comparison + if sent + .iter() + .zip(&expected) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + != 0 + { + return Err(invalid()); + } + let read = |at: usize| u64::from_be_bytes(body[at..at + 8].try_into().unwrap()); + let (offset, issued_count, expires, bound) = (read(1), read(9), read(17), read(25)); + if bound != binding { + return Err(invalid()); + } + if expires < now { + return Err(ScimError::bad_request( + ScimType::ExpiredCursor, + "The cursor has expired", + )); + } + if issued_count != count { + return Err(ScimError::bad_request( + ScimType::InvalidCount, + "The count differs from the one the cursor was issued for", + )); + } + Ok(offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_refuses_changes() { + let key = b"a server key"; + let bind = binding(&["principal", "userName eq \"a\""]); + let cursor = encode(key, 200, 200, 1000, bind); + assert_eq!(decode(key, &cursor, 200, 999, bind).unwrap(), 200); + + let err = |r: Result| r.unwrap_err().scim_type.unwrap(); + assert_eq!( + err(decode(key, &cursor, 100, 999, bind)), + ScimType::InvalidCount + ); + assert_eq!( + err(decode(key, &cursor, 200, 1001, bind)), + ScimType::ExpiredCursor + ); + assert_eq!( + err(decode(key, &cursor, 200, 999, bind + 1)), + ScimType::InvalidCursor + ); + assert_eq!( + err(decode(b"other key", &cursor, 200, 999, bind)), + ScimType::InvalidCursor + ); + + let mut tampered = URL_SAFE_NO_PAD.decode(&cursor).unwrap(); + tampered[8] ^= 1; + let tampered = URL_SAFE_NO_PAD.encode(tampered); + assert_eq!( + err(decode(key, &tampered, 200, 999, bind)), + ScimType::InvalidCursor + ); + assert_eq!( + err(decode(key, "garbage", 200, 999, bind)), + ScimType::InvalidCursor + ); + } +} diff --git a/crates/scim/src/discovery.rs b/crates/scim/src/discovery.rs new file mode 100644 index 0000000..369e05a --- /dev/null +++ b/crates/scim/src/discovery.rs @@ -0,0 +1,294 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The discovery endpoints (SCIM-3 to SCIM-6): fixed documents, no account +//! data. + +use crate::{ + CURSOR_TIMEOUT, DEFAULT_PAGE_SIZE, MAX_OPERATIONS, MAX_PAYLOAD, MAX_RESULTS, ScimResponse, +}; +use scim_proto::{ + MESSAGE_LIST_RESPONSE, SCHEMA_GROUP, SCHEMA_RESOURCE_TYPE, SCHEMA_SCHEMA, + SCHEMA_SERVICE_PROVIDER_CONFIG, SCHEMA_USER, ScimError, +}; +use serde_json::{Value, json}; + +/// INBUXA's own documentation, never upstream's (SCIM-4). +const DOCUMENTATION: &str = "https://inbuxa.org"; + +pub fn service_provider_config(base: &str) -> Value { + json!({ + "schemas": [SCHEMA_SERVICE_PROVIDER_CONFIG], + "documentationUri": DOCUMENTATION, + "patch": {"supported": true}, + "bulk": { + "supported": true, + "maxOperations": MAX_OPERATIONS, + "maxPayloadSize": MAX_PAYLOAD, + }, + "filter": {"supported": true, "maxResults": MAX_RESULTS}, + "changePassword": {"supported": false}, + "sort": {"supported": true}, + "etag": {"supported": true}, + "authenticationSchemes": [{ + "type": "oauthbearertoken", + "name": "API key", + "description": "An API key of the service principal's, sent as an Authorization: Bearer token", + "documentationUri": DOCUMENTATION, + "primary": true, + }], + "pagination": { + "cursor": true, + "index": true, + "defaultPaginationMethod": "index", + "defaultPageSize": DEFAULT_PAGE_SIZE, + "maxPageSize": MAX_RESULTS, + "cursorTimeout": CURSOR_TIMEOUT, + }, + "interopProfileConformant": false, + "meta": { + "resourceType": "ServiceProviderConfig", + "location": format!("{base}/ServiceProviderConfig"), + }, + }) +} + +fn resource_type(base: &str, name: &str) -> Option { + let (endpoint, schema, description) = match name { + "User" => ("/Users", SCHEMA_USER, "A mailbox account"), + "Group" => ("/Groups", SCHEMA_GROUP, "A group of users"), + _ => return None, + }; + Some(json!({ + "schemas": [SCHEMA_RESOURCE_TYPE], + "id": name, + "name": name, + "endpoint": endpoint, + "description": description, + "schema": schema, + "meta": { + "resourceType": "ResourceType", + "location": format!("{base}/ResourceTypes/{name}"), + }, + })) +} + +fn list(items: Vec) -> Value { + json!({ + "schemas": [MESSAGE_LIST_RESPONSE], + "totalResults": items.len(), + "itemsPerPage": items.len(), + "startIndex": 1, + "Resources": items, + }) +} + +pub fn resource_types(base: &str, id: Option<&str>) -> ScimResponse { + match id { + Some(id) => match ["User", "Group"] + .into_iter() + .find(|name| name.eq_ignore_ascii_case(id)) + .and_then(|name| resource_type(base, name)) + { + Some(value) => ScimResponse::json(200, value), + None => ScimError::not_found(format!("There is no resource type '{id}'")).into(), + }, + None => ScimResponse::json( + 200, + list( + ["User", "Group"] + .into_iter() + .filter_map(|name| resource_type(base, name)) + .collect(), + ), + ), + } +} + +pub fn schemas(base: &str, id: Option<&str>) -> ScimResponse { + let all = [user_schema(base), group_schema(base)]; + match id { + Some(id) => match all.into_iter().find(|schema| { + schema["id"] + .as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(id)) + }) { + Some(value) => ScimResponse::json(200, value), + None => ScimError::not_found(format!("There is no schema '{id}'")).into(), + }, + None => ScimResponse::json(200, list(all.into_iter().collect())), + } +} + +/// One attribute definition (RFC 7643 §7). +struct Attr { + name: &'static str, + kind: &'static str, + multi: bool, + required: bool, + case_exact: bool, + mutability: &'static str, + returned: &'static str, + uniqueness: &'static str, + sub: Vec, + canonical: &'static [&'static str], + reference_types: &'static [&'static str], +} + +impl Attr { + fn new(name: &'static str, kind: &'static str) -> Self { + Attr { + name, + kind, + multi: false, + required: false, + case_exact: false, + mutability: "readWrite", + returned: "default", + uniqueness: "none", + sub: Vec::new(), + canonical: &[], + reference_types: &[], + } + } + + fn multi(mut self) -> Self { + self.multi = true; + self + } + + fn required(mut self) -> Self { + self.required = true; + self + } + + fn case_exact(mut self) -> Self { + self.case_exact = true; + self + } + + fn read_only(mut self) -> Self { + self.mutability = "readOnly"; + self + } + + fn immutable(mut self) -> Self { + self.mutability = "immutable"; + self + } + + fn unique(mut self) -> Self { + self.uniqueness = "server"; + self + } + + fn with(mut self, sub: Vec) -> Self { + self.sub = sub; + self + } + + fn canonical(mut self, values: &'static [&'static str]) -> Self { + self.canonical = values; + self + } + + fn refs(mut self, types: &'static [&'static str]) -> Self { + self.reference_types = types; + self + } + + fn to_json(&self) -> Value { + let mut value = json!({ + "name": self.name, + "type": self.kind, + "multiValued": self.multi, + "description": "", + "required": self.required, + "caseExact": self.case_exact, + "mutability": self.mutability, + "returned": self.returned, + "uniqueness": self.uniqueness, + }); + if !self.sub.is_empty() { + value["subAttributes"] = Value::Array(self.sub.iter().map(Attr::to_json).collect()); + } + if !self.canonical.is_empty() { + value["canonicalValues"] = json!(self.canonical); + } + if !self.reference_types.is_empty() { + value["referenceTypes"] = json!(self.reference_types); + } + value + } +} + +fn schema(base: &str, id: &str, name: &str, description: &str, attributes: Vec) -> Value { + json!({ + "schemas": [SCHEMA_SCHEMA], + "id": id, + "name": name, + "description": description, + "attributes": attributes.iter().map(Attr::to_json).collect::>(), + "meta": { + "resourceType": "Schema", + "location": format!("{base}/Schemas/{id}"), + }, + }) +} + +/// The User attributes of the mapping table (SCIM-6); `password` isn't +/// published. +pub fn user_schema(base: &str) -> Value { + schema( + base, + SCHEMA_USER, + "User", + "A mailbox account", + vec![ + Attr::new("userName", "string").required().unique(), + Attr::new("externalId", "string").case_exact(), + Attr::new("displayName", "string"), + Attr::new("name", "complex").with(vec![Attr::new("formatted", "string")]), + Attr::new("active", "boolean"), + Attr::new("emails", "complex").multi().with(vec![ + Attr::new("value", "string"), + Attr::new("type", "string").canonical(&["work"]), + Attr::new("primary", "boolean"), + ]), + Attr::new("locale", "string"), + Attr::new("preferredLanguage", "string"), + Attr::new("timezone", "string"), + Attr::new("groups", "complex") + .multi() + .read_only() + .with(vec![ + Attr::new("value", "string").read_only(), + Attr::new("display", "string").read_only(), + Attr::new("$ref", "reference").read_only().refs(&["Group"]), + ]), + ], + ) +} + +/// The Group attributes of the mapping table (SCIM-6). +pub fn group_schema(base: &str) -> Value { + schema( + base, + SCHEMA_GROUP, + "Group", + "A group of users", + vec![ + Attr::new("displayName", "string").required().unique(), + Attr::new("externalId", "string").case_exact(), + Attr::new("members", "complex").multi().with(vec![ + Attr::new("value", "string").immutable(), + Attr::new("display", "string").read_only(), + Attr::new("type", "string").immutable().canonical(&["User"]), + Attr::new("$ref", "reference").immutable().refs(&["User"]), + ]), + ], + ) +} diff --git a/crates/scim/src/groups.rs b/crates/scim/src/groups.rs new file mode 100644 index 0000000..68d0596 --- /dev/null +++ b/crates/scim/src/groups.rs @@ -0,0 +1,440 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Groups (SCIM-34 to SCIM-38): an `x:GroupAccount` as a SCIM Group, with +//! membership kept on each member (`memberGroupIds`). + +use crate::{ + MAX_RESULTS, ResourceKind, + context::Ctx, + resource::{Projection, WriteMode, audit, check_attributes, get, stamp}, + server_error, + users::{check_external_id, display_of}, +}; +use registry::schema::{enums::Permission, prelude::Property, structs::Account}; +use scim_proto::{SCHEMA_GROUP, ScimError}; +use serde_json::{Map, Value, json}; +use std::str::FromStr; +use types::id::Id; + +const KNOWN: &[&str] = &[ + "schemas", + "id", + "externalId", + "meta", + "displayName", + "members", + "description", +]; + +/// Ids of the users in scope that are members of the group. +pub async fn member_ids(ctx: &Ctx<'_>, group_id: Id) -> Result, ScimError> { + let ids = ctx + .query_ids( + Ctx::accounts_query(ResourceKind::User).equal(Property::MemberGroupIds, group_id.id()), + ) + .await?; + let mut members = Vec::new(); + for id in ids { + if let Some(account) = ctx.load_id(id).await? + && ctx.in_scope(&account).await? + { + members.push((id, account)); + } + } + members.sort_by_key(|(id, _)| id.id()); + Ok(members) +} + +pub async fn render( + ctx: &Ctx<'_>, + id: Id, + account: &Account, + projection: Option<&Projection>, +) -> Result { + let Account::Group(group) = account else { + return Err(ScimError::not_found(format!("Group {id} not found"))); + }; + let members = member_ids(ctx, id).await?; + // SCIM-37 + if members.len() > MAX_RESULTS && projection.is_some_and(|p| p.includes("members")) { + return Err(ScimError::too_many(format!( + "The group has more than {MAX_RESULTS} members: read it with \ + excludedAttributes=members, and membership from the users' 'groups'" + ))); + } + let mut doc = Map::new(); + doc.insert("schemas".into(), json!([SCHEMA_GROUP])); + doc.insert("id".into(), json!(id.to_string())); + if let Some(external_id) = &group.external_id { + doc.insert("externalId".into(), json!(external_id)); + } + doc.insert("displayName".into(), json!(display_of(account))); + doc.insert( + "members".into(), + Value::Array( + members + .iter() + .map(|(member_id, member)| { + json!({ + "value": member_id.to_string(), + "display": display_of(member), + "type": "User", + "$ref": ctx.location(ResourceKind::User, *member_id), + }) + }) + .collect(), + ), + ); + doc.insert( + "meta".into(), + json!({ + "resourceType": "Group", + "created": group.created_at.to_string(), + "location": ctx.location(ResourceKind::Group, id), + }), + ); + Ok(stamp(Value::Object(doc))) +} + +/// `Sales EMEA` becomes `sales-emea` (SCIM-35). +pub fn slug(display: &str) -> String { + let mut out = String::new(); + let mut hyphen = false; + for c in display.chars() { + if c.is_ascii_alphanumeric() { + if hyphen && !out.is_empty() { + out.push('-'); + } + hyphen = false; + out.push(c.to_ascii_lowercase()); + } else { + hyphen = true; + } + } + if out.is_empty() { + "group".to_string() + } else { + out + } +} + +/// `displayName` is unique among groups in scope, in any case (SCIM-34). +async fn check_display_name( + ctx: &Ctx<'_>, + display: &str, + except: Option, +) -> Result<(), ScimError> { + for domain in ctx.scoped_domains().await? { + let ids = ctx + .query_ids( + Ctx::accounts_query(ResourceKind::Group) + .equal(Property::DomainId, domain.id as u64), + ) + .await?; + for id in ids { + if Some(id) == except { + continue; + } + if let Some(group) = ctx.load_id(id).await? + && display_of(&group).is_some_and(|name| name.eq_ignore_ascii_case(display)) + { + return Err(ScimError::conflict(format!( + "A group named '{display}' already exists" + ))); + } + } + } + Ok(()) +} + +/// The members sent, as ids of users in scope and in the group's tenant +/// (SCIM-19, SCIM-36). +async fn resolve_members( + ctx: &Ctx<'_>, + body: &Map, + tenant: Option, +) -> Result, ScimError> { + let Some(members) = get(body, "members") else { + return Ok(vec![]); + }; + let members = members + .as_array() + .ok_or_else(|| ScimError::invalid_syntax("'members' must be a list"))?; + let mut resolved: Vec<(Id, Account)> = Vec::new(); + for member in members { + let value = member + .as_object() + .and_then(|m| get(m, "value")) + .and_then(Value::as_str) + .ok_or_else(|| ScimError::invalid_value("Each member needs a 'value'"))?; + if resolved.iter().any(|(id, _)| id.to_string() == value) { + continue; + } + let unknown = + || ScimError::invalid_value(format!("The member '{value}' isn't a user in scope")); + let id = Id::from_str(value).map_err(|_| unknown())?; + let account = ctx.load_id(id).await?.ok_or_else(unknown)?; + // Outside the caller's scope it doesn't exist (SCIM-17) + if !ctx.in_scope(&account).await? { + return Err(unknown()); + } + if matches!(account, Account::Group(_)) { + return Err(ScimError::invalid_value(format!( + "The member '{value}' is a group: only users can be members" + ))); + } + let member_tenant = match &account { + Account::User(user) => user.member_tenant_id.map(|t| t.document_id()), + Account::Group(_) => None, + }; + // In scope, it may still be in another tenant from the group's + // (SCIM-19) + if member_tenant != tenant { + return Err(ScimError::invalid_value(format!( + "The member '{value}' is in a different tenant from the group" + ))); + } + resolved.push((id, account)); + } + Ok(resolved) +} + +/// Adds or removes one membership, written on the user (SCIM-36). +async fn set_membership( + ctx: &Ctx<'_>, + user_id: Id, + user: &Account, + group_id: Id, + member: bool, +) -> Result<(), ScimError> { + let Account::User(user) = user else { + return Ok(()); + }; + let mut groups = user + .member_group_ids + .iter() + .copied() + .filter(|id| *id != group_id) + .collect::>(); + if member { + groups.push(group_id); + } + let map = groups + .iter() + .map(|id| (id.to_string(), Value::Bool(true))) + .collect::>(); + ctx.update(user_id, json!({"memberGroupIds": map})).await +} + +/// Removes every membership of the group, in scope or not, so it can be +/// destroyed (SCIM-53). +pub async fn remove_all_members(ctx: &Ctx<'_>, group_id: Id) -> Result<(), ScimError> { + let ids = ctx + .query_ids( + Ctx::accounts_query(ResourceKind::User).equal(Property::MemberGroupIds, group_id.id()), + ) + .await?; + for id in ids { + if let Some(account) = ctx.load_id(id).await? { + set_membership(ctx, id, &account, group_id, false).await?; + } + } + Ok(()) +} + +fn display_name(body: &Map) -> Result { + get(body, "displayName") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| ScimError::invalid_value("'displayName' is required")) +} + +fn external_id(body: &Map) -> Result, ScimError> { + match get(body, "externalId") { + Some(Value::String(id)) if id.is_empty() => { + Err(ScimError::invalid_value("'externalId' can't be empty")) + } + Some(Value::String(id)) => Ok(Some(id.clone())), + Some(_) => Err(ScimError::invalid_value("'externalId' must be a string")), + None => Ok(None), + } +} + +/// `POST /Groups` (SCIM-18, SCIM-34 to SCIM-36, SCIM-39). +pub async fn create(ctx: &Ctx<'_>, body: &Map) -> Result { + check_attributes(body, ResourceKind::Group, KNOWN)?; + let display = display_name(body)?; + let external_id = external_id(body)?; + + // SCIM-18: on the service principal's own domain + let principal = ctx + .load_id(Id::from(ctx.principal_id())) + .await? + .ok_or_else(|| ScimError::forbidden("The service principal no longer exists"))?; + let Account::User(principal) = principal else { + return Err(ScimError::forbidden("The service principal isn't a user")); + }; + let domain = match ctx.scoped_domain(principal.domain_id.document_id()).await? { + Some(domain) => domain, + None => { + let name = ctx + .server + .domain_by_id(principal.domain_id.document_id()) + .await + .map_err(server_error)? + .map(|d| d.name().to_string()) + .unwrap_or_default(); + return Err(ScimError::invalid_value(format!( + "Groups go on the service principal's domain '{name}', which isn't open to SCIM provisioning" + ))); + } + }; + let tenant = domain.id_tenant; + + let members = resolve_members(ctx, body, tenant).await?; + if !members.is_empty() { + ctx.require(Permission::SysAccountUpdate)?; + } + check_display_name(ctx, &display, None).await?; + if let Some(external_id) = &external_id { + check_external_id(ctx, ResourceKind::Group, external_id, tenant, None).await?; + } + + // SCIM-35: the first free address from the display name + let base = slug(&display); + // Cut to 64 with room for a `-1000` suffix + let base = base[..base.len().min(59)].trim_end_matches('-').to_string(); + let mut name = None; + for n in 1..=1000 { + let candidate = if n == 1 { + base.clone() + } else { + format!("{base}-{n}") + }; + if ctx + .server + .rcpt_id_from_parts(&candidate, domain.id) + .await + .map_err(server_error)? + .is_none() + { + name = Some(candidate); + break; + } + } + let name = name.ok_or_else(|| { + ScimError::conflict(format!( + "No free address was found for the group '{display}'" + )) + })?; + + let mut object = json!({ + "@type": "Group", + "name": name, + "domainId": Id::from(domain.id).to_string(), + "description": display, + "externalId": external_id, + }); + // MT-7: in its domain's tenant; a tenant caller's writes get it anyway + if let Some(tenant) = tenant + && ctx.tenant_id().is_none() + { + object["memberTenantId"] = json!(Id::from(tenant).to_string()); + } + let id = ctx.create(object).await?; + for (member_id, member) in &members { + set_membership(ctx, *member_id, member, id, true).await?; + } + audit( + ctx, + trc::ScimEvent::ResourceCreated, + ResourceKind::Group, + id, + external_id.as_deref(), + ); + Ok(id) +} + +/// `PUT` and the result of `PATCH` (SCIM-36, SCIM-41, SCIM-42). +pub async fn replace( + ctx: &Ctx<'_>, + id: Id, + account: &Account, + _current: &Value, + body: &Map, + _mode: WriteMode, +) -> Result<(), ScimError> { + let Account::Group(group) = account else { + return Err(ScimError::not_found(format!("Group {id} not found"))); + }; + check_attributes(body, ResourceKind::Group, KNOWN)?; + if let Some(sent) = get(body, "id").and_then(Value::as_str) + && sent != id.to_string() + { + return Err(ScimError::mutability("'id' can't be changed")); + } + let display = display_name(body)?; + let external_id = external_id(body)?; + let tenant = group.member_tenant_id.map(|t| t.document_id()); + let wanted = resolve_members(ctx, body, tenant).await?; + + let mut patch = Map::new(); + if Some(&display) != display_of(account).as_ref() { + check_display_name(ctx, &display, Some(id)).await?; + patch.insert("description".into(), json!(display)); + } + if external_id != group.external_id { + if let Some(external_id) = &external_id { + check_external_id(ctx, ResourceKind::Group, external_id, tenant, Some(id)).await?; + } + patch.insert("externalId".into(), json!(external_id)); + } + + // Membership: add the new, remove the gone (SCIM-36) + let now = member_ids(ctx, id).await?; + let changed = !patch.is_empty(); + if changed { + ctx.update(id, Value::Object(patch)).await?; + } + let mut membership_changed = false; + for (member_id, member) in &wanted { + if !now.iter().any(|(id, _)| id == member_id) { + set_membership(ctx, *member_id, member, id, true).await?; + membership_changed = true; + } + } + for (member_id, member) in &now { + if !wanted.iter().any(|(id, _)| id == member_id) { + set_membership(ctx, *member_id, member, id, false).await?; + membership_changed = true; + } + } + if changed || membership_changed { + audit( + ctx, + trc::ScimEvent::ResourceUpdated, + ResourceKind::Group, + id, + external_id.as_deref(), + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::slug; + + #[test] + fn derives_addresses() { + assert_eq!(slug("Sales EMEA"), "sales-emea"); + assert_eq!(slug(" --R&D / Ops!! "), "r-d-ops"); + assert_eq!(slug("日本"), "group"); + } +} diff --git a/crates/scim/src/lib.rs b/crates/scim/src/lib.rs index 698407d..75cecac 100644 --- a/crates/scim/src/lib.rs +++ b/crates/scim/src/lib.rs @@ -1,5 +1,329 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ + +//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the +//! service provider: an identity provider pushes users and groups to +//! `/scim/v2`, and each request becomes the same `x:Account` reads and +//! writes JMAP makes, so permissions, tenancy, address uniqueness and quotas +//! are enforced in one place. The HTTP crate authenticates the caller; this +//! crate routes and translates. + +pub mod bulk; +pub mod context; +pub mod cursor; +pub mod discovery; +pub mod groups; +pub mod patch; +pub mod query; +pub mod resource; +pub mod users; + +use common::{Server, auth::AccessToken}; +use context::Ctx; +use http_proto::{HttpResponse, HttpSessionData}; +use hyper::{HeaderMap, Method, StatusCode}; +use scim_proto::{CONTENT_TYPE, ScimError}; +use serde_json::Value; + +/// The largest body accepted, `/Bulk` included (SCIM-51). +pub const MAX_PAYLOAD: usize = 1024 * 1024; +/// `/Bulk` operations per request (SCIM-51). +pub const MAX_OPERATIONS: usize = 1000; +/// The most results a page, a filter or a group's members may hold (SCIM-4). +pub const MAX_RESULTS: usize = 200; +/// A page's size when `count` isn't given (SCIM-48). +pub const DEFAULT_PAGE_SIZE: usize = 100; +/// How long a cursor stays good, in seconds (SCIM-49). +pub const CURSOR_TIMEOUT: u64 = 3600; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceKind { + User, + Group, +} + +impl ResourceKind { + pub fn endpoint(&self) -> &'static str { + match self { + ResourceKind::User => "Users", + ResourceKind::Group => "Groups", + } + } + + pub fn name(&self) -> &'static str { + match self { + ResourceKind::User => "User", + ResourceKind::Group => "Group", + } + } + + pub fn schema(&self) -> &'static str { + match self { + ResourceKind::User => scim_proto::SCHEMA_USER, + ResourceKind::Group => scim_proto::SCHEMA_GROUP, + } + } +} + +/// What a path and method ask for (SCIM-2, "Interfaces"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Route { + Options, + ServiceProviderConfig, + ResourceTypes(Option), + Schemas(Option), + Me, + List(ResourceKind), + Create(ResourceKind), + Search(Option), + Get(ResourceKind, String), + Replace(ResourceKind, String), + Modify(ResourceKind, String), + Delete(ResourceKind, String), + Bulk, +} + +impl Route { + /// Routes the path segments after `/scim/v2`. + pub fn parse(method: &Method, segments: &[String]) -> Result { + if method == Method::OPTIONS { + return Ok(Route::Options); + } + let not_allowed = |allow: &str| Err(ScimResponse::method_not_allowed(allow)); + let segments = segments + .iter() + .map(String::as_str) + .filter(|s| !s.is_empty()) + .collect::>(); + let kind = |name: &str| { + if name.eq_ignore_ascii_case("Users") { + Some(ResourceKind::User) + } else if name.eq_ignore_ascii_case("Groups") { + Some(ResourceKind::Group) + } else { + None + } + }; + match segments.as_slice() { + [name] if name.eq_ignore_ascii_case("ServiceProviderConfig") => match *method { + Method::GET => Ok(Route::ServiceProviderConfig), + _ => not_allowed("GET, OPTIONS"), + }, + [name, rest @ ..] if name.eq_ignore_ascii_case("ResourceTypes") && rest.len() <= 1 => { + match *method { + Method::GET => Ok(Route::ResourceTypes(rest.first().map(|s| s.to_string()))), + _ => not_allowed("GET, OPTIONS"), + } + } + [name, rest @ ..] if name.eq_ignore_ascii_case("Schemas") && rest.len() <= 1 => { + match *method { + Method::GET => Ok(Route::Schemas(rest.first().map(|s| s.to_string()))), + _ => not_allowed("GET, OPTIONS"), + } + } + [name, ..] if name.eq_ignore_ascii_case("Me") => Ok(Route::Me), + [name] if name.eq_ignore_ascii_case("Bulk") => match *method { + Method::POST => Ok(Route::Bulk), + _ => not_allowed("POST, OPTIONS"), + }, + [".search"] => match *method { + Method::POST => Ok(Route::Search(None)), + _ => not_allowed("POST, OPTIONS"), + }, + [name] if kind(name).is_some() => { + let kind = kind(name).unwrap(); + match *method { + Method::GET => Ok(Route::List(kind)), + Method::POST => Ok(Route::Create(kind)), + _ => not_allowed("GET, POST, OPTIONS"), + } + } + [name, ".search"] if kind(name).is_some() => match *method { + Method::POST => Ok(Route::Search(kind(name))), + _ => not_allowed("POST, OPTIONS"), + }, + [name, id] if kind(name).is_some() => { + let kind = kind(name).unwrap(); + let id = id.to_string(); + match *method { + Method::GET => Ok(Route::Get(kind, id)), + Method::PUT => Ok(Route::Replace(kind, id)), + Method::PATCH => Ok(Route::Modify(kind, id)), + Method::DELETE => Ok(Route::Delete(kind, id)), + _ => not_allowed("GET, PUT, PATCH, DELETE, OPTIONS"), + } + } + _ => Err(ScimResponse::error(ScimError::not_found( + "There is no such SCIM endpoint", + ))), + } + } + + /// Discovery, `OPTIONS` and `/Me` need no credential (SCIM-2, SCIM-3). + pub fn is_anonymous(&self) -> bool { + matches!( + self, + Route::Options + | Route::ServiceProviderConfig + | Route::ResourceTypes(_) + | Route::Schemas(_) + | Route::Me + ) + } +} + +/// A SCIM answer, turned into an HTTP response at the edge. +#[derive(Debug, Clone)] +pub struct ScimResponse { + pub status: u16, + pub body: Option, + pub headers: Vec<(&'static str, String)>, +} + +impl ScimResponse { + pub fn json(status: u16, body: Value) -> Self { + ScimResponse { + status, + body: Some(body), + headers: Vec::new(), + } + } + + pub fn empty(status: u16) -> Self { + ScimResponse { + status, + body: None, + headers: Vec::new(), + } + } + + pub fn error(error: ScimError) -> Self { + let mut response = ScimResponse::json(error.status, error.to_json()); + if error.status == 401 { + response.headers.push(( + "WWW-Authenticate", + "Bearer realm=\"INBUXA SCIM\"".to_string(), + )); + } + response + } + + pub fn method_not_allowed(allow: &str) -> Self { + let mut response = ScimResponse::error(ScimError::new( + 405, + format!("This endpoint accepts {allow}"), + )); + response.headers.push(("Allow", allow.to_string())); + response + } + + pub fn with_header(mut self, name: &'static str, value: String) -> Self { + self.headers.push((name, value)); + self + } + + pub fn into_http_response(self) -> HttpResponse { + let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut response = HttpResponse::new(status); + for (name, value) in self.headers { + response = response.with_header(name, value); + } + match self.body { + Some(body) => response + .with_content_type(CONTENT_TYPE) + .with_text_body(body.to_string()), + None => response, + } + } +} + +impl From for ScimResponse { + fn from(error: ScimError) -> Self { + ScimResponse::error(error) + } +} + +/// A request that has passed authentication. +pub struct ScimRequest<'x> { + pub route: Route, + pub query: Option<&'x str>, + pub headers: &'x HeaderMap, + pub body: Vec, +} + +/// An internal failure as a SCIM answer. Details stay in the log. +pub fn server_error(err: trc::Error) -> ScimError { + trc::error!(err.clone().details("SCIM request failed")); + ScimError::new(500, "The request couldn't be completed") +} + +/// Answers an anonymous route (SCIM-2, SCIM-3). +pub fn handle_anonymous(server: &Server, route: &Route, query: Option<&str>) -> ScimResponse { + let base = context::base_url(server); + if query.is_some_and(|query| { + query.split('&').any(|pair| { + pair.split('=') + .next() + .is_some_and(|k| k.eq_ignore_ascii_case("filter")) + }) + }) && !matches!(route, Route::Options | Route::Me) + { + return ScimError::forbidden("Discovery endpoints don't take a filter").into(); + } + match route { + Route::Options => ScimResponse::empty(204), + Route::Me => ScimError::new( + 501, + "/Me isn't supported: the caller is a service account, not a provisioned user", + ) + .into(), + Route::ServiceProviderConfig => { + ScimResponse::json(200, discovery::service_provider_config(&base)) + } + Route::ResourceTypes(id) => discovery::resource_types(&base, id.as_deref()), + Route::Schemas(id) => discovery::schemas(&base, id.as_deref()), + _ => ScimError::not_found("There is no such SCIM endpoint").into(), + } +} + +/// Answers an authenticated route. +pub async fn handle( + server: &Server, + access_token: &AccessToken, + session: &HttpSessionData, + request: ScimRequest<'_>, +) -> ScimResponse { + let ctx = match Ctx::new(server, access_token, session).await { + Ok(ctx) => ctx, + Err(err) => return err.into(), + }; + let ScimRequest { + route, + query, + headers, + body, + } = request; + let result = match &route { + Route::List(kind) => query::list(&ctx, *kind, query).await, + Route::Search(kind) => match resource::parse_body(&body) { + Ok(body) => query::search(&ctx, *kind, &body).await, + Err(err) => Err(err), + }, + Route::Bulk => bulk::bulk(&ctx, &body).await, + Route::Create(kind) + | Route::Get(kind, _) + | Route::Replace(kind, _) + | Route::Modify(kind, _) + | Route::Delete(kind, _) => { + resource::dispatch(&ctx, *kind, &route, query, headers, &body).await + } + _ => Err(ScimError::not_found("There is no such SCIM endpoint")), + }; + match result { + Ok(response) => response, + Err(err) => err.into(), + } +} diff --git a/crates/scim/src/patch.rs b/crates/scim/src/patch.rs new file mode 100644 index 0000000..e8f7d6f --- /dev/null +++ b/crates/scim/src/patch.rs @@ -0,0 +1,469 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `PATCH` (SCIM-42). The operations are applied, in order, to the +//! resource as it is now; the result is then written the way a `PUT` is. +//! So either every operation takes effect or none does. + +use crate::{ResourceKind, resource::get, users::parse_bool}; +use scim_proto::{Filter, MESSAGE_PATCH_OP, PatchPath, ScimError, filter::CompareOp}; +use serde_json::{Map, Value}; + +/// Attributes of the core schemas accepted and discarded (SCIM-33). +const IGNORED_USER: &[&str] = &[ + "password", + "phoneNumbers", + "addresses", + "photos", + "ims", + "title", + "userType", + "nickName", + "profileUrl", + "entitlements", + "roles", + "x509Certificates", +]; +const IGNORED_GROUP: &[&str] = &["description"]; + +const USER_ATTRS: &[&str] = &[ + "userName", + "displayName", + "name", + "active", + "emails", + "locale", + "preferredLanguage", + "timezone", + "externalId", +]; +const GROUP_ATTRS: &[&str] = &["displayName", "externalId", "members"]; +const READ_ONLY: &[&str] = &["id", "meta", "groups", "schemas"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Op { + Add, + Remove, + Replace, +} + +pub fn apply( + kind: ResourceKind, + current: &Value, + body: &Map, +) -> Result, ScimError> { + let schemas = get(body, "schemas") + .and_then(Value::as_array) + .ok_or_else(|| ScimError::invalid_syntax("The 'schemas' attribute is missing"))?; + if !schemas.iter().any(|s| { + s.as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(MESSAGE_PATCH_OP)) + }) { + return Err(ScimError::invalid_syntax(format!( + "'schemas' must include '{MESSAGE_PATCH_OP}'" + ))); + } + let operations = get(body, "Operations") + .and_then(Value::as_array) + .filter(|ops| !ops.is_empty()) + .ok_or_else(|| ScimError::invalid_syntax("'Operations' must be a non-empty list"))?; + + let mut doc = current.as_object().cloned().unwrap_or_default(); + doc.remove("meta"); + let mut state = State::default(); + for operation in operations { + let operation = operation + .as_object() + .ok_or_else(|| ScimError::invalid_syntax("Each operation must be an object"))?; + let op = match get(operation, "op") + .and_then(Value::as_str) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("add") => Op::Add, + Some("remove") => Op::Remove, + Some("replace") => Op::Replace, + other => { + return Err(ScimError::invalid_syntax(format!( + "'{}' isn't a PATCH operation", + other.unwrap_or_default() + ))); + } + }; + let value = get(operation, "value").cloned().unwrap_or(Value::Null); + match get(operation, "path").and_then(Value::as_str) { + Some(path) => { + let path = PatchPath::parse(path)?; + apply_path(kind, &mut doc, &mut state, op, &path, value)?; + } + None if op == Op::Remove => { + return Err(ScimError::bad_request( + scim_proto::ScimType::NoTarget, + "'remove' needs a 'path'", + )); + } + None => { + // No path: the value is an object of attributes (Keycloak) + let Value::Object(attributes) = value else { + return Err(ScimError::invalid_value( + "Without a 'path', the value must be an object of attributes", + )); + }; + for (name, value) in attributes { + if is_extension(kind, &name) { + continue; + } + let path = PatchPath::parse(&name)?; + apply_path(kind, &mut doc, &mut state, op, &path, value)?; + } + } + } + } + state.finish(kind, &mut doc); + + // The primary entry is derived from userName: drop it, so a renamed + // account doesn't keep its old address as an alias (SCIM-23) + if let Some(original) = current.get("userName").and_then(Value::as_str) + && let Some(Value::Array(emails)) = doc.get_mut("emails") + { + emails.retain(|email| { + !(is_primary(email) + && email + .get("value") + .and_then(Value::as_str) + .is_some_and(|value| value.eq_ignore_ascii_case(original))) + }); + } + Ok(doc) +} + +fn is_extension(kind: ResourceKind, name: &str) -> bool { + kind == ResourceKind::User && name.eq_ignore_ascii_case(scim_proto::SCHEMA_ENTERPRISE_USER) +} + +/// Keeps the pairs that store one value in step. +#[derive(Default)] +struct State { + display: Option>, + formatted: Option>, + locale: Option>, + language: Option>, +} + +impl State { + fn finish(self, kind: ResourceKind, doc: &mut Map) { + // SCIM-24: displayName and name.formatted are one stored value + if let Some(display) = self.display.or(self.formatted) { + if kind == ResourceKind::User { + set_formatted(doc, display.clone()); + } + match display { + Some(display) => doc.insert("displayName".into(), display), + None => doc.remove("displayName"), + }; + } + // SCIM-26: locale and preferredLanguage are one stored value + if let Some(locale) = self.locale.or(self.language) { + match locale { + Some(locale) => { + doc.insert("locale".into(), locale.clone()); + doc.insert("preferredLanguage".into(), locale); + } + None => { + doc.remove("locale"); + doc.remove("preferredLanguage"); + } + } + } + } +} + +fn set_formatted(doc: &mut Map, value: Option) { + let name = doc + .entry("name") + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(name) = name { + name.retain(|k, _| !k.eq_ignore_ascii_case("formatted")); + if let Some(value) = value { + name.insert("formatted".into(), value); + } + } +} + +fn key_of(doc: &Map, name: &str) -> Option { + doc.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned() +} + +fn apply_path( + kind: ResourceKind, + doc: &mut Map, + state: &mut State, + op: Op, + path: &PatchPath, + value: Value, +) -> Result<(), ScimError> { + let attr = &path.attr; + if let Some(urn) = &attr.urn { + if kind == ResourceKind::User + && urn.eq_ignore_ascii_case(scim_proto::SCHEMA_ENTERPRISE_USER) + { + return Ok(()); + } + if !urn.eq_ignore_ascii_case(kind.schema()) { + return Err(ScimError::invalid_path(format!( + "The schema '{urn}' isn't supported" + ))); + } + } + let (attrs, ignored) = match kind { + ResourceKind::User => (USER_ATTRS, IGNORED_USER), + ResourceKind::Group => (GROUP_ATTRS, IGNORED_GROUP), + }; + if READ_ONLY.iter().any(|a| attr.name.eq_ignore_ascii_case(a)) { + return Err(ScimError::mutability(format!( + "'{}' is read-only", + attr.name + ))); + } + if ignored.iter().any(|a| attr.name.eq_ignore_ascii_case(a)) { + return Ok(()); + } + let Some(name) = attrs + .iter() + .find(|a| attr.name.eq_ignore_ascii_case(a)) + .copied() + else { + return Err(ScimError::invalid_path(format!( + "'{}' isn't a supported path", + attr.name + ))); + }; + let set = |value: Value| { + if op == Op::Remove || value.is_null() { + None + } else { + Some(value) + } + }; + + match name { + "emails" | "members" => list_op(doc, name, op, path, value), + "name" => match attr.sub.as_deref() { + Some(sub) if sub.eq_ignore_ascii_case("formatted") => { + state.formatted = Some(set(value)); + Ok(()) + } + Some(sub) => { + // The other parts are accepted and discarded (SCIM-33) + let _ = sub; + Ok(()) + } + None => { + let formatted = value + .as_object() + .and_then(|name| get(name, "formatted")) + .cloned(); + if op == Op::Remove { + state.formatted = Some(None); + } else if let Some(formatted) = formatted { + state.formatted = Some(Some(formatted)); + } + Ok(()) + } + }, + _ if attr.sub.is_some() || path.filter.is_some() => Err(ScimError::invalid_path(format!( + "'{name}' has no sub-attributes" + ))), + "displayName" => { + state.display = Some(set(value)); + Ok(()) + } + "locale" => { + state.locale = Some(set(value)); + Ok(()) + } + "preferredLanguage" => { + state.language = Some(set(value)); + Ok(()) + } + "active" => { + match set(value) { + Some(value) => { + let active = parse_bool(&value) + .ok_or_else(|| ScimError::invalid_value("'active' must be a boolean"))?; + doc.insert("active".into(), Value::Bool(active)); + } + None => { + doc.remove("active"); + } + } + Ok(()) + } + _ => { + if let Some(key) = key_of(doc, name) { + doc.remove(&key); + } + if let Some(value) = set(value) { + doc.insert(name.to_string(), value); + } + Ok(()) + } + } +} + +fn items(value: Value) -> Vec { + match value { + Value::Array(items) => items, + Value::Null => vec![], + item => vec![item], + } +} + +fn is_primary(item: &Value) -> bool { + item.get("primary") == Some(&Value::Bool(true)) +} + +/// `emails` and `members`: whole-list and value-filtered operations. +fn list_op( + doc: &mut Map, + name: &str, + op: Op, + path: &PatchPath, + value: Value, +) -> Result<(), ScimError> { + let is_emails = name == "emails"; + if path.attr.sub.is_some() { + return Err(ScimError::invalid_path(format!( + "Use a value filter to change one of '{name}'" + ))); + } + let mut list = doc.remove(name).map(items).unwrap_or_default(); + + match (&path.filter, op) { + (None, Op::Add) => { + for item in items(value) { + if !list.iter().any(|i| same_value(i, &item)) { + list.push(item); + } + } + } + (None, Op::Replace) => { + let primary = list.iter().filter(|i| is_emails && is_primary(i)).cloned(); + let mut new = primary.collect::>(); + new.extend(items(value)); + list = new; + } + (None, Op::Remove) => { + // SCIM-25: the primary address stays; SCIM-36: every member goes + list.retain(|i| is_emails && is_primary(i)); + } + (Some(_), Op::Add) => { + return Err(ScimError::invalid_path("'add' can't take a value filter")); + } + (Some(filter), op) => { + let matched = list.iter().map(|i| matches(filter, i)).collect::>(); + if is_emails + && list + .iter() + .zip(&matched) + .any(|(item, hit)| *hit && is_primary(item)) + { + let unchanged = op == Op::Replace + && match &path.sub_after_filter { + Some(sub) => list.iter().zip(&matched).all(|(item, hit)| { + !*hit || !is_primary(item) || item.get(sub.as_str()) == Some(&value) + }), + None => false, + }; + if !unchanged { + return Err(ScimError::mutability( + "The primary email is set by 'userName' and can't be changed through 'emails'", + )); + } + } + match op { + Op::Remove => { + let mut hits = matched.iter(); + // Removing what isn't there succeeds, since clients retry + list.retain(|_| !*hits.next().unwrap_or(&false)); + } + Op::Replace => { + if !matched.iter().any(|hit| *hit) { + return Err(ScimError::bad_request( + scim_proto::ScimType::NoTarget, + format!("No entry of '{name}' matches the filter"), + )); + } + for (item, hit) in list.iter_mut().zip(&matched) { + if !*hit { + continue; + } + match &path.sub_after_filter { + Some(sub) => { + if let Value::Object(item) = item { + item.insert(sub.clone(), value.clone()); + } + } + None => *item = value.clone(), + } + } + } + Op::Add => unreachable!(), + } + } + } + doc.insert(name.to_string(), Value::Array(list)); + Ok(()) +} + +fn same_value(a: &Value, b: &Value) -> bool { + match ( + a.get("value").and_then(Value::as_str), + b.get("value").and_then(Value::as_str), + ) { + (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), + _ => false, + } +} + +/// A value filter against one entry of a multi-valued attribute. +pub fn matches(filter: &Filter, item: &Value) -> bool { + match filter { + Filter::And(a, b) => matches(a, item) && matches(b, item), + Filter::Or(a, b) => matches(a, item) || matches(b, item), + Filter::Not(inner) => !matches(inner, item), + Filter::Present(path) => item + .as_object() + .and_then(|item| get(item, &path.name)) + .is_some(), + Filter::Compare { path, op, value } => { + let Some(actual) = item.as_object().and_then(|item| get(item, &path.name)) else { + return false; + }; + match (actual, value) { + (Value::String(actual), Value::String(wanted)) => { + let (actual, wanted) = (actual.to_lowercase(), wanted.to_lowercase()); + match op { + CompareOp::Eq => actual == wanted, + CompareOp::Ne => actual != wanted, + CompareOp::Co => actual.contains(&wanted), + CompareOp::Sw => actual.starts_with(&wanted), + CompareOp::Ew => actual.ends_with(&wanted), + _ => false, + } + } + (actual, wanted) => match op { + CompareOp::Eq => parse_bool(actual) + .zip(parse_bool(wanted)) + .map_or(actual == wanted, |(a, b)| a == b), + CompareOp::Ne => actual != wanted, + _ => false, + }, + } + } + Filter::ValuePath { .. } => false, + } +} diff --git a/crates/scim/src/query.rs b/crates/scim/src/query.rs new file mode 100644 index 0000000..4bb5d28 --- /dev/null +++ b/crates/scim/src/query.rs @@ -0,0 +1,602 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Queries (SCIM-45 to SCIM-50): `GET /Users`, `GET /Groups` and the +//! `.search` endpoints. Indexed clauses pick the candidates; the rest are +//! checked on at most 200 of them. + +use crate::{ + CURSOR_TIMEOUT, DEFAULT_PAGE_SIZE, MAX_RESULTS, ResourceKind, ScimResponse, + context::Ctx, + cursor, groups, + resource::{Projection, get, param}, + server_error, + users::{self, display_of, is_active, split_address}, +}; +use registry::schema::{enums::Permission, prelude::Property, structs::Account}; +use scim_proto::{ + AttrPath, Filter, MESSAGE_LIST_RESPONSE, MESSAGE_SEARCH_REQUEST, ScimError, filter::CompareOp, +}; +use serde_json::{Map, Value, json}; +use std::{collections::BTreeSet, str::FromStr}; +use store::write::now; +use types::id::Id; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Attr { + Id, + ExternalId, + UserName, + Emails, + Active, + DisplayName, + Groups, + Members, +} + +impl Attr { + fn is_indexed(&self) -> bool { + !matches!(self, Attr::Active | Attr::DisplayName) + } +} + +#[derive(Debug, Clone)] +struct Clause { + attr: Attr, + value: Value, +} + +/// The query parameters, from the URL or a `SearchRequest`. +#[derive(Debug, Clone, Default)] +pub struct Params { + pub filter: Option, + pub sort_by: Option, + pub sort_order: Option, + pub start_index: Option, + pub count: Option, + pub cursor: Option, + pub projection: Projection, +} + +fn number(value: &str, name: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| ScimError::invalid_value(format!("'{name}' must be a number"))) +} + +impl Params { + fn from_query(query: Option<&str>) -> Result { + Ok(Params { + filter: param(query, "filter"), + sort_by: param(query, "sortBy"), + sort_order: param(query, "sortOrder"), + start_index: param(query, "startIndex") + .map(|v| number(&v, "startIndex")) + .transpose()?, + count: param(query, "count") + .map(|v| number(&v, "count")) + .transpose()?, + cursor: param(query, "cursor"), + projection: Projection::parse( + param(query, "attributes").as_deref(), + param(query, "excludedAttributes").as_deref(), + ), + }) + } + + fn from_body(body: &Map) -> Result { + let schemas = get(body, "schemas") + .and_then(Value::as_array) + .ok_or_else(|| ScimError::invalid_syntax("The 'schemas' attribute is missing"))?; + if !schemas.iter().any(|s| { + s.as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(MESSAGE_SEARCH_REQUEST)) + }) { + return Err(ScimError::invalid_syntax(format!( + "'schemas' must include '{MESSAGE_SEARCH_REQUEST}'" + ))); + } + let string = |name: &str| get(body, name).and_then(Value::as_str).map(str::to_string); + let int = |name: &str| -> Result, ScimError> { + match get(body, name) { + Some(Value::Number(n)) => Ok(n.as_i64()), + Some(Value::String(s)) => number(s, name).map(Some), + Some(_) => Err(ScimError::invalid_value(format!( + "'{name}' must be a number" + ))), + None => Ok(None), + } + }; + let list = |name: &str| match get(body, name) { + Some(Value::Array(items)) => Some( + items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(","), + ), + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + Ok(Params { + filter: string("filter"), + sort_by: string("sortBy"), + sort_order: string("sortOrder"), + start_index: int("startIndex")?, + count: int("count")?, + cursor: get(body, "cursor").map(|c| c.as_str().unwrap_or_default().to_string()), + projection: Projection::parse( + list("attributes").as_deref(), + list("excludedAttributes").as_deref(), + ), + }) + } +} + +/// The first construct outside `eq` and `and`, named (SCIM-45). +fn unsupported(filter: &Filter) -> Option { + match filter { + Filter::And(a, b) => unsupported(a).or_else(|| unsupported(b)), + Filter::Or(..) => Some("The 'or' operator isn't supported: use 'eq' and 'and'".into()), + Filter::Not(_) => Some("The 'not' operator isn't supported: use 'eq' and 'and'".into()), + Filter::Present(path) => Some(format!("'{path} pr' isn't supported: use 'eq' and 'and'")), + Filter::ValuePath { path, .. } => Some(format!( + "Value filters such as '{path}[...]' aren't supported in 'filter'" + )), + Filter::Compare { op, .. } if *op != CompareOp::Eq => Some(format!( + "The '{}' operator isn't supported: use 'eq' and 'and'", + op.as_str() + )), + Filter::Compare { .. } => None, + } +} + +fn attr_of(kind: ResourceKind, path: &AttrPath) -> Result { + if let Some(urn) = &path.urn + && !urn.eq_ignore_ascii_case(kind.schema()) + { + return Err(ScimError::invalid_filter(format!( + "The schema '{urn}' can't be filtered on here" + ))); + } + let is = |name: &str, sub: Option<&str>| path.is(name, sub); + let attr = match kind { + ResourceKind::User => { + if is("id", None) { + Some(Attr::Id) + } else if is("externalId", None) { + Some(Attr::ExternalId) + } else if is("userName", None) { + Some(Attr::UserName) + } else if is("emails", None) || is("emails", Some("value")) { + Some(Attr::Emails) + } else if is("active", None) { + Some(Attr::Active) + } else if is("displayName", None) || is("name", Some("formatted")) { + Some(Attr::DisplayName) + } else if is("groups", None) || is("groups", Some("value")) { + Some(Attr::Groups) + } else { + None + } + } + ResourceKind::Group => { + if is("id", None) { + Some(Attr::Id) + } else if is("externalId", None) { + Some(Attr::ExternalId) + } else if is("displayName", None) { + Some(Attr::DisplayName) + } else if is("members", None) || is("members", Some("value")) { + Some(Attr::Members) + } else { + None + } + } + }; + attr.ok_or_else(|| { + ScimError::invalid_filter(format!("The attribute '{path}' can't be filtered on")) + }) +} + +fn clauses(kind: ResourceKind, filter: &Filter, out: &mut Vec) -> Result<(), ScimError> { + match filter { + Filter::And(a, b) => { + clauses(kind, a, out)?; + clauses(kind, b, out) + } + Filter::Compare { path, value, .. } => { + out.push(Clause { + attr: attr_of(kind, path)?, + value: value.clone(), + }); + Ok(()) + } + _ => unreachable!("checked by unsupported()"), + } +} + +fn parse_filter(kind: ResourceKind, text: Option<&str>) -> Result, ScimError> { + let Some(text) = text.filter(|t| !t.trim().is_empty()) else { + return Ok(vec![]); + }; + let filter = Filter::parse(text)?; + if let Some(detail) = unsupported(&filter) { + return Err(ScimError::invalid_filter(detail)); + } + let mut out = Vec::new(); + clauses(kind, &filter, &mut out)?; + Ok(out) +} + +fn as_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Every account of the kind in scope, by domain (SCIM-16). +async fn all_in_scope(ctx: &Ctx<'_>, kind: ResourceKind) -> Result, ScimError> { + let mut ids = BTreeSet::new(); + for domain in ctx.scoped_domains().await? { + for id in ctx + .query_ids(Ctx::accounts_query(kind).equal(Property::DomainId, domain.id as u64)) + .await? + { + ids.insert(id.id()); + } + } + Ok(ids) +} + +/// Candidates for one indexed clause. +async fn candidates( + ctx: &Ctx<'_>, + kind: ResourceKind, + clause: &Clause, +) -> Result, ScimError> { + let value = as_text(&clause.value); + let mut out = BTreeSet::new(); + match clause.attr { + Attr::Id => { + if let Ok(id) = Id::from_str(&value) { + out.insert(id.id()); + } + } + Attr::ExternalId => { + for id in ctx + .query_ids(Ctx::accounts_query(kind).equal(Property::ExternalId, value)) + .await? + { + out.insert(id.id()); + } + } + Attr::UserName => { + if let Ok((local, domain)) = split_address(&value) + && let Some(domain) = ctx.server.domain(&domain).await.map_err(server_error)? + { + for id in ctx + .query_ids( + Ctx::accounts_query(kind) + .equal(Property::Name, local) + .equal(Property::DomainId, domain.id as u64), + ) + .await? + { + out.insert(id.id()); + } + } + } + Attr::Emails => { + if let Some(common::auth::EmailCache::Account(id)) = ctx + .server + .rcpt_id_from_email(&value) + .await + .map_err(server_error)? + { + out.insert(id as u64); + } + } + Attr::Groups => { + if let Ok(group) = Id::from_str(&value) { + for id in ctx + .query_ids( + Ctx::accounts_query(kind).equal(Property::MemberGroupIds, group.id()), + ) + .await? + { + out.insert(id.id()); + } + } + } + Attr::Members => { + if let Ok(user) = Id::from_str(&value) + && let Some(Account::User(user)) = ctx.load_id(user).await? + { + out.extend(user.member_group_ids.iter().map(|id| id.id())); + } + } + Attr::Active | Attr::DisplayName => {} + } + Ok(out) +} + +/// Checks every clause exactly on a loaded account. +async fn holds( + ctx: &Ctx<'_>, + id: Id, + account: &Account, + clauses: &[Clause], +) -> Result { + for clause in clauses { + let value = as_text(&clause.value); + let ok = match (clause.attr, account) { + (Attr::Id, _) => id.to_string() == value, + (Attr::ExternalId, Account::User(u)) => u.external_id.as_deref() == Some(&value), + (Attr::ExternalId, Account::Group(g)) => g.external_id.as_deref() == Some(&value), + (Attr::UserName, Account::User(u)) => users::primary_address(ctx, u) + .await? + .eq_ignore_ascii_case(&value), + (Attr::Emails, Account::User(u)) => { + let rendered = users::render(ctx, id, account).await?; + let _ = u; + rendered + .get("emails") + .and_then(Value::as_array) + .is_some_and(|emails| { + emails.iter().any(|e| { + e.get("value") + .and_then(Value::as_str) + .is_some_and(|v| v.eq_ignore_ascii_case(&value)) + }) + }) + } + (Attr::Active, Account::User(_)) => { + let wanted = users::parse_bool(&clause.value); + wanted.is_some() && Some(is_active(ctx, id).await?) == wanted + } + (Attr::DisplayName, account) => { + display_of(account).is_some_and(|d| d.eq_ignore_ascii_case(&value)) + } + (Attr::Groups, Account::User(u)) => { + u.member_group_ids.iter().any(|g| g.to_string() == value) + } + (Attr::Members, Account::Group(_)) => match Id::from_str(&value) { + Ok(user) => matches!( + ctx.load_id(user).await?, + Some(Account::User(u)) if u.member_group_ids.iter().any(|g| *g == id) + ), + Err(_) => false, + }, + _ => false, + }; + if !ok { + return Ok(false); + } + } + Ok(true) +} + +/// The ids matching the filter, in scope, sorted (SCIM-45 to SCIM-47). +async fn matching( + ctx: &Ctx<'_>, + kind: ResourceKind, + clauses: &[Clause], + sort_by: Option<&str>, + descending: bool, +) -> Result, ScimError> { + let indexed = clauses + .iter() + .filter(|c| c.attr.is_indexed()) + .collect::>(); + let mut ids: Vec = if indexed.is_empty() { + let all = all_in_scope(ctx, kind).await?; + if clauses.is_empty() { + all.into_iter().map(Id::from).collect() + } else { + // SCIM-46: unindexed clauses on at most 200 candidates + if all.len() > MAX_RESULTS { + return Err(ScimError::too_many(format!( + "The filter leaves more than {MAX_RESULTS} candidates: narrow it with \ + an indexed attribute such as userName or externalId" + ))); + } + let mut out = Vec::new(); + for id in all { + let id = Id::from(id); + if let Some(account) = ctx.load_id(id).await? + && holds(ctx, id, &account, clauses).await? + { + out.push(id); + } + } + out + } + } else { + let mut set: Option> = None; + for clause in &indexed { + let found = candidates(ctx, kind, clause).await?; + set = Some(match set { + Some(set) => set.intersection(&found).copied().collect(), + None => found, + }); + } + let set = set.unwrap_or_default(); + if clauses.iter().any(|c| !c.attr.is_indexed()) && set.len() > MAX_RESULTS { + return Err(ScimError::too_many(format!( + "The filter leaves more than {MAX_RESULTS} candidates: narrow it" + ))); + } + let mut out = Vec::new(); + for id in set { + let id = Id::from(id); + if let Ok((id, account)) = ctx.load(kind, &id.to_string()).await + && holds(ctx, id, &account, clauses).await? + { + out.push(id); + } + } + out + }; + + // SCIM-47: by id unless told otherwise + match sort_by { + None => ids.sort_by_key(|id| id.id()), + Some(attr) if attr.eq_ignore_ascii_case("id") => ids.sort_by_key(|id| id.id()), + Some(attr) if kind == ResourceKind::User && attr.eq_ignore_ascii_case("userName") => { + let mut keyed = Vec::with_capacity(ids.len()); + for id in ids { + let name = match ctx.load_id(id).await? { + Some(Account::User(user)) => users::primary_address(ctx, &user).await?, + _ => String::new(), + }; + keyed.push((name, id.id(), id)); + } + keyed.sort(); + ids = keyed.into_iter().map(|(_, _, id)| id).collect(); + } + Some(attr) => { + return Err(ScimError::invalid_value(format!( + "Results can't be sorted by '{attr}'" + ))); + } + } + if descending { + ids.reverse(); + } + Ok(ids) +} + +pub async fn list( + ctx: &Ctx<'_>, + kind: ResourceKind, + query: Option<&str>, +) -> Result { + run(ctx, &[kind], Params::from_query(query)?).await +} + +pub async fn search( + ctx: &Ctx<'_>, + kind: Option, + body: &Map, +) -> Result { + let params = Params::from_body(body)?; + match kind { + Some(kind) => run(ctx, &[kind], params).await, + // SCIM-50: users first, then groups + None => run(ctx, &[ResourceKind::User, ResourceKind::Group], params).await, + } +} + +async fn run( + ctx: &Ctx<'_>, + kinds: &[ResourceKind], + params: Params, +) -> Result { + ctx.require(Permission::SysAccountGet)?; + + let descending = match params.sort_order.as_deref() { + None => false, + Some(order) if order.eq_ignore_ascii_case("ascending") => false, + Some(order) if order.eq_ignore_ascii_case("descending") => true, + Some(order) => { + return Err(ScimError::invalid_value(format!( + "'{order}' isn't a sort order" + ))); + } + }; + if params.cursor.is_some() && params.start_index.is_some() { + return Err(ScimError::invalid_value( + "'startIndex' and 'cursor' can't be used together", + )); + } + let count = params + .count + .map(|c| c.clamp(0, MAX_RESULTS as i64) as usize) + .unwrap_or(DEFAULT_PAGE_SIZE); + + // Each kind's filter; one the kind can't answer matches none of it + let mut all = Vec::new(); + let mut last_error = None; + let mut answered = false; + for kind in kinds { + match parse_filter(*kind, params.filter.as_deref()) { + Ok(clauses) => { + answered = true; + for id in + matching(ctx, *kind, &clauses, params.sort_by.as_deref(), descending).await? + { + all.push((*kind, id)); + } + } + Err(err) + if kinds.len() > 1 + && err.scim_type == Some(scim_proto::ScimType::InvalidFilter) => + { + last_error = Some(err); + } + Err(err) => return Err(err), + } + } + if !answered && let Some(err) = last_error { + return Err(err); + } + let total = all.len(); + + // SCIM-48 and SCIM-49: pages by index or by cursor + let key = ctx.server.core.oauth.oauth_key.as_bytes(); + let binding = cursor::binding(&[ + &ctx.principal_id().to_string(), + &kinds.iter().map(|k| k.name()).collect::>().join(","), + params.filter.as_deref().unwrap_or_default(), + params.sort_by.as_deref().unwrap_or_default(), + params.sort_order.as_deref().unwrap_or_default(), + ]); + let start = match ¶ms.cursor { + Some(c) if c.is_empty() => 0, + Some(c) => cursor::decode(key, c, count as u64, now(), binding)? as usize, + None => params.start_index.unwrap_or(1).max(1) as usize - 1, + }; + let page = all.iter().skip(start).take(count).collect::>(); + + let mut resources = Vec::with_capacity(page.len()); + for (kind, id) in &page { + let (id, account) = ctx.load(*kind, &id.to_string()).await?; + let doc = match kind { + ResourceKind::User => users::render(ctx, id, &account).await?, + ResourceKind::Group => { + groups::render(ctx, id, &account, Some(¶ms.projection)).await? + } + }; + resources.push(params.projection.apply(doc)); + } + + let mut body = json!({ + "schemas": [MESSAGE_LIST_RESPONSE], + "totalResults": total, + "itemsPerPage": resources.len(), + }); + if params.cursor.is_some() { + let next = start + page.len(); + if next < total && count > 0 { + body["nextCursor"] = json!(cursor::encode( + key, + next as u64, + count as u64, + now() + CURSOR_TIMEOUT, + binding + )); + } + } else { + body["startIndex"] = json!(start + 1); + } + if count > 0 { + body["Resources"] = Value::Array(resources); + } + Ok(ScimResponse::json(200, body)) +} diff --git a/crates/scim/src/resource.rs b/crates/scim/src/resource.rs new file mode 100644 index 0000000..f90050e --- /dev/null +++ b/crates/scim/src/resource.rs @@ -0,0 +1,481 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What users and groups share: body parsing (SCIM-33), attribute +//! projection (SCIM-40), versions and conditional requests (SCIM-44), the +//! audit event (SCIM-54), and the per-resource operations (SCIM-39 to +//! SCIM-43). + +use crate::{ResourceKind, Route, ScimResponse, context::Ctx, groups, patch, users}; +use hyper::HeaderMap; +use registry::schema::enums::Permission; +use scim_proto::ScimError; +use serde::de::{Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Value}; +use std::fmt; +use types::id::Id; + +/// Parses a JSON object, refusing duplicated attributes, exact or in +/// another case (SCIM-33). +pub fn parse_body(body: &[u8]) -> Result, ScimError> { + let mut deserializer = serde_json::Deserializer::from_slice(body); + let value = deserializer + .deserialize_any(StrictValue) + .map_err(|err| ScimError::invalid_syntax(format!("The body isn't valid JSON: {err}")))?; + deserializer + .end() + .map_err(|err| ScimError::invalid_syntax(format!("The body isn't valid JSON: {err}")))?; + match value { + Value::Object(map) => Ok(map), + _ => Err(ScimError::invalid_syntax("The body must be a JSON object")), + } +} + +struct StrictValue; + +impl<'de> serde::de::DeserializeSeed<'de> for StrictValue { + type Value = Value; + + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a JSON value") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(Value::from(v)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Value::from(v)) + } + + fn visit_f64(self, v: f64) -> Result { + Ok(Value::from(v)) + } + + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_string())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_none(self) -> Result { + Ok(Value::Null) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = seq.next_element_seed(StrictValue)? { + items.push(item); + } + Ok(Value::Array(items)) + } + + fn visit_map>(self, mut access: A) -> Result { + let mut map = Map::new(); + while let Some(key) = access.next_key::()? { + if map.keys().any(|k: &String| k.eq_ignore_ascii_case(&key)) { + return Err(serde::de::Error::custom(format!( + "the attribute '{key}' appears twice" + ))); + } + let value = access.next_value_seed(StrictValue)?; + map.insert(key, value); + } + Ok(Value::Object(map)) + } +} + +/// An attribute, whatever case it was sent in (RFC 7643 §2.1). +pub fn get<'x>(map: &'x Map, name: &str) -> Option<&'x Value> { + map.iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v) + .filter(|v| !v.is_null()) +} + +/// Checks `schemas` and every attribute name against what the resource +/// knows (SCIM-33). +pub fn check_attributes( + map: &Map, + kind: ResourceKind, + known: &[&str], +) -> Result<(), ScimError> { + let schemas = get(map, "schemas") + .and_then(Value::as_array) + .ok_or_else(|| ScimError::invalid_syntax("The 'schemas' attribute is missing"))?; + let mut has_core = false; + for schema in schemas { + let schema = schema + .as_str() + .ok_or_else(|| ScimError::invalid_syntax("'schemas' must hold strings"))?; + if schema.eq_ignore_ascii_case(kind.schema()) { + has_core = true; + } else if !(kind == ResourceKind::User + && schema.eq_ignore_ascii_case(scim_proto::SCHEMA_ENTERPRISE_USER)) + { + return Err(ScimError::invalid_syntax(format!( + "The schema '{schema}' isn't known" + ))); + } + } + if !has_core { + return Err(ScimError::invalid_syntax(format!( + "'schemas' must include '{}'", + kind.schema() + ))); + } + for key in map.keys() { + let is_known = known.iter().any(|k| k.eq_ignore_ascii_case(key)) + || (kind == ResourceKind::User + && key.eq_ignore_ascii_case(scim_proto::SCHEMA_ENTERPRISE_USER)); + if !is_known { + return Err(ScimError::invalid_syntax(format!( + "The attribute '{key}' isn't known" + ))); + } + } + Ok(()) +} + +/// A weak version computed from the resource's content (SCIM-44). +pub fn version_of(doc: &Value) -> String { + let mut doc = doc.clone(); + if let Some(meta) = doc.get_mut("meta").and_then(Value::as_object_mut) { + meta.remove("version"); + } + format!( + "W/\"{:016x}\"", + xxhash_rust::xxh3::xxh3_64(doc.to_string().as_bytes()) + ) +} + +/// Sets `meta.version` on a rendered resource. +pub fn stamp(mut doc: Value) -> Value { + let version = version_of(&doc); + if let Some(meta) = doc.get_mut("meta").and_then(Value::as_object_mut) { + meta.insert("version".to_string(), Value::String(version)); + } + doc +} + +pub fn version(doc: &Value) -> String { + doc.pointer("/meta/version") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn opaque(tag: &str) -> &str { + tag.trim().trim_start_matches("W/").trim_matches('"') +} + +fn header<'x>(headers: &'x HeaderMap, name: &str) -> Option<&'x str> { + headers.get(name).and_then(|v| v.to_str().ok()) +} + +/// `If-Match` on a write: `412` when the resource has changed (SCIM-44). +pub fn check_if_match(headers: &HeaderMap, current: &str) -> Result<(), ScimError> { + match header(headers, "if-match") { + Some(tags) + if !tags + .split(',') + .any(|tag| tag.trim() == "*" || opaque(tag) == opaque(current)) => + { + Err(ScimError::new( + 412, + "The resource has changed since that version", + )) + } + _ => Ok(()), + } +} + +fn not_modified(headers: &HeaderMap, current: &str) -> bool { + header(headers, "if-none-match").is_some_and(|tags| { + tags.split(',') + .any(|tag| tag.trim() == "*" || opaque(tag) == opaque(current)) + }) +} + +/// `attributes` and `excludedAttributes` (RFC 7644 §3.9, SCIM-40). `id`, +/// `schemas` and `meta` always stay. +#[derive(Debug, Clone, Default)] +pub struct Projection { + pub attributes: Vec, + pub excluded: Vec, +} + +impl Projection { + pub fn parse(attributes: Option<&str>, excluded: Option<&str>) -> Self { + let split = |list: Option<&str>| { + list.map(|list| { + list.split(',') + .map(|s| { + let s = s.trim(); + // A fully qualified name keeps only its attribute part + match s.len() > 4 && s[..4].eq_ignore_ascii_case("urn:") { + true => s.rsplit(':').next().unwrap_or(s).to_string(), + false => s.to_string(), + } + }) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .unwrap_or_default() + }; + Projection { + attributes: split(attributes), + excluded: split(excluded), + } + } + + /// Whether the rendered resource will hold `name` (SCIM-37). + pub fn includes(&self, name: &str) -> bool { + let matches = |list: &[String]| { + list.iter().any(|a| { + a.eq_ignore_ascii_case(name) + || a.split_once('.') + .is_some_and(|(top, _)| top.eq_ignore_ascii_case(name)) + }) + }; + if !self.attributes.is_empty() && !matches(&self.attributes) { + return false; + } + !self.excluded.iter().any(|a| a.eq_ignore_ascii_case(name)) + } + + pub fn apply(&self, doc: Value) -> Value { + let Value::Object(mut map) = doc else { + return doc; + }; + const ALWAYS: [&str; 3] = ["id", "schemas", "meta"]; + if !self.attributes.is_empty() { + map.retain(|key, value| { + if ALWAYS.iter().any(|a| a.eq_ignore_ascii_case(key)) { + return true; + } + let mut keep = false; + let mut subs = Vec::new(); + for attr in &self.attributes { + match attr.split_once('.') { + Some((top, sub)) if top.eq_ignore_ascii_case(key) => subs.push(sub), + None if attr.eq_ignore_ascii_case(key) => keep = true, + _ => {} + } + } + if keep { + return true; + } + if subs.is_empty() { + return false; + } + let only = |item: &mut Value| { + if let Value::Object(item) = item { + item.retain(|k, _| subs.iter().any(|s| s.eq_ignore_ascii_case(k))); + } + }; + match value { + Value::Array(items) => items.iter_mut().for_each(only), + item => only(item), + } + true + }); + } + for attr in &self.excluded { + if ALWAYS.iter().any(|a| a.eq_ignore_ascii_case(attr)) { + continue; + } + match attr.split_once('.') { + None => map.retain(|key, _| !key.eq_ignore_ascii_case(attr)), + Some((top, sub)) => { + for (key, value) in map.iter_mut() { + if !key.eq_ignore_ascii_case(top) { + continue; + } + let drop = |item: &mut Value| { + if let Value::Object(item) = item { + item.retain(|k, _| !k.eq_ignore_ascii_case(sub)); + } + }; + match value { + Value::Array(items) => items.iter_mut().for_each(drop), + item => drop(item), + } + } + } + } + } + Value::Object(map) + } +} + +/// A query parameter, in any case. +pub fn param<'x>(query: Option<&'x str>, name: &str) -> Option { + query.and_then(|query| { + http_proto::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.into_owned()) + }) +} + +/// A rendered resource with its `Location` and `ETag` headers. +pub fn resource_response(status: u16, doc: Value, projection: &Projection) -> ScimResponse { + let version = version(&doc); + let location = doc + .pointer("/meta/location") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let mut response = + ScimResponse::json(status, projection.apply(doc)).with_header("ETag", version); + if status == 201 { + response = response.with_header("Location", location); + } + response +} + +/// The audit event of every write (SCIM-54). +pub fn audit( + ctx: &Ctx<'_>, + event: trc::ScimEvent, + kind: ResourceKind, + id: Id, + external_id: Option<&str>, +) { + trc::event!( + Scim(event), + AccountId = ctx.principal_id(), + Id = id.document_id(), + Type = kind.name(), + Details = external_id.unwrap_or_default().to_string(), + ); +} + +/// The resource as it is now, rendered in full. +pub async fn render(ctx: &Ctx<'_>, kind: ResourceKind, id: Id) -> Result { + let (id, account) = ctx.load(kind, &id.to_string()).await?; + match kind { + ResourceKind::User => users::render(ctx, id, &account).await, + ResourceKind::Group => groups::render(ctx, id, &account, None).await, + } +} + +/// How the new state of a resource was given. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteMode { + Create, + Replace, + Patch, +} + +/// Create, read, replace, modify and delete of one resource. +pub async fn dispatch( + ctx: &Ctx<'_>, + kind: ResourceKind, + route: &Route, + query: Option<&str>, + headers: &HeaderMap, + body: &[u8], +) -> Result { + let projection = Projection::parse( + param(query, "attributes").as_deref(), + param(query, "excludedAttributes").as_deref(), + ); + match route { + Route::Create(_) => { + ctx.require(Permission::SysAccountCreate)?; + let body = parse_body(body)?; + let id = match kind { + ResourceKind::User => users::create(ctx, &body).await?, + ResourceKind::Group => groups::create(ctx, &body).await?, + }; + let doc = render(ctx, kind, id).await?; + Ok(resource_response(201, doc, &projection)) + } + Route::Get(_, id) => { + ctx.require(Permission::SysAccountGet)?; + let (id, account) = ctx.load(kind, id).await?; + let doc = match kind { + ResourceKind::User => users::render(ctx, id, &account).await?, + ResourceKind::Group => groups::render(ctx, id, &account, Some(&projection)).await?, + }; + if not_modified(headers, &version(&doc)) { + return Ok(ScimResponse::empty(304).with_header("ETag", version(&doc))); + } + Ok(resource_response(200, doc, &projection)) + } + Route::Replace(_, id) | Route::Modify(_, id) => { + ctx.require(Permission::SysAccountUpdate)?; + let (id, account) = ctx.load(kind, id).await?; + let current = match kind { + ResourceKind::User => users::render(ctx, id, &account).await?, + ResourceKind::Group => groups::render(ctx, id, &account, None).await?, + }; + check_if_match(headers, &version(¤t))?; + let body = parse_body(body)?; + let (doc, mode) = if matches!(route, Route::Replace(..)) { + (body, WriteMode::Replace) + } else { + (patch::apply(kind, ¤t, &body)?, WriteMode::Patch) + }; + match kind { + ResourceKind::User => { + users::replace(ctx, id, &account, ¤t, &doc, mode).await? + } + ResourceKind::Group => { + groups::replace(ctx, id, &account, ¤t, &doc, mode).await? + } + } + let doc = render(ctx, kind, id).await?; + Ok(resource_response(200, doc, &projection)) + } + Route::Delete(_, id) => { + ctx.require(Permission::SysAccountDestroy)?; + let (id, account) = ctx.load(kind, id).await?; + if id.document_id() == ctx.principal_id() { + return Err(ScimError::forbidden( + "The service principal can't delete itself", + )); + } + let current = match kind { + ResourceKind::User => users::render(ctx, id, &account).await?, + ResourceKind::Group => groups::render(ctx, id, &account, None).await?, + }; + check_if_match(headers, &version(¤t))?; + if kind == ResourceKind::Group { + groups::remove_all_members(ctx, id).await?; + } + ctx.destroy(id).await?; + audit( + ctx, + trc::ScimEvent::ResourceDeleted, + kind, + id, + current.get("externalId").and_then(Value::as_str), + ); + Ok(ScimResponse::empty(204)) + } + _ => Err(ScimError::not_found("There is no such SCIM endpoint")), + } +} diff --git a/crates/scim/src/users.rs b/crates/scim/src/users.rs new file mode 100644 index 0000000..0d74cde --- /dev/null +++ b/crates/scim/src/users.rs @@ -0,0 +1,707 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Users (SCIM-21 to SCIM-33): an `x:UserAccount` as a SCIM User, and a +//! SCIM User written back as one. + +use crate::{ + ResourceKind, + context::Ctx, + resource::{WriteMode, audit, check_attributes, get, stamp}, + server_error, +}; +use registry::{ + schema::{ + enums::{Locale, Permission, TimeZone}, + structs::{Account, Permissions, UserAccount}, + }, + types::EnumImpl, +}; +use scim_proto::{SCHEMA_USER, ScimError}; +use serde_json::{Map, Value, json}; +use std::{collections::HashMap, sync::OnceLock}; +use types::id::Id; + +/// Every attribute a User body may carry. Those not in the mapping table +/// are accepted and discarded (SCIM-33). +const KNOWN: &[&str] = &[ + "schemas", + "id", + "externalId", + "meta", + "userName", + "name", + "displayName", + "nickName", + "profileUrl", + "title", + "userType", + "preferredLanguage", + "locale", + "timezone", + "active", + "password", + "emails", + "phoneNumbers", + "ims", + "photos", + "addresses", + "groups", + "entitlements", + "roles", + "x509Certificates", +]; + +/// A User as sent, reduced to what is stored. +#[derive(Debug, Clone)] +pub struct UserInput { + pub user_name: String, + pub local: String, + pub domain: String, + pub display: Option, + pub active: Option, + pub aliases: Vec, + pub locale: Option, + pub time_zone: Option, + pub external_id: Option, + pub groups: Option>, +} + +fn text(value: &Value) -> Option<&str> { + value.as_str().map(str::trim).filter(|s| !s.is_empty()) +} + +/// `userName` as a full address, lowercased (SCIM-15, SCIM-22). +pub fn split_address(address: &str) -> Result<(String, String), ScimError> { + let address = address.trim().to_lowercase(); + let invalid = || { + ScimError::invalid_value(format!( + "The userName '{address}' is not a valid email address" + )) + }; + let (local, domain) = address.rsplit_once('@').ok_or_else(invalid)?; + if local.is_empty() + || domain.is_empty() + || domain.starts_with('.') + || domain.ends_with('.') + || local.chars().any(|c| c.is_whitespace() || c == '@') + || domain + .chars() + .any(|c| !(c.is_alphanumeric() || c == '.' || c == '-')) + { + return Err(invalid()); + } + Ok((local.to_string(), domain.to_string())) +} + +/// `true` and `false`, as JSON or as strings in any case (SCIM-27). +pub fn parse_bool(value: &Value) -> Option { + match value { + Value::Bool(b) => Some(*b), + Value::String(s) if s.eq_ignore_ascii_case("true") => Some(true), + Value::String(s) if s.eq_ignore_ascii_case("false") => Some(false), + _ => None, + } +} + +/// A locale in SCIM's form (`en-US`, `ca-ES@valencia`), matched in any +/// case (SCIM-26). +pub fn parse_locale(value: &str) -> Option { + static LOCALES: OnceLock> = OnceLock::new(); + let key = value.trim().replace(['_', '@'], "-").to_lowercase(); + LOCALES + .get_or_init(|| { + (0..Locale::COUNT as u16) + .filter_map(Locale::from_id) + .map(|locale| (locale.as_str().to_lowercase(), locale)) + .collect() + }) + .get(&key) + .copied() +} + +/// An IANA time zone, matched in any case (SCIM-26). +pub fn parse_time_zone(value: &str) -> Option { + static ZONES: OnceLock> = OnceLock::new(); + ZONES + .get_or_init(|| { + (0..TimeZone::COUNT as u16) + .filter_map(TimeZone::from_id) + .map(|zone| (zone.as_str().to_lowercase(), zone)) + .collect() + }) + .get(&value.trim().to_lowercase()) + .copied() +} + +/// The display name by precedence: `displayName`, `name.formatted`, then +/// the given and family names (SCIM-24). +fn display_name(body: &Map) -> Option { + if let Some(name) = get(body, "displayName").and_then(text) { + return Some(name.to_string()); + } + let name = get(body, "name").and_then(Value::as_object)?; + if let Some(formatted) = get(name, "formatted").and_then(text) { + return Some(formatted.to_string()); + } + let parts = ["givenName", "familyName"] + .into_iter() + .filter_map(|part| get(name, part).and_then(text)) + .collect::>(); + (!parts.is_empty()).then(|| parts.join(" ")) +} + +pub fn parse(body: &Map) -> Result { + check_attributes(body, ResourceKind::User, KNOWN)?; + + let user_name = get(body, "userName") + .and_then(Value::as_str) + .ok_or_else(|| ScimError::invalid_value("'userName' is required"))?; + let (local, domain) = split_address(user_name)?; + let user_name = format!("{local}@{domain}"); + + // SCIM-25: the primary comes from userName; every other entry is an alias + let mut aliases: Vec = Vec::new(); + if let Some(emails) = get(body, "emails") { + let emails = emails + .as_array() + .ok_or_else(|| ScimError::invalid_syntax("'emails' must be a list"))?; + for email in emails { + let email = email + .as_object() + .ok_or_else(|| ScimError::invalid_syntax("Each email must be an object"))?; + let value = get(email, "value") + .and_then(text) + .ok_or_else(|| ScimError::invalid_value("An email needs a 'value'"))? + .to_lowercase(); + if value == user_name { + let is_primary = get(email, "primary").and_then(parse_bool); + let typ = get(email, "type").and_then(Value::as_str); + if is_primary == Some(false) || typ.is_some_and(|t| !t.eq_ignore_ascii_case("work")) + { + return Err(ScimError::mutability( + "The primary email is set by 'userName' and can't be changed through 'emails'", + )); + } + continue; + } + split_address(&value).map_err(|_| { + ScimError::invalid_value(format!("The email '{value}' isn't a valid address")) + })?; + if !aliases.contains(&value) { + aliases.push(value); + } + } + } + + // SCIM-26: locale wins over preferredLanguage + let locale = match get(body, "locale").or_else(|| get(body, "preferredLanguage")) { + Some(value) => { + let text = value + .as_str() + .ok_or_else(|| ScimError::invalid_value("A locale must be a string"))?; + Some(parse_locale(text).ok_or_else(|| { + ScimError::invalid_value(format!("The locale '{text}' isn't supported")) + })?) + } + None => None, + }; + let time_zone = match get(body, "timezone") { + Some(value) => { + let text = value + .as_str() + .ok_or_else(|| ScimError::invalid_value("'timezone' must be a string"))?; + Some(parse_time_zone(text).ok_or_else(|| { + ScimError::invalid_value(format!("The time zone '{text}' isn't known")) + })?) + } + None => None, + }; + + // SCIM-29 + let external_id = match get(body, "externalId") { + Some(Value::String(id)) if id.is_empty() => { + return Err(ScimError::invalid_value("'externalId' can't be empty")); + } + Some(Value::String(id)) => Some(id.clone()), + Some(_) => return Err(ScimError::invalid_value("'externalId' must be a string")), + None => None, + }; + + let active = match get(body, "active") { + Some(value) => Some( + parse_bool(value) + .ok_or_else(|| ScimError::invalid_value("'active' must be a boolean"))?, + ), + None => None, + }; + + let groups = match get(body, "groups") { + Some(Value::Array(groups)) => Some( + groups + .iter() + .filter_map(|g| g.get("value").and_then(Value::as_str).map(str::to_string)) + .collect::>(), + ), + Some(_) => return Err(ScimError::invalid_syntax("'groups' must be a list")), + None => None, + }; + + Ok(UserInput { + user_name, + local, + domain, + display: display_name(body), + active, + aliases, + locale, + time_zone, + external_id, + groups, + }) +} + +/// A domain's first name. +async fn domain_name(ctx: &Ctx<'_>, domain_id: Id) -> Result { + Ok(ctx + .server + .domain_by_id(domain_id.document_id()) + .await + .map_err(server_error)? + .map(|domain| domain.name().to_string()) + .unwrap_or_default()) +} + +/// The account's effective `authenticate` permission (SCIM-27). +pub async fn is_active(ctx: &Ctx<'_>, id: Id) -> Result { + Ok(ctx + .server + .access_token(id.document_id()) + .await + .map_err(server_error)? + .account_has_permission(Permission::Authenticate)) +} + +pub async fn primary_address(ctx: &Ctx<'_>, user: &UserAccount) -> Result { + Ok(format!( + "{}@{}", + user.name, + domain_name(ctx, user.domain_id).await? + )) +} + +/// The display name of a user or group, as SCIM shows it. +pub fn display_of(account: &Account) -> Option { + match account { + Account::User(user) => user.description.clone(), + Account::Group(group) => group + .description + .clone() + .or_else(|| Some(group.name.clone())), + } +} + +pub async fn render(ctx: &Ctx<'_>, id: Id, account: &Account) -> Result { + let Account::User(user) = account else { + return Err(ScimError::not_found(format!("User {id} not found"))); + }; + let user_name = primary_address(ctx, user).await?; + let mut emails = vec![json!({"value": user_name, "type": "work", "primary": true})]; + for alias in user.aliases.values() { + let address = format!( + "{}@{}", + alias.name, + domain_name(ctx, alias.domain_id).await? + ); + if address != user_name { + emails.push(json!({"value": address, "primary": false})); + } + } + let mut groups = Vec::new(); + for group_id in user.member_group_ids.iter() { + if let Some(group) = ctx.load_id(*group_id).await? + && matches!(group, Account::Group(_)) + && ctx.in_scope(&group).await? + { + groups.push(json!({ + "value": group_id.to_string(), + "display": display_of(&group), + "$ref": ctx.location(ResourceKind::Group, *group_id), + })); + } + } + + let mut doc = Map::new(); + doc.insert("schemas".into(), json!([SCHEMA_USER])); + doc.insert("id".into(), json!(id.to_string())); + if let Some(external_id) = &user.external_id { + doc.insert("externalId".into(), json!(external_id)); + } + doc.insert("userName".into(), json!(user_name)); + if let Some(display) = &user.description { + doc.insert("displayName".into(), json!(display)); + doc.insert("name".into(), json!({"formatted": display})); + } + doc.insert("active".into(), json!(is_active(ctx, id).await?)); + doc.insert("emails".into(), Value::Array(emails)); + doc.insert("locale".into(), json!(user.locale.as_str())); + doc.insert("preferredLanguage".into(), json!(user.locale.as_str())); + if let Some(zone) = &user.time_zone { + doc.insert("timezone".into(), json!(zone.as_str())); + } + doc.insert("groups".into(), Value::Array(groups)); + doc.insert( + "meta".into(), + json!({ + "resourceType": "User", + "created": user.created_at.to_string(), + "location": ctx.location(ResourceKind::User, id), + }), + ); + Ok(stamp(Value::Object(doc))) +} + +/// `permissions` with the `authenticate` entry that SCIM owns set or +/// cleared (SCIM-27). `None` when nothing changes. +fn with_active(permissions: &Permissions, active: bool) -> Option { + let disabled = |permissions: &Permissions| match permissions { + Permissions::Inherit => false, + Permissions::Merge(list) | Permissions::Replace(list) => list + .disabled_permissions + .iter() + .any(|p| *p == Permission::Authenticate), + }; + let is_disabled = disabled(permissions); + if is_disabled != active { + return None; + } + let mut permissions = permissions.clone(); + if active { + match &mut permissions { + Permissions::Merge(list) | Permissions::Replace(list) => { + list.disabled_permissions + .inner_mut() + .retain(|p| *p != Permission::Authenticate); + } + Permissions::Inherit => {} + } + // An account that was Inherit goes back to exactly Inherit + if let Permissions::Merge(list) = &permissions + && list.enabled_permissions.is_empty() + && list.disabled_permissions.is_empty() + { + permissions = Permissions::Inherit; + } + } else { + match &mut permissions { + Permissions::Inherit => { + permissions = Permissions::Merge(registry::schema::structs::PermissionsList { + enabled_permissions: Default::default(), + disabled_permissions: registry::types::map::Map::new(vec![ + Permission::Authenticate, + ]), + }); + } + Permissions::Merge(list) | Permissions::Replace(list) => { + list.disabled_permissions.push(Permission::Authenticate); + } + } + } + Some(permissions) +} + +/// The aliases as `x:UserAccount.aliases`, each on a domain open to SCIM +/// in the account's tenant (SCIM-15, SCIM-25). +async fn alias_objects( + ctx: &Ctx<'_>, + aliases: &[String], + tenant: Option, +) -> Result { + let mut objects = Map::new(); + for (index, alias) in aliases.iter().enumerate() { + let (local, domain) = split_address(alias)?; + let domain = ctx.writable_domain(&domain).await?; + if domain.id_tenant != tenant { + return Err(ScimError::invalid_value(format!( + "The domain '{}' is in a different tenant from the account", + domain.name() + ))); + } + objects.insert( + index.to_string(), + json!({ + "enabled": true, + "name": local, + "domainId": Id::from(domain.id).to_string(), + }), + ); + } + Ok(Value::Object(objects)) +} + +/// SCIM-29: no other user in the same tenant holds that `externalId`. +pub async fn check_external_id( + ctx: &Ctx<'_>, + kind: ResourceKind, + external_id: &str, + tenant: Option, + except: Option, +) -> Result<(), ScimError> { + let ids = ctx + .server + .registry() + .query::>( + Ctx::accounts_query(kind) + .equal( + registry::schema::prelude::Property::ExternalId, + external_id.to_string(), + ) + .with_tenant(tenant), + ) + .await + .map_err(server_error)?; + for id in ids { + if Some(id) == except { + continue; + } + if let Some(account) = ctx.load_id(id).await? { + let (other_tenant, other_external) = match &account { + Account::User(u) => (u.member_tenant_id, u.external_id.as_deref()), + Account::Group(g) => (g.member_tenant_id, g.external_id.as_deref()), + }; + if other_tenant.map(|t| t.document_id()) == tenant + && other_external == Some(external_id) + { + return Err(ScimError::conflict(format!( + "The externalId '{external_id}' is already in use" + ))); + } + } + } + Ok(()) +} + +/// Checks a `groups` value is the current membership (SCIM-28 decision). +fn check_groups(input: &UserInput, current: &Value) -> Result<(), ScimError> { + if let Some(groups) = &input.groups { + let mut sent = groups.clone(); + sent.sort(); + let mut now = current + .get("groups") + .and_then(Value::as_array) + .map(|groups| { + groups + .iter() + .filter_map(|g| g.get("value").and_then(Value::as_str).map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + now.sort(); + if sent != now { + return Err(ScimError::mutability( + "'groups' is read-only: change membership through the Group", + )); + } + } + Ok(()) +} + +/// `POST /Users` (SCIM-31, SCIM-39). +pub async fn create(ctx: &Ctx<'_>, body: &Map) -> Result { + let input = parse(body)?; + if input + .groups + .as_ref() + .is_some_and(|groups| !groups.is_empty()) + { + return Err(ScimError::mutability( + "'groups' is read-only: add the user through the Group", + )); + } + let domain = ctx.writable_domain(&input.domain).await?; + let tenant = domain.id_tenant; + let aliases = alias_objects(ctx, &input.aliases, tenant).await?; + if let Some(external_id) = &input.external_id { + check_external_id(ctx, ResourceKind::User, external_id, tenant, None).await?; + } + let permissions = if input.active == Some(false) { + with_active(&Permissions::Inherit, false).unwrap_or(Permissions::Inherit) + } else { + Permissions::Inherit + }; + let mut object = json!({ + "@type": "User", + "name": input.local, + "domainId": Id::from(domain.id).to_string(), + "description": input.display, + "aliases": aliases, + "roles": {"@type": "User"}, + "permissions": permissions, + "externalId": input.external_id, + }); + // MT-7: in its domain's tenant; a tenant caller's writes get it anyway + if let Some(tenant) = tenant + && ctx.tenant_id().is_none() + { + object["memberTenantId"] = json!(Id::from(tenant).to_string()); + } + if let Some(locale) = input.locale { + object["locale"] = json!(locale.as_str()); + } + if let Some(zone) = input.time_zone { + object["timeZone"] = json!(zone.as_str()); + } + let id = ctx.create(object).await?; + audit( + ctx, + trc::ScimEvent::ResourceCreated, + ResourceKind::User, + id, + input.external_id.as_deref(), + ); + Ok(id) +} + +/// `PUT` and the result of `PATCH` (SCIM-23, SCIM-41, SCIM-42): every +/// readWrite attribute takes the sent value or its default. +pub async fn replace( + ctx: &Ctx<'_>, + id: Id, + account: &Account, + current: &Value, + body: &Map, + mode: crate::resource::WriteMode, +) -> Result<(), ScimError> { + let Account::User(user) = account else { + return Err(ScimError::not_found(format!("User {id} not found"))); + }; + if let Some(sent) = get(body, "id").and_then(Value::as_str) + && sent != id.to_string() + { + return Err(ScimError::mutability("'id' can't be changed")); + } + let input = parse(body)?; + check_groups(&input, current)?; + + let is_self = id.document_id() == ctx.principal_id(); + let current_name = current + .get("userName") + .and_then(Value::as_str) + .unwrap_or_default(); + let tenant = user.member_tenant_id.map(|t| t.document_id()); + let mut patch = Map::new(); + + // SCIM-23: a new userName moves the account, within its tenant + if input.user_name != current_name { + if is_self { + return Err(ScimError::forbidden( + "The service principal can't rename itself", + )); + } + let domain = ctx.writable_domain(&input.domain).await?; + if domain.id_tenant != tenant { + return Err(ScimError::invalid_value(format!( + "The domain '{}' is in a different tenant from the account", + domain.name() + ))); + } + patch.insert("name".into(), json!(input.local)); + patch.insert("domainId".into(), json!(Id::from(domain.id).to_string())); + } + + // SCIM-24 + if input.display != user.description { + patch.insert("description".into(), json!(input.display)); + } + + // SCIM-25: PUT replaces the aliases; PATCH arrives with the full list + let current_aliases = current + .get("emails") + .and_then(Value::as_array) + .map(|emails| { + emails + .iter() + .filter(|e| e.get("primary") != Some(&Value::Bool(true))) + .filter_map(|e| e.get("value").and_then(Value::as_str).map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + // A renamed account's old address isn't kept (SCIM-23) + let aliases = input + .aliases + .iter() + .filter(|a| **a != input.user_name) + .cloned() + .collect::>(); + if aliases != current_aliases { + patch.insert( + "aliases".into(), + alias_objects(ctx, &aliases, tenant).await?, + ); + } + + // SCIM-26 + let locale = input.locale.unwrap_or_default(); + if locale != user.locale { + patch.insert("locale".into(), json!(locale.as_str())); + } + if input.time_zone != user.time_zone { + patch.insert( + "timeZone".into(), + json!(input.time_zone.map(|zone| zone.as_str())), + ); + } + + // SCIM-29 + if input.external_id != user.external_id { + if let Some(external_id) = &input.external_id { + check_external_id(ctx, ResourceKind::User, external_id, tenant, Some(id)).await?; + } + patch.insert("externalId".into(), json!(input.external_id)); + } + + // SCIM-27: a PUT without active means true; a PATCH carries the current value + let active = input.active.unwrap_or(match mode { + WriteMode::Patch => current.get("active") != Some(&Value::Bool(false)), + _ => true, + }); + let active_change = with_active(&user.permissions, active); + if !active && is_self && active_change.is_some() { + return Err(ScimError::forbidden( + "The service principal can't deactivate itself", + )); + } + if let Some(permissions) = &active_change { + patch.insert("permissions".into(), json!(permissions)); + } + + if patch.is_empty() { + return Ok(()); + } + ctx.update(id, Value::Object(patch)).await?; + let external_id = input.external_id.as_deref(); + audit( + ctx, + trc::ScimEvent::ResourceUpdated, + ResourceKind::User, + id, + external_id, + ); + if active_change.is_some() { + audit( + ctx, + if active { + trc::ScimEvent::ResourceReactivated + } else { + trc::ScimEvent::ResourceSuspended + }, + ResourceKind::User, + id, + external_id, + ); + } + Ok(()) +} diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index cad2503..dc1f90a 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -6,7 +6,8 @@ // This file is auto-generated. Do not edit directly. -pub const TOTAL_EVENT_COUNT: usize = 637; +// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54) +pub const TOTAL_EVENT_COUNT: usize = 642; pub const TOTAL_METRIC_COUNT: usize = 369; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -42,6 +43,8 @@ pub enum EventType { Queue(QueueEvent), Registry(RegistryEvent), Resource(ResourceEvent), + // inbuxa: SCIM-54 + Scim(ScimEvent), Security(SecurityEvent), Server(ServerEvent), Sieve(SieveEvent), @@ -624,6 +627,17 @@ pub enum ResourceEvent { ApplicationUnpacked = 602, } +// inbuxa: SCIM-54: every write an identity provider makes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum ScimEvent { + ResourceCreated = 637, + ResourceUpdated = 638, + ResourceSuspended = 639, + ResourceReactivated = 640, + ResourceDeleted = 641, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u16)] pub enum SecurityEvent { diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index e7d8cc6..9192c3a 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -36,6 +36,12 @@ impl EventType { b"acme.error" => EventType::Acme(AcmeEvent::Error), b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse), b"ai.api-error" => EventType::Ai(AiEvent::ApiError), + // inbuxa: SCIM-54 + b"scim.resource-created" => EventType::Scim(ScimEvent::ResourceCreated), + b"scim.resource-updated" => EventType::Scim(ScimEvent::ResourceUpdated), + b"scim.resource-suspended" => EventType::Scim(ScimEvent::ResourceSuspended), + b"scim.resource-reactivated" => EventType::Scim(ScimEvent::ResourceReactivated), + b"scim.resource-deleted" => EventType::Scim(ScimEvent::ResourceDeleted), b"arc.chain-too-long" => EventType::Arc(ArcEvent::ChainTooLong), b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance), b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv), @@ -679,6 +685,12 @@ impl EventType { EventType::Acme(AcmeEvent::Error) => "acme.error", EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response", EventType::Ai(AiEvent::ApiError) => "ai.api-error", + // inbuxa: SCIM-54 + EventType::Scim(ScimEvent::ResourceCreated) => "scim.resource-created", + EventType::Scim(ScimEvent::ResourceUpdated) => "scim.resource-updated", + EventType::Scim(ScimEvent::ResourceSuspended) => "scim.resource-suspended", + EventType::Scim(ScimEvent::ResourceReactivated) => "scim.resource-reactivated", + EventType::Scim(ScimEvent::ResourceDeleted) => "scim.resource-deleted", EventType::Arc(ArcEvent::ChainTooLong) => "arc.chain-too-long", EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance", EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv", @@ -1457,6 +1469,12 @@ impl EventType { EventType::Acme(AcmeEvent::Error) => 15, EventType::Ai(AiEvent::LlmResponse) => 556, EventType::Ai(AiEvent::ApiError) => 557, + // inbuxa: SCIM-54 + EventType::Scim(ScimEvent::ResourceCreated) => 637, + EventType::Scim(ScimEvent::ResourceUpdated) => 638, + EventType::Scim(ScimEvent::ResourceSuspended) => 639, + EventType::Scim(ScimEvent::ResourceReactivated) => 640, + EventType::Scim(ScimEvent::ResourceDeleted) => 641, EventType::Arc(ArcEvent::ChainTooLong) => 28, EventType::Arc(ArcEvent::InvalidInstance) => 31, EventType::Arc(ArcEvent::InvalidCv) => 30, @@ -2099,6 +2117,12 @@ impl EventType { 15 => Some(EventType::Acme(AcmeEvent::Error)), 556 => Some(EventType::Ai(AiEvent::LlmResponse)), 557 => Some(EventType::Ai(AiEvent::ApiError)), + // inbuxa: SCIM-54 + 637 => Some(EventType::Scim(ScimEvent::ResourceCreated)), + 638 => Some(EventType::Scim(ScimEvent::ResourceUpdated)), + 639 => Some(EventType::Scim(ScimEvent::ResourceSuspended)), + 640 => Some(EventType::Scim(ScimEvent::ResourceReactivated)), + 641 => Some(EventType::Scim(ScimEvent::ResourceDeleted)), 28 => Some(EventType::Arc(ArcEvent::ChainTooLong)), 31 => Some(EventType::Arc(ArcEvent::InvalidInstance)), 30 => Some(EventType::Arc(ArcEvent::InvalidCv)), @@ -3056,6 +3080,12 @@ impl EventType { EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn, EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn, EventType::Ai(AiEvent::ApiError) => Level::Warn, + // inbuxa: SCIM-54 + EventType::Scim(ScimEvent::ResourceCreated) => Level::Info, + EventType::Scim(ScimEvent::ResourceUpdated) => Level::Info, + EventType::Scim(ScimEvent::ResourceSuspended) => Level::Info, + EventType::Scim(ScimEvent::ResourceReactivated) => Level::Info, + EventType::Scim(ScimEvent::ResourceDeleted) => Level::Info, EventType::Arc(ArcEvent::SealerNotFound) => Level::Warn, EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn, EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn, @@ -3137,6 +3167,12 @@ impl EventType { EventType::Acme(AcmeEvent::Error) => "ACME error", EventType::Ai(AiEvent::LlmResponse) => "LLM response", EventType::Ai(AiEvent::ApiError) => "AI API error", + // inbuxa: SCIM-54 + EventType::Scim(ScimEvent::ResourceCreated) => "SCIM resource created", + EventType::Scim(ScimEvent::ResourceUpdated) => "SCIM resource updated", + EventType::Scim(ScimEvent::ResourceSuspended) => "SCIM user suspended", + EventType::Scim(ScimEvent::ResourceReactivated) => "SCIM user reactivated", + EventType::Scim(ScimEvent::ResourceDeleted) => "SCIM resource deleted", EventType::Arc(ArcEvent::ChainTooLong) => "ARC chain too long", EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance", EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV", @@ -4203,6 +4239,12 @@ impl EventType { EventType::Acme(AcmeEvent::Error), EventType::Ai(AiEvent::LlmResponse), EventType::Ai(AiEvent::ApiError), + // inbuxa: SCIM-54 + EventType::Scim(ScimEvent::ResourceCreated), + EventType::Scim(ScimEvent::ResourceUpdated), + EventType::Scim(ScimEvent::ResourceSuspended), + EventType::Scim(ScimEvent::ResourceReactivated), + EventType::Scim(ScimEvent::ResourceDeleted), EventType::Arc(ArcEvent::ChainTooLong), EventType::Arc(ArcEvent::InvalidInstance), EventType::Arc(ArcEvent::InvalidCv), diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 686766f..a9639ea 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 56f7d41..31e21b0 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -ngxMcJdAkSNEy0lbKUXrU9VRRrX7R0tPVHHh_gGCf7E \ No newline at end of file +-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU \ No newline at end of file diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 994523f..3975eff 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -24,6 +24,8 @@ pub mod imap; #[cfg(test)] pub mod jmap; #[cfg(test)] +pub mod scim; +#[cfg(test)] pub mod smtp; #[cfg(test)] pub mod store; diff --git a/tests/src/scim/acceptance.rs b/tests/src/scim/acceptance.rs new file mode 100644 index 0000000..486a17d --- /dev/null +++ b/tests/src/scim/acceptance.rs @@ -0,0 +1,1392 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! SCIM acceptance tests, from `docs/spec/features/scim.md`. Each check +//! names its test number or requirement. + +use crate::{ + scim::{ + PRINCIPAL_SECRET, SCIM_DOMAIN, ScimClient, ScimTest, api_key, api_key_with_id, + create_principal, full_permissions, group_body, patch_body, query, user_body, + }, + utils::{account::Account, server::TestServer, smtp::SmtpConnection}, +}; +use registry::{ + schema::{ + enums::{Permission, TenantStorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + self, Action, CertificateManagement, DataRetention, DkimManagement, DnsManagement, + Domain, PasswordCredential, Permissions, PermissionsList, Tenant, UserAccount, + }, + }, + types::{duration::Duration, list::List, map::Map}, +}; +use scim_proto::{ + MESSAGE_BULK_REQUEST, MESSAGE_SEARCH_REQUEST, SCHEMA_ENTERPRISE_USER, SCHEMA_USER, +}; +use serde_json::json; +use std::str::FromStr; +use trc::{Collector, MetricType}; +use types::id::Id; +use utils::map::vec_map::VecMap; + +const CLOSED: &str = "closed.example.com"; +const USER_SECRET: &str = "scim acceptance user passphrase"; + +pub async fn test(test: &TestServer, scim: &ScimTest) { + println!("Running SCIM acceptance tests..."); + let admin = test.account("admin"); + let closed_id = admin + .registry_create_object(Domain { + is_enabled: true, + name: CLOSED.to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await; + admin.registry_create_object(Action::InvalidateCaches).await; + + discovery(scim).await; + authentication(test, scim).await; + domains(test, scim, closed_id).await; + users(test, scim).await; + groups(scim).await; + patching(scim).await; + conditional(scim).await; + queries(scim).await; + bulk(scim).await; + suspension(test, scim).await; + deletion(test, scim).await; + adoption(test, scim).await; + tenants(test, scim).await; + + admin + .registry_destroy(ObjectType::Domain, [closed_id]) + .await; +} + +/// Test 10 (SCIM-2 to SCIM-6). +async fn discovery(scim: &ScimTest) { + let anonymous = ScimClient::anonymous(); + let config = anonymous.get("/ServiceProviderConfig").await; + config.assert_status(200); + assert_eq!( + config.header("content-type").as_deref(), + Some("application/scim+json") + ); + assert_eq!(config.json["bulk"]["maxOperations"], json!(1000), "SCIM-4"); + assert_eq!( + config.json["bulk"]["maxPayloadSize"], + json!(1048576), + "SCIM-4" + ); + assert_eq!(config.json["filter"]["maxResults"], json!(200), "SCIM-4"); + assert_eq!( + config.json["changePassword"]["supported"], + json!(false), + "SCIM-4" + ); + assert_eq!( + config.json["pagination"]["cursorTimeout"], + json!(3600), + "SCIM-4" + ); + assert_eq!( + config.json["interopProfileConformant"], + json!(false), + "SCIM-4" + ); + assert!( + !config.body.contains("stalw"), + "SCIM-4: documentation is INBUXA's own" + ); + + let types = anonymous.get("/ResourceTypes").await; + types.assert_status(200); + assert_eq!(types.total_results(), 2, "SCIM-5"); + assert!(!types.body.contains("schemaExtensions\":[{"), "SCIM-5"); + anonymous + .get("/ResourceTypes/User") + .await + .assert_status(200); + + let schemas = anonymous.get("/Schemas").await; + schemas.assert_status(200); + assert!(!schemas.body.contains("\"password\""), "SCIM-6"); + anonymous + .get(&format!("/Schemas/{SCHEMA_USER}")) + .await + .assert_status(200); + + anonymous + .get("/Schemas?filter=id%20eq%20%22x%22") + .await + .assert_error(403, None); + anonymous.get("/Nothing").await.assert_error(404, None); + let wrong = anonymous + .request(reqwest::Method::DELETE, "/ServiceProviderConfig", None, &[]) + .await; + wrong.assert_error(405, None); + assert!(wrong.header("allow").is_some(), "SCIM-2: Allow"); + let options = anonymous + .request(reqwest::Method::OPTIONS, "/Users", None, &[]) + .await; + assert_eq!(options.status, 204, "SCIM-2"); + anonymous.get("/Me").await.assert_error(501, None); + scim.client.get("/Me").await.assert_error(501, None); +} + +/// Tests 6, 7, 8 (SCIM-7, SCIM-9, SCIM-11, SCIM-13, SCIM-52). +async fn authentication(test: &TestServer, scim: &ScimTest) { + let admin = test.account("admin"); + + // Test 6: missing, Basic, a bearer that isn't an API key + for client in [ + ScimClient::anonymous(), + ScimClient::with_authorization(Some(format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("scim-svc@{SCIM_DOMAIN}:{PRINCIPAL_SECRET}") + ) + ))), + ScimClient::bearer("not-an-api-key"), + ScimClient::bearer("API_bm90IGEgcmVhbCBrZXk"), + ] { + let reply = client.get("/Users").await; + reply.assert_error(401, None); + assert!( + reply + .header("www-authenticate") + .is_some_and(|h| h.starts_with("Bearer")), + "test 6: {}", + reply.body + ); + } + let basic = ScimClient::with_authorization(Some("Basic eDp5".into())) + .get("/Users") + .await; + basic.assert_detail_contains("Bearer"); + + // A key from a disallowed address, and a deleted key + let principal = Account::new( + "scim-svc@scim.example.com", + PRINCIPAL_SECRET, + &[], + "", + scim.principal_id, + ); + let (fenced_id, fenced) = api_key_with_id(&principal, json!({"@type": "Inherit"})).await; + principal + .jmap_update( + "x:ApiKey", + [( + fenced_id.to_string(), + json!({"allowedIps": {"10.9.9.9": true}}), + )], + Vec::<(&str, &str)>::new(), + ) + .await; + ScimClient::bearer(&fenced) + .get("/Users") + .await + .assert_error(401, None); + let (doomed_id, doomed) = api_key_with_id(&principal, json!({"@type": "Inherit"})).await; + ScimClient::bearer(&doomed) + .get("/Users") + .await + .assert_status(200); + principal + .registry_destroy(ObjectType::ApiKey, [doomed_id]) + .await; + ScimClient::bearer(&doomed) + .get("/Users") + .await + .assert_error(401, None); + + // A Replace key without scimAccess: 403 naming it + let narrow = api_key( + admin, + &principal, + json!({"@type": "Replace", "permissions": {"authenticate": true, "sysAccountGet": true}}), + ) + .await; + ScimClient::bearer(&narrow) + .get("/Users") + .await + .assert_error(403, None) + .assert_detail_contains("scimAccess"); + + // Test 7: no sysAccountDestroy: DELETE refused, suspension works + let no_destroy = api_key( + admin, + &principal, + json!({"@type": "Disable", "permissions": {"sysAccountDestroy": true}}), + ) + .await; + let limited = ScimClient::bearer(&no_destroy); + let id = scim.create_user(&format!("leaver@{SCIM_DOMAIN}")).await; + limited + .delete(&format!("/Users/{id}")) + .await + .assert_error(403, None) + .assert_detail_contains("sysAccountDestroy"); + let patched = limited + .patch( + &format!("/Users/{id}"), + patch_body(json!([{"op": "replace", "path": "active", "value": false}])), + ) + .await; + patched.assert_status(200); + assert_eq!(patched.json["active"], json!(false), "test 7"); + scim.destroy(&format!("/Users/{id}")).await; + + // Test 8: the principal can't deactivate, rename or delete itself + let me = format!("/Users/{}", scim.principal_id); + let before = scim.client.get(&me).await; + before.assert_status(200); + for body in [ + patch_body(json!([{"op": "replace", "path": "active", "value": false}])), + patch_body( + json!([{"op": "replace", "path": "userName", "value": format!("other@{SCIM_DOMAIN}")}]), + ), + ] { + scim.client.patch(&me, body).await.assert_error(403, None); + } + scim.client.delete(&me).await.assert_error(403, None); + let after = scim.client.get(&me).await; + assert_eq!(after.etag(), before.etag(), "test 8: unchanged"); +} + +/// Tests 11, 12, 13 (SCIM-15, SCIM-16, SCIM-18). +async fn domains(test: &TestServer, scim: &ScimTest, closed_id: Id) { + let admin = test.account("admin"); + + // Test 11 + scim.client + .post("/Users", user_body(&format!("nobody@{CLOSED}"))) + .await + .assert_error(400, Some("invalidValue")) + .assert_detail_contains(CLOSED); + scim.client + .post( + "/Users", + json!({ + "schemas": [SCHEMA_USER], + "userName": format!("half@{SCIM_DOMAIN}"), + "emails": [{"value": format!("half@{CLOSED}")}], + }), + ) + .await + .assert_error(400, Some("invalidValue")); + scim.client + .get(&query( + "/Users", + &format!("userName eq \"half@{SCIM_DOMAIN}\""), + )) + .await + .assert_status(200); + scim.client + .post("/Users", user_body("not an address")) + .await + .assert_error(400, Some("invalidValue")) + .assert_detail_contains("is not a valid email address"); + + // Test 12: an account on a closed domain isn't listed + let hidden = admin + .registry_create_object(structs::Account::User(UserAccount { + name: "hidden".to_string(), + domain_id: closed_id, + ..Default::default() + })) + .await; + scim.client + .get("/Users?count=200") + .await + .assert_status(200) + .assert_lacks_id(&hidden.to_string()); + scim.client + .get(&format!("/Users/{hidden}")) + .await + .assert_error(404, None); + + // Test 13: a principal on a closed domain can't create groups + let outsider_id = + create_principal(admin, "outside-svc", closed_id, None, full_permissions()).await; + let outsider = Account::new( + "outside-svc@closed.example.com", + PRINCIPAL_SECRET, + &[], + "", + outsider_id, + ); + let token = api_key(admin, &outsider, json!({"@type": "Inherit"})).await; + let client = ScimClient::bearer(&token); + client + .post("/Groups", group_body("Closed Team")) + .await + .assert_error(400, Some("invalidValue")) + .assert_detail_contains(CLOSED); + let id = client + .post("/Users", user_body(&format!("managed@{SCIM_DOMAIN}"))) + .await + .assert_status(201) + .id(); + scim.destroy(&format!("/Users/{id}")).await; + + admin + .registry_destroy(ObjectType::Account, [hidden, outsider_id]) + .await; +} + +/// Tests 15 to 20 (SCIM-21 to SCIM-29). +async fn users(test: &TestServer, scim: &ScimTest) { + let admin = test.account("admin"); + + // Test 16: a duplicate address is 409 and changes nothing + let first = scim + .client + .post( + "/Users", + json!({ + "schemas": [SCHEMA_USER], + "userName": format!("Jane.Doe@{SCIM_DOMAIN}"), + "displayName": "Jane Doe", + "emails": [ + {"value": format!("jane.doe@{SCIM_DOMAIN}"), "primary": true, "type": "work"}, + {"value": format!("jd@{SCIM_DOMAIN}")}, + {"value": format!("JD@{SCIM_DOMAIN}"), "type": "home"}, + ], + }), + ) + .await; + first.assert_status(201); + let jane = first.id(); + assert_eq!( + first.json["userName"], + json!(format!("jane.doe@{SCIM_DOMAIN}")), + "SCIM-22" + ); + assert!( + first.header("location").is_some_and(|l| l.ends_with(&jane)), + "SCIM-39" + ); + assert!( + first.etag().is_some_and(|e| e.starts_with("W/\"")), + "SCIM-44" + ); + assert!(first.json["meta"].get("lastModified").is_none(), "SCIM-30"); + // Test 17: primary first, read-only; the duplicate alias skipped + assert_eq!( + first.json["emails"], + json!([ + {"value": format!("jane.doe@{SCIM_DOMAIN}"), "type": "work", "primary": true}, + {"value": format!("jd@{SCIM_DOMAIN}"), "primary": false}, + ]), + "test 17" + ); + scim.client + .post("/Users", user_body(&format!("JANE.DOE@{SCIM_DOMAIN}"))) + .await + .assert_error(409, Some("uniqueness")); + scim.client + .post("/Users", user_body(&format!("jd@{SCIM_DOMAIN}"))) + .await + .assert_error(409, Some("uniqueness")); + let unchanged = scim.client.get(&format!("/Users/{jane}")).await; + assert_eq!(unchanged.etag(), first.etag(), "test 16"); + + // Test 17: the primary through emails is 400 mutability; an alias + // another account holds is 409; PATCH remove drops an alias + scim.client + .patch( + &format!("/Users/{jane}"), + patch_body(json!([{ + "op": "remove", + "path": format!("emails[value eq \"jane.doe@{SCIM_DOMAIN}\"]"), + }])), + ) + .await + .assert_error(400, Some("mutability")) + .assert_detail_contains("userName"); + let other = scim.create_user(&format!("other@{SCIM_DOMAIN}")).await; + scim.client + .patch( + &format!("/Users/{other}"), + patch_body(json!([{"op": "add", "path": "emails", "value": [{"value": format!("jd@{SCIM_DOMAIN}")}]}])), + ) + .await + .assert_error(409, Some("uniqueness")); + let dropped = scim + .client + .patch( + &format!("/Users/{jane}"), + patch_body( + json!([{"op": "remove", "path": format!("emails[value eq \"jd@{SCIM_DOMAIN}\"]")}]), + ), + ) + .await; + dropped.assert_status(200); + assert_eq!( + dropped.json["emails"].as_array().unwrap().len(), + 1, + "test 17" + ); + + // Test 15: a rename moves the account; the old address is released + let renamed = scim + .client + .patch( + &format!("/Users/{jane}"), + patch_body(json!([{"op": "replace", "path": "userName", "value": format!("jane.smith@{SCIM_DOMAIN}")}])), + ) + .await; + renamed.assert_status(200); + assert_eq!(renamed.json["id"], json!(jane), "SCIM-21"); + assert_eq!( + renamed.json["emails"].as_array().unwrap().len(), + 1, + "SCIM-23" + ); + let mut lmtp = SmtpConnection::connect().await; + lmtp.mail_from("sender@remote.example.org", 2).await; + lmtp.rcpt_to(&format!("jane.doe@{SCIM_DOMAIN}"), 5).await; + lmtp.rcpt_to(&format!("jane.smith@{SCIM_DOMAIN}"), 2).await; + lmtp.quit().await; + + // Test 18: locales and time zones + for (sent, stored) in [ + (json!({"locale": "EN-us"}), "en-US"), + (json!({"locale": "ca-ES@valencia"}), "ca-ES-valencia"), + (json!({"preferredLanguage": "fr-FR"}), "fr-FR"), + ( + json!({"locale": "de-DE", "preferredLanguage": "fr-FR"}), + "de-DE", + ), + ] { + let mut body = user_body(&format!("jane.smith@{SCIM_DOMAIN}")); + body.as_object_mut() + .unwrap() + .extend(sent.as_object().unwrap().clone()); + let reply = scim.client.put(&format!("/Users/{jane}"), body).await; + reply.assert_status(200); + assert_eq!(reply.json["locale"], json!(stored), "test 18 {sent}"); + assert_eq!( + reply.json["preferredLanguage"], + json!(stored), + "test 18 {sent}" + ); + } + for bad in [ + json!({"locale": "xx-YY"}), + json!({"timezone": "Mars/Olympus"}), + ] { + let mut body = user_body(&format!("jane.smith@{SCIM_DOMAIN}")); + body.as_object_mut() + .unwrap() + .extend(bad.as_object().unwrap().clone()); + scim.client + .put(&format!("/Users/{jane}"), body) + .await + .assert_error(400, Some("invalidValue")); + } + let mut body = user_body(&format!("jane.smith@{SCIM_DOMAIN}")); + body["timezone"] = json!("europe/madrid"); + let reply = scim.client.put(&format!("/Users/{jane}"), body).await; + assert_eq!(reply.json["timezone"], json!("Europe/Madrid"), "test 18"); + + // SCIM-24: the display name by precedence, and returned twice + let reply = scim + .client + .put( + &format!("/Users/{jane}"), + json!({ + "schemas": [SCHEMA_USER], + "userName": format!("jane.smith@{SCIM_DOMAIN}"), + "name": {"givenName": "Jane", "familyName": "Smith"}, + }), + ) + .await; + assert_eq!(reply.json["displayName"], json!("Jane Smith"), "SCIM-24"); + assert_eq!( + reply.json["name"]["formatted"], + json!("Jane Smith"), + "SCIM-24" + ); + + // SCIM-33: ignored attributes, never echoed; unknown ones refused + let reply = scim + .client + .put( + &format!("/Users/{jane}"), + json!({ + "schemas": [SCHEMA_USER, SCHEMA_ENTERPRISE_USER], + "userName": format!("jane.smith@{SCIM_DOMAIN}"), + "password": "not stored anywhere", + "title": "Engineer", + "phoneNumbers": [{"value": "555-0100"}], + SCHEMA_ENTERPRISE_USER: {"department": "Sales"}, + }), + ) + .await; + reply.assert_status(200); + assert!(!reply.body.contains("not stored anywhere"), "SCIM-33"); + assert!(!reply.body.contains("Engineer"), "SCIM-33"); + for bad in [ + json!({"schemas": [SCHEMA_USER], "userName": format!("jane.smith@{SCIM_DOMAIN}"), "dispalyName": "x"}), + json!({"schemas": ["urn:example:unknown"], "userName": format!("jane.smith@{SCIM_DOMAIN}")}), + json!({"userName": format!("jane.smith@{SCIM_DOMAIN}")}), + ] { + scim.client + .put(&format!("/Users/{jane}"), bad) + .await + .assert_error(400, Some("invalidSyntax")); + } + + // Test 19: active on an Inherit account leaves exactly Inherit + let id = Id::from_str(&jane).unwrap(); + for active in [false, true] { + scim.client + .patch( + &format!("/Users/{jane}"), + patch_body(json!([{"op": "Replace", "value": {"active": if active { "True" } else { "False" }}}])), + ) + .await + .assert_status(200); + let account = admin.registry_get::(id).await; + let structs::Account::User(user) = account else { + panic!() + }; + if active { + assert_eq!(user.permissions, Permissions::Inherit, "test 19"); + } else { + assert_ne!(user.permissions, Permissions::Inherit, "test 19"); + } + } + // ... and custom permissions come back as they were + let custom = Permissions::Merge(PermissionsList { + enabled_permissions: Map::new(vec![Permission::JmapEmailGet]), + disabled_permissions: Map::new(vec![Permission::JmapEmailQuery]), + }); + admin + .registry_update_object( + ObjectType::Account, + id, + json!({ Property::Permissions: custom }), + ) + .await; + for active in [false, true] { + scim.client + .patch( + &format!("/Users/{jane}"), + patch_body(json!([{"op": "replace", "path": "active", "value": active}])), + ) + .await + .assert_status(200); + } + let structs::Account::User(user) = admin.registry_get::(id).await else { + panic!() + }; + assert_eq!(user.permissions, custom, "test 19"); + + // Test 20: externalId unique in a tenant, matched case-exactly + scim.client + .patch( + &format!("/Users/{jane}"), + patch_body(json!([{"op": "add", "path": "externalId", "value": "EXT-1"}])), + ) + .await + .assert_status(200); + scim.client + .patch( + &format!("/Users/{other}"), + patch_body(json!([{"op": "add", "path": "externalId", "value": "EXT-1"}])), + ) + .await + .assert_error(409, Some("uniqueness")); + let found = scim + .client + .get(&query("/Users", "externalId eq \"EXT-1\"")) + .await; + assert_eq!(found.total_results(), 1, "test 20"); + let found = scim + .client + .get(&query("/Users", "externalId eq \"ext-1\"")) + .await; + assert_eq!(found.total_results(), 0, "test 20: case-exact"); + scim.client + .patch( + &format!("/Users/{other}"), + patch_body(json!([{"op": "add", "path": "externalId", "value": ""}])), + ) + .await + .assert_error(400, Some("invalidValue")); + + // SCIM-28: groups is read-only + scim.client + .patch( + &format!("/Users/{jane}"), + patch_body(json!([{"op": "add", "path": "groups", "value": [{"value": "x"}]}])), + ) + .await + .assert_error(400, Some("mutability")); + + scim.destroy(&format!("/Users/{jane}")).await; + scim.destroy(&format!("/Users/{other}")).await; +} + +/// Test 21 (SCIM-34 to SCIM-38). +async fn groups(scim: &ScimTest) { + let sales = scim.client.post("/Groups", group_body("Sales EMEA")).await; + sales.assert_status(201); + let sales_id = sales.id(); + scim.client + .post("/Groups", group_body("sales emea")) + .await + .assert_error(409, Some("uniqueness")); + + // SCIM-35: the address, and a second group taking the suffix + let mut lmtp = SmtpConnection::connect().await; + lmtp.mail_from("sender@remote.example.org", 2).await; + lmtp.rcpt_to(&format!("sales-emea@{SCIM_DOMAIN}"), 2).await; + lmtp.quit().await; + let renamed = scim + .client + .patch( + &format!("/Groups/{sales_id}"), + patch_body(json!([{"op": "replace", "path": "displayName", "value": "Sales Europe"}])), + ) + .await; + renamed.assert_status(200); + let second = scim.create_group("Sales-EMEA").await; + let mut lmtp = SmtpConnection::connect().await; + lmtp.mail_from("sender@remote.example.org", 2).await; + lmtp.rcpt_to(&format!("sales-emea-2@{SCIM_DOMAIN}"), 2) + .await; + lmtp.quit().await; + + // A group as member, an unknown member: 400 + for member in [second.clone(), "zzzzzzzz".to_string()] { + scim.client + .patch( + &format!("/Groups/{sales_id}"), + patch_body(json!([{"op": "add", "path": "members", "value": [{"value": member}]}])), + ) + .await + .assert_error(400, Some("invalidValue")); + } + + // SCIM-38: the version changes when a member is added + let user = scim.create_user(&format!("member@{SCIM_DOMAIN}")).await; + let before = scim.client.get(&format!("/Groups/{sales_id}")).await; + let after = scim + .client + .patch( + &format!("/Groups/{sales_id}"), + patch_body(json!([{"op": "add", "path": "members", "value": [{"value": user}]}])), + ) + .await; + after.assert_status(200); + assert_ne!(after.etag(), before.etag(), "SCIM-38"); + assert_eq!(after.json["members"][0]["value"], json!(user), "SCIM-36"); + assert_eq!(after.json["members"][0]["type"], json!("User"), "SCIM-36"); + let member = scim.client.get(&format!("/Users/{user}")).await; + assert_eq!( + member.json["groups"][0]["value"], + json!(sales_id), + "SCIM-28" + ); + assert_eq!( + member.json["groups"][0]["display"], + json!("Sales Europe"), + "SCIM-28" + ); + + // Test 22: removing a non-member succeeds + scim.client + .patch( + &format!("/Groups/{sales_id}"), + patch_body(json!([{"op": "remove", "path": "members[value eq \"zzzzzzzz\"]"}])), + ) + .await + .assert_status(200); + // SCIM-36: remove without a filter empties the group + let emptied = scim + .client + .patch( + &format!("/Groups/{sales_id}"), + patch_body(json!([{"op": "remove", "path": "members"}])), + ) + .await; + assert_eq!(emptied.json["members"], json!([]), "SCIM-36"); + + // SCIM-37: over 200 members needs excludedAttributes=members + let mut members = Vec::new(); + let mut operations = Vec::new(); + for n in 0..201 { + operations.push(json!({ + "method": "POST", + "path": "/Users", + "bulkId": format!("m{n}"), + "data": user_body(&format!("crowd{n}@{SCIM_DOMAIN}")), + })); + } + let created = scim + .client + .post( + "/Bulk", + json!({"schemas": [MESSAGE_BULK_REQUEST], "Operations": operations}), + ) + .await; + created.assert_status(200); + for result in created.json["Operations"].as_array().unwrap() { + assert_eq!(result["status"], json!("201"), "{result}"); + let id = result["location"] + .as_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .to_string(); + members.push(json!({"value": id})); + } + scim.client + .patch( + &format!("/Groups/{sales_id}"), + patch_body(json!([{"op": "add", "path": "members", "value": members}])), + ) + .await + .assert_status(200); + scim.client + .get(&format!("/Groups/{sales_id}")) + .await + .assert_error(400, Some("tooMany")); + scim.client + .get(&format!("/Groups/{sales_id}?excludedAttributes=members")) + .await + .assert_status(200); + + // Test 25: count is capped at 200; count=0 gives totals only + let page = scim.client.get("/Users?count=500").await; + assert_eq!(page.json["itemsPerPage"], json!(200), "test 25"); + assert!(page.total_results() > 200, "test 25"); + let totals = scim.client.get("/Users?count=0").await; + assert!(totals.json.get("Resources").is_none(), "test 25"); + assert_eq!(totals.total_results(), page.total_results(), "test 25"); + let a = scim.client.get("/Users?startIndex=11&count=10").await; + let b = scim.client.get("/Users?startIndex=11&count=10").await; + assert_eq!(a.resource_ids(), b.resource_ids(), "test 25: stable"); + let mut ids = page.resource_ids(); + let sorted = { + let mut s = ids.clone(); + s.sort_by_key(|id| Id::from_str(id).unwrap().id()); + s + }; + assert_eq!(ids, sorted, "SCIM-47"); + let descending = scim + .client + .get("/Users?count=200&sortBy=userName&sortOrder=descending") + .await; + descending.assert_status(200); + scim.client + .get("/Users?sortBy=title") + .await + .assert_error(400, Some("invalidValue")); + + // Test 26: a cursor walk, and cursors that don't match + let mut seen = Vec::new(); + let mut cursor = String::new(); + loop { + let page = scim + .client + .get(&format!("/Users?count=100&cursor={cursor}")) + .await; + page.assert_status(200); + seen.extend(page.resource_ids()); + match page.json["nextCursor"].as_str() { + Some(next) => cursor = next.to_string(), + None => break, + } + } + ids.sort(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len() as u64, page.total_results(), "test 26"); + let first = scim.client.get("/Users?count=100&cursor=").await; + let next = first.json["nextCursor"].as_str().unwrap().to_string(); + scim.client + .get(&format!("/Users?count=50&cursor={next}")) + .await + .assert_error(400, Some("invalidCount")); + scim.client + .get(&format!( + "{}&count=100&cursor={next}", + query("/Users", "externalId eq \"nobody\"") + )) + .await + .assert_error(400, Some("invalidCursor")); + let mut tampered = next.clone().into_bytes(); + tampered[4] = if tampered[4] == b'A' { b'B' } else { b'A' }; + scim.client + .get(&format!( + "/Users?count=100&cursor={}", + String::from_utf8(tampered).unwrap() + )) + .await + .assert_error(400, Some("invalidCursor")); + scim.client + .get("/Users?cursor=&startIndex=1") + .await + .assert_error(400, Some("invalidValue")); + + // Test 24: an unindexed filter over 200 candidates + scim.client + .get(&query("/Users", "active eq false")) + .await + .assert_error(400, Some("tooMany")); + + // Clean up + let mut operations = vec![]; + for member in &members { + operations.push(json!({ + "method": "DELETE", + "path": format!("/Users/{}", member["value"].as_str().unwrap()), + })); + } + scim.client + .post( + "/Bulk", + json!({"schemas": [MESSAGE_BULK_REQUEST], "Operations": operations}), + ) + .await + .assert_status(200); + scim.destroy(&format!("/Groups/{sales_id}")).await; + scim.destroy(&format!("/Groups/{second}")).await; + scim.destroy(&format!("/Users/{user}")).await; +} + +/// Test 22 (SCIM-42). +async fn patching(scim: &ScimTest) { + let id = scim.create_user(&format!("patchy@{SCIM_DOMAIN}")).await; + let before = scim.client.get(&format!("/Users/{id}")).await; + scim.client + .patch( + &format!("/Users/{id}"), + patch_body(json!([ + {"op": "replace", "path": "displayName", "value": "Changed"}, + {"op": "replace", "path": "nothing.here", "value": "x"}, + ])), + ) + .await + .assert_error(400, Some("invalidPath")); + let after = scim.client.get(&format!("/Users/{id}")).await; + assert_eq!(after.etag(), before.etag(), "test 22: nothing changed"); + // Entra's shape: Replace, a sub-attribute, an extension path + let reply = scim + .client + .patch( + &format!("/Users/{id}"), + patch_body(json!([ + {"op": "Replace", "path": "name.givenName", "value": "Pat"}, + {"op": "Replace", "path": "displayName", "value": "Pat Chy"}, + {"op": "Add", "path": format!("{SCHEMA_ENTERPRISE_USER}:department"), "value": "Ops"}, + ])), + ) + .await; + reply.assert_status(200); + assert_eq!(reply.json["displayName"], json!("Pat Chy"), "SCIM-42"); + scim.client + .patch( + &format!("/Users/{id}"), + patch_body(json!([{"op": "remove"}])), + ) + .await + .assert_error(400, None); + scim.destroy(&format!("/Users/{id}")).await; +} + +/// Test 23 (SCIM-44). +async fn conditional(scim: &ScimTest) { + let id = scim.create_user(&format!("etag@{SCIM_DOMAIN}")).await; + let path = format!("/Users/{id}"); + let current = scim.client.get(&path).await; + let etag = current.etag().unwrap(); + let not_modified = scim + .client + .request( + reqwest::Method::GET, + &path, + None, + &[("if-none-match", &etag)], + ) + .await; + assert_eq!(not_modified.status, 304, "test 23"); + scim.client + .patch( + &path, + patch_body(json!([{"op": "replace", "path": "displayName", "value": "Moved on"}])), + ) + .await + .assert_status(200); + for (method, body) in [ + ( + reqwest::Method::PUT, + Some(user_body(&format!("etag@{SCIM_DOMAIN}"))), + ), + ( + reqwest::Method::PATCH, + Some(patch_body( + json!([{"op": "replace", "path": "displayName", "value": "x"}]), + )), + ), + (reqwest::Method::DELETE, None), + ] { + scim.client + .request(method, &path, body, &[("if-match", &etag)]) + .await + .assert_error(412, None); + } + scim.destroy(&path).await; +} + +/// Test 24 (SCIM-45, SCIM-46, SCIM-50). +async fn queries(scim: &ScimTest) { + let id = scim + .client + .post( + "/Users", + json!({ + "schemas": [SCHEMA_USER], + "userName": format!("findme@{SCIM_DOMAIN}"), + "displayName": "Find Me", + "externalId": "FIND-1", + "emails": [{"value": format!("found@{SCIM_DOMAIN}")}], + }), + ) + .await + .assert_status(201) + .id(); + let group = scim + .client + .post( + "/Groups", + json!({"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Finders", "members": [{"value": id}]}), + ) + .await + .assert_status(201) + .id(); + for filter in [ + format!("id eq \"{id}\""), + "externalId eq \"FIND-1\"".to_string(), + format!("userName eq \"FINDME@{SCIM_DOMAIN}\""), + format!("emails eq \"found@{SCIM_DOMAIN}\""), + format!("emails.value eq \"findme@{SCIM_DOMAIN}\""), + format!("groups eq \"{group}\""), + format!("groups.value eq \"{group}\" and displayName eq \"find me\""), + format!("userName eq \"findme@{SCIM_DOMAIN}\" and active eq true"), + format!("userName eq \"findme@{SCIM_DOMAIN}\" and name.formatted eq \"Find Me\""), + ] { + let reply = scim.client.get(&query("/Users", &filter)).await; + reply.assert_status(200); + assert_eq!(reply.total_results(), 1, "{filter}: {}", reply.body); + reply.assert_contains_id(&id); + } + for filter in [ + format!("members eq \"{id}\""), + "displayName eq \"finders\"".to_string(), + ] { + let reply = scim.client.get(&query("/Groups", &filter)).await; + assert_eq!(reply.total_results(), 1, "{filter}: {}", reply.body); + } + let empty = scim + .client + .get(&query("/Users", "userName eq \"no@one.example\"")) + .await; + empty.assert_status(200); + assert_eq!(empty.total_results(), 0, "SCIM-45: an empty ListResponse"); + for filter in [ + "userName co \"find\"", + "userName eq \"a\" or userName eq \"b\"", + "title pr", + "emails[type eq \"work\"]", + "title eq \"x\"", + ] { + scim.client + .get(&query("/Users", filter)) + .await + .assert_error(400, Some("invalidFilter")); + } + let searched = scim + .client + .post( + "/Users/.search", + json!({"schemas": [MESSAGE_SEARCH_REQUEST], "filter": "externalId eq \"FIND-1\"", "attributes": ["userName"]}), + ) + .await; + assert_eq!(searched.total_results(), 1, "SCIM-50"); + assert!( + searched.json["Resources"][0].get("displayName").is_none(), + "SCIM-40" + ); + let both = scim + .client + .post( + "/.search", + json!({"schemas": [MESSAGE_SEARCH_REQUEST], "count": 200}), + ) + .await; + both.assert_status(200); + let kinds = both.json["Resources"] + .as_array() + .unwrap() + .iter() + .map(|r| r["schemas"][0].as_str().unwrap().to_string()) + .collect::>(); + let first_group = kinds.iter().position(|k| k.ends_with("Group")).unwrap(); + assert!( + kinds[first_group..].iter().all(|k| k.ends_with("Group")), + "SCIM-50: users first" + ); + + scim.destroy(&format!("/Groups/{group}")).await; + scim.destroy(&format!("/Users/{id}")).await; +} + +/// Test 27 (SCIM-51). +async fn bulk(scim: &ScimTest) { + let reply = scim + .client + .post( + "/Bulk", + json!({ + "schemas": [MESSAGE_BULK_REQUEST], + "Operations": [ + {"method": "POST", "path": "/Users", "bulkId": "u1", "data": user_body(&format!("bulk1@{SCIM_DOMAIN}"))}, + {"method": "POST", "path": "/Groups", "bulkId": "g1", "data": { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": "Bulk Team", + "members": [{"value": "bulkId:u1"}], + }}, + {"method": "PATCH", "path": "/Users/bulkId:u1", "data": patch_body(json!([{"op": "replace", "path": "displayName", "value": "Bulk One"}]))}, + {"method": "DELETE", "path": "/Users/bulkId:nope"}, + ], + }), + ) + .await; + reply.assert_status(200); + let results = reply.json["Operations"].as_array().unwrap(); + assert_eq!(results[0]["status"], json!("201"), "{results:?}"); + assert_eq!(results[1]["status"], json!("201"), "{results:?}"); + assert_eq!(results[2]["status"], json!("200"), "{results:?}"); + assert_eq!(results[3]["status"], json!("409"), "{results:?}"); + assert!(results[0]["version"].is_string(), "SCIM-51"); + let user = results[0]["location"] + .as_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .to_string(); + let group = results[1]["location"] + .as_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .to_string(); + let member = scim.client.get(&format!("/Users/{user}")).await; + assert_eq!(member.json["groups"][0]["value"], json!(group), "SCIM-51"); + assert_eq!(member.json["displayName"], json!("Bulk One"), "SCIM-51"); + + // failOnErrors stops after the first failure + let reply = scim + .client + .post( + "/Bulk", + json!({ + "schemas": [MESSAGE_BULK_REQUEST], + "failOnErrors": 1, + "Operations": [ + {"method": "POST", "path": "/Users", "bulkId": "x", "data": user_body("bad address")}, + {"method": "POST", "path": "/Users", "bulkId": "y", "data": user_body(&format!("never@{SCIM_DOMAIN}"))}, + ], + }), + ) + .await; + assert_eq!( + reply.json["Operations"].as_array().unwrap().len(), + 1, + "test 27" + ); + + // 1001 operations: 413 + let operations = (0..1001) + .map(|_| json!({"method": "DELETE", "path": "/Users/x"})) + .collect::>(); + let reply = scim + .client + .post( + "/Bulk", + json!({"schemas": [MESSAGE_BULK_REQUEST], "Operations": operations}), + ) + .await; + assert_eq!(reply.status, 413, "test 27"); + + scim.destroy(&format!("/Groups/{group}")).await; + scim.destroy(&format!("/Users/{user}")).await; +} + +/// An account made by an administrator, with a password. +async fn manual_user(test: &TestServer, scim: &ScimTest, name: &str) -> Id { + test.account("admin") + .registry_create_object(structs::Account::User(UserAccount { + name: name.to_string(), + domain_id: scim.domain_id, + credentials: List::from_iter([structs::Credential::Password(PasswordCredential { + secret: USER_SECRET.to_string(), + ..Default::default() + })]), + ..Default::default() + })) + .await +} + +/// Whether an IMAP LOGIN is accepted. A refusal may be a tagged `NO` or +/// the server closing the connection. +async fn imap_login(address: &str, ok: bool) { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let stream = tokio::net::TcpStream::connect("127.0.0.1:9991") + .await + .unwrap(); + let (reader, mut writer) = tokio::io::split(stream); + let mut lines = BufReader::new(reader).lines(); + let greeting = lines.next_line().await.unwrap().unwrap_or_default(); + assert!(greeting.starts_with("* OK"), "{greeting}"); + writer + .write_all(format!("a LOGIN \"{address}\" \"{USER_SECRET}\"\r\n").as_bytes()) + .await + .unwrap(); + let mut accepted = false; + while let Ok(Ok(Some(line))) = + tokio::time::timeout(std::time::Duration::from_secs(10), lines.next_line()).await + { + if let Some(status) = line.strip_prefix("a ") { + accepted = status.starts_with("OK"); + break; + } + } + assert_eq!(accepted, ok, "IMAP login of {address}"); +} + +/// Test 28 (SCIM-52): suspension stops sign-in, not mail. +async fn suspension(test: &TestServer, scim: &ScimTest) { + let address = format!("suspended@{SCIM_DOMAIN}"); + let id = manual_user(test, scim, "suspended").await; + imap_login(&address, true).await; + scim.client + .patch( + &format!("/Users/{id}"), + patch_body(json!([{"op": "replace", "path": "active", "value": false}])), + ) + .await + .assert_status(200); + imap_login(&address, false).await; + let mut lmtp = SmtpConnection::connect().await; + lmtp.ingest( + "sender@remote.example.org", + &[&address], + "From: sender@remote.example.org\r\nSubject: still arrives\r\n\r\nHello\r\n", + ) + .await; + scim.client + .patch( + &format!("/Users/{id}"), + patch_body(json!([{"op": "replace", "path": "active", "value": true}])), + ) + .await + .assert_status(200); + imap_login(&address, true).await; + scim.destroy(&format!("/Users/{id}")).await; +} + +/// Test 29 (SCIM-52): deletion, and a held address. +async fn deletion(test: &TestServer, scim: &ScimTest) { + let admin = test.account("admin"); + let address = format!("gone@{SCIM_DOMAIN}"); + let id = scim.create_user(&address).await; + scim.client + .delete(&format!("/Users/{id}")) + .await + .assert_status(204); + scim.client + .get(&format!("/Users/{id}")) + .await + .assert_error(404, None); + let mut lmtp = SmtpConnection::connect().await; + lmtp.mail_from("sender@remote.example.org", 2).await; + lmtp.rcpt_to(&address, 5).await; + lmtp.quit().await; + + // With accounts held after deletion, the address stays reserved + admin + .registry_update_setting( + DataRetention { + archive_deleted_accounts_for: Some(Duration::from_millis(86_400_000)), + ..Default::default() + }, + &[Property::ArchiveDeletedAccountsFor], + ) + .await; + let held = format!("held@{SCIM_DOMAIN}"); + let id = scim.create_user(&held).await; + scim.client + .delete(&format!("/Users/{id}")) + .await + .assert_status(204); + scim.client + .post("/Users", user_body(&held)) + .await + .assert_error(409, Some("uniqueness")); + admin + .registry_update_setting( + DataRetention { + archive_deleted_accounts_for: None, + ..Default::default() + }, + &[Property::ArchiveDeletedAccountsFor], + ) + .await; +} + +/// Test 30 (SCIM-32, SCIM-55, SCIM-56): an administrator's account is +/// found, adopted, and keeps its local settings. +async fn adoption(test: &TestServer, scim: &ScimTest) { + let admin = test.account("admin"); + let id = manual_user(test, scim, "handmade").await; + admin + .registry_update_object( + ObjectType::Account, + id, + json!({ Property::Roles: {"@type": "Admin"} }), + ) + .await; + let found = scim + .client + .get(&query( + "/Users", + &format!("userName eq \"handmade@{SCIM_DOMAIN}\""), + )) + .await; + assert_eq!(found.total_results(), 1, "test 30"); + scim.client + .post("/Users", user_body(&format!("handmade@{SCIM_DOMAIN}"))) + .await + .assert_error(409, Some("uniqueness")); + scim.client + .patch( + &format!("/Users/{id}"), + patch_body(json!([{"op": "add", "path": "externalId", "value": "IDP-9"}])), + ) + .await + .assert_status(200); + let structs::Account::User(user) = admin.registry_get::(id).await else { + panic!() + }; + assert_eq!(user.external_id.as_deref(), Some("IDP-9"), "test 30"); + assert_eq!(user.roles, structs::UserRoles::Admin, "SCIM-32"); + assert_eq!(user.credentials.len(), 1, "SCIM-32"); + imap_login(&format!("handmade@{SCIM_DOMAIN}"), true).await; + scim.destroy(&format!("/Users/{id}")).await; +} + +/// Tests 9 and 14 (SCIM-12, SCIM-20). +async fn tenants(test: &TestServer, scim: &ScimTest) { + let admin = test.account("admin"); + let _ = scim; + for (name, allows_scim) in [("nosync", false), ("capped", true)] { + let tenant = admin + .registry_create_object(Tenant { + name: name.to_string(), + permissions: Permissions::Merge(PermissionsList { + disabled_permissions: if allows_scim { + Default::default() + } else { + Map::new(vec![Permission::ScimAccess]) + }, + enabled_permissions: if allows_scim { + Map::new(vec![Permission::ScimAccess, Permission::UnlimitedRequests]) + } else { + Default::default() + }, + }), + ..Default::default() + }) + .await; + let domain_name = format!("{name}.example.com"); + let domain = admin + .registry_create_object(Domain { + is_enabled: true, + name: domain_name.clone(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + member_tenant_id: Some(tenant), + allow_scim_provisioning: true, + ..Default::default() + }) + .await; + let principal_id = + create_principal(admin, "svc", domain, Some(tenant), full_permissions()).await; + admin.registry_create_object(Action::InvalidateCaches).await; + let principal = Account::new( + Box::leak(format!("svc@{domain_name}").into_boxed_str()), + PRINCIPAL_SECRET, + &[], + "", + principal_id, + ); + let client = + ScimClient::bearer(&api_key(admin, &principal, json!({"@type": "Inherit"})).await); + + if !allows_scim { + // Test 9: the tenant's ceiling wins + client + .get("/Users") + .await + .assert_error(403, None) + .assert_detail_contains("scimAccess"); + } else { + // Test 14: maxAccounts reached + admin + .registry_update_object( + ObjectType::Tenant, + tenant, + json!({ Property::Quotas: VecMap::from_iter([(TenantStorageQuota::MaxAccounts, 1u64)]) }), + ) + .await; + let events = Collector::read_metric(MetricType::LimitTenantQuota); + client + .post("/Users", user_body(&format!("extra@{domain_name}"))) + .await + .assert_error(403, None) + .assert_detail_contains("maxAccounts"); + assert!( + Collector::read_metric(MetricType::LimitTenantQuota) > events, + "test 14: limit.tenant-quota" + ); + } + + admin + .registry_destroy(ObjectType::Account, [principal_id]) + .await; + admin.registry_destroy(ObjectType::Domain, [domain]).await; + admin.registry_destroy(ObjectType::Tenant, [tenant]).await; + } + admin.registry_create_object(Action::InvalidateCaches).await; +} diff --git a/tests/src/scim/mod.rs b/tests/src/scim/mod.rs new file mode 100644 index 0000000..886021b --- /dev/null +++ b/tests/src/scim/mod.rs @@ -0,0 +1,453 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). The helpers the +//! surviving suites import, rebuilt from the spec, and the suites' entry +//! points. `scim_tests` runs the acceptance suite, tenant isolation and, +//! with `SCIM_CONFORMANCE=1`, the third-party clients in a container. + +pub mod acceptance; +pub mod conformance; +pub mod oidc; +pub mod tenant; + +use crate::utils::{account::Account, server::TestServer}; +use registry::{ + schema::{ + enums::{Permission, StorageQuota}, + prelude::{ObjectType, Property}, + structs::{ + self, Action, CertificateManagement, DkimManagement, DnsManagement, Domain, + PasswordCredential, Permissions, PermissionsList, UserAccount, + }, + }, + types::{EnumImpl, list::List, map::Map}, +}; +use scim_proto::{MESSAGE_PATCH_OP, SCHEMA_GROUP, SCHEMA_USER}; +use serde_json::{Value, json}; +use types::id::Id; + +/// The server-level domain the main SCIM client provisions into. +pub const SCIM_DOMAIN: &str = "scim.example.com"; +/// The test server's HTTP listener. +pub const HTTP_PORT: u16 = 8899; +pub const PRINCIPAL: &str = "scim-svc@scim.example.com"; +pub const PRINCIPAL_SECRET: &str = "these_pretzels_are_making_me_thirsty"; + +fn http() -> reqwest::Client { + reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap() +} + +/// A SCIM client with a fixed `Authorization` header. +#[derive(Clone)] +pub struct ScimClient { + authorization: Option, +} + +/// A SCIM answer, with assertions. +#[derive(Debug, Clone)] +pub struct ScimReply { + pub status: u16, + pub headers: reqwest::header::HeaderMap, + pub body: String, + pub json: Value, +} + +impl ScimClient { + pub fn bearer(token: &str) -> Self { + ScimClient { + authorization: Some(format!("Bearer {token}")), + } + } + + pub fn with_authorization(authorization: Option) -> Self { + ScimClient { authorization } + } + + pub fn anonymous() -> Self { + ScimClient { + authorization: None, + } + } + + pub async fn request( + &self, + method: reqwest::Method, + path: &str, + body: Option, + headers: &[(&str, &str)], + ) -> ScimReply { + let url = format!("https://127.0.0.1:{HTTP_PORT}/scim/v2{path}"); + let mut request = http().request(method, url); + if let Some(authorization) = &self.authorization { + request = request.header("authorization", authorization); + } + for (name, value) in headers { + request = request.header(*name, *value); + } + if let Some(body) = body { + request = request + .header("content-type", "application/scim+json") + .body(body.to_string()); + } + let response = request.send().await.unwrap(); + let status = response.status().as_u16(); + let headers = response.headers().clone(); + let body = response.text().await.unwrap(); + let json = serde_json::from_str(&body).unwrap_or(Value::Null); + ScimReply { + status, + headers, + body, + json, + } + } + + pub async fn get(&self, path: &str) -> ScimReply { + self.request(reqwest::Method::GET, path, None, &[]).await + } + + pub async fn post(&self, path: &str, body: Value) -> ScimReply { + self.request(reqwest::Method::POST, path, Some(body), &[]) + .await + } + + pub async fn put(&self, path: &str, body: Value) -> ScimReply { + self.request(reqwest::Method::PUT, path, Some(body), &[]) + .await + } + + pub async fn patch(&self, path: &str, body: Value) -> ScimReply { + self.request(reqwest::Method::PATCH, path, Some(body), &[]) + .await + } + + pub async fn delete(&self, path: &str) -> ScimReply { + self.request(reqwest::Method::DELETE, path, None, &[]).await + } +} + +impl ScimReply { + pub fn assert_status(&self, status: u16) -> &Self { + assert_eq!(self.status, status, "Unexpected status: {}", self.body); + self + } + + /// A SCIM error document with that status and, if given, `scimType`. + pub fn assert_error(&self, status: u16, scim_type: Option<&str>) -> &Self { + assert_eq!(self.status, status, "Unexpected status: {}", self.body); + assert_eq!( + self.json["schemas"], + json!(["urn:ietf:params:scim:api:messages:2.0:Error"]), + "Not a SCIM error: {}", + self.body + ); + assert_eq!( + self.json["status"], + json!(status.to_string()), + "{}", + self.body + ); + if let Some(scim_type) = scim_type { + assert_eq!(self.json["scimType"], json!(scim_type), "{}", self.body); + } + assert_eq!( + self.header("content-type").as_deref(), + Some("application/scim+json"), + "{}", + self.body + ); + self + } + + pub fn assert_detail_contains(&self, text: &str) -> &Self { + let detail = self.json["detail"].as_str().unwrap_or_default(); + assert!(detail.contains(text), "'{detail}' lacks '{text}'"); + self + } + + pub fn id(&self) -> String { + self.json["id"] + .as_str() + .unwrap_or_else(|| panic!("No id in {}", self.body)) + .to_string() + } + + pub fn etag(&self) -> Option { + self.header("etag") + } + + pub fn header(&self, name: &str) -> Option { + self.headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + } + + pub fn total_results(&self) -> u64 { + self.json["totalResults"] + .as_u64() + .unwrap_or_else(|| panic!("No totalResults in {}", self.body)) + } + + pub fn resource_ids(&self) -> Vec { + self.json["Resources"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item["id"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } + + pub fn assert_contains_id(&self, id: &str) -> &Self { + assert!( + self.resource_ids().iter().any(|i| i == id), + "{id} missing from {}", + self.body + ); + self + } + + pub fn assert_lacks_id(&self, id: &str) -> &Self { + assert!( + !self.resource_ids().iter().any(|i| i == id), + "{id} present in {}", + self.body + ); + self + } +} + +pub fn user_body(user_name: &str) -> Value { + json!({"schemas": [SCHEMA_USER], "userName": user_name}) +} + +pub fn group_body(display_name: &str) -> Value { + json!({"schemas": [SCHEMA_GROUP], "displayName": display_name}) +} + +pub fn patch_body(operations: Value) -> Value { + json!({"schemas": [MESSAGE_PATCH_OP], "Operations": operations}) +} + +/// `path?filter=…`, encoded. +pub fn query(path: &str, filter: &str) -> String { + let encoded = + http_proto::form_urlencoded::byte_serialize(filter.as_bytes()).collect::(); + format!("{path}?filter={encoded}") +} + +/// The status of `GET /jmap/session` with that `Authorization` header. +pub async fn jmap_session_status(authorization: &str) -> u16 { + http() + .get(format!("https://127.0.0.1:{HTTP_PORT}/jmap/session")) + .header("authorization", authorization) + .send() + .await + .unwrap() + .status() + .as_u16() +} + +/// An API key for `principal`, created over JMAP as the principal itself. +/// Returns the secret. +pub async fn api_key(admin: &Account, principal: &Account, permissions: Value) -> String { + let _ = admin; + api_key_with_id(principal, permissions).await.1 +} + +/// An API key's registry id and secret. +pub async fn api_key_with_id(principal: &Account, permissions: Value) -> (Id, String) { + let response = principal + .jmap_create( + "x:ApiKey", + [json!({"description": "SCIM", "permissions": permissions})], + Vec::<(&str, &str)>::new(), + ) + .await; + let created = response.created(0); + let secret = created["secret"] + .as_str() + .unwrap_or_else(|| panic!("No API key secret in {response:?}")) + .to_string(); + (response.created_id(0), secret) +} + +/// A user account with a password and extra permissions, for principals. +pub async fn create_principal( + admin: &Account, + name: &str, + domain_id: Id, + tenant_id: Option, + permissions: Vec, +) -> Id { + let id = admin + .registry_create_object(structs::Account::User(UserAccount { + name: name.to_string(), + domain_id, + member_tenant_id: tenant_id, + description: Some("SCIM service principal".to_string()), + credentials: List::from_iter([structs::Credential::Password(PasswordCredential { + secret: PRINCIPAL_SECRET.to_string(), + ..Default::default() + })]), + permissions: Permissions::Merge(PermissionsList { + disabled_permissions: Default::default(), + enabled_permissions: Map::new(permissions), + }), + ..Default::default() + })) + .await; + admin + .registry_update_object( + ObjectType::Account, + id, + json!({ Property::Quotas: { StorageQuota::MaxApiKeys.as_str(): 20 } }), + ) + .await; + id +} + +/// The permissions a full SCIM key needs (spec, "Setting it up"). +pub fn full_permissions() -> Vec { + vec![ + Permission::ScimAccess, + Permission::SysAccountGet, + Permission::SysAccountCreate, + Permission::SysAccountUpdate, + Permission::SysAccountDestroy, + Permission::UnlimitedRequests, + ] +} + +/// The main SCIM client, a server-level principal on [`SCIM_DOMAIN`]. +pub struct ScimTest { + pub client: ScimClient, + pub token: String, + pub domain_id: Id, + pub principal_id: Id, +} + +impl ScimTest { + pub async fn new(test: &TestServer) -> Self { + let admin = test.account("admin"); + let domain_id = admin + .registry_create_object(Domain { + is_enabled: true, + name: SCIM_DOMAIN.to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + allow_scim_provisioning: true, + ..Default::default() + }) + .await; + let principal_id = + create_principal(admin, "scim-svc", domain_id, None, full_permissions()).await; + admin.registry_create_object(Action::InvalidateCaches).await; + let principal = Account::new(PRINCIPAL, PRINCIPAL_SECRET, &[], "", principal_id); + let token = api_key(admin, &principal, json!({"@type": "Inherit"})).await; + ScimTest { + client: ScimClient::bearer(&token), + token, + domain_id, + principal_id, + } + } + + /// Creates a user on the SCIM domain; its id. + pub async fn create_user(&self, user_name: &str) -> String { + self.client + .post("/Users", user_body(user_name)) + .await + .assert_status(201) + .id() + } + + /// Creates a group; its id. + pub async fn create_group(&self, display_name: &str) -> String { + self.client + .post("/Groups", group_body(display_name)) + .await + .assert_status(201) + .id() + } + + /// Deletes a resource, whether or not it's still there. + pub async fn destroy(&self, path: &str) { + let reply = self.client.delete(path).await; + assert!( + matches!(reply.status, 204 | 404), + "Deleting {path}: {}", + reply.body + ); + } +} + +/// The SCIM suites that run without containers, and the third-party +/// clients with `SCIM_CONFORMANCE=1`. +/// `cargo test -p tests scim_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn scim_tests() { + let test = crate::utils::server::TestServerBuilder::new("scim_tests") + .await + .with_default_listeners() + .await + .with_object(registry::schema::structs::Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await + .with_object(registry::schema::structs::MtaStageRcpt { + wait_on_fail: registry::schema::structs::Expression { + else_: "1ms".into(), + ..Default::default() + }, + ..Default::default() + }) + .await + .with_object(registry::schema::structs::MtaStageAuth { + require: registry::schema::structs::Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await + .build() + .await; + let scim = ScimTest::new(&test).await; + acceptance::test(&test, &scim).await; + tenant::test(&test, &scim).await; + if conformance::is_enabled() { + conformance::test(&scim).await; + } + if test.is_reset() { + test.temp_dir.delete(); + } +} + +/// Acceptance test 5, deferred until per-domain directories (feature 9) +/// are built: it binds an OIDC directory to one domain (SCIM-61 decision). +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn scim_oidc_tests() { + let test = crate::utils::server::TestServerBuilder::new("scim_oidc_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let scim = ScimTest::new(&test).await; + oidc::test(&test, &scim).await; +}