Skip to content

Commit f489fb1

Browse files
authored
Merge pull request #98 from python-scim/prepatch
Implement PatchOp validation checks
2 parents 875c7ea + cbe3142 commit f489fb1

File tree

5 files changed

+596
-16
lines changed

5 files changed

+596
-16
lines changed

scim2_models/rfc7644/message.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from pydantic import Tag
1111
from pydantic._internal._model_construction import ModelMetaclass
1212

13+
from scim2_models.rfc7643.resource import Resource
14+
1315
from ..base import BaseModel
1416
from ..scim_object import ScimObject
1517
from ..utils import UNION_TYPES
@@ -108,3 +110,16 @@ def __new__(
108110

109111
klass = super().__new__(cls, name, bases, attrs, **kwargs)
110112
return klass
113+
114+
115+
def get_resource_class(obj) -> Optional[type[Resource]]:
116+
"""Extract the resource class from generic type parameter."""
117+
metadata = getattr(obj.__class__, "__pydantic_generic_metadata__", None)
118+
if not metadata or not metadata.get("args"):
119+
return None
120+
121+
resource_class = metadata["args"][0]
122+
if isinstance(resource_class, type) and issubclass(resource_class, Resource):
123+
return resource_class
124+
125+
return None

scim2_models/rfc7644/patch_op.py

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
from enum import Enum
22
from typing import Annotated
33
from typing import Any
4+
from typing import Generic
45
from typing import Optional
56

67
from pydantic import Field
78
from pydantic import field_validator
89
from pydantic import model_validator
910
from typing_extensions import Self
1011

12+
from ..annotations import Mutability
1113
from ..annotations import Required
1214
from ..attributes import ComplexAttribute
15+
from ..base import BaseModel
16+
from ..rfc7643.resource import AnyResource
17+
from ..utils import extract_field_name
18+
from ..utils import validate_scim_path_syntax
19+
from .error import Error
1320
from .message import Message
21+
from .message import get_resource_class
1422

1523

1624
class PatchOperation(ComplexAttribute):
@@ -34,15 +42,67 @@ class Op(str, Enum):
3442
"""The "path" attribute value is a String containing an attribute path
3543
describing the target of the operation."""
3644

37-
@model_validator(mode="after")
38-
def validate_path(self) -> Self:
39-
# The "path" attribute value is a String containing an attribute path
40-
# describing the target of the operation. The "path" attribute is
41-
# OPTIONAL for "add" and "replace" and is REQUIRED for "remove"
42-
# operations. See relevant operation sections below for details.
45+
@field_validator("path")
46+
@classmethod
47+
def validate_path_syntax(cls, v: Optional[str]) -> Optional[str]:
48+
"""Validate path syntax according to RFC 7644 ABNF grammar (simplified)."""
49+
if v is None:
50+
return v
51+
52+
# RFC 7644 Section 3.5.2: Path syntax validation according to ABNF grammar
53+
if not validate_scim_path_syntax(v):
54+
raise ValueError(Error.make_invalid_path_error().detail)
55+
56+
return v
57+
58+
def _validate_mutability(
59+
self, resource_class: type[BaseModel], field_name: str
60+
) -> None:
61+
"""Validate mutability constraints."""
62+
# RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions
63+
if field_name not in resource_class.model_fields:
64+
return
65+
66+
mutability = resource_class.get_field_annotation(field_name, Mutability)
4367

68+
# RFC 7643 Section 7: Attributes with mutability "readOnly" SHALL NOT be modified
69+
if mutability == Mutability.read_only:
70+
if self.op in (PatchOperation.Op.add, PatchOperation.Op.replace_):
71+
raise ValueError(Error.make_mutability_error().detail)
72+
73+
# RFC 7643 Section 7: Attributes with mutability "immutable" SHALL NOT be updated
74+
elif mutability == Mutability.immutable:
75+
if self.op == PatchOperation.Op.replace_:
76+
raise ValueError(Error.make_mutability_error().detail)
77+
78+
def _validate_required_attribute(
79+
self, resource_class: type[BaseModel], field_name: str
80+
) -> None:
81+
"""Validate required attribute constraints for remove operations."""
82+
# RFC 7644 Section 3.5.2.3: Only validate for remove operations
83+
if self.op != PatchOperation.Op.remove:
84+
return
85+
86+
# RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions
87+
if field_name not in resource_class.model_fields:
88+
return
89+
90+
required = resource_class.get_field_annotation(field_name, Required)
91+
92+
# RFC 7643 Section 7: Required attributes SHALL NOT be removed
93+
if required == Required.true:
94+
raise ValueError(Error.make_invalid_value_error().detail)
95+
96+
@model_validator(mode="after")
97+
def validate_operation_requirements(self) -> Self:
98+
"""Validate operation requirements according to RFC 7644."""
99+
# RFC 7644 Section 3.5.2.3: Path is required for remove operations
44100
if self.path is None and self.op == PatchOperation.Op.remove:
45-
raise ValueError("Op.path is required for remove operations")
101+
raise ValueError(Error.make_invalid_value_error().detail)
102+
103+
# RFC 7644 Section 3.5.2.1: Value is required for "add" operations
104+
if self.op == PatchOperation.Op.add and self.value is None:
105+
raise ValueError(Error.make_invalid_value_error().detail)
46106

47107
return self
48108

@@ -51,7 +111,7 @@ def validate_path(self) -> Self:
51111
@field_validator("op", mode="before")
52112
@classmethod
53113
def normalize_op(cls, v: Any) -> Any:
54-
"""Ignorecase for op.
114+
"""Ignore case for op.
55115
56116
This brings
57117
`compatibility with Microsoft Entra <https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#general>`_:
@@ -65,13 +125,8 @@ def normalize_op(cls, v: Any) -> Any:
65125
return v
66126

67127

68-
class PatchOp(Message):
69-
"""Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
70-
71-
.. todo::
72-
73-
The models for Patch operations are defined, but their behavior is not implemented nor tested yet.
74-
"""
128+
class PatchOp(Message, Generic[AnyResource]):
129+
"""Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`."""
75130

76131
schemas: Annotated[list[str], Required.true] = [
77132
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
@@ -82,3 +137,27 @@ class PatchOp(Message):
82137
)
83138
"""The body of an HTTP PATCH request MUST contain the attribute
84139
"Operations", whose value is an array of one or more PATCH operations."""
140+
141+
@model_validator(mode="after")
142+
def validate_operations(self) -> Self:
143+
"""Validate operations against resource type metadata if available.
144+
145+
When PatchOp is used with a specific resource type (e.g., PatchOp[User]),
146+
this validator will automatically check mutability and required constraints.
147+
"""
148+
resource_class = get_resource_class(self)
149+
if resource_class is None or not self.operations:
150+
return self
151+
152+
for operation in self.operations:
153+
if operation.path is None:
154+
continue
155+
156+
field_name = extract_field_name(operation.path)
157+
if field_name is None:
158+
continue
159+
160+
operation._validate_mutability(resource_class, field_name)
161+
operation._validate_required_attribute(resource_class, field_name)
162+
163+
return self

scim2_models/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,30 @@ def validate_scim_urn_syntax(path: str) -> bool:
156156
return False
157157

158158
return True
159+
160+
161+
def extract_field_name(path: str) -> Optional[str]:
162+
"""Extract the field name from a path.
163+
164+
For now, only handle simple paths (no filters, no complex expressions).
165+
Returns None for complex paths that require filter parsing.
166+
167+
"""
168+
# Handle URN paths
169+
if path.startswith("urn:"):
170+
# First validate it's a proper URN
171+
if not validate_scim_urn_syntax(path):
172+
return None
173+
parts = path.rsplit(":", 1)
174+
return parts[1]
175+
176+
# Handle simple paths (no brackets, no filters)
177+
if "[" in path or "]" in path:
178+
return None # Complex filter path, not handled
179+
180+
# Simple attribute path (may have dots for sub-attributes)
181+
# For now, just take the first part before any dot
182+
if "." in path:
183+
return path.split(".")[0]
184+
185+
return path

0 commit comments

Comments
 (0)