1
1
from enum import Enum
2
2
from typing import Annotated
3
3
from typing import Any
4
+ from typing import Generic
4
5
from typing import Optional
5
6
6
7
from pydantic import Field
7
8
from pydantic import field_validator
8
9
from pydantic import model_validator
9
10
from typing_extensions import Self
10
11
12
+ from ..annotations import Mutability
11
13
from ..annotations import Required
12
14
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
13
20
from .message import Message
21
+ from .message import get_resource_class
14
22
15
23
16
24
class PatchOperation (ComplexAttribute ):
@@ -34,15 +42,67 @@ class Op(str, Enum):
34
42
"""The "path" attribute value is a String containing an attribute path
35
43
describing the target of the operation."""
36
44
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 )
43
67
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
44
100
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 )
46
106
47
107
return self
48
108
@@ -51,7 +111,7 @@ def validate_path(self) -> Self:
51
111
@field_validator ("op" , mode = "before" )
52
112
@classmethod
53
113
def normalize_op (cls , v : Any ) -> Any :
54
- """Ignorecase for op.
114
+ """Ignore case for op.
55
115
56
116
This brings
57
117
`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:
65
125
return v
66
126
67
127
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>`."""
75
130
76
131
schemas : Annotated [list [str ], Required .true ] = [
77
132
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
@@ -82,3 +137,27 @@ class PatchOp(Message):
82
137
)
83
138
"""The body of an HTTP PATCH request MUST contain the attribute
84
139
"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
0 commit comments