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",
|
"registry",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"scim",
|
"scim",
|
||||||
|
"scim-proto",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"services",
|
"services",
|
||||||
@@ -7417,8 +7418,11 @@ dependencies = [
|
|||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.22"
|
version = "0.16.22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
"base64 0.23.1",
|
||||||
"common",
|
"common",
|
||||||
"directory",
|
"directory",
|
||||||
|
"hmac 0.13.0",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"hyper",
|
"hyper",
|
||||||
"icu_locale",
|
"icu_locale",
|
||||||
@@ -7428,6 +7432,7 @@ dependencies = [
|
|||||||
"scim-proto",
|
"scim-proto",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"store",
|
"store",
|
||||||
"trc",
|
"trc",
|
||||||
"types",
|
"types",
|
||||||
|
|||||||
@@ -796,6 +796,14 @@ impl AccessToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AccessTokenInner {
|
impl AccessTokenInner {
|
||||||
|
/// inbuxa: SCIM-27: the account's own effective permission, from its
|
||||||
|
/// roles, its own settings and its tenant, before a credential narrows it
|
||||||
|
pub fn account_has_permission(&self, permission: Permission) -> bool {
|
||||||
|
self.scopes
|
||||||
|
.first()
|
||||||
|
.is_some_and(|scope| scope.permissions.get(permission as usize))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_id(account_id: u32) -> Self {
|
pub fn from_id(account_id: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
account_id,
|
account_id,
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ pub struct DomainCache {
|
|||||||
|
|
||||||
pub const DOMAIN_FLAG_RELAY: u8 = 1;
|
pub const DOMAIN_FLAG_RELAY: u8 = 1;
|
||||||
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
|
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
|
||||||
|
// inbuxa: SCIM-15, SCIM-58
|
||||||
|
pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AccountCache {
|
pub struct AccountCache {
|
||||||
@@ -329,4 +330,8 @@ impl DomainCache {
|
|||||||
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
|
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: SCIM-15, SCIM-58
|
||||||
|
pub fn allows_scim(&self) -> bool {
|
||||||
|
self.flags & DOMAIN_FLAG_SCIM != 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -158,7 +158,11 @@ impl Server {
|
|||||||
if domain.allow_relaying {
|
if domain.allow_relaying {
|
||||||
flags |= DOMAIN_FLAG_RELAY;
|
flags |= DOMAIN_FLAG_RELAY;
|
||||||
}
|
}
|
||||||
|
// inbuxa: SCIM-15, SCIM-58: the domain is open to SCIM, and SCIM is
|
||||||
|
// authoritative for its accounts
|
||||||
|
if domain.allow_scim_provisioning {
|
||||||
|
flags |= crate::auth::DOMAIN_FLAG_SCIM;
|
||||||
|
}
|
||||||
|
|
||||||
let sub_addressing_custom = match domain.sub_addressing {
|
let sub_addressing_custom = match domain.sub_addressing {
|
||||||
SubAddressing::Enabled => {
|
SubAddressing::Enabled => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ smtp = { path = "../smtp" }
|
|||||||
jmap = { path = "../jmap" }
|
jmap = { path = "../jmap" }
|
||||||
dav = { path = "../dav" }
|
dav = { path = "../dav" }
|
||||||
scim = { path = "../scim" }
|
scim = { path = "../scim" }
|
||||||
|
scim-proto = { path = "../scim-proto" }
|
||||||
groupware = { path = "../groupware" }
|
groupware = { path = "../groupware" }
|
||||||
http_proto = { path = "../http-proto" }
|
http_proto = { path = "../http-proto" }
|
||||||
jmap_proto = { path = "../jmap-proto" }
|
jmap_proto = { path = "../jmap-proto" }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod branding; // inbuxa: branding
|
|||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod live; // inbuxa: monitoring (MON-20 to MON-24)
|
pub mod live; // inbuxa: monitoring (MON-20 to MON-24)
|
||||||
pub mod request;
|
pub mod request;
|
||||||
|
pub mod scim; // inbuxa: SCIM 2.0 provisioning
|
||||||
|
|
||||||
use common::Inner;
|
use common::Inner;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -478,6 +478,12 @@ impl ParseHttp for Server {
|
|||||||
return crate::branding::rsvp_page(self, RSVP_PAGE).await;
|
return crate::branding::rsvp_page(self, RSVP_PAGE).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: SCIM 2.0 provisioning (feature 7)
|
||||||
|
"scim" => {
|
||||||
|
if path.next() == Some("v2") {
|
||||||
|
return Ok(crate::scim::handle(self, &mut req, &session).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
// inbuxa: BT-5: the logo that applies, anonymous
|
// inbuxa: BT-5: the logo that applies, anonymous
|
||||||
"logo" if req.method() == Method::GET => {
|
"logo" if req.method() == Method::GET => {
|
||||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||||
|
|||||||
@@ -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>),
|
Or(Box<Filter>, Box<Filter>),
|
||||||
Not(Box<Filter>),
|
Not(Box<Filter>),
|
||||||
/// `attr[filter]`, with the inner filter's paths relative to `attr`.
|
/// `attr[filter]`, with the inner filter's paths relative to `attr`.
|
||||||
ValuePath { path: AttrPath, filter: Box<Filter> },
|
ValuePath {
|
||||||
|
path: AttrPath,
|
||||||
|
filter: Box<Filter>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Filter {
|
impl Filter {
|
||||||
@@ -280,18 +283,18 @@ impl Parser {
|
|||||||
let inner = self.or()?;
|
let inner = self.or()?;
|
||||||
match self.next() {
|
match self.next() {
|
||||||
Some(Token::Close) => Ok(inner),
|
Some(Token::Close) => Ok(inner),
|
||||||
_ => Err(ScimError::invalid_filter("A '(' without its ')' in the filter")),
|
_ => Err(ScimError::invalid_filter(
|
||||||
|
"A '(' without its ')' in the filter",
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unary(&mut self) -> Result<Filter, ScimError> {
|
fn unary(&mut self) -> Result<Filter, ScimError> {
|
||||||
match self.next() {
|
match self.next() {
|
||||||
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => {
|
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => match self.next() {
|
||||||
match self.next() {
|
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
|
||||||
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
|
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
|
||||||
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Token::Open) => self.group(),
|
Some(Token::Open) => self.group(),
|
||||||
Some(Token::Word(word)) => {
|
Some(Token::Word(word)) => {
|
||||||
let path = AttrPath::parse(&word).ok_or_else(|| {
|
let path = AttrPath::parse(&word).ok_or_else(|| {
|
||||||
@@ -385,11 +388,14 @@ mod tests {
|
|||||||
Filter::parse("emails.value eq \"a\\\"b\"").unwrap(),
|
Filter::parse("emails.value eq \"a\\\"b\"").unwrap(),
|
||||||
eq("emails.value", json!("a\"b"))
|
eq("emails.value", json!("a\"b"))
|
||||||
);
|
);
|
||||||
let urn = Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"")
|
let urn =
|
||||||
.unwrap();
|
Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"").unwrap();
|
||||||
match urn {
|
match urn {
|
||||||
Filter::Compare { path, .. } => {
|
Filter::Compare { path, .. } => {
|
||||||
assert_eq!(path.urn.as_deref(), Some("urn:ietf:params:scim:schemas:core:2.0:User"));
|
assert_eq!(
|
||||||
|
path.urn.as_deref(),
|
||||||
|
Some("urn:ietf:params:scim:schemas:core:2.0:User")
|
||||||
|
);
|
||||||
assert!(path.is("username", None));
|
assert!(path.is("username", None));
|
||||||
}
|
}
|
||||||
other => panic!("{other:?}"),
|
other => panic!("{other:?}"),
|
||||||
@@ -426,7 +432,11 @@ mod tests {
|
|||||||
"1abc eq \"a\"",
|
"1abc eq \"a\"",
|
||||||
] {
|
] {
|
||||||
let err = Filter::parse(text).unwrap_err();
|
let err = Filter::parse(text).unwrap_err();
|
||||||
assert_eq!(err.scim_type, Some(crate::ScimType::InvalidFilter), "{text}");
|
assert_eq!(
|
||||||
|
err.scim_type,
|
||||||
|
Some(crate::ScimType::InvalidFilter),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ impl PatchPath {
|
|||||||
let text = text.trim();
|
let text = text.trim();
|
||||||
let (head, filter, after) = match text.find('[') {
|
let (head, filter, after) = match text.find('[') {
|
||||||
Some(open) => {
|
Some(open) => {
|
||||||
let close = text.rfind(']').filter(|close| *close > open).ok_or_else(invalid)?;
|
let close = text
|
||||||
|
.rfind(']')
|
||||||
|
.filter(|close| *close > open)
|
||||||
|
.ok_or_else(invalid)?;
|
||||||
let inner = &text[open + 1..close];
|
let inner = &text[open + 1..close];
|
||||||
let filter = Filter::parse(inner).map_err(|err| {
|
let filter = Filter::parse(inner)
|
||||||
ScimError::invalid_path(format!("'{text}': {}", err.detail))
|
.map_err(|err| ScimError::invalid_path(format!("'{text}': {}", err.detail)))?;
|
||||||
})?;
|
|
||||||
let after = &text[close + 1..];
|
let after = &text[close + 1..];
|
||||||
let after = if after.is_empty() {
|
let after = if after.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -85,7 +87,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refuses_bad_paths() {
|
fn refuses_bad_paths() {
|
||||||
for text in ["", "members[", "members[value eq]", "a[b eq 1]x", "1a", "a.b[c eq 1]"] {
|
for text in [
|
||||||
|
"",
|
||||||
|
"members[",
|
||||||
|
"members[value eq]",
|
||||||
|
"a[b eq 1]x",
|
||||||
|
"1a",
|
||||||
|
"a.b[c eq 1]",
|
||||||
|
] {
|
||||||
assert!(PatchPath::parse(text).is_err(), "{text}");
|
assert!(PatchPath::parse(text).is_err(), "{text}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
xxhash-rust = { version = "0.8.18", features = ["xxh3"] }
|
||||||
icu_locale = "2.3.1"
|
icu_locale = "2.3.1"
|
||||||
|
ahash = { version = "0.8.12", features = ["serde"] }
|
||||||
|
base64 = "0.23"
|
||||||
|
hmac = "0.13"
|
||||||
|
sha2 = "0.11"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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.
|
// This file is auto-generated. Do not edit directly.
|
||||||
|
|
||||||
pub const TOTAL_EVENT_COUNT: usize = 637;
|
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54)
|
||||||
|
pub const TOTAL_EVENT_COUNT: usize = 642;
|
||||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -42,6 +43,8 @@ pub enum EventType {
|
|||||||
Queue(QueueEvent),
|
Queue(QueueEvent),
|
||||||
Registry(RegistryEvent),
|
Registry(RegistryEvent),
|
||||||
Resource(ResourceEvent),
|
Resource(ResourceEvent),
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
Scim(ScimEvent),
|
||||||
Security(SecurityEvent),
|
Security(SecurityEvent),
|
||||||
Server(ServerEvent),
|
Server(ServerEvent),
|
||||||
Sieve(SieveEvent),
|
Sieve(SieveEvent),
|
||||||
@@ -624,6 +627,17 @@ pub enum ResourceEvent {
|
|||||||
ApplicationUnpacked = 602,
|
ApplicationUnpacked = 602,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: SCIM-54: every write an identity provider makes
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
#[repr(u16)]
|
||||||
|
pub enum ScimEvent {
|
||||||
|
ResourceCreated = 637,
|
||||||
|
ResourceUpdated = 638,
|
||||||
|
ResourceSuspended = 639,
|
||||||
|
ResourceReactivated = 640,
|
||||||
|
ResourceDeleted = 641,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
#[repr(u16)]
|
#[repr(u16)]
|
||||||
pub enum SecurityEvent {
|
pub enum SecurityEvent {
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ impl EventType {
|
|||||||
b"acme.error" => EventType::Acme(AcmeEvent::Error),
|
b"acme.error" => EventType::Acme(AcmeEvent::Error),
|
||||||
b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse),
|
b"ai.llm-response" => EventType::Ai(AiEvent::LlmResponse),
|
||||||
b"ai.api-error" => EventType::Ai(AiEvent::ApiError),
|
b"ai.api-error" => EventType::Ai(AiEvent::ApiError),
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
b"scim.resource-created" => EventType::Scim(ScimEvent::ResourceCreated),
|
||||||
|
b"scim.resource-updated" => EventType::Scim(ScimEvent::ResourceUpdated),
|
||||||
|
b"scim.resource-suspended" => EventType::Scim(ScimEvent::ResourceSuspended),
|
||||||
|
b"scim.resource-reactivated" => EventType::Scim(ScimEvent::ResourceReactivated),
|
||||||
|
b"scim.resource-deleted" => EventType::Scim(ScimEvent::ResourceDeleted),
|
||||||
b"arc.chain-too-long" => EventType::Arc(ArcEvent::ChainTooLong),
|
b"arc.chain-too-long" => EventType::Arc(ArcEvent::ChainTooLong),
|
||||||
b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance),
|
b"arc.invalid-instance" => EventType::Arc(ArcEvent::InvalidInstance),
|
||||||
b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv),
|
b"arc.invalid-cv" => EventType::Arc(ArcEvent::InvalidCv),
|
||||||
@@ -679,6 +685,12 @@ impl EventType {
|
|||||||
EventType::Acme(AcmeEvent::Error) => "acme.error",
|
EventType::Acme(AcmeEvent::Error) => "acme.error",
|
||||||
EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response",
|
EventType::Ai(AiEvent::LlmResponse) => "ai.llm-response",
|
||||||
EventType::Ai(AiEvent::ApiError) => "ai.api-error",
|
EventType::Ai(AiEvent::ApiError) => "ai.api-error",
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
EventType::Scim(ScimEvent::ResourceCreated) => "scim.resource-created",
|
||||||
|
EventType::Scim(ScimEvent::ResourceUpdated) => "scim.resource-updated",
|
||||||
|
EventType::Scim(ScimEvent::ResourceSuspended) => "scim.resource-suspended",
|
||||||
|
EventType::Scim(ScimEvent::ResourceReactivated) => "scim.resource-reactivated",
|
||||||
|
EventType::Scim(ScimEvent::ResourceDeleted) => "scim.resource-deleted",
|
||||||
EventType::Arc(ArcEvent::ChainTooLong) => "arc.chain-too-long",
|
EventType::Arc(ArcEvent::ChainTooLong) => "arc.chain-too-long",
|
||||||
EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance",
|
EventType::Arc(ArcEvent::InvalidInstance) => "arc.invalid-instance",
|
||||||
EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv",
|
EventType::Arc(ArcEvent::InvalidCv) => "arc.invalid-cv",
|
||||||
@@ -1457,6 +1469,12 @@ impl EventType {
|
|||||||
EventType::Acme(AcmeEvent::Error) => 15,
|
EventType::Acme(AcmeEvent::Error) => 15,
|
||||||
EventType::Ai(AiEvent::LlmResponse) => 556,
|
EventType::Ai(AiEvent::LlmResponse) => 556,
|
||||||
EventType::Ai(AiEvent::ApiError) => 557,
|
EventType::Ai(AiEvent::ApiError) => 557,
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
EventType::Scim(ScimEvent::ResourceCreated) => 637,
|
||||||
|
EventType::Scim(ScimEvent::ResourceUpdated) => 638,
|
||||||
|
EventType::Scim(ScimEvent::ResourceSuspended) => 639,
|
||||||
|
EventType::Scim(ScimEvent::ResourceReactivated) => 640,
|
||||||
|
EventType::Scim(ScimEvent::ResourceDeleted) => 641,
|
||||||
EventType::Arc(ArcEvent::ChainTooLong) => 28,
|
EventType::Arc(ArcEvent::ChainTooLong) => 28,
|
||||||
EventType::Arc(ArcEvent::InvalidInstance) => 31,
|
EventType::Arc(ArcEvent::InvalidInstance) => 31,
|
||||||
EventType::Arc(ArcEvent::InvalidCv) => 30,
|
EventType::Arc(ArcEvent::InvalidCv) => 30,
|
||||||
@@ -2099,6 +2117,12 @@ impl EventType {
|
|||||||
15 => Some(EventType::Acme(AcmeEvent::Error)),
|
15 => Some(EventType::Acme(AcmeEvent::Error)),
|
||||||
556 => Some(EventType::Ai(AiEvent::LlmResponse)),
|
556 => Some(EventType::Ai(AiEvent::LlmResponse)),
|
||||||
557 => Some(EventType::Ai(AiEvent::ApiError)),
|
557 => Some(EventType::Ai(AiEvent::ApiError)),
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
637 => Some(EventType::Scim(ScimEvent::ResourceCreated)),
|
||||||
|
638 => Some(EventType::Scim(ScimEvent::ResourceUpdated)),
|
||||||
|
639 => Some(EventType::Scim(ScimEvent::ResourceSuspended)),
|
||||||
|
640 => Some(EventType::Scim(ScimEvent::ResourceReactivated)),
|
||||||
|
641 => Some(EventType::Scim(ScimEvent::ResourceDeleted)),
|
||||||
28 => Some(EventType::Arc(ArcEvent::ChainTooLong)),
|
28 => Some(EventType::Arc(ArcEvent::ChainTooLong)),
|
||||||
31 => Some(EventType::Arc(ArcEvent::InvalidInstance)),
|
31 => Some(EventType::Arc(ArcEvent::InvalidInstance)),
|
||||||
30 => Some(EventType::Arc(ArcEvent::InvalidCv)),
|
30 => Some(EventType::Arc(ArcEvent::InvalidCv)),
|
||||||
@@ -3056,6 +3080,12 @@ impl EventType {
|
|||||||
EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn,
|
EventType::Acme(AcmeEvent::TlsAlpnError) => Level::Warn,
|
||||||
EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn,
|
EventType::Acme(AcmeEvent::TokenNotFound) => Level::Warn,
|
||||||
EventType::Ai(AiEvent::ApiError) => Level::Warn,
|
EventType::Ai(AiEvent::ApiError) => Level::Warn,
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
EventType::Scim(ScimEvent::ResourceCreated) => Level::Info,
|
||||||
|
EventType::Scim(ScimEvent::ResourceUpdated) => Level::Info,
|
||||||
|
EventType::Scim(ScimEvent::ResourceSuspended) => Level::Info,
|
||||||
|
EventType::Scim(ScimEvent::ResourceReactivated) => Level::Info,
|
||||||
|
EventType::Scim(ScimEvent::ResourceDeleted) => Level::Info,
|
||||||
EventType::Arc(ArcEvent::SealerNotFound) => Level::Warn,
|
EventType::Arc(ArcEvent::SealerNotFound) => Level::Warn,
|
||||||
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
|
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
|
||||||
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
|
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
|
||||||
@@ -3137,6 +3167,12 @@ impl EventType {
|
|||||||
EventType::Acme(AcmeEvent::Error) => "ACME error",
|
EventType::Acme(AcmeEvent::Error) => "ACME error",
|
||||||
EventType::Ai(AiEvent::LlmResponse) => "LLM response",
|
EventType::Ai(AiEvent::LlmResponse) => "LLM response",
|
||||||
EventType::Ai(AiEvent::ApiError) => "AI API error",
|
EventType::Ai(AiEvent::ApiError) => "AI API error",
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
EventType::Scim(ScimEvent::ResourceCreated) => "SCIM resource created",
|
||||||
|
EventType::Scim(ScimEvent::ResourceUpdated) => "SCIM resource updated",
|
||||||
|
EventType::Scim(ScimEvent::ResourceSuspended) => "SCIM user suspended",
|
||||||
|
EventType::Scim(ScimEvent::ResourceReactivated) => "SCIM user reactivated",
|
||||||
|
EventType::Scim(ScimEvent::ResourceDeleted) => "SCIM resource deleted",
|
||||||
EventType::Arc(ArcEvent::ChainTooLong) => "ARC chain too long",
|
EventType::Arc(ArcEvent::ChainTooLong) => "ARC chain too long",
|
||||||
EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance",
|
EventType::Arc(ArcEvent::InvalidInstance) => "Invalid ARC instance",
|
||||||
EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV",
|
EventType::Arc(ArcEvent::InvalidCv) => "Invalid ARC CV",
|
||||||
@@ -4203,6 +4239,12 @@ impl EventType {
|
|||||||
EventType::Acme(AcmeEvent::Error),
|
EventType::Acme(AcmeEvent::Error),
|
||||||
EventType::Ai(AiEvent::LlmResponse),
|
EventType::Ai(AiEvent::LlmResponse),
|
||||||
EventType::Ai(AiEvent::ApiError),
|
EventType::Ai(AiEvent::ApiError),
|
||||||
|
// inbuxa: SCIM-54
|
||||||
|
EventType::Scim(ScimEvent::ResourceCreated),
|
||||||
|
EventType::Scim(ScimEvent::ResourceUpdated),
|
||||||
|
EventType::Scim(ScimEvent::ResourceSuspended),
|
||||||
|
EventType::Scim(ScimEvent::ResourceReactivated),
|
||||||
|
EventType::Scim(ScimEvent::ResourceDeleted),
|
||||||
EventType::Arc(ArcEvent::ChainTooLong),
|
EventType::Arc(ArcEvent::ChainTooLong),
|
||||||
EventType::Arc(ArcEvent::InvalidInstance),
|
EventType::Arc(ArcEvent::InvalidInstance),
|
||||||
EventType::Arc(ArcEvent::InvalidCv),
|
EventType::Arc(ArcEvent::InvalidCv),
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
ngxMcJdAkSNEy0lbKUXrU9VRRrX7R0tPVHHh_gGCf7E
|
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
|
||||||
@@ -24,6 +24,8 @@ pub mod imap;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod jmap;
|
pub mod jmap;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
pub mod scim;
|
||||||
|
#[cfg(test)]
|
||||||
pub mod smtp;
|
pub mod smtp;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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