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