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:
@@ -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")),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user