scim2-client builds its models from /Schemas, so meta is described there as the mapping tables give it, without lastModified. The tester container now shares the host's network, so a host firewall that drops the Docker bridge doesn't block the test server. With SCIM_CONFORMANCE=1, the scim2-client lifecycle (12 steps), the eight replayed Okta, Keycloak and Entra payloads, and scim2-tester (errors only for its generated non-address userName) all pass.
309 lines
9.2 KiB
Rust
309 lines
9.2 KiB
Rust
/*
|
|
* 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}"),
|
|
},
|
|
})
|
|
}
|
|
|
|
/// `meta` as the mapping tables give it: no `lastModified` (SCIM-30).
|
|
fn meta() -> Attr {
|
|
Attr::new("meta", "complex").read_only().with(vec![
|
|
Attr::new("resourceType", "string").read_only().case_exact(),
|
|
Attr::new("created", "dateTime").read_only(),
|
|
Attr::new("location", "reference")
|
|
.read_only()
|
|
.refs(&["uri"]),
|
|
Attr::new("version", "string").read_only().case_exact(),
|
|
])
|
|
}
|
|
|
|
/// 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"]),
|
|
]),
|
|
meta(),
|
|
],
|
|
)
|
|
}
|
|
|
|
/// 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"]),
|
|
]),
|
|
meta(),
|
|
],
|
|
)
|
|
}
|