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.
This commit is contained in:
2026-09-19 09:35:23 -07:00
parent 776d18d06e
commit 0ca26070d7
28 changed files with 6141 additions and 22 deletions
Generated
+5
View File
@@ -3337,6 +3337,7 @@ dependencies = [
"registry", "registry",
"rkyv", "rkyv",
"scim", "scim",
"scim-proto",
"serde", "serde",
"serde_json", "serde_json",
"services", "services",
@@ -7417,8 +7418,11 @@ dependencies = [
name = "scim" name = "scim"
version = "0.16.22" version = "0.16.22"
dependencies = [ dependencies = [
"ahash",
"base64 0.23.1",
"common", "common",
"directory", "directory",
"hmac 0.13.0",
"http_proto", "http_proto",
"hyper", "hyper",
"icu_locale", "icu_locale",
@@ -7428,6 +7432,7 @@ dependencies = [
"scim-proto", "scim-proto",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"store", "store",
"trc", "trc",
"types", "types",
+8
View File
@@ -796,6 +796,14 @@ impl AccessToken {
} }
impl AccessTokenInner { 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 { pub fn from_id(account_id: u32) -> Self {
Self { Self {
account_id, account_id,
+6 -1
View File
@@ -69,7 +69,8 @@ pub struct DomainCache {
pub const DOMAIN_FLAG_RELAY: u8 = 1; pub const DOMAIN_FLAG_RELAY: u8 = 1;
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 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)] #[derive(Debug, Clone, Default)]
pub struct AccountCache { pub struct AccountCache {
@@ -329,4 +330,8 @@ impl DomainCache {
self.names.first().map(|s| s.as_ref()).unwrap_or_default() 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
}
} }
+5 -1
View File
@@ -158,7 +158,11 @@ impl Server {
if domain.allow_relaying { if domain.allow_relaying {
flags |= DOMAIN_FLAG_RELAY; 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 { let sub_addressing_custom = match domain.sub_addressing {
SubAddressing::Enabled => { SubAddressing::Enabled => {
+1
View File
@@ -13,6 +13,7 @@ smtp = { path = "../smtp" }
jmap = { path = "../jmap" } jmap = { path = "../jmap" }
dav = { path = "../dav" } dav = { path = "../dav" }
scim = { path = "../scim" } scim = { path = "../scim" }
scim-proto = { path = "../scim-proto" }
groupware = { path = "../groupware" } groupware = { path = "../groupware" }
http_proto = { path = "../http-proto" } http_proto = { path = "../http-proto" }
jmap_proto = { path = "../jmap-proto" } jmap_proto = { path = "../jmap-proto" }
+1
View File
@@ -12,6 +12,7 @@ pub mod branding; // inbuxa: branding
pub mod form; pub mod form;
pub mod live; // inbuxa: monitoring (MON-20 to MON-24) pub mod live; // inbuxa: monitoring (MON-20 to MON-24)
pub mod request; pub mod request;
pub mod scim; // inbuxa: SCIM 2.0 provisioning
use common::Inner; use common::Inner;
use std::sync::Arc; use std::sync::Arc;
+6
View File
@@ -478,6 +478,12 @@ impl ParseHttp for Server {
return crate::branding::rsvp_page(self, RSVP_PAGE).await; 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 // inbuxa: BT-5: the logo that applies, anonymous
"logo" if req.method() == Method::GET => { "logo" if req.method() == Method::GET => {
self.is_http_anonymous_request_allowed(session.remote_ip) self.is_http_anonymous_request_allowed(session.remote_ip)
+128
View File
@@ -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::<Vec<_>>();
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
}
+22 -12
View File
@@ -133,7 +133,10 @@ pub enum Filter {
Or(Box<Filter>, Box<Filter>), Or(Box<Filter>, Box<Filter>),
Not(Box<Filter>), Not(Box<Filter>),
/// `attr[filter]`, with the inner filter's paths relative to `attr`. /// `attr[filter]`, with the inner filter's paths relative to `attr`.
ValuePath { path: AttrPath, filter: Box<Filter> }, ValuePath {
path: AttrPath,
filter: Box<Filter>,
},
} }
impl Filter { impl Filter {
@@ -280,18 +283,18 @@ impl Parser {
let inner = self.or()?; let inner = self.or()?;
match self.next() { match self.next() {
Some(Token::Close) => Ok(inner), 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<Filter, ScimError> { fn unary(&mut self) -> Result<Filter, ScimError> {
match self.next() { match self.next() {
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => { Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => match self.next() {
match self.next() { Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))), _ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")), },
}
}
Some(Token::Open) => self.group(), Some(Token::Open) => self.group(),
Some(Token::Word(word)) => { Some(Token::Word(word)) => {
let path = AttrPath::parse(&word).ok_or_else(|| { let path = AttrPath::parse(&word).ok_or_else(|| {
@@ -385,11 +388,14 @@ mod tests {
Filter::parse("emails.value eq \"a\\\"b\"").unwrap(), Filter::parse("emails.value eq \"a\\\"b\"").unwrap(),
eq("emails.value", json!("a\"b")) eq("emails.value", json!("a\"b"))
); );
let urn = Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"") let urn =
.unwrap(); Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"").unwrap();
match urn { match urn {
Filter::Compare { path, .. } => { 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)); assert!(path.is("username", None));
} }
other => panic!("{other:?}"), other => panic!("{other:?}"),
@@ -426,7 +432,11 @@ mod tests {
"1abc eq \"a\"", "1abc eq \"a\"",
] { ] {
let err = Filter::parse(text).unwrap_err(); 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}"
);
} }
} }
} }
+14 -5
View File
@@ -25,11 +25,13 @@ impl PatchPath {
let text = text.trim(); let text = text.trim();
let (head, filter, after) = match text.find('[') { let (head, filter, after) = match text.find('[') {
Some(open) => { 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 inner = &text[open + 1..close];
let filter = Filter::parse(inner).map_err(|err| { let filter = Filter::parse(inner)
ScimError::invalid_path(format!("'{text}': {}", err.detail)) .map_err(|err| ScimError::invalid_path(format!("'{text}': {}", err.detail)))?;
})?;
let after = &text[close + 1..]; let after = &text[close + 1..];
let after = if after.is_empty() { let after = if after.is_empty() {
None None
@@ -85,7 +87,14 @@ mod tests {
#[test] #[test]
fn refuses_bad_paths() { 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}"); assert!(PatchPath::parse(text).is_err(), "{text}");
} }
} }
+4
View File
@@ -20,6 +20,10 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
xxhash-rust = { version = "0.8.18", features = ["xxh3"] } xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
icu_locale = "2.3.1" icu_locale = "2.3.1"
ahash = { version = "0.8.12", features = ["serde"] }
base64 = "0.23"
hmac = "0.13"
sha2 = "0.11"
[dev-dependencies] [dev-dependencies]
+246
View File
@@ -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:<id>`. 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:<id>` references; `Err` names the first unresolved.
fn resolve(value: &mut Value, created: &AHashMap<String, String>) -> 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<String, String>) -> Result<String, String> {
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::<Result<Vec<_>, _>>()
.map(|segments| segments.join("/"))
}
pub async fn bulk(ctx: &Ctx<'_>, body: &[u8]) -> Result<ScimResponse, ScimError> {
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<String, String> = 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<String, Value>,
created: &AHashMap<String, String>,
) -> (Value, Option<String>) {
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<String, Value>, 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::<Vec<_>>();
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),
}
}
+334
View File
@@ -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<Ctx<'x>, 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<u32> {
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<Arc<DomainCache>, 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<Option<Arc<DomainCache>>, 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<Vec<Arc<DomainCache>>, ScimError> {
let ids = self
.server
.registry()
.query::<Vec<Id>>(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<bool, ScimError> {
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<Option<Account>, ScimError> {
self.server
.registry()
.object::<Account>(id)
.await
.map_err(server_error)
}
/// Account ids matching `query`, within the caller's tenant.
pub async fn query_ids(&self, query: RegistryQuery) -> Result<Vec<Id>, ScimError> {
self.server
.registry()
.query::<Vec<Id>>(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<Value, ScimError> {
let text = request.to_string();
let request = serde_json::from_str::<SetRequest<'_, Registry>>(&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<Id, ScimError> {
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)
}
}
}
+138
View File
@@ -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<u8> {
let mut mac = <Hmac<Sha256> 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<u64, ScimError> {
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<u64, ScimError>| 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
);
}
}
+294
View File
@@ -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<Value> {
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>) -> 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<Attr>,
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<Attr>) -> 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<Attr>) -> Value {
json!({
"schemas": [SCHEMA_SCHEMA],
"id": id,
"name": name,
"description": description,
"attributes": attributes.iter().map(Attr::to_json).collect::<Vec<_>>(),
"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"]),
]),
],
)
}
+440
View File
@@ -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<Vec<(Id, Account)>, 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<Value, ScimError> {
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<Id>,
) -> 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<String, Value>,
tenant: Option<u32>,
) -> Result<Vec<(Id, Account)>, 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::<Vec<_>>();
if member {
groups.push(group_id);
}
let map = groups
.iter()
.map(|id| (id.to_string(), Value::Bool(true)))
.collect::<Map<_, _>>();
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<String, Value>) -> Result<String, ScimError> {
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<String, Value>) -> Result<Option<String>, 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<String, Value>) -> Result<Id, ScimError> {
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<String, Value>,
_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");
}
}
+325 -1
View File
@@ -1,5 +1,329 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2026 Coffey Labs
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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<String>),
Schemas(Option<String>),
Me,
List(ResourceKind),
Create(ResourceKind),
Search(Option<ResourceKind>),
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<Route, ScimResponse> {
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::<Vec<_>>();
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<Value>,
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<ScimError> 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<u8>,
}
/// 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(),
}
}
+469
View File
@@ -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<String, Value>,
) -> Result<Map<String, Value>, 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<Option<Value>>,
formatted: Option<Option<Value>>,
locale: Option<Option<Value>>,
language: Option<Option<Value>>,
}
impl State {
fn finish(self, kind: ResourceKind, doc: &mut Map<String, Value>) {
// 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<String, Value>, value: Option<Value>) {
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<String, Value>, name: &str) -> Option<String> {
doc.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned()
}
fn apply_path(
kind: ResourceKind,
doc: &mut Map<String, Value>,
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<Value> {
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<String, Value>,
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::<Vec<_>>();
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::<Vec<_>>();
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,
}
}
+602
View File
@@ -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<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub start_index: Option<i64>,
pub count: Option<i64>,
pub cursor: Option<String>,
pub projection: Projection,
}
fn number(value: &str, name: &str) -> Result<i64, ScimError> {
value
.trim()
.parse::<i64>()
.map_err(|_| ScimError::invalid_value(format!("'{name}' must be a number")))
}
impl Params {
fn from_query(query: Option<&str>) -> Result<Params, ScimError> {
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<String, Value>) -> Result<Params, 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_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<Option<i64>, 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::<Vec<_>>()
.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<String> {
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<Attr, ScimError> {
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<Clause>) -> 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<Vec<Clause>, 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<BTreeSet<u64>, 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<BTreeSet<u64>, 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<bool, ScimError> {
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<Vec<Id>, ScimError> {
let indexed = clauses
.iter()
.filter(|c| c.attr.is_indexed())
.collect::<Vec<_>>();
let mut ids: Vec<Id> = 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<BTreeSet<u64>> = 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<ScimResponse, ScimError> {
run(ctx, &[kind], Params::from_query(query)?).await
}
pub async fn search(
ctx: &Ctx<'_>,
kind: Option<ResourceKind>,
body: &Map<String, Value>,
) -> Result<ScimResponse, ScimError> {
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<ScimResponse, ScimError> {
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::<Vec<_>>().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 &params.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::<Vec<_>>();
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(&params.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))
}
+481
View File
@@ -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<Map<String, Value>, 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<D: Deserializer<'de>>(self, deserializer: D) -> Result<Value, D::Error> {
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<E>(self, v: bool) -> Result<Value, E> {
Ok(Value::Bool(v))
}
fn visit_i64<E>(self, v: i64) -> Result<Value, E> {
Ok(Value::from(v))
}
fn visit_u64<E>(self, v: u64) -> Result<Value, E> {
Ok(Value::from(v))
}
fn visit_f64<E>(self, v: f64) -> Result<Value, E> {
Ok(Value::from(v))
}
fn visit_str<E>(self, v: &str) -> Result<Value, E> {
Ok(Value::String(v.to_string()))
}
fn visit_string<E>(self, v: String) -> Result<Value, E> {
Ok(Value::String(v))
}
fn visit_unit<E>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Value, A::Error> {
let mut items = Vec::new();
while let Some(item) = seq.next_element_seed(StrictValue)? {
items.push(item);
}
Ok(Value::Array(items))
}
fn visit_map<A: MapAccess<'de>>(self, mut access: A) -> Result<Value, A::Error> {
let mut map = Map::new();
while let Some(key) = access.next_key::<String>()? {
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<String, Value>, 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<String, Value>,
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<String>,
pub excluded: Vec<String>,
}
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::<Vec<_>>()
})
.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<String> {
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<Value, ScimError> {
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<ScimResponse, ScimError> {
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(&current))?;
let body = parse_body(body)?;
let (doc, mode) = if matches!(route, Route::Replace(..)) {
(body, WriteMode::Replace)
} else {
(patch::apply(kind, &current, &body)?, WriteMode::Patch)
};
match kind {
ResourceKind::User => {
users::replace(ctx, id, &account, &current, &doc, mode).await?
}
ResourceKind::Group => {
groups::replace(ctx, id, &account, &current, &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(&current))?;
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")),
}
}
+707
View File
@@ -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<String>,
pub active: Option<bool>,
pub aliases: Vec<String>,
pub locale: Option<Locale>,
pub time_zone: Option<TimeZone>,
pub external_id: Option<String>,
pub groups: Option<Vec<String>>,
}
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<bool> {
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<Locale> {
static LOCALES: OnceLock<HashMap<String, Locale>> = 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<TimeZone> {
static ZONES: OnceLock<HashMap<String, TimeZone>> = 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<String, Value>) -> Option<String> {
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::<Vec<_>>();
(!parts.is_empty()).then(|| parts.join(" "))
}
pub fn parse(body: &Map<String, Value>) -> Result<UserInput, ScimError> {
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<String> = 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::<Vec<_>>(),
),
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<String, ScimError> {
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<bool, ScimError> {
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<String, ScimError> {
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<String> {
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<Value, ScimError> {
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<Permissions> {
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<u32>,
) -> Result<Value, ScimError> {
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<u32>,
except: Option<Id>,
) -> Result<(), ScimError> {
let ids = ctx
.server
.registry()
.query::<Vec<Id>>(
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::<Vec<_>>()
})
.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<String, Value>) -> Result<Id, ScimError> {
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<String, Value>,
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::<Vec<_>>()
})
.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::<Vec<_>>();
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(())
}
+15 -1
View File
@@ -6,7 +6,8 @@
// This file is auto-generated. Do not edit directly. // 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; pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -42,6 +43,8 @@ pub enum EventType {
Queue(QueueEvent), Queue(QueueEvent),
Registry(RegistryEvent), Registry(RegistryEvent),
Resource(ResourceEvent), Resource(ResourceEvent),
// inbuxa: SCIM-54
Scim(ScimEvent),
Security(SecurityEvent), Security(SecurityEvent),
Server(ServerEvent), Server(ServerEvent),
Sieve(SieveEvent), Sieve(SieveEvent),
@@ -624,6 +627,17 @@ pub enum ResourceEvent {
ApplicationUnpacked = 602, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u16)] #[repr(u16)]
pub enum SecurityEvent { pub enum SecurityEvent {
+42
View File
@@ -36,6 +36,12 @@ impl EventType {
b"acme.error" => EventType::Acme(AcmeEvent::Error), b"acme.error" => EventType::Acme(AcmeEvent::Error),
b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse), b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse),
b"ai.api-error" => EventType::Ai(AiEvent::ApiError), 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.chain-too-long" => EventType::Arc(ArcEvent::ChainTooLong),
b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance), b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance),
b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv), b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv),
@@ -679,6 +685,12 @@ impl EventType {
EventType::Acme(AcmeEvent::Error) => "acme.error", EventType::Acme(AcmeEvent::Error) => "acme.error",
EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response", EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response",
EventType::Ai(AiEvent::ApiError) => "ai.api-error", 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::ChainTooLong) => "arc.chain-too-long",
EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance", EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance",
EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv", EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv",
@@ -1457,6 +1469,12 @@ impl EventType {
EventType::Acme(AcmeEvent::Error) => 15, EventType::Acme(AcmeEvent::Error) => 15,
EventType::Ai(AiEvent::LlmResponse) => 556, EventType::Ai(AiEvent::LlmResponse) => 556,
EventType::Ai(AiEvent::ApiError) => 557, 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::ChainTooLong) => 28,
EventType::Arc(ArcEvent::InvalidInstance) => 31, EventType::Arc(ArcEvent::InvalidInstance) => 31,
EventType::Arc(ArcEvent::InvalidCv) => 30, EventType::Arc(ArcEvent::InvalidCv) => 30,
@@ -2099,6 +2117,12 @@ impl EventType {
15 => Some(EventType::Acme(AcmeEvent::Error)), 15 => Some(EventType::Acme(AcmeEvent::Error)),
556 => Some(EventType::Ai(AiEvent::LlmResponse)), 556 => Some(EventType::Ai(AiEvent::LlmResponse)),
557 => Some(EventType::Ai(AiEvent::ApiError)), 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)), 28 => Some(EventType::Arc(ArcEvent::ChainTooLong)),
31 => Some(EventType::Arc(ArcEvent::InvalidInstance)), 31 => Some(EventType::Arc(ArcEvent::InvalidInstance)),
30 => Some(EventType::Arc(ArcEvent::InvalidCv)), 30 => Some(EventType::Arc(ArcEvent::InvalidCv)),
@@ -3056,6 +3080,12 @@ impl EventType {
EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn, EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn,
EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn, EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn,
EventType::Ai(AiEvent::ApiError) => 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::Arc(ArcEvent::SealerNotFound) => Level::Warn,
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn, EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn, EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
@@ -3137,6 +3167,12 @@ impl EventType {
EventType::Acme(AcmeEvent::Error) => "ACME error", EventType::Acme(AcmeEvent::Error) => "ACME error",
EventType::Ai(AiEvent::LlmResponse) => "LLM response", EventType::Ai(AiEvent::LlmResponse) => "LLM response",
EventType::Ai(AiEvent::ApiError) => "AI API error", 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::ChainTooLong) => "ARC chain too long",
EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance", EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance",
EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV", EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV",
@@ -4203,6 +4239,12 @@ impl EventType {
EventType::Acme(AcmeEvent::Error), EventType::Acme(AcmeEvent::Error),
EventType::Ai(AiEvent::LlmResponse), EventType::Ai(AiEvent::LlmResponse),
EventType::Ai(AiEvent::ApiError), 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::ChainTooLong),
EventType::Arc(ArcEvent::InvalidInstance), EventType::Arc(ArcEvent::InvalidInstance),
EventType::Arc(ArcEvent::InvalidCv), EventType::Arc(ArcEvent::InvalidCv),
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
ngxMcJdAkSNEy0lbKUXrU9VRRrX7R0tPVHHh_gGCf7E -DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
+2
View File
@@ -24,6 +24,8 @@ pub mod imap;
#[cfg(test)] #[cfg(test)]
pub mod jmap; pub mod jmap;
#[cfg(test)] #[cfg(test)]
pub mod scim;
#[cfg(test)]
pub mod smtp; pub mod smtp;
#[cfg(test)] #[cfg(test)]
pub mod store; pub mod store;
File diff suppressed because it is too large Load Diff
+453
View File
@@ -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 = "[email protected]";
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<String>,
}
/// 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<String>) -> Self {
ScimClient { authorization }
}
pub fn anonymous() -> Self {
ScimClient {
authorization: None,
}
}
pub async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<Value>,
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<String> {
self.header("etag")
}
pub fn header(&self, name: &str) -> Option<String> {
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<String> {
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::<String>();
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<Id>,
permissions: Vec<Permission>,
) -> 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<Permission> {
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;
}