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.
102 lines
3.1 KiB
Rust
102 lines
3.1 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! PATCH paths (RFC 7644 §3.5.2): `attrPath` or `valuePath [subAttr]`,
|
|
//! optionally prefixed by a schema URN.
|
|
|
|
use crate::{AttrPath, Filter, ScimError};
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct PatchPath {
|
|
/// The attribute, with its sub-attribute when written `name.sub`.
|
|
pub attr: AttrPath,
|
|
/// `attr[filter]`.
|
|
pub filter: Option<Filter>,
|
|
/// `attr[filter].sub`.
|
|
pub sub_after_filter: Option<String>,
|
|
}
|
|
|
|
impl PatchPath {
|
|
pub fn parse(text: &str) -> Result<PatchPath, ScimError> {
|
|
let invalid = || ScimError::invalid_path(format!("'{text}' isn't a valid path"));
|
|
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 inner = &text[open + 1..close];
|
|
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
|
|
} else {
|
|
Some(after.strip_prefix('.').ok_or_else(invalid)?.to_string())
|
|
};
|
|
(&text[..open], Some(filter), after)
|
|
}
|
|
None => (text, None, None),
|
|
};
|
|
let attr = AttrPath::parse(head).ok_or_else(invalid)?;
|
|
if filter.is_some() && attr.sub.is_some() {
|
|
return Err(invalid());
|
|
}
|
|
Ok(PatchPath {
|
|
attr,
|
|
filter,
|
|
sub_after_filter: after,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_paths() {
|
|
let path = PatchPath::parse("displayName").unwrap();
|
|
assert!(path.attr.is("displayname", None));
|
|
assert!(path.filter.is_none());
|
|
|
|
let path = PatchPath::parse("name.givenName").unwrap();
|
|
assert!(path.attr.is("name", Some("givenName")));
|
|
|
|
let path = PatchPath::parse("members[value eq \"2819c223\"]").unwrap();
|
|
assert!(path.attr.is("members", None));
|
|
assert!(path.filter.is_some());
|
|
|
|
let path = PatchPath::parse("emails[type eq \"work\"].value").unwrap();
|
|
assert_eq!(path.sub_after_filter.as_deref(), Some("value"));
|
|
|
|
let path = PatchPath::parse(
|
|
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
path.attr.urn.as_deref(),
|
|
Some("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")
|
|
);
|
|
assert!(path.attr.is("department", None));
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_bad_paths() {
|
|
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}");
|
|
}
|
|
}
|
|
}
|