11from enum import Enum
22from typing import Annotated
33from typing import Any
4+ from typing import Generic
45from typing import Optional
56
67from pydantic import Field
78from pydantic import field_validator
89from pydantic import model_validator
910from typing_extensions import Self
1011
12+ from ..annotations import Mutability
1113from ..annotations import Required
1214from ..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
1320from .message import Message
21+ from .message import get_resource_class
1422
1523
1624class 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
0 commit comments