SCIM: users, groups, queries, PATCH, Bulk and cursors at /scim/v2, over x:Account (SCIM-1 to SCIM-57)

Every SCIM operation becomes the x:Account get, query or set JMAP makes,
as the service principal, so permissions, tenant scope and limits,
address uniqueness and account destruction are enforced in one place.
Discovery is anonymous; everything else takes an API key as a bearer
token and nothing else. Domains open to SCIM carry a flag in the domain
cache. Filters take eq and and, answered from the account indexes, with
unindexed attributes checked on at most 200 candidates. Cursors are
stateless, HMAC-sealed under the server key. PATCH applies to the
resource in memory and saves it as a PUT, so it is all or nothing.
Groups get an address from their display name on the principal's
domain; membership is written on each user.

Every write emits one of five new scim.* events (ids 637 to 641), also
added to the packaged schema. The helpers the surviving SCIM suites
import are rebuilt from the spec; scim_tests runs the new acceptance
suite and the surviving tenant isolation suite, and both pass.
This commit is contained in:
2026-09-19 09:35:23 -07:00
parent 776d18d06e
commit 0ca26070d7
28 changed files with 6141 additions and 22 deletions
+1
View File
@@ -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" }
+1
View File
@@ -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;
+6
View File
@@ -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)
+128
View File
@@ -0,0 +1,128 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `/scim/v2` (SCIM spec): routes, authenticates with an API key only,
//! enforces the rate limits and the body cap, and hands the request to the
//! `scim` crate.
use crate::auth::authenticate::{Authenticator, HttpHeaders};
use common::Server;
use http_proto::{HttpRequest, HttpResponse, HttpSessionData, request::fetch_body};
use percent_encoding::percent_decode_str;
use scim::{Route, ScimRequest, ScimResponse};
use scim_proto::ScimError;
/// Seconds until a rate limit resets, from the error the limiter gave.
fn retry_after(err: &trc::Error) -> u64 {
let now = store::write::now();
match err.value(trc::Key::Expires).and_then(|v| v.to_uint()) {
Some(at) if at > now => at - now,
Some(seconds) if seconds > 0 => seconds,
_ => 1,
}
}
/// A limiter refusal as `429` with `Retry-After` (SCIM-14), anything else
/// as `401` (SCIM-7).
fn refusal(err: trc::Error) -> ScimResponse {
match err.event_type() {
trc::EventType::Limit(
trc::LimitEvent::TooManyRequests | trc::LimitEvent::ConcurrentRequest,
) => ScimResponse::error(ScimError::new(429, "Too many requests"))
.with_header("Retry-After", retry_after(&err).to_string()),
_ => ScimResponse::error(ScimError::unauthorized(
"The API key is missing, invalid, expired, revoked, or not allowed from this address",
)),
}
}
pub async fn handle(
server: &Server,
req: &mut HttpRequest,
session: &HttpSessionData,
) -> HttpResponse {
respond(server, req, session).await.into_http_response()
}
async fn respond(
server: &Server,
req: &mut HttpRequest,
session: &HttpSessionData,
) -> ScimResponse {
let segments = req
.uri()
.path()
.split('/')
.skip(3)
.map(|segment| percent_decode_str(segment).decode_utf8_lossy().into_owned())
.collect::<Vec<_>>();
let route = match Route::parse(req.method(), &segments) {
Ok(route) => route,
Err(response) => return response,
};
let query = req.uri().query().map(str::to_string);
// SCIM-3: discovery is anonymous, under the anonymous rate limit
if route.is_anonymous() {
if let Err(err) = server
.is_http_anonymous_request_allowed(session.remote_ip)
.await
{
return refusal(err);
}
return scim::handle_anonymous(server, &route, query.as_deref());
}
// SCIM-7: an API key as a bearer token, and nothing else
match req.authorization() {
None => {
return ScimResponse::error(ScimError::unauthorized(
"An API key is required, as an Authorization: Bearer token",
));
}
Some((mechanism, _)) if mechanism.eq_ignore_ascii_case("basic") => {
return ScimResponse::error(ScimError::unauthorized(
"Basic authentication isn't accepted: send an API key as an Authorization: Bearer token",
));
}
Some((mechanism, token))
if !mechanism.eq_ignore_ascii_case("bearer") || !token.starts_with("API_") =>
{
return ScimResponse::error(ScimError::unauthorized(
"Only API keys are accepted, as an Authorization: Bearer token",
));
}
Some(_) => {}
}
let (_in_flight, access_token) = match server.authenticate_headers(req, session).await {
Ok(result) => result,
Err(err) => {
trc::error!(err.clone().span_id(session.session_id));
return refusal(err);
}
};
// SCIM-51: 1 MiB for every body, /Bulk included
let Some(body) = fetch_body(req, scim::MAX_PAYLOAD, session.session_id).await else {
return ScimResponse::error(ScimError::new(
413,
format!("The body is larger than {} bytes", scim::MAX_PAYLOAD),
));
};
scim::handle(
server,
&access_token,
session,
ScimRequest {
route,
query: query.as_deref(),
headers: req.headers(),
body,
},
)
.await
}