From 0ca26070d77dd4eb93bc12444acc73209cae3db2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 09:35:23 -0700 Subject: [PATCH] SCIM: users, groups, queries, PATCH, Bulk and cursors at /scim/v2, over x:Account (SCIM-1 to SCIM-57) Every SCIM operation becomes the x:Account get, query or set JMAP makes, as the service principal, so permissions, tenant scope and limits, address uniqueness and account destruction are enforced in one place. Discovery is anonymous; everything else takes an API key as a bearer token and nothing else. Domains open to SCIM carry a flag in the domain cache. Filters take eq and and, answered from the account indexes, with unindexed attributes checked on at most 200 candidates. Cursors are stateless, HMAC-sealed under the server key. PATCH applies to the resource in memory and saves it as a PUT, so it is all or nothing. Groups get an address from their display name on the principal's domain; membership is written on each user. Every write emits one of five new scim.* events (ids 637 to 641), also added to the packaged schema. The helpers the surviving SCIM suites import are rebuilt from the spec; scim_tests runs the new acceptance suite and the surviving tenant isolation suite, and both pass. --- Cargo.lock | 5 + crates/common/src/auth/access_token.rs | 8 + crates/common/src/auth/mod.rs | 7 +- crates/common/src/cache/principals.rs | 6 +- crates/http/Cargo.toml | 1 + crates/http/src/lib.rs | 1 + crates/http/src/request.rs | 6 + crates/http/src/scim.rs | 128 +++ crates/scim-proto/src/filter.rs | 34 +- crates/scim-proto/src/path.rs | 19 +- crates/scim/Cargo.toml | 4 + crates/scim/src/bulk.rs | 246 +++++ crates/scim/src/context.rs | 334 ++++++ crates/scim/src/cursor.rs | 138 +++ crates/scim/src/discovery.rs | 294 +++++ crates/scim/src/groups.rs | 440 ++++++++ crates/scim/src/lib.rs | 326 +++++- crates/scim/src/patch.rs | 469 ++++++++ crates/scim/src/query.rs | 602 ++++++++++ crates/scim/src/resource.rs | 481 ++++++++ crates/scim/src/users.rs | 707 ++++++++++++ crates/trc/src/event/enums.rs | 16 +- crates/trc/src/event/enums_impl.rs | 42 + resources/schema/schema.json.gz | Bin 150099 -> 150277 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/lib.rs | 2 + tests/src/scim/acceptance.rs | 1392 ++++++++++++++++++++++++ tests/src/scim/mod.rs | 453 ++++++++ 28 files changed, 6141 insertions(+), 22 deletions(-) create mode 100644 crates/http/src/scim.rs create mode 100644 crates/scim/src/bulk.rs create mode 100644 crates/scim/src/context.rs create mode 100644 crates/scim/src/cursor.rs create mode 100644 crates/scim/src/discovery.rs create mode 100644 crates/scim/src/groups.rs create mode 100644 crates/scim/src/patch.rs create mode 100644 crates/scim/src/query.rs create mode 100644 crates/scim/src/resource.rs create mode 100644 crates/scim/src/users.rs create mode 100644 tests/src/scim/acceptance.rs create mode 100644 tests/src/scim/mod.rs 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 686766f26907f6a506a48f2268440ca12d2fd000..a9639eaa13095c61de4f01b96f6ffc5c25110f02 100644 GIT binary patch delta 52821 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 delta 52585 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 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; +}