SCIM: the protocol crate: URNs, the error document, and the filter and PATCH path grammars (SCIM-42, SCIM-45)

The filter parser reads all of RFC 7644's grammar, so a server supporting
only eq and and can name the construct it refuses. PATCH paths take a
schema URN prefix, sub-attributes and value filters.
This commit is contained in:
2026-09-19 09:06:50 -07:00
parent 2d3f8251c5
commit 776d18d06e
4 changed files with 695 additions and 1 deletions
+140
View File
@@ -0,0 +1,140 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The SCIM error document (RFC 7644 §3.12, RFC 9865 for the cursor types).
use crate::MESSAGE_ERROR;
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScimType {
InvalidFilter,
TooMany,
Uniqueness,
Mutability,
InvalidSyntax,
InvalidPath,
NoTarget,
InvalidValue,
InvalidVers,
Sensitive,
InvalidCursor,
ExpiredCursor,
InvalidCount,
}
impl ScimType {
pub fn as_str(&self) -> &'static str {
match self {
ScimType::InvalidFilter => "invalidFilter",
ScimType::TooMany => "tooMany",
ScimType::Uniqueness => "uniqueness",
ScimType::Mutability => "mutability",
ScimType::InvalidSyntax => "invalidSyntax",
ScimType::InvalidPath => "invalidPath",
ScimType::NoTarget => "noTarget",
ScimType::InvalidValue => "invalidValue",
ScimType::InvalidVers => "invalidVers",
ScimType::Sensitive => "sensitive",
ScimType::InvalidCursor => "invalidCursor",
ScimType::ExpiredCursor => "expiredCursor",
ScimType::InvalidCount => "invalidCount",
}
}
}
/// An error answer: status, optional `scimType`, and a `detail`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScimError {
pub status: u16,
pub scim_type: Option<ScimType>,
pub detail: String,
}
impl ScimError {
pub fn new(status: u16, detail: impl Into<String>) -> Self {
ScimError {
status,
scim_type: None,
detail: detail.into(),
}
}
pub fn bad_request(scim_type: ScimType, detail: impl Into<String>) -> Self {
ScimError {
status: 400,
scim_type: Some(scim_type),
detail: detail.into(),
}
}
pub fn conflict(detail: impl Into<String>) -> Self {
ScimError {
status: 409,
scim_type: Some(ScimType::Uniqueness),
detail: detail.into(),
}
}
pub fn invalid_syntax(detail: impl Into<String>) -> Self {
Self::bad_request(ScimType::InvalidSyntax, detail)
}
pub fn invalid_value(detail: impl Into<String>) -> Self {
Self::bad_request(ScimType::InvalidValue, detail)
}
pub fn invalid_filter(detail: impl Into<String>) -> Self {
Self::bad_request(ScimType::InvalidFilter, detail)
}
pub fn invalid_path(detail: impl Into<String>) -> Self {
Self::bad_request(ScimType::InvalidPath, detail)
}
pub fn mutability(detail: impl Into<String>) -> Self {
Self::bad_request(ScimType::Mutability, detail)
}
pub fn too_many(detail: impl Into<String>) -> Self {
Self::bad_request(ScimType::TooMany, detail)
}
pub fn not_found(detail: impl Into<String>) -> Self {
Self::new(404, detail)
}
pub fn forbidden(detail: impl Into<String>) -> Self {
Self::new(403, detail)
}
pub fn unauthorized(detail: impl Into<String>) -> Self {
Self::new(401, detail)
}
pub fn to_json(&self) -> Value {
let mut value = json!({
"schemas": [MESSAGE_ERROR],
"status": self.status.to_string(),
"detail": self.detail,
});
if let Some(scim_type) = self.scim_type {
value["scimType"] = Value::String(scim_type.as_str().to_string());
}
value
}
}
impl std::fmt::Display for ScimError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.scim_type {
Some(scim_type) => write!(f, "{} {}: {}", self.status, scim_type.as_str(), self.detail),
None => write!(f, "{}: {}", self.status, self.detail),
}
}
}
impl std::error::Error for ScimError {}
+432
View File
@@ -0,0 +1,432 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The filter grammar of RFC 7644 §3.4.2.2, parsed in full so that a server
//! supporting only part of it can name the construct it refuses.
//! Precedence: `not` and grouping, then `and`, then `or`.
use crate::ScimError;
use serde_json::Value;
/// `[URN ":"] name ["." sub]`. Names keep the case sent; compare them with
/// [`AttrPath::is`].
#[derive(Debug, Clone, PartialEq)]
pub struct AttrPath {
pub urn: Option<String>,
pub name: String,
pub sub: Option<String>,
}
impl AttrPath {
/// Parses `urn:…:Schema:name.sub`, `name.sub` or `name`.
pub fn parse(text: &str) -> Option<AttrPath> {
let (urn, rest) = if text.len() > 4 && text[..4].eq_ignore_ascii_case("urn:") {
let at = text.rfind(':')?;
(Some(text[..at].to_string()), &text[at + 1..])
} else {
(None, text)
};
let (name, sub) = match rest.split_once('.') {
Some((name, sub)) => (name, Some(sub)),
None => (rest, None),
};
if !is_attr_name(name) || sub.is_some_and(|sub| !is_attr_name(sub)) {
return None;
}
Some(AttrPath {
urn,
name: name.to_string(),
sub: sub.map(str::to_string),
})
}
/// Whether this is `name` (and `sub`, when given), in any case.
pub fn is(&self, name: &str, sub: Option<&str>) -> bool {
self.name.eq_ignore_ascii_case(name)
&& match (sub, &self.sub) {
(None, None) => true,
(Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
_ => false,
}
}
}
impl std::fmt::Display for AttrPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(urn) = &self.urn {
write!(f, "{urn}:")?;
}
write!(f, "{}", self.name)?;
if let Some(sub) = &self.sub {
write!(f, ".{sub}")?;
}
Ok(())
}
}
/// ATTRNAME = ALPHA *(nameChar), nameChar = "-" / "_" / DIGIT / ALPHA, and
/// `$ref`.
fn is_attr_name(name: &str) -> bool {
name == "$ref"
|| name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompareOp {
Eq,
Ne,
Co,
Sw,
Ew,
Gt,
Ge,
Lt,
Le,
}
impl CompareOp {
fn parse(word: &str) -> Option<CompareOp> {
Some(match word.to_ascii_lowercase().as_str() {
"eq" => CompareOp::Eq,
"ne" => CompareOp::Ne,
"co" => CompareOp::Co,
"sw" => CompareOp::Sw,
"ew" => CompareOp::Ew,
"gt" => CompareOp::Gt,
"ge" => CompareOp::Ge,
"lt" => CompareOp::Lt,
"le" => CompareOp::Le,
_ => return None,
})
}
pub fn as_str(&self) -> &'static str {
match self {
CompareOp::Eq => "eq",
CompareOp::Ne => "ne",
CompareOp::Co => "co",
CompareOp::Sw => "sw",
CompareOp::Ew => "ew",
CompareOp::Gt => "gt",
CompareOp::Ge => "ge",
CompareOp::Lt => "lt",
CompareOp::Le => "le",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Filter {
Compare {
path: AttrPath,
op: CompareOp,
value: Value,
},
Present(AttrPath),
And(Box<Filter>, Box<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> },
}
impl Filter {
pub fn parse(text: &str) -> Result<Filter, ScimError> {
let tokens = tokenize(text)?;
let mut parser = Parser { tokens, pos: 0 };
let filter = parser.or()?;
match parser.peek() {
None => Ok(filter),
Some(token) => Err(ScimError::invalid_filter(format!(
"Unexpected {} in the filter",
token.describe()
))),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Word(String),
Str(String),
Open,
Close,
OpenBracket,
CloseBracket,
}
impl Token {
fn describe(&self) -> String {
match self {
Token::Word(word) => format!("'{word}'"),
Token::Str(text) => format!("\"{text}\""),
Token::Open => "'('".to_string(),
Token::Close => "')'".to_string(),
Token::OpenBracket => "'['".to_string(),
Token::CloseBracket => "']'".to_string(),
}
}
}
fn tokenize(text: &str) -> Result<Vec<Token>, ScimError> {
let mut tokens = Vec::new();
let mut chars = text.char_indices().peekable();
while let Some(&(start, c)) = chars.peek() {
match c {
c if c.is_whitespace() => {
chars.next();
}
'(' => {
chars.next();
tokens.push(Token::Open);
}
')' => {
chars.next();
tokens.push(Token::Close);
}
'[' => {
chars.next();
tokens.push(Token::OpenBracket);
}
']' => {
chars.next();
tokens.push(Token::CloseBracket);
}
'"' => {
// A JSON string, escapes and all
chars.next();
let mut end = None;
let mut escaped = false;
for (at, c) in chars.by_ref() {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
end = Some(at);
break;
}
}
let end = end.ok_or_else(|| {
ScimError::invalid_filter("An unterminated string in the filter")
})?;
let value = serde_json::from_str::<String>(&text[start..=end])
.map_err(|_| ScimError::invalid_filter("A malformed string in the filter"))?;
tokens.push(Token::Str(value));
}
_ => {
let mut end = text.len();
while let Some(&(at, c)) = chars.peek() {
if c.is_whitespace() || matches!(c, '(' | ')' | '[' | ']' | '"') {
end = at;
break;
}
chars.next();
}
tokens.push(Token::Word(text[start..end].to_string()));
}
}
}
Ok(tokens)
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn next(&mut self) -> Option<Token> {
let token = self.tokens.get(self.pos).cloned();
self.pos += 1;
token
}
fn is_word(&self, word: &str) -> bool {
matches!(self.peek(), Some(Token::Word(w)) if w.eq_ignore_ascii_case(word))
}
fn or(&mut self) -> Result<Filter, ScimError> {
let mut left = self.and()?;
while self.is_word("or") {
self.pos += 1;
let right = self.and()?;
left = Filter::Or(Box::new(left), Box::new(right));
}
Ok(left)
}
fn and(&mut self) -> Result<Filter, ScimError> {
let mut left = self.unary()?;
while self.is_word("and") {
self.pos += 1;
let right = self.unary()?;
left = Filter::And(Box::new(left), Box::new(right));
}
Ok(left)
}
fn group(&mut self) -> Result<Filter, ScimError> {
let inner = self.or()?;
match self.next() {
Some(Token::Close) => Ok(inner),
_ => 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::Open) => self.group(),
Some(Token::Word(word)) => {
let path = AttrPath::parse(&word).ok_or_else(|| {
ScimError::invalid_filter(format!("'{word}' isn't an attribute path"))
})?;
if self.peek() == Some(&Token::OpenBracket) {
self.pos += 1;
let inner = self.or()?;
return match self.next() {
Some(Token::CloseBracket) => Ok(Filter::ValuePath {
path,
filter: Box::new(inner),
}),
_ => Err(ScimError::invalid_filter(
"A '[' without its ']' in the filter",
)),
};
}
match self.next() {
Some(Token::Word(op)) if op.eq_ignore_ascii_case("pr") => {
Ok(Filter::Present(path))
}
Some(Token::Word(op)) => {
let op = CompareOp::parse(&op).ok_or_else(|| {
ScimError::invalid_filter(format!("'{op}' isn't a filter operator"))
})?;
let value = match self.next() {
Some(Token::Str(text)) => Value::String(text),
Some(Token::Word(word)) => match word.as_str() {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
"null" => Value::Null,
number => serde_json::from_str::<serde_json::Number>(number)
.map(Value::Number)
.map_err(|_| {
ScimError::invalid_filter(format!(
"'{number}' isn't a filter value"
))
})?,
},
_ => {
return Err(ScimError::invalid_filter(format!(
"'{path} {}' needs a value",
op.as_str()
)));
}
};
Ok(Filter::Compare { path, op, value })
}
_ => Err(ScimError::invalid_filter(format!(
"'{path}' needs an operator"
))),
}
}
Some(token) => Err(ScimError::invalid_filter(format!(
"Unexpected {} in the filter",
token.describe()
))),
None => Err(ScimError::invalid_filter("The filter ends too soon")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn eq(name: &str, value: Value) -> Filter {
Filter::Compare {
path: AttrPath::parse(name).unwrap(),
op: CompareOp::Eq,
value,
}
}
#[test]
fn parses_the_supported_subset() {
assert_eq!(
Filter::parse("userName eq \"[email protected]\"").unwrap(),
eq("userName", json!("[email protected]"))
);
assert_eq!(
Filter::parse("USERNAME EQ \"a\" AND active eq false").unwrap(),
Filter::And(
Box::new(eq("USERNAME", json!("a"))),
Box::new(eq("active", json!(false)))
)
);
assert_eq!(
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();
match urn {
Filter::Compare { path, .. } => {
assert_eq!(path.urn.as_deref(), Some("urn:ietf:params:scim:schemas:core:2.0:User"));
assert!(path.is("username", None));
}
other => panic!("{other:?}"),
}
}
#[test]
fn parses_the_rest_of_the_grammar() {
assert!(matches!(
Filter::parse("title pr or not (a co \"b\")").unwrap(),
Filter::Or(..)
));
assert!(matches!(
Filter::parse("emails[type eq \"work\" and value co \"@\"]").unwrap(),
Filter::ValuePath { .. }
));
// and binds tighter than or
match Filter::parse("a eq 1 or b eq 2 and c eq 3").unwrap() {
Filter::Or(_, right) => assert!(matches!(*right, Filter::And(..))),
other => panic!("{other:?}"),
}
}
#[test]
fn refuses_malformed_filters() {
for text in [
"",
"userName",
"userName eq",
"userName xx \"a\"",
"(userName eq \"a\"",
"userName eq \"a",
"userName eq \"a\" extra",
"1abc eq \"a\"",
] {
let err = Filter::parse(text).unwrap_err();
assert_eq!(err.scim_type, Some(crate::ScimType::InvalidFilter), "{text}");
}
}
}
+31 -1
View File
@@ -1,5 +1,35 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2026 Coffey Labs
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
//! The SCIM 2.0 wire contract (RFC 7643, RFC 7644, RFC 9865): schema and
//! message URNs, the error document, and the filter and PATCH path grammars.
//! No server behavior lives here; see `docs/spec/features/scim.md`.
pub mod error;
pub mod filter;
pub mod path;
pub use error::{ScimError, ScimType};
pub use filter::{AttrPath, CompareOp, Filter};
pub use path::PatchPath;
pub const SCHEMA_USER: &str = "urn:ietf:params:scim:schemas:core:2.0:User";
pub const SCHEMA_GROUP: &str = "urn:ietf:params:scim:schemas:core:2.0:Group";
pub const SCHEMA_ENTERPRISE_USER: &str =
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
pub const SCHEMA_SERVICE_PROVIDER_CONFIG: &str =
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig";
pub const SCHEMA_RESOURCE_TYPE: &str = "urn:ietf:params:scim:schemas:core:2.0:ResourceType";
pub const SCHEMA_SCHEMA: &str = "urn:ietf:params:scim:schemas:core:2.0:Schema";
pub const MESSAGE_LIST_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
pub const MESSAGE_SEARCH_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:SearchRequest";
pub const MESSAGE_PATCH_OP: &str = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
pub const MESSAGE_BULK_REQUEST: &str = "urn:ietf:params:scim:api:messages:2.0:BulkRequest";
pub const MESSAGE_BULK_RESPONSE: &str = "urn:ietf:params:scim:api:messages:2.0:BulkResponse";
pub const MESSAGE_ERROR: &str = "urn:ietf:params:scim:api:messages:2.0:Error";
pub const CONTENT_TYPE: &str = "application/scim+json";
+92
View File
@@ -0,0 +1,92 @@
/*
* 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}");
}
}
}