From 776d18d06e9741db3a3e00f14085624762ca71c6 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 09:06:50 -0700 Subject: [PATCH] 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. --- crates/scim-proto/src/error.rs | 140 +++++++++++ crates/scim-proto/src/filter.rs | 432 ++++++++++++++++++++++++++++++++ crates/scim-proto/src/lib.rs | 32 ++- crates/scim-proto/src/path.rs | 92 +++++++ 4 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 crates/scim-proto/src/error.rs create mode 100644 crates/scim-proto/src/filter.rs create mode 100644 crates/scim-proto/src/path.rs diff --git a/crates/scim-proto/src/error.rs b/crates/scim-proto/src/error.rs new file mode 100644 index 0000000..7dfef9d --- /dev/null +++ b/crates/scim-proto/src/error.rs @@ -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, + pub detail: String, +} + +impl ScimError { + pub fn new(status: u16, detail: impl Into) -> Self { + ScimError { + status, + scim_type: None, + detail: detail.into(), + } + } + + pub fn bad_request(scim_type: ScimType, detail: impl Into) -> Self { + ScimError { + status: 400, + scim_type: Some(scim_type), + detail: detail.into(), + } + } + + pub fn conflict(detail: impl Into) -> Self { + ScimError { + status: 409, + scim_type: Some(ScimType::Uniqueness), + detail: detail.into(), + } + } + + pub fn invalid_syntax(detail: impl Into) -> Self { + Self::bad_request(ScimType::InvalidSyntax, detail) + } + + pub fn invalid_value(detail: impl Into) -> Self { + Self::bad_request(ScimType::InvalidValue, detail) + } + + pub fn invalid_filter(detail: impl Into) -> Self { + Self::bad_request(ScimType::InvalidFilter, detail) + } + + pub fn invalid_path(detail: impl Into) -> Self { + Self::bad_request(ScimType::InvalidPath, detail) + } + + pub fn mutability(detail: impl Into) -> Self { + Self::bad_request(ScimType::Mutability, detail) + } + + pub fn too_many(detail: impl Into) -> Self { + Self::bad_request(ScimType::TooMany, detail) + } + + pub fn not_found(detail: impl Into) -> Self { + Self::new(404, detail) + } + + pub fn forbidden(detail: impl Into) -> Self { + Self::new(403, detail) + } + + pub fn unauthorized(detail: impl Into) -> 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 {} diff --git a/crates/scim-proto/src/filter.rs b/crates/scim-proto/src/filter.rs new file mode 100644 index 0000000..f844dea --- /dev/null +++ b/crates/scim-proto/src/filter.rs @@ -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, + pub name: String, + pub sub: Option, +} + +impl AttrPath { + /// Parses `urn:…:Schema:name.sub`, `name.sub` or `name`. + pub fn parse(text: &str) -> Option { + 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 { + 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, Box), + Or(Box, Box), + Not(Box), + /// `attr[filter]`, with the inner filter's paths relative to `attr`. + ValuePath { path: AttrPath, filter: Box }, +} + +impl Filter { + pub fn parse(text: &str) -> Result { + 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, 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::(&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, + pos: usize, +} + +impl Parser { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn next(&mut self) -> Option { + 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 { + 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 { + 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 { + 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 { + 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::(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 \"bjensen@example.com\"").unwrap(), + eq("userName", json!("bjensen@example.com")) + ); + 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}"); + } + } +} diff --git a/crates/scim-proto/src/lib.rs b/crates/scim-proto/src/lib.rs index 698407d..4896632 100644 --- a/crates/scim-proto/src/lib.rs +++ b/crates/scim-proto/src/lib.rs @@ -1,5 +1,35 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs * * 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"; diff --git a/crates/scim-proto/src/path.rs b/crates/scim-proto/src/path.rs new file mode 100644 index 0000000..5f36828 --- /dev/null +++ b/crates/scim-proto/src/path.rs @@ -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, + /// `attr[filter].sub`. + pub sub_after_filter: Option, +} + +impl PatchPath { + pub fn parse(text: &str) -> Result { + 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}"); + } + } +}