Files
inbuxa-server/crates/scim-proto/src/path.rs
T
jcoffey-dev 0ca26070d7 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.
2026-09-19 09:35:23 -07:00

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}");
}
}
}