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

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

Every write emits one of five new scim.* events (ids 637 to 641), also
added to the packaged schema. The helpers the surviving SCIM suites
import are rebuilt from the spec; scim_tests runs the new acceptance
suite and the surviving tenant isolation suite, and both pass.
This commit is contained in:
2026-09-19 09:35:23 -07:00
parent 776d18d06e
commit 0ca26070d7
28 changed files with 6141 additions and 22 deletions
+22 -12
View File
@@ -133,7 +133,10 @@ pub enum Filter {
Or(Box<Filter>, Box<Filter>),
Not(Box<Filter>),
/// `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 {
@@ -280,18 +283,18 @@ impl Parser {
let inner = self.or()?;
match self.next() {
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> {
match self.next() {
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => {
match self.next() {
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
}
}
Some(Token::Word(word)) if word.eq_ignore_ascii_case("not") => match self.next() {
Some(Token::Open) => Ok(Filter::Not(Box::new(self.group()?))),
_ => Err(ScimError::invalid_filter("'not' must be followed by '('")),
},
Some(Token::Open) => self.group(),
Some(Token::Word(word)) => {
let path = AttrPath::parse(&word).ok_or_else(|| {
@@ -385,11 +388,14 @@ mod tests {
Filter::parse("emails.value eq \"a\\\"b\"").unwrap(),
eq("emails.value", json!("a\"b"))
);
let urn = Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"")
.unwrap();
let urn =
Filter::parse("urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"x\"").unwrap();
match urn {
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));
}
other => panic!("{other:?}"),
@@ -426,7 +432,11 @@ mod tests {
"1abc eq \"a\"",
] {
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}"
);
}
}
}
+14 -5
View File
@@ -25,11 +25,13 @@ impl PatchPath {
let text = text.trim();
let (head, filter, after) = match text.find('[') {
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 filter = Filter::parse(inner).map_err(|err| {
ScimError::invalid_path(format!("'{text}': {}", err.detail))
})?;
let filter = Filter::parse(inner)
.map_err(|err| ScimError::invalid_path(format!("'{text}': {}", err.detail)))?;
let after = &text[close + 1..];
let after = if after.is_empty() {
None
@@ -85,7 +87,14 @@ mod tests {
#[test]
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}");
}
}