Skip to content

Commit 875c7ea

Browse files
authored
Merge pull request #97 from python-scim/path
Implement proper path validation for SearchRequest attributes
2 parents 25bce4b + c48ab6d commit 875c7ea

File tree

5 files changed

+374
-2
lines changed

5 files changed

+374
-2
lines changed

doc/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
[0.4.0] - Unreleased
5+
--------------------
6+
7+
Added
8+
^^^^^
9+
- Proper path validation for :attr:`~scim2_models.SearchRequest.attributes`, :attr:`~scim2_models.SearchRequest.excluded_attributes` and :attr:`~scim2_models.SearchRequest.sort_by`.
10+
411
[0.3.7] - 2025-07-17
512
--------------------
613

scim2_models/rfc7644/search_request.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pydantic import model_validator
77

88
from ..annotations import Required
9+
from ..utils import validate_scim_path_syntax
10+
from .error import Error
911
from .message import Message
1012

1113

@@ -24,17 +26,64 @@ class SearchRequest(Message):
2426
attributes to return in the response, overriding the set of attributes that
2527
would be returned by default."""
2628

29+
@field_validator("attributes")
30+
@classmethod
31+
def validate_attributes_syntax(cls, v: Optional[list[str]]) -> Optional[list[str]]:
32+
"""Validate syntax of attribute paths."""
33+
if v is None:
34+
return v
35+
36+
for attr in v:
37+
if not validate_scim_path_syntax(attr):
38+
raise ValueError(Error.make_invalid_path_error().detail)
39+
40+
return v
41+
2742
excluded_attributes: Optional[list[str]] = None
2843
"""A multi-valued list of strings indicating the names of resource
2944
attributes to be removed from the default set of attributes to return."""
3045

46+
@field_validator("excluded_attributes")
47+
@classmethod
48+
def validate_excluded_attributes_syntax(
49+
cls, v: Optional[list[str]]
50+
) -> Optional[list[str]]:
51+
"""Validate syntax of excluded attribute paths."""
52+
if v is None:
53+
return v
54+
55+
for attr in v:
56+
if not validate_scim_path_syntax(attr):
57+
raise ValueError(Error.make_invalid_path_error().detail)
58+
59+
return v
60+
3161
filter: Optional[str] = None
3262
"""The filter string used to request a subset of resources."""
3363

3464
sort_by: Optional[str] = None
3565
"""A string indicating the attribute whose value SHALL be used to order the
3666
returned responses."""
3767

68+
@field_validator("sort_by")
69+
@classmethod
70+
def validate_sort_by_syntax(cls, v: Optional[str]) -> Optional[str]:
71+
"""Validate syntax of sort_by attribute path.
72+
73+
:param v: The sort_by attribute path to validate
74+
:type v: Optional[str]
75+
:return: The validated sort_by attribute path
76+
:rtype: Optional[str]
77+
:raises ValueError: If sort_by attribute path has invalid syntax
78+
"""
79+
if v is None:
80+
return v
81+
82+
if not validate_scim_path_syntax(v):
83+
raise ValueError(Error.make_invalid_path_error().detail)
84+
85+
return v
86+
3887
class SortOrder(str, Enum):
3988
ascending = "ascending"
4089
descending = "descending"
@@ -48,7 +97,7 @@ class SortOrder(str, Enum):
4897

4998
@field_validator("start_index")
5099
@classmethod
51-
def start_index_floor(cls, value: int) -> int:
100+
def start_index_floor(cls, value: Optional[int]) -> Optional[int]:
52101
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than 1 are interpreted as 1.
53102
54103
A value less than 1 SHALL be interpreted as 1.
@@ -61,7 +110,7 @@ def start_index_floor(cls, value: int) -> int:
61110

62111
@field_validator("count")
63112
@classmethod
64-
def count_floor(cls, value: int) -> int:
113+
def count_floor(cls, value: Optional[int]) -> Optional[int]:
65114
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than 0 are interpreted as 0.
66115
67116
A negative value SHALL be interpreted as 0.

scim2_models/utils.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,62 @@ def normalize_attribute_name(attribute_name: str) -> str:
9797
attribute_name = re.sub(r"[\W_]+", "", attribute_name)
9898

9999
return attribute_name.lower()
100+
101+
102+
def validate_scim_path_syntax(path: str) -> bool:
103+
"""Check if path syntax is valid according to RFC 7644 simplified rules.
104+
105+
:param path: The path to validate
106+
:type path: str
107+
:return: True if path syntax is valid, False otherwise
108+
:rtype: bool
109+
"""
110+
if not path or not path.strip():
111+
return False
112+
113+
# Cannot start with a digit
114+
if path[0].isdigit():
115+
return False
116+
117+
# Cannot contain double dots
118+
if ".." in path:
119+
return False
120+
121+
# Cannot contain invalid characters (basic check)
122+
# Allow alphanumeric, dots, underscores, hyphens, colons (for URNs), brackets
123+
if not re.match(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$', path):
124+
return False
125+
126+
# If it contains a colon, validate it's a proper URN format
127+
if ":" in path:
128+
if not validate_scim_urn_syntax(path):
129+
return False
130+
131+
return True
132+
133+
134+
def validate_scim_urn_syntax(path: str) -> bool:
135+
"""Validate URN-based path format.
136+
137+
:param path: The URN path to validate
138+
:type path: str
139+
:return: True if URN path format is valid, False otherwise
140+
:rtype: bool
141+
"""
142+
# Basic URN validation: should start with urn:
143+
if not path.startswith("urn:"):
144+
return False
145+
146+
# Split on the last colon to separate URN from attribute
147+
urn_part, attr_part = path.rsplit(":", 1)
148+
149+
# URN part should have at least 4 parts (urn:namespace:specific:resource)
150+
urn_segments = urn_part.split(":")
151+
if len(urn_segments) < 4:
152+
return False
153+
154+
# Attribute part should be valid
155+
if not attr_part or attr_part[0].isdigit():
156+
return False
157+
158+
return True

tests/test_path_validation.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Tests for SCIM path validation utilities."""
2+
3+
from scim2_models.utils import validate_scim_path_syntax
4+
from scim2_models.utils import validate_scim_urn_syntax
5+
6+
7+
def test_validate_scim_path_syntax_valid_paths():
8+
"""Test that valid SCIM paths are accepted."""
9+
valid_paths = [
10+
"userName",
11+
"name.familyName",
12+
"emails.value",
13+
"groups.display",
14+
"urn:ietf:params:scim:schemas:core:2.0:User:userName",
15+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber",
16+
'emails[type eq "work"].value',
17+
'groups[display eq "Admin"]',
18+
"meta.lastModified",
19+
]
20+
21+
for path in valid_paths:
22+
assert validate_scim_path_syntax(path), f"Path should be valid: {path}"
23+
24+
25+
def test_validate_scim_path_syntax_invalid_paths():
26+
"""Test that invalid SCIM paths are rejected."""
27+
invalid_paths = [
28+
"", # Empty string
29+
" ", # Whitespace only
30+
"123invalid", # Starts with digit
31+
"invalid..path", # Double dots
32+
"invalid@path", # Invalid character
33+
"urn:invalid", # Invalid URN format
34+
"urn:too:short", # URN too short
35+
]
36+
37+
for path in invalid_paths:
38+
assert not validate_scim_path_syntax(path), f"Path should be invalid: {path}"
39+
40+
41+
def test_validate_scim_urn_syntax_valid_urns():
42+
"""Test that valid SCIM URN paths are accepted."""
43+
valid_urns = [
44+
"urn:ietf:params:scim:schemas:core:2.0:User:userName",
45+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber",
46+
"urn:custom:namespace:schema:1.0:Resource:attribute",
47+
"urn:example:extension:v2:MyResource:customField",
48+
]
49+
50+
for urn in valid_urns:
51+
assert validate_scim_urn_syntax(urn), f"URN should be valid: {urn}"
52+
53+
54+
def test_validate_scim_urn_syntax_invalid_urns():
55+
"""Test that invalid SCIM URN paths are rejected."""
56+
invalid_urns = [
57+
"not_an_urn", # Doesn't start with urn:
58+
"urn:too:short", # Not enough segments
59+
"urn:ietf:params:scim:schemas:core:2.0:User:", # Empty attribute
60+
"urn:ietf:params:scim:schemas:core:2.0:User:123invalid", # Attribute starts with digit
61+
"urn:invalid", # Too short
62+
"urn:only:two:attribute", # URN part too short
63+
]
64+
65+
for urn in invalid_urns:
66+
assert not validate_scim_urn_syntax(urn), f"URN should be invalid: {urn}"
67+
68+
69+
def test_validate_scim_path_syntax_edge_cases():
70+
"""Test edge cases for path validation."""
71+
# Test None handling (shouldn't happen in practice but defensive)
72+
assert not validate_scim_path_syntax("")
73+
74+
# Test borderline valid cases
75+
assert validate_scim_path_syntax("a") # Single character
76+
assert validate_scim_path_syntax("a.b") # Simple dotted
77+
assert validate_scim_path_syntax("a_b") # Underscore
78+
assert validate_scim_path_syntax("a-b") # Hyphen
79+
80+
# Test borderline invalid cases
81+
assert not validate_scim_path_syntax("9invalid") # Starts with digit
82+
assert not validate_scim_path_syntax("a..b") # Double dots
83+
84+
85+
def test_validate_scim_urn_syntax_edge_cases():
86+
"""Test edge cases for URN validation."""
87+
# Test minimal valid URN
88+
assert validate_scim_urn_syntax("urn:a:b:c:d")
89+
90+
# Test boundary cases
91+
assert not validate_scim_urn_syntax("urn:a:b:c:") # Empty attribute
92+
assert not validate_scim_urn_syntax("urn:a:b:") # Missing resource
93+
assert not validate_scim_urn_syntax("urn:") # Just urn:

0 commit comments

Comments
 (0)