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:
Generated
+5
@@ -3337,6 +3337,7 @@ dependencies = [
|
||||
"registry",
|
||||
"rkyv",
|
||||
"scim",
|
||||
"scim-proto",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"services",
|
||||
@@ -7417,8 +7418,11 @@ dependencies = [
|
||||
name = "scim"
|
||||
version = "0.16.22"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64 0.23.1",
|
||||
"common",
|
||||
"directory",
|
||||
"hmac 0.13.0",
|
||||
"http_proto",
|
||||
"hyper",
|
||||
"icu_locale",
|
||||
@@ -7428,6 +7432,7 @@ dependencies = [
|
||||
"scim-proto",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"store",
|
||||
"trc",
|
||||
"types",
|
||||
|
||||
@@ -796,6 +796,14 @@ impl AccessToken {
|
||||
}
|
||||
|
||||
impl AccessTokenInner {
|
||||
/// inbuxa: SCIM-27: the account's own effective permission, from its
|
||||
/// roles, its own settings and its tenant, before a credential narrows it
|
||||
pub fn account_has_permission(&self, permission: Permission) -> bool {
|
||||
self.scopes
|
||||
.first()
|
||||
.is_some_and(|scope| scope.permissions.get(permission as usize))
|
||||
}
|
||||
|
||||
pub fn from_id(account_id: u32) -> Self {
|
||||
Self {
|
||||
account_id,
|
||||
|
||||
@@ -69,7 +69,8 @@ pub struct DomainCache {
|
||||
|
||||
pub const DOMAIN_FLAG_RELAY: u8 = 1;
|
||||
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
|
||||
|
||||
// inbuxa: SCIM-15, SCIM-58
|
||||
pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AccountCache {
|
||||
@@ -329,4 +330,8 @@ impl DomainCache {
|
||||
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
|
||||
}
|
||||
|
||||
// inbuxa: SCIM-15, SCIM-58
|
||||
pub fn allows_scim(&self) -> bool {
|
||||
self.flags & DOMAIN_FLAG_SCIM != 0
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -158,7 +158,11 @@ impl Server {
|
||||
if domain.allow_relaying {
|
||||
flags |= DOMAIN_FLAG_RELAY;
|
||||
}
|
||||
|
||||
// inbuxa: SCIM-15, SCIM-58: the domain is open to SCIM, and SCIM is
|
||||
// authoritative for its accounts
|
||||
if domain.allow_scim_provisioning {
|
||||
flags |= crate::auth::DOMAIN_FLAG_SCIM;
|
||||
}
|
||||
|
||||
let sub_addressing_custom = match domain.sub_addressing {
|
||||
SubAddressing::Enabled => {
|
||||
|
||||
@@ -13,6 +13,7 @@ smtp = { path = "../smtp" }
|
||||
jmap = { path = "../jmap" }
|
||||
dav = { path = "../dav" }
|
||||
scim = { path = "../scim" }
|
||||
scim-proto = { path = "../scim-proto" }
|
||||
groupware = { path = "../groupware" }
|
||||
http_proto = { path = "../http-proto" }
|
||||
jmap_proto = { path = "../jmap-proto" }
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod branding; // inbuxa: branding
|
||||
pub mod form;
|
||||
pub mod live; // inbuxa: monitoring (MON-20 to MON-24)
|
||||
pub mod request;
|
||||
pub mod scim; // inbuxa: SCIM 2.0 provisioning
|
||||
|
||||
use common::Inner;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -478,6 +478,12 @@ impl ParseHttp for Server {
|
||||
return crate::branding::rsvp_page(self, RSVP_PAGE).await;
|
||||
}
|
||||
}
|
||||
// inbuxa: SCIM 2.0 provisioning (feature 7)
|
||||
"scim" => {
|
||||
if path.next() == Some("v2") {
|
||||
return Ok(crate::scim::handle(self, &mut req, &session).await);
|
||||
}
|
||||
}
|
||||
// inbuxa: BT-5: the logo that applies, anonymous
|
||||
"logo" if req.method() == Method::GET => {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -133,7 +133,10 @@ pub enum Filter {
|
||||
Or(Box<Filter>, Box<Filter>),
|
||||
Not(Box<Filter>),
|
||||
/// `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 {
|
||||
@@ -280,18 +283,18 @@ impl Parser {
|
||||
let inner = self.or()?;
|
||||
match self.next() {
|
||||
Some(Token::Close) => Ok(inner),
|
||||
_ => Err(ScimError::invalid_filter("A '(' without its ')' in the filter")),
|
||||
_ => Err(ScimError::invalid_filter(
|
||||
"A '(' without its ')' in the filter",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> Result<Filter, ScimError> {
|
||||
match self.next() {
|
||||
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => {
|
||||
match self.next() {
|
||||
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
|
||||
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
|
||||
}
|
||||
}
|
||||
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => match self.next() {
|
||||
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
|
||||
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
|
||||
},
|
||||
Some(Token::Open) => self.group(),
|
||||
Some(Token::Word(word)) => {
|
||||
let path = AttrPath::parse(&word).ok_or_else(|| {
|
||||
@@ -385,11 +388,14 @@ mod tests {
|
||||
Filter::parse("emails.value eq \"a\\\"b\"").unwrap(),
|
||||
eq("emails.value", json!("a\"b"))
|
||||
);
|
||||
let urn = Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"")
|
||||
.unwrap();
|
||||
let urn =
|
||||
Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"").unwrap();
|
||||
match urn {
|
||||
Filter::Compare { path, .. } => {
|
||||
assert_eq!(path.urn.as_deref(), Some("urn:ietf:params:scim:schemas:core:2.0:User"));
|
||||
assert_eq!(
|
||||
path.urn.as_deref(),
|
||||
Some("urn:ietf:params:scim:schemas:core:2.0:User")
|
||||
);
|
||||
assert!(path.is("username", None));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
@@ -426,7 +432,11 @@ mod tests {
|
||||
"1abc eq \"a\"",
|
||||
] {
|
||||
let err = Filter::parse(text).unwrap_err();
|
||||
assert_eq!(err.scim_type, Some(crate::ScimType::InvalidFilter), "{text}");
|
||||
assert_eq!(
|
||||
err.scim_type,
|
||||
Some(crate::ScimType::InvalidFilter),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,13 @@ impl PatchPath {
|
||||
let text = text.trim();
|
||||
let (head, filter, after) = match text.find('[') {
|
||||
Some(open) => {
|
||||
let close = text.rfind(']').filter(|close| *close > open).ok_or_else(invalid)?;
|
||||
let close = text
|
||||
.rfind(']')
|
||||
.filter(|close| *close > open)
|
||||
.ok_or_else(invalid)?;
|
||||
let inner = &text[open + 1..close];
|
||||
let filter = Filter::parse(inner).map_err(|err| {
|
||||
ScimError::invalid_path(format!("'{text}': {}", err.detail))
|
||||
})?;
|
||||
let filter = Filter::parse(inner)
|
||||
.map_err(|err| ScimError::invalid_path(format!("'{text}': {}", err.detail)))?;
|
||||
let after = &text[close + 1..];
|
||||
let after = if after.is_empty() {
|
||||
None
|
||||
@@ -85,7 +87,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn refuses_bad_paths() {
|
||||
for text in ["", "members[", "members[value eq]", "a[b eq 1]x", "1a", "a.b[c eq 1]"] {
|
||||
for text in [
|
||||
"",
|
||||
"members[",
|
||||
"members[value eq]",
|
||||
"a[b eq 1]x",
|
||||
"1a",
|
||||
"a.b[c eq 1]",
|
||||
] {
|
||||
assert!(PatchPath::parse(text).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
||||
icu_locale = "2.3.1"
|
||||
ahash = { version = "0.8.12", features = ["serde"] }
|
||||
base64 = "0.23"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -1,5 +1,329 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the
|
||||
//! service provider: an identity provider pushes users and groups to
|
||||
//! `/scim/v2`, and each request becomes the same `x:Account` reads and
|
||||
//! writes JMAP makes, so permissions, tenancy, address uniqueness and quotas
|
||||
//! are enforced in one place. The HTTP crate authenticates the caller; this
|
||||
//! crate routes and translates.
|
||||
|
||||
pub mod bulk;
|
||||
pub mod context;
|
||||
pub mod cursor;
|
||||
pub mod discovery;
|
||||
pub mod groups;
|
||||
pub mod patch;
|
||||
pub mod query;
|
||||
pub mod resource;
|
||||
pub mod users;
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use context::Ctx;
|
||||
use http_proto::{HttpResponse, HttpSessionData};
|
||||
use hyper::{HeaderMap, Method, StatusCode};
|
||||
use scim_proto::{CONTENT_TYPE, ScimError};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The largest body accepted, `/Bulk` included (SCIM-51).
|
||||
pub const MAX_PAYLOAD: usize = 1024 * 1024;
|
||||
/// `/Bulk` operations per request (SCIM-51).
|
||||
pub const MAX_OPERATIONS: usize = 1000;
|
||||
/// The most results a page, a filter or a group's members may hold (SCIM-4).
|
||||
pub const MAX_RESULTS: usize = 200;
|
||||
/// A page's size when `count` isn't given (SCIM-48).
|
||||
pub const DEFAULT_PAGE_SIZE: usize = 100;
|
||||
/// How long a cursor stays good, in seconds (SCIM-49).
|
||||
pub const CURSOR_TIMEOUT: u64 = 3600;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResourceKind {
|
||||
User,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl ResourceKind {
|
||||
pub fn endpoint(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::User => "Users",
|
||||
ResourceKind::Group => "Groups",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::User => "User",
|
||||
ResourceKind::Group => "Group",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema(&self) -> &'static str {
|
||||
match self {
|
||||
ResourceKind::User => scim_proto::SCHEMA_USER,
|
||||
ResourceKind::Group => scim_proto::SCHEMA_GROUP,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a path and method ask for (SCIM-2, "Interfaces").
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
Options,
|
||||
ServiceProviderConfig,
|
||||
ResourceTypes(Option<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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 ¶ms.cursor {
|
||||
Some(c) if c.is_empty() => 0,
|
||||
Some(c) => cursor::decode(key, c, count as u64, now(), binding)? as usize,
|
||||
None => params.start_index.unwrap_or(1).max(1) as usize - 1,
|
||||
};
|
||||
let page = all.iter().skip(start).take(count).collect::<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(¶ms.projection)).await?
|
||||
}
|
||||
};
|
||||
resources.push(params.projection.apply(doc));
|
||||
}
|
||||
|
||||
let mut body = json!({
|
||||
"schemas": [MESSAGE_LIST_RESPONSE],
|
||||
"totalResults": total,
|
||||
"itemsPerPage": resources.len(),
|
||||
});
|
||||
if params.cursor.is_some() {
|
||||
let next = start + page.len();
|
||||
if next < total && count > 0 {
|
||||
body["nextCursor"] = json!(cursor::encode(
|
||||
key,
|
||||
next as u64,
|
||||
count as u64,
|
||||
now() + CURSOR_TIMEOUT,
|
||||
binding
|
||||
));
|
||||
}
|
||||
} else {
|
||||
body["startIndex"] = json!(start + 1);
|
||||
}
|
||||
if count > 0 {
|
||||
body["Resources"] = Value::Array(resources);
|
||||
}
|
||||
Ok(ScimResponse::json(200, body))
|
||||
}
|
||||
@@ -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(¤t))?;
|
||||
let body = parse_body(body)?;
|
||||
let (doc, mode) = if matches!(route, Route::Replace(..)) {
|
||||
(body, WriteMode::Replace)
|
||||
} else {
|
||||
(patch::apply(kind, ¤t, &body)?, WriteMode::Patch)
|
||||
};
|
||||
match kind {
|
||||
ResourceKind::User => {
|
||||
users::replace(ctx, id, &account, ¤t, &doc, mode).await?
|
||||
}
|
||||
ResourceKind::Group => {
|
||||
groups::replace(ctx, id, &account, ¤t, &doc, mode).await?
|
||||
}
|
||||
}
|
||||
let doc = render(ctx, kind, id).await?;
|
||||
Ok(resource_response(200, doc, &projection))
|
||||
}
|
||||
Route::Delete(_, id) => {
|
||||
ctx.require(Permission::SysAccountDestroy)?;
|
||||
let (id, account) = ctx.load(kind, id).await?;
|
||||
if id.document_id() == ctx.principal_id() {
|
||||
return Err(ScimError::forbidden(
|
||||
"The service principal can't delete itself",
|
||||
));
|
||||
}
|
||||
let current = match kind {
|
||||
ResourceKind::User => users::render(ctx, id, &account).await?,
|
||||
ResourceKind::Group => groups::render(ctx, id, &account, None).await?,
|
||||
};
|
||||
check_if_match(headers, &version(¤t))?;
|
||||
if kind == ResourceKind::Group {
|
||||
groups::remove_all_members(ctx, id).await?;
|
||||
}
|
||||
ctx.destroy(id).await?;
|
||||
audit(
|
||||
ctx,
|
||||
trc::ScimEvent::ResourceDeleted,
|
||||
kind,
|
||||
id,
|
||||
current.get("externalId").and_then(Value::as_str),
|
||||
);
|
||||
Ok(ScimResponse::empty(204))
|
||||
}
|
||||
_ => Err(ScimError::not_found("There is no such SCIM endpoint")),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
// This file is auto-generated. Do not edit directly.
|
||||
|
||||
pub const TOTAL_EVENT_COUNT: usize = 637;
|
||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 642;
|
||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -42,6 +43,8 @@ pub enum EventType {
|
||||
Queue(QueueEvent),
|
||||
Registry(RegistryEvent),
|
||||
Resource(ResourceEvent),
|
||||
// inbuxa: SCIM-54
|
||||
Scim(ScimEvent),
|
||||
Security(SecurityEvent),
|
||||
Server(ServerEvent),
|
||||
Sieve(SieveEvent),
|
||||
@@ -624,6 +627,17 @@ pub enum ResourceEvent {
|
||||
ApplicationUnpacked = 602,
|
||||
}
|
||||
|
||||
// inbuxa: SCIM-54: every write an identity provider makes
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u16)]
|
||||
pub enum ScimEvent {
|
||||
ResourceCreated = 637,
|
||||
ResourceUpdated = 638,
|
||||
ResourceSuspended = 639,
|
||||
ResourceReactivated = 640,
|
||||
ResourceDeleted = 641,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[repr(u16)]
|
||||
pub enum SecurityEvent {
|
||||
|
||||
@@ -36,6 +36,12 @@ impl EventType {
|
||||
b"acme.error" => EventType::Acme(AcmeEvent::Error),
|
||||
b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse),
|
||||
b"ai.api-error" => EventType::Ai(AiEvent::ApiError),
|
||||
// inbuxa: SCIM-54
|
||||
b"scim.resource-created" => EventType::Scim(ScimEvent::ResourceCreated),
|
||||
b"scim.resource-updated" => EventType::Scim(ScimEvent::ResourceUpdated),
|
||||
b"scim.resource-suspended" => EventType::Scim(ScimEvent::ResourceSuspended),
|
||||
b"scim.resource-reactivated" => EventType::Scim(ScimEvent::ResourceReactivated),
|
||||
b"scim.resource-deleted" => EventType::Scim(ScimEvent::ResourceDeleted),
|
||||
b"arc.chain-too-long" => EventType::Arc(ArcEvent::ChainTooLong),
|
||||
b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance),
|
||||
b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv),
|
||||
@@ -679,6 +685,12 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::Error) => "acme.error",
|
||||
EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response",
|
||||
EventType::Ai(AiEvent::ApiError) => "ai.api-error",
|
||||
// inbuxa: SCIM-54
|
||||
EventType::Scim(ScimEvent::ResourceCreated) => "scim.resource-created",
|
||||
EventType::Scim(ScimEvent::ResourceUpdated) => "scim.resource-updated",
|
||||
EventType::Scim(ScimEvent::ResourceSuspended) => "scim.resource-suspended",
|
||||
EventType::Scim(ScimEvent::ResourceReactivated) => "scim.resource-reactivated",
|
||||
EventType::Scim(ScimEvent::ResourceDeleted) => "scim.resource-deleted",
|
||||
EventType::Arc(ArcEvent::ChainTooLong) => "arc.chain-too-long",
|
||||
EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance",
|
||||
EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv",
|
||||
@@ -1457,6 +1469,12 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::Error) => 15,
|
||||
EventType::Ai(AiEvent::LlmResponse) => 556,
|
||||
EventType::Ai(AiEvent::ApiError) => 557,
|
||||
// inbuxa: SCIM-54
|
||||
EventType::Scim(ScimEvent::ResourceCreated) => 637,
|
||||
EventType::Scim(ScimEvent::ResourceUpdated) => 638,
|
||||
EventType::Scim(ScimEvent::ResourceSuspended) => 639,
|
||||
EventType::Scim(ScimEvent::ResourceReactivated) => 640,
|
||||
EventType::Scim(ScimEvent::ResourceDeleted) => 641,
|
||||
EventType::Arc(ArcEvent::ChainTooLong) => 28,
|
||||
EventType::Arc(ArcEvent::InvalidInstance) => 31,
|
||||
EventType::Arc(ArcEvent::InvalidCv) => 30,
|
||||
@@ -2099,6 +2117,12 @@ impl EventType {
|
||||
15 => Some(EventType::Acme(AcmeEvent::Error)),
|
||||
556 => Some(EventType::Ai(AiEvent::LlmResponse)),
|
||||
557 => Some(EventType::Ai(AiEvent::ApiError)),
|
||||
// inbuxa: SCIM-54
|
||||
637 => Some(EventType::Scim(ScimEvent::ResourceCreated)),
|
||||
638 => Some(EventType::Scim(ScimEvent::ResourceUpdated)),
|
||||
639 => Some(EventType::Scim(ScimEvent::ResourceSuspended)),
|
||||
640 => Some(EventType::Scim(ScimEvent::ResourceReactivated)),
|
||||
641 => Some(EventType::Scim(ScimEvent::ResourceDeleted)),
|
||||
28 => Some(EventType::Arc(ArcEvent::ChainTooLong)),
|
||||
31 => Some(EventType::Arc(ArcEvent::InvalidInstance)),
|
||||
30 => Some(EventType::Arc(ArcEvent::InvalidCv)),
|
||||
@@ -3056,6 +3080,12 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn,
|
||||
EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn,
|
||||
EventType::Ai(AiEvent::ApiError) => Level::Warn,
|
||||
// inbuxa: SCIM-54
|
||||
EventType::Scim(ScimEvent::ResourceCreated) => Level::Info,
|
||||
EventType::Scim(ScimEvent::ResourceUpdated) => Level::Info,
|
||||
EventType::Scim(ScimEvent::ResourceSuspended) => Level::Info,
|
||||
EventType::Scim(ScimEvent::ResourceReactivated) => Level::Info,
|
||||
EventType::Scim(ScimEvent::ResourceDeleted) => Level::Info,
|
||||
EventType::Arc(ArcEvent::SealerNotFound) => Level::Warn,
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
|
||||
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
|
||||
@@ -3137,6 +3167,12 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::Error) => "ACME error",
|
||||
EventType::Ai(AiEvent::LlmResponse) => "LLM response",
|
||||
EventType::Ai(AiEvent::ApiError) => "AI API error",
|
||||
// inbuxa: SCIM-54
|
||||
EventType::Scim(ScimEvent::ResourceCreated) => "SCIM resource created",
|
||||
EventType::Scim(ScimEvent::ResourceUpdated) => "SCIM resource updated",
|
||||
EventType::Scim(ScimEvent::ResourceSuspended) => "SCIM user suspended",
|
||||
EventType::Scim(ScimEvent::ResourceReactivated) => "SCIM user reactivated",
|
||||
EventType::Scim(ScimEvent::ResourceDeleted) => "SCIM resource deleted",
|
||||
EventType::Arc(ArcEvent::ChainTooLong) => "ARC chain too long",
|
||||
EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance",
|
||||
EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV",
|
||||
@@ -4203,6 +4239,12 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::Error),
|
||||
EventType::Ai(AiEvent::LlmResponse),
|
||||
EventType::Ai(AiEvent::ApiError),
|
||||
// inbuxa: SCIM-54
|
||||
EventType::Scim(ScimEvent::ResourceCreated),
|
||||
EventType::Scim(ScimEvent::ResourceUpdated),
|
||||
EventType::Scim(ScimEvent::ResourceSuspended),
|
||||
EventType::Scim(ScimEvent::ResourceReactivated),
|
||||
EventType::Scim(ScimEvent::ResourceDeleted),
|
||||
EventType::Arc(ArcEvent::ChainTooLong),
|
||||
EventType::Arc(ArcEvent::InvalidInstance),
|
||||
EventType::Arc(ArcEvent::InvalidCv),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ngxMcJdAkSNEy0lbKUXrU9VRRrX7R0tPVHHh_gGCf7E
|
||||
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
|
||||
@@ -24,6 +24,8 @@ pub mod imap;
|
||||
#[cfg(test)]
|
||||
pub mod jmap;
|
||||
#[cfg(test)]
|
||||
pub mod scim;
|
||||
#[cfg(test)]
|
||||
pub mod smtp;
|
||||
#[cfg(test)]
|
||||
pub mod store;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user