Tutorial

Attribute access

SCIM resources support two ways to access and modify attributes. The standard Python dot notation uses snake_case attribute names, while the bracket notation accepts SCIM paths as defined in RFC7644 §3.10.

>>> from scim2_models import User

>>> user = User(user_name="bjensen")
>>> user.display_name = "Barbara Jensen"
>>> user["nickName"] = "Babs"
>>> user["name.familyName"] = "Jensen"

Attributes can be removed with del or by assigning None to the attribute.

>>> del user["nickName"]
>>> user.nick_name is None
True

Model parsing

Use Pydantic’s model_validate() method to parse and validate SCIM2 payloads.

>>> from scim2_models import User
>>> import datetime

>>> payload = {
...     "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
...     "id": "2819c223-7f76-453a-919d-413861904646",
...     "userName": "[email protected]",
...     "meta": {
...         "resourceType": "User",
...         "created": "2010-01-23T04:56:22Z",
...         "lastModified": "2011-05-13T04:42:34Z",
...         "version": 'W\\/"3694e05e9dff590"',
...         "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
...     },
... }

>>> user = User.model_validate(payload)
>>> user.user_name
'[email protected]'
>>> user.meta.created
datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=...)

Model serialization

Pydantic model_dump() method has been tuned to produce valid SCIM2 payloads.

>>> from scim2_models import User, Meta
>>> import datetime

>>> user = User(
...     id="2819c223-7f76-453a-919d-413861904646",
...     user_name="[email protected]",
...     meta=Meta(
...         resource_type="User",
...         created=datetime.datetime(2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc),
...         last_modified=datetime.datetime(2011, 5, 13, 4, 42, 34, tzinfo=datetime.timezone.utc),
...         version='W\\/"3694e05e9dff590"',
...         location="https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
...     ),
... )

>>> dump = user.model_dump()
>>> assert dump == {
...     "schemas": [
...         "urn:ietf:params:scim:schemas:core:2.0:User"
...     ],
...     "id": "2819c223-7f76-453a-919d-413861904646",
...     "meta": {
...         "resourceType": "User",
...         "created": "2010-01-23T04:56:22Z",
...         "lastModified": "2011-05-13T04:42:34Z",
...         "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
...         "version": "W\\/\"3694e05e9dff590\""
...     },
...     "userName": "[email protected]"
... }

Contexts

The SCIM specifications detail some Mutability and Returned parameters for model attributes. Depending on the context, they will indicate that attributes should be present, absent, or ignored.

For instance, attributes marked as read_only should not be sent by SCIM clients on resource creation requests. By passing the right Context to the model_dump() method, only the expected fields will be dumped for this context:

Client generating a resource creation request payload
>>> from scim2_models import User, Context
>>> user = User(user_name="[email protected]")
>>> payload = user.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)

In the same fashion, by passing the right Context to the model_validate() method, fields with unexpected values will raise ValidationError:

Server validating a resource creation request payload
>>> from scim2_models import User, Context, Error
>>> from pydantic import ValidationError
>>> try:
...    obj = User.model_validate(payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
... except ValidationError:
...    obj = Error(...)

Context annotations

Context type aliases

scim2-models provides generic type aliases that wrap SCIMValidator and SCIMSerializer for each SCIM context. *RequestContext aliases inject the context during validation, *ResponseContext aliases during serialization:

>>> from pydantic import TypeAdapter
>>> from scim2_models import User, CreationRequestContext, CreationResponseContext

>>> adapter = TypeAdapter(CreationRequestContext[User])
>>> user = adapter.validate_python({
...     "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
...     "userName": "bjensen",
...     "id": "should-be-stripped",
... })
>>> user.id is None
True

>>> adapter = TypeAdapter(CreationResponseContext[User])
>>> user.id = "123"
>>> data = adapter.dump_python(user)
>>> "password" not in data
True

In FastAPI for instance, they can be used directly in endpoint signatures:

from scim2_models import CreationRequestContext, CreationResponseContext, User

@router.post("/Users", status_code=201)
async def create_user(
    user: CreationRequestContext[User],
) -> CreationResponseContext[User]:
    ...

See the FastAPI guide for a complete example.

Note

*ResponseContext aliases do not support the attributes / excludedAttributes parameters defined in RFC 7644 §3.9. When you need to forward those parameters, use model_dump_json explicitly instead.

Low-level markers

For more advanced usage, the underlying markers can be used directly with typing.Annotated:

>>> from typing import Annotated
>>> from pydantic import TypeAdapter
>>> from scim2_models import User, Context, SCIMSerializer

>>> adapter = TypeAdapter(
...     Annotated[User, SCIMSerializer(Context.RESOURCE_QUERY_RESPONSE)]
... )
>>> user = User(user_name="bjensen", password="secret")
>>> user.id = "123"
>>> data = adapter.dump_python(user)
>>> "password" not in data
True

Attributes inclusions and exclusions

In some situations it might be needed to exclude, or only include a given set of attributes when serializing a model. This happens for instance when servers build response payloads for clients requesting only a subset of the model attributes. As defined in RFC7644 §3.9, attributes and excluded_attributes parameters can be passed to model_dump(). The expected attribute notation is the one detailed on RFC7644 §3.10, like urn:ietf:params:scim:schemas:core:2.0:User:userName, or userName for short.

>>> from scim2_models import User, Context
>>> user = User(user_name="[email protected]", display_name="bjensen")
>>> payload = user.model_dump(
...     scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
...     excluded_attributes=["displayName"]
... )
>>> assert payload == {
...     "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
...     "userName": "[email protected]",
... }

Values read from attributes and excluded_attributes in SearchRequest objects can directly be used in model_dump().

Attributes inclusions and exclusions interact with attributes Returned, in the server response Contexts:

  • attributes annotated with always will always be dumped;

  • attributes annotated with never will never be dumped;

  • attributes annotated with default will be dumped unless being explicitly excluded;

  • attributes annotated with request will not be dumped unless being explicitly included.

Typed ListResponse

ListResponse models take a type or a Union of types. You must pass the type you expect in the response, e.g. ListResponse[User] or ListResponse[Union[User, Group]]. If a response resource type cannot be found, a pydantic.ValidationError will be raised.

>>> from typing import Union
>>> from scim2_models import User, Group, ListResponse

>>> payload = {
...     "totalResults": 2,
...     "itemsPerPage": 10,
...     "startIndex": 1,
...     "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
...     "Resources": [
...         {
...             "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
...             "id": "2819c223-7f76-453a-919d-413861904646",
...             "userName": "[email protected]",
...             "meta": {
...                 "resourceType": "User",
...                 "created": "2010-01-23T04:56:22Z",
...                 "lastModified": "2011-05-13T04:42:34Z",
...                 "version": 'W\\/"3694e05e9dff590"',
...                 "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
...             },
...         },
...         {
...             "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
...             "id": "e9e30dba-f08f-4109-8486-d5c6a331660a",
...             "displayName": "Tour Guides",
...             "members": [
...                 {
...                     "value": "2819c223-7f76-453a-919d-413861904646",
...                     "$ref": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646",
...                     "display": "Babs Jensen",
...                 },
...                 {
...                     "value": "902c246b-6245-4190-8e05-00816be7344a",
...                     "$ref": "https://example.com/v2/Users/902c246b-6245-4190-8e05-00816be7344a",
...                     "display": "Mandy Pepperidge",
...                 },
...             ],
...             "meta": {
...                 "resourceType": "Group",
...                 "created": "2010-01-23T04:56:22Z",
...                 "lastModified": "2011-05-13T04:42:34Z",
...                 "version": 'W\\/"3694e05e9dff592"',
...                 "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a",
...             },
...         },
...     ],
... }

>>> response = ListResponse[Union[User, Group]].model_validate(payload)
>>> user, group = response.resources
>>> type(user)
<class 'scim2_models.resources.user.User'>
>>> type(group)
<class 'scim2_models.resources.group.Group'>

Schema extensions

RFC7643 §3.3 extensions are supported. Any class inheriting from Extension can be passed as a Resource type parameter, e.g. user = User[EnterpriseUser] or user = User[Union[EnterpriseUser, SuperHero]]. Extensions attributes are accessed with brackets, e.g. user[EnterpriseUser].employee_number, where user[EnterpriseUser] is a shortcut for user["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].

>>> import datetime
>>> from scim2_models import User, EnterpriseUser, Meta

>>> user = User[EnterpriseUser](
...     id="2819c223-7f76-453a-919d-413861904646",
...     user_name="[email protected]",
...     meta=Meta(
...         resource_type="User",
...         created=datetime.datetime(
...             2010, 1, 23, 4, 56, 22, tzinfo=datetime.timezone.utc
...         ),
...     ),
... )

>>> user[EnterpriseUser] = EnterpriseUser(employee_number = "701984")
>>> user[EnterpriseUser].division="Theme Park"
>>> dump = user.model_dump()
>>> assert dump == {
...     "schemas": [
...         "urn:ietf:params:scim:schemas:core:2.0:User",
...         "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
...     ],
...     "id": "2819c223-7f76-453a-919d-413861904646",
...     "meta": {
...         "resourceType": "User",
...         "created": "2010-01-23T04:56:22Z"
...     },
...     "userName": "[email protected]",
...     "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
...         "employeeNumber": "701984",
...         "division": "Theme Park",
...     }
... }

Errors and Exceptions

scim2-models provides a hierarchy of exceptions corresponding to RFC7644 §3.12 error types. Each exception can be converted to an Error response object or used in Pydantic validators.

Raising exceptions

Exceptions are named after their scimType value:

>>> from scim2_models import InvalidPathException, PathNotFoundException

>>> raise InvalidPathException(path="invalid..path")
Traceback (most recent call last):
    ...
scim2_models.exceptions.InvalidPathException: The path attribute was invalid or malformed

>>> raise PathNotFoundException(path="unknownAttr")
Traceback (most recent call last):
    ...
scim2_models.exceptions.PathNotFoundException: The specified path references a non-existent field

Converting to Error response

Use to_error() to convert an exception to an Error response:

>>> from scim2_models import InvalidPathException

>>> exc = InvalidPathException(path="invalid..path")
>>> error = exc.to_error()
>>> error.status
400
>>> error.scim_type
'invalidPath'

Converting from ValidationError

Use Error.from_validation_error to convert a single Pydantic error to an Error:

>>> from pydantic import ValidationError
>>> from scim2_models import Error, User
>>> from scim2_models.base import Context

>>> try:
...     User.model_validate({"userName": None}, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
... except ValidationError as exc:
...     error = Error.from_validation_error(exc.errors()[0])
>>> error.scim_type
'invalidValue'

Use Error.from_validation_errors to convert all errors at once:

>>> try:
...     User.model_validate({"userName": 123, "displayName": 456})
... except ValidationError as exc:
...     errors = Error.from_validation_errors(exc)
>>> len(errors)
2
>>> [e.detail for e in errors]
['Input should be a valid string: username', 'Input should be a valid string: displayname']

The exhaustive list of exceptions is available in the reference.

Custom models

You can write your own model and use it the same way as the other scim2-models models. Just inherit from Resource for your main resource, or Extension for extensions. Use ComplexAttribute as base class for complex attributes:

>>> from typing import Annotated, Optional
>>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute, URN
>>> from enum import Enum

>>> class PetType(ComplexAttribute):
...     type: Optional[str]
...     """The pet type like 'cat' or 'dog'."""
...
...     color: Optional[str]
...     """The pet color."""

>>> class Pet(Resource):
...     __schema__ = URN("urn:example:schemas:Pet")
...
...     name: Annotated[Optional[str], Mutability.immutable, Returned.always]
...     """The name of the pet."""
...
...     pet_type: Optional[PetType]
...     """The pet type."""

You can annotate fields to indicate their Mutability and Returned. If unset the default values will be read_write and default.

Warning

Be sure to make all the fields of your model Optional. There will always be a Context in which this will be true.

There is a dedicated type for RFC7643 §2.3.7 Reference that can take type parameters to represent RFC7643 §7 ‘referenceTypes’:

>>> from scim2_models import Reference
>>> class PetOwner(Resource):
...    pet: Optional[Reference["Pet"]]

Reference has two special type parameters External and URI that matches RFC7643 §7 external and URI reference types.

Dynamic schemas from models

With Resource.to_schema and Extension.to_schema, any model can be exported as a Schema object. This is useful for server implementations, so custom models or models provided by scim2-models can easily be exported on the /Schemas endpoint.

>>> from scim2_models import Resource, URN

>>> class MyCustomResource(Resource):
...     """My awesome custom schema."""
...
...     __schema__ = URN("urn:example:schemas:MyCustomResource")
...
...     foobar: Optional[str]
...
>>> schema = MyCustomResource.to_schema()
>>> dump = schema.model_dump()
>>> assert dump == {
...     "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
...     "id": "urn:example:schemas:MyCustomResource",
...     "name": "MyCustomResource",
...     "description": "My awesome custom schema.",
...     "attributes": [
...         {
...             "caseExact": False,
...              "multiValued": False,
...              "mutability": "readWrite",
...              "name": "foobar",
...              "required": False,
...              "returned": "default",
...              "type": "string",
...              "uniqueness": "none",
...         },
...     ],
... }

Dynamic models from schemas

Given a Schema object, scim2-models can dynamically generate a pythonic model to be used in your code with the Resource.from_schema and Extension.from_schema methods.

sample

Client applications can use this to dynamically discover server resources by browsing the /Schemas endpoint.

Tip

Sub-Attribute models are automatically created and set as members of their parent model classes. For instance the RFC7643 Group members sub-attribute can be accessed with Group.Members.

schema-group.json
{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
    "id": "urn:ietf:params:scim:schemas:core:2.0:Group",
    "name": "Group",
    "description": "Group",
    "attributes": [
        {
            "name": "displayName",
            "type": "string",
            "multiValued": false,
            "description": "A human-readable name for the Group. REQUIRED.",
            "required": true,
            "caseExact": false,
            "mutability": "readWrite",
            "returned": "default",
            "uniqueness": "none"
        },
        {
            "name": "members",
            "type": "complex",
            "multiValued": true,
            "description": "A list of members of the Group.",
            "required": false,
            "subAttributes": [
                {
                    "name": "value",
                    "type": "string",
                    "multiValued": false,
                    "description": "Identifier of the member of this Group.",
                    "required": false,
                    "caseExact": false,
                    "mutability": "immutable",
                    "returned": "default",
                    "uniqueness": "none"
                },
                {
                    "name": "$ref",
                    "type": "reference",
                    "referenceTypes": [
                        "User",
                        "Group"
                    ],
                    "multiValued": false,
                    "description": "The URI corresponding to a SCIM resource that is a member of this Group.",
                    "required": false,
                    "caseExact": false,
                    "mutability": "immutable",
                    "returned": "default",
                    "uniqueness": "none"
                },
                {
                    "name": "type",
                    "type": "string",
                    "multiValued": false,
                    "description": "A label indicating the type of resource, e.g., 'User' or 'Group'.",
                    "required": false,
                    "caseExact": false,
                    "canonicalValues": [
                        "User",
                        "Group"
                    ],
                    "mutability": "immutable",
                    "returned": "default",
                    "uniqueness": "none"
                },
                {
                    "name": "display",
                    "type": "string",
                    "multiValued": false,
                    "description": "A human-readable name for the group member, primarily used for display purposes.",
                    "required": false,
                    "caseExact": false,
                    "mutability": "readOnly",
                    "returned": "default",
                    "uniqueness": "none"
                }
            ],
            "mutability": "readWrite",
            "returned": "default"
        }
    ],
    "meta": {
        "resourceType": "Schema",
        "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group"
    }
}

Replace operations

When handling a PUT request, validate the incoming payload with the RESOURCE_REPLACEMENT_REQUEST context, then call replace() against the existing resource to verify that immutable attributes have not been modified.

>>> from scim2_models import User, Context
>>> existing = User(user_name="bjensen")
>>> replacement = User.model_validate(
...     {"userName": "bjensen"},
...     scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
... )
>>> replacement.replace(existing)

If an immutable attribute differs, a MutabilityException is raised.

Patch operations

PatchOp allows you to apply patch operations to modify SCIM resources. The patch() method applies operations in sequence and returns whether the resource was modified. The return code is a boolean indicating whether the object has been modified by the operations.

Note

PatchOp takes a type parameter that should be the class of the resource that is expected to be patched.

>>> from scim2_models import User, PatchOp, PatchOperation
>>> user = User(user_name="john.doe", nick_name="Johnny")

>>> payload = {
...   "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
...   "Operations": [
...     {"op": "replace", "path": "nickName", "value": "John" },
...     {"op": "add", "path": "emails", "value": [{"value": "[email protected]"}]},
...   ]
... }
>>> patch = PatchOp[User].model_validate(
...     payload, scim_ctx=Context.RESOURCE_PATCH_REQUEST
... )

>>> modified = patch.patch(user)
>>> print(modified)
True
>>> print(user.nick_name)
John
>>> print(user.emails[0].value)
[email protected]

Warning

Patch operations are validated in the RESOURCE_PATCH_REQUEST context. Make sure to validate patch operations with the correct context to ensure proper validation of mutability and required constraints.

Bulk operations

Todo

Bulk operations are not implemented yet, but any help is welcome!