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