Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions scim2_models/rfc7644/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from pydantic import Tag
from pydantic._internal._model_construction import ModelMetaclass

from scim2_models.rfc7643.resource import Resource

from ..base import BaseModel
from ..scim_object import ScimObject
from ..utils import UNION_TYPES
Expand Down Expand Up @@ -108,3 +110,16 @@ def __new__(

klass = super().__new__(cls, name, bases, attrs, **kwargs)
return klass


def get_resource_class(obj) -> Optional[type[Resource]]:
"""Extract the resource class from generic type parameter."""
metadata = getattr(obj.__class__, "__pydantic_generic_metadata__", None)
if not metadata or not metadata.get("args"):
return None

resource_class = metadata["args"][0]
if isinstance(resource_class, type) and issubclass(resource_class, Resource):
return resource_class

return None
109 changes: 94 additions & 15 deletions scim2_models/rfc7644/patch_op.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
from enum import Enum
from typing import Annotated
from typing import Any
from typing import Generic
from typing import Optional

from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from typing_extensions import Self

from ..annotations import Mutability
from ..annotations import Required
from ..attributes import ComplexAttribute
from ..base import BaseModel
from ..rfc7643.resource import AnyResource
from ..utils import extract_field_name
from ..utils import validate_scim_path_syntax
from .error import Error
from .message import Message
from .message import get_resource_class


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

@model_validator(mode="after")
def validate_path(self) -> Self:
# The "path" attribute value is a String containing an attribute path
# describing the target of the operation. The "path" attribute is
# OPTIONAL for "add" and "replace" and is REQUIRED for "remove"
# operations. See relevant operation sections below for details.
@field_validator("path")
@classmethod
def validate_path_syntax(cls, v: Optional[str]) -> Optional[str]:
"""Validate path syntax according to RFC 7644 ABNF grammar (simplified)."""
if v is None:
return v

# RFC 7644 Section 3.5.2: Path syntax validation according to ABNF grammar
if not validate_scim_path_syntax(v):
raise ValueError(Error.make_invalid_path_error().detail)

return v

def _validate_mutability(
self, resource_class: type[BaseModel], field_name: str
) -> None:
"""Validate mutability constraints."""
# RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions
if field_name not in resource_class.model_fields:
return

mutability = resource_class.get_field_annotation(field_name, Mutability)

# RFC 7643 Section 7: Attributes with mutability "readOnly" SHALL NOT be modified
if mutability == Mutability.read_only:
if self.op in (PatchOperation.Op.add, PatchOperation.Op.replace_):
raise ValueError(Error.make_mutability_error().detail)

# RFC 7643 Section 7: Attributes with mutability "immutable" SHALL NOT be updated
elif mutability == Mutability.immutable:
if self.op == PatchOperation.Op.replace_:
raise ValueError(Error.make_mutability_error().detail)

def _validate_required_attribute(
self, resource_class: type[BaseModel], field_name: str
) -> None:
"""Validate required attribute constraints for remove operations."""
# RFC 7644 Section 3.5.2.3: Only validate for remove operations
if self.op != PatchOperation.Op.remove:
return

# RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions
if field_name not in resource_class.model_fields:
return

required = resource_class.get_field_annotation(field_name, Required)

# RFC 7643 Section 7: Required attributes SHALL NOT be removed
if required == Required.true:
raise ValueError(Error.make_invalid_value_error().detail)

@model_validator(mode="after")
def validate_operation_requirements(self) -> Self:
"""Validate operation requirements according to RFC 7644."""
# RFC 7644 Section 3.5.2.3: Path is required for remove operations
if self.path is None and self.op == PatchOperation.Op.remove:
raise ValueError("Op.path is required for remove operations")
raise ValueError(Error.make_invalid_value_error().detail)

# RFC 7644 Section 3.5.2.1: Value is required for "add" operations
if self.op == PatchOperation.Op.add and self.value is None:
raise ValueError(Error.make_invalid_value_error().detail)

return self

Expand All @@ -51,7 +111,7 @@ def validate_path(self) -> Self:
@field_validator("op", mode="before")
@classmethod
def normalize_op(cls, v: Any) -> Any:
"""Ignorecase for op.
"""Ignore case for op.

This brings
`compatibility with Microsoft Entra <https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#general>`_:
Expand All @@ -65,13 +125,8 @@ def normalize_op(cls, v: Any) -> Any:
return v


class PatchOp(Message):
"""Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.

.. todo::

The models for Patch operations are defined, but their behavior is not implemented nor tested yet.
"""
class PatchOp(Message, Generic[AnyResource]):
"""Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`."""

schemas: Annotated[list[str], Required.true] = [
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
Expand All @@ -82,3 +137,27 @@ class PatchOp(Message):
)
"""The body of an HTTP PATCH request MUST contain the attribute
"Operations", whose value is an array of one or more PATCH operations."""

@model_validator(mode="after")
def validate_operations(self) -> Self:
"""Validate operations against resource type metadata if available.

When PatchOp is used with a specific resource type (e.g., PatchOp[User]),
this validator will automatically check mutability and required constraints.
"""
resource_class = get_resource_class(self)
if resource_class is None or not self.operations:
return self

for operation in self.operations:
if operation.path is None:
continue

field_name = extract_field_name(operation.path)
if field_name is None:
continue

operation._validate_mutability(resource_class, field_name)
operation._validate_required_attribute(resource_class, field_name)

return self
27 changes: 27 additions & 0 deletions scim2_models/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,30 @@ def validate_scim_urn_syntax(path: str) -> bool:
return False

return True


def extract_field_name(path: str) -> Optional[str]:
"""Extract the field name from a path.

For now, only handle simple paths (no filters, no complex expressions).
Returns None for complex paths that require filter parsing.

"""
# Handle URN paths
if path.startswith("urn:"):
# First validate it's a proper URN
if not validate_scim_urn_syntax(path):
return None
parts = path.rsplit(":", 1)
return parts[1]

# Handle simple paths (no brackets, no filters)
if "[" in path or "]" in path:
return None # Complex filter path, not handled

# Simple attribute path (may have dots for sub-attributes)
# For now, just take the first part before any dot
if "." in path:
return path.split(".")[0]

return path
Loading
Loading