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
+2
View File
@@ -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
+453
View File
@@ -0,0 +1,453 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). The helpers the
//! surviving suites import, rebuilt from the spec, and the suites' entry
//! points. `scim_tests` runs the acceptance suite, tenant isolation and,
//! with `SCIM_CONFORMANCE=1`, the third-party clients in a container.
pub mod acceptance;
pub mod conformance;
pub mod oidc;
pub mod tenant;
use crate::utils::{account::Account, server::TestServer};
use registry::{
schema::{
enums::{Permission, StorageQuota},
prelude::{ObjectType, Property},
structs::{
self, Action, CertificateManagement, DkimManagement, DnsManagement, Domain,
PasswordCredential, Permissions, PermissionsList, UserAccount,
},
},
types::{EnumImpl, list::List, map::Map},
};
use scim_proto::{MESSAGE_PATCH_OP, SCHEMA_GROUP, SCHEMA_USER};
use serde_json::{Value, json};
use types::id::Id;
/// The server-level domain the main SCIM client provisions into.
pub const SCIM_DOMAIN: &str = "scim.example.com";
/// The test server's HTTP listener.
pub const HTTP_PORT: u16 = 8899;
pub const PRINCIPAL: &str = "[email protected]";
pub const PRINCIPAL_SECRET: &str = "these_pretzels_are_making_me_thirsty";
fn http() -> reqwest::Client {
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap()
}
/// A SCIM client with a fixed `Authorization` header.
#[derive(Clone)]
pub struct ScimClient {
authorization: Option<String>,
}
/// A SCIM answer, with assertions.
#[derive(Debug, Clone)]
pub struct ScimReply {
pub status: u16,
pub headers: reqwest::header::HeaderMap,
pub body: String,
pub json: Value,
}
impl ScimClient {
pub fn bearer(token: &str) -> Self {
ScimClient {
authorization: Some(format!("Bearer {token}")),
}
}
pub fn with_authorization(authorization: Option<String>) -> Self {
ScimClient { authorization }
}
pub fn anonymous() -> Self {
ScimClient {
authorization: None,
}
}
pub async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<Value>,
headers: &[(&str, &str)],
) -> ScimReply {
let url = format!("https://127.0.0.1:{HTTP_PORT}/scim/v2{path}");
let mut request = http().request(method, url);
if let Some(authorization) = &self.authorization {
request = request.header("authorization", authorization);
}
for (name, value) in headers {
request = request.header(*name, *value);
}
if let Some(body) = body {
request = request
.header("content-type", "application/scim+json")
.body(body.to_string());
}
let response = request.send().await.unwrap();
let status = response.status().as_u16();
let headers = response.headers().clone();
let body = response.text().await.unwrap();
let json = serde_json::from_str(&body).unwrap_or(Value::Null);
ScimReply {
status,
headers,
body,
json,
}
}
pub async fn get(&self, path: &str) -> ScimReply {
self.request(reqwest::Method::GET, path, None, &[]).await
}
pub async fn post(&self, path: &str, body: Value) -> ScimReply {
self.request(reqwest::Method::POST, path, Some(body), &[])
.await
}
pub async fn put(&self, path: &str, body: Value) -> ScimReply {
self.request(reqwest::Method::PUT, path, Some(body), &[])
.await
}
pub async fn patch(&self, path: &str, body: Value) -> ScimReply {
self.request(reqwest::Method::PATCH, path, Some(body), &[])
.await
}
pub async fn delete(&self, path: &str) -> ScimReply {
self.request(reqwest::Method::DELETE, path, None, &[]).await
}
}
impl ScimReply {
pub fn assert_status(&self, status: u16) -> &Self {
assert_eq!(self.status, status, "Unexpected status: {}", self.body);
self
}
/// A SCIM error document with that status and, if given, `scimType`.
pub fn assert_error(&self, status: u16, scim_type: Option<&str>) -> &Self {
assert_eq!(self.status, status, "Unexpected status: {}", self.body);
assert_eq!(
self.json["schemas"],
json!(["urn:ietf:params:scim:api:messages:2.0:Error"]),
"Not a SCIM error: {}",
self.body
);
assert_eq!(
self.json["status"],
json!(status.to_string()),
"{}",
self.body
);
if let Some(scim_type) = scim_type {
assert_eq!(self.json["scimType"], json!(scim_type), "{}", self.body);
}
assert_eq!(
self.header("content-type").as_deref(),
Some("application/scim+json"),
"{}",
self.body
);
self
}
pub fn assert_detail_contains(&self, text: &str) -> &Self {
let detail = self.json["detail"].as_str().unwrap_or_default();
assert!(detail.contains(text), "'{detail}' lacks '{text}'");
self
}
pub fn id(&self) -> String {
self.json["id"]
.as_str()
.unwrap_or_else(|| panic!("No id in {}", self.body))
.to_string()
}
pub fn etag(&self) -> Option<String> {
self.header("etag")
}
pub fn header(&self, name: &str) -> Option<String> {
self.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
pub fn total_results(&self) -> u64 {
self.json["totalResults"]
.as_u64()
.unwrap_or_else(|| panic!("No totalResults in {}", self.body))
}
pub fn resource_ids(&self) -> Vec<String> {
self.json["Resources"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item["id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
pub fn assert_contains_id(&self, id: &str) -> &Self {
assert!(
self.resource_ids().iter().any(|i| i == id),
"{id} missing from {}",
self.body
);
self
}
pub fn assert_lacks_id(&self, id: &str) -> &Self {
assert!(
!self.resource_ids().iter().any(|i| i == id),
"{id} present in {}",
self.body
);
self
}
}
pub fn user_body(user_name: &str) -> Value {
json!({"schemas": [SCHEMA_USER], "userName": user_name})
}
pub fn group_body(display_name: &str) -> Value {
json!({"schemas": [SCHEMA_GROUP], "displayName": display_name})
}
pub fn patch_body(operations: Value) -> Value {
json!({"schemas": [MESSAGE_PATCH_OP], "Operations": operations})
}
/// `path?filter=…`, encoded.
pub fn query(path: &str, filter: &str) -> String {
let encoded =
http_proto::form_urlencoded::byte_serialize(filter.as_bytes()).collect::<String>();
format!("{path}?filter={encoded}")
}
/// The status of `GET /jmap/session` with that `Authorization` header.
pub async fn jmap_session_status(authorization: &str) -> u16 {
http()
.get(format!("https://127.0.0.1:{HTTP_PORT}/jmap/session"))
.header("authorization", authorization)
.send()
.await
.unwrap()
.status()
.as_u16()
}
/// An API key for `principal`, created over JMAP as the principal itself.
/// Returns the secret.
pub async fn api_key(admin: &Account, principal: &Account, permissions: Value) -> String {
let _ = admin;
api_key_with_id(principal, permissions).await.1
}
/// An API key's registry id and secret.
pub async fn api_key_with_id(principal: &Account, permissions: Value) -> (Id, String) {
let response = principal
.jmap_create(
"x:ApiKey",
[json!({"description": "SCIM", "permissions": permissions})],
Vec::<(&str, &str)>::new(),
)
.await;
let created = response.created(0);
let secret = created["secret"]
.as_str()
.unwrap_or_else(|| panic!("No API key secret in {response:?}"))
.to_string();
(response.created_id(0), secret)
}
/// A user account with a password and extra permissions, for principals.
pub async fn create_principal(
admin: &Account,
name: &str,
domain_id: Id,
tenant_id: Option<Id>,
permissions: Vec<Permission>,
) -> Id {
let id = admin
.registry_create_object(structs::Account::User(UserAccount {
name: name.to_string(),
domain_id,
member_tenant_id: tenant_id,
description: Some("SCIM service principal".to_string()),
credentials: List::from_iter([structs::Credential::Password(PasswordCredential {
secret: PRINCIPAL_SECRET.to_string(),
..Default::default()
})]),
permissions: Permissions::Merge(PermissionsList {
disabled_permissions: Default::default(),
enabled_permissions: Map::new(permissions),
}),
..Default::default()
}))
.await;
admin
.registry_update_object(
ObjectType::Account,
id,
json!({ Property::Quotas: { StorageQuota::MaxApiKeys.as_str(): 20 } }),
)
.await;
id
}
/// The permissions a full SCIM key needs (spec, "Setting it up").
pub fn full_permissions() -> Vec<Permission> {
vec![
Permission::ScimAccess,
Permission::SysAccountGet,
Permission::SysAccountCreate,
Permission::SysAccountUpdate,
Permission::SysAccountDestroy,
Permission::UnlimitedRequests,
]
}
/// The main SCIM client, a server-level principal on [`SCIM_DOMAIN`].
pub struct ScimTest {
pub client: ScimClient,
pub token: String,
pub domain_id: Id,
pub principal_id: Id,
}
impl ScimTest {
pub async fn new(test: &TestServer) -> Self {
let admin = test.account("admin");
let domain_id = admin
.registry_create_object(Domain {
is_enabled: true,
name: SCIM_DOMAIN.to_string(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
allow_scim_provisioning: true,
..Default::default()
})
.await;
let principal_id =
create_principal(admin, "scim-svc", domain_id, None, full_permissions()).await;
admin.registry_create_object(Action::InvalidateCaches).await;
let principal = Account::new(PRINCIPAL, PRINCIPAL_SECRET, &[], "", principal_id);
let token = api_key(admin, &principal, json!({"@type": "Inherit"})).await;
ScimTest {
client: ScimClient::bearer(&token),
token,
domain_id,
principal_id,
}
}
/// Creates a user on the SCIM domain; its id.
pub async fn create_user(&self, user_name: &str) -> String {
self.client
.post("/Users", user_body(user_name))
.await
.assert_status(201)
.id()
}
/// Creates a group; its id.
pub async fn create_group(&self, display_name: &str) -> String {
self.client
.post("/Groups", group_body(display_name))
.await
.assert_status(201)
.id()
}
/// Deletes a resource, whether or not it's still there.
pub async fn destroy(&self, path: &str) {
let reply = self.client.delete(path).await;
assert!(
matches!(reply.status, 204 | 404),
"Deleting {path}: {}",
reply.body
);
}
}
/// The SCIM suites that run without containers, and the third-party
/// clients with `SCIM_CONFORMANCE=1`.
/// `cargo test -p tests scim_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn scim_tests() {
let test = crate::utils::server::TestServerBuilder::new("scim_tests")
.await
.with_default_listeners()
.await
.with_object(registry::schema::structs::Imap {
allow_plain_text_auth: true,
..Default::default()
})
.await
.with_object(registry::schema::structs::MtaStageRcpt {
wait_on_fail: registry::schema::structs::Expression {
else_: "1ms".into(),
..Default::default()
},
..Default::default()
})
.await
.with_object(registry::schema::structs::MtaStageAuth {
require: registry::schema::structs::Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await
.build()
.await;
let scim = ScimTest::new(&test).await;
acceptance::test(&test, &scim).await;
tenant::test(&test, &scim).await;
if conformance::is_enabled() {
conformance::test(&scim).await;
}
if test.is_reset() {
test.temp_dir.delete();
}
}
/// Acceptance test 5, deferred until per-domain directories (feature 9)
/// are built: it binds an OIDC directory to one domain (SCIM-61 decision).
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn scim_oidc_tests() {
let test = crate::utils::server::TestServerBuilder::new("scim_oidc_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let scim = ScimTest::new(&test).await;
oidc::test(&test, &scim).await;
}