From 0d784f8179eddcd823233c22f9f17872337dc57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Fri, 11 Sep 2026 09:38:23 +0200 Subject: [PATCH 1/2] feat: implement ScimProvider --- doc/changelog.rst | 15 + doc/how-to/describe-a-scim-service.rst | 194 +++++++++ doc/how-to/generate-models-from-schemas.rst | 4 + doc/how-to/index.rst | 1 + doc/integrations/_examples/django_example.py | 10 +- doc/integrations/_examples/fastapi_example.py | 10 +- doc/integrations/_examples/flask_example.py | 10 +- doc/integrations/_examples/integrations.py | 61 ++- doc/integrations/django.rst | 9 +- doc/integrations/fastapi.rst | 9 +- doc/integrations/flask.rst | 9 +- doc/integrations/helpers.rst | 11 +- doc/reference.rst | 14 +- scim2_models/__init__.py | 6 + scim2_models/provider.py | 309 +++++++++++++ tests/test_provider.py | 412 ++++++++++++++++++ 16 files changed, 1014 insertions(+), 70 deletions(-) create mode 100644 doc/how-to/describe-a-scim-service.rst create mode 100644 scim2_models/provider.py create mode 100644 tests/test_provider.py diff --git a/doc/changelog.rst b/doc/changelog.rst index f729b54d..e1902bf4 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -32,6 +32,21 @@ Added :meth:`ResourceType.from_resource ` publishes the necessity under ``schemaExtensions.required``, which :rfc:`RFC7643 §6 <7643#section-6>` defines. See :doc:`how-to/define-custom-models`. :issue:`105` +- :class:`~scim2_models.ScimProvider` describes a SCIM service: the models it builds its + resources from, and the :class:`~scim2_models.Schema`, :class:`~scim2_models.ResourceType` and + :class:`~scim2_models.ServiceProviderConfig` objects its discovery endpoints answer + (:rfc:`RFC7644 §4 <7644#section-4>`). Its ``models`` hold bare resources and extensions alike, + and its ``resource_types`` bind them as :rfc:`RFC7643 §6 <7643#section-6>` defines: a name or + an endpoint answers the composed model, a schema URI answers the bare one. Two resource types + may therefore share a base schema and differ by the extensions they carry, each served under + its own endpoint. A service whose models share a schema, whose resource types share a name or + an endpoint, or whose resource type names a schema no model describes, is refused with + :class:`~scim2_models.ScimProviderError`. See :doc:`how-to/describe-a-scim-service`. + :issue:`108` +- :meth:`ScimProvider.from_discovery ` builds a + provider from what a service publishes, turning each resource type into a model carrying the + extensions it declares, required ones included. A resource type naming a schema the service + does not publish is refused with an error naming that schema. - lark is a new dependency. Changed diff --git a/doc/how-to/describe-a-scim-service.rst b/doc/how-to/describe-a-scim-service.rst new file mode 100644 index 00000000..b838ef44 --- /dev/null +++ b/doc/how-to/describe-a-scim-service.rst @@ -0,0 +1,194 @@ +Describe a SCIM service +======================= + +Use this guide when an application needs to know, in one place, which resources a SCIM service +serves and what it supports. A :class:`~scim2_models.ScimProvider` describes both, whether the +application exposes the service or queries it. + +Describe the service you expose +------------------------------- + +A :class:`~scim2_models.ScimProvider` holds the attributes that describe a service. ``models`` +and ``resource_types`` cover the resources it serves and the endpoints it serves them under. + +``models`` is the catalogue of what the service can build: bare resources and extensions, each +identified by its schema URI. ``resource_types`` binds extensions to a resource and gives it an +endpoint, as :rfc:`RFC7643 §6 <7643#section-6>` describes. Build one from a parameterized model +with :meth:`ResourceType.from_resource `, which supplies +default values: + +.. doctest:: + + >>> from scim2_models import EnterpriseUser, Group, ResourceType, ScimProvider, User + >>> provider = ScimProvider( + ... models=[User, EnterpriseUser, Group], + ... resource_types=[ + ... ResourceType.from_resource(User[EnterpriseUser]), + ... ResourceType.from_resource(Group), + ... ], + ... ) + >>> for schema in provider.schemas: + ... print(schema.id) + urn:ietf:params:scim:schemas:core:2.0:User + urn:ietf:params:scim:schemas:extension:enterprise:2.0:User + urn:ietf:params:scim:schemas:core:2.0:Group + +The provider composes the two, and an endpoint serves the composed model. Look it up by the name +the resource type declares, the name ``meta.resourceType`` carries on every resource +(:rfc:`RFC7643 §6 <7643#section-6>`). Those are the defaults at work: here the name is ``"User"``, +the last segment of the schema URI, and the endpoint is ``/Users``, that segment with an ``s`` +appended. Write the :class:`~scim2_models.ResourceType` yourself when a service publishes +something else. + +.. doctest:: + + >>> provider.model_for("User") is User[EnterpriseUser] + True + +Leave ``resource_types`` out for a service whose resources carry no extension: the provider +derives one per resource. + +Announce what the service supports +---------------------------------- + +``config`` is the ``/ServiceProviderConfig`` response: which operations the service supports, and +within which bounds (:rfc:`RFC7644 §4 <7644#section-4>`). Pass it alongside the models: + +.. doctest:: + + >>> from scim2_models import Filter, Patch, ServiceProviderConfig + >>> config = ServiceProviderConfig( + ... patch=Patch(supported=True), + ... filter=Filter(supported=True, max_results=200), + ... ) + >>> provider = ScimProvider( + ... models=[User, EnterpriseUser, Group], + ... resource_types=[ + ... ResourceType.from_resource(User[EnterpriseUser]), + ... ResourceType.from_resource(Group), + ... ], + ... config=config, + ... ) + >>> provider.config.filter.max_results + 200 + +Serve two variants of one resource +---------------------------------- + +Two resource types may share a base schema, declare different extensions, and serve them under +different endpoints. Their resources carry identical ``schemas``, and ``meta.resourceType`` tells +them apart (:rfc:`RFC7643 §3 <7643#section-3>`): + +.. doctest:: + + >>> from scim2_models import URI, Reference, SchemaExtension + >>> staff = ResourceType( + ... id="Staff", + ... name="Staff", + ... endpoint=Reference[URI]("/Staff"), + ... schema_=Reference[URI](str(User.__schema__)), + ... schema_extensions=[ + ... SchemaExtension( + ... schema_=Reference[URI](str(EnterpriseUser.__schema__)), + ... required=True, + ... ) + ... ], + ... ) + >>> provider = ScimProvider( + ... models=[User, EnterpriseUser], + ... resource_types=[ResourceType.from_resource(User), staff], + ... config=config, + ... ) + >>> provider.model_for_endpoint("/Users") is User + True + >>> provider.model_for_endpoint("/Staff") is provider.model_for("Staff") + True + +Route a request to a model +-------------------------- + +:meth:`~scim2_models.ScimProvider.model_for` takes a resource type name or a schema URI, and +:meth:`~scim2_models.ScimProvider.model_for_endpoint` takes an endpoint. A name gives the composed +model, a schema URI gives the catalogue entry: the bare resource, or the extension the URI names. +Neither method takes the name of a Python class: + +.. doctest:: + + >>> provider.model_for("/Staff") is None + True + >>> provider.model_for(str(User.__schema__)) is User + True + >>> provider.model_for(str(EnterpriseUser.__schema__)) is EnterpriseUser + True + +Both methods return :data:`None` for an unknown key, which a server turns into a 404. Both also +resolve the three discovery resources, even though ``models`` never lists them: + +.. doctest:: + + >>> from scim2_models import Schema + >>> provider.model_for_endpoint("/Schemas") is Schema + True + >>> provider.model_for("Pet") is None + True + +Describe the service you query +------------------------------ + +A client queries the three discovery endpoints and validates each response, which gives it a +``ListResponse[Schema]``, a ``ListResponse[ResourceType]`` and a +:class:`~scim2_models.ServiceProviderConfig`. Pass the ``resources`` of the first two, and the +third one, to :meth:`~scim2_models.ScimProvider.from_discovery`: + +.. doctest:: + :hide: + + >>> schemas, resource_types, service_provider_config = ( + ... provider.schemas, + ... provider.resource_types, + ... provider.config, + ... ) + +.. doctest:: + + >>> discovered = ScimProvider.from_discovery( + ... schemas, resource_types, service_provider_config + ... ) + +Each schema becomes a model, a resource or an extension depending on how the resource types name +it, and the resource types bind them back together: + +.. doctest:: + + >>> model = discovered.model_for("Staff") + >>> list(model.get_extension_models()) + ['urn:ietf:params:scim:schemas:extension:enterprise:2.0:User'] + >>> discovered.config.filter.max_results + 200 + +The provider reads back an extension a resource type declares required, and the rebuilt model +refuses a creation request that leaves it out. See :doc:`define-custom-models` for what that +annotation means. + +When a resource type references a schema the service did not publish, +:meth:`~scim2_models.ScimProvider.from_discovery` raises +:class:`~scim2_models.ScimProviderError` and names the missing schema. + +Diagnose a refused provider +--------------------------- + +A provider validates its whole description at construction, since nothing can change afterwards. +Two models sharing a schema, or two resource types sharing a name or an endpoint, raise +:class:`~scim2_models.ScimProviderError`: one key would designate two models. + +Passing an already parameterized model raises it too. List the bare resource and its extensions +instead, and bind them with a resource type: + +.. doctest:: + + >>> from scim2_models import ScimProviderError + >>> try: + ... ScimProvider(models=[User[EnterpriseUser]]) + ... except ScimProviderError as exc: + ... print(exc) + User[EnterpriseUser] is already parameterized: list the bare resource and its extensions, and bind them with a resource type, as ResourceType.from_resource builds one diff --git a/doc/how-to/generate-models-from-schemas.rst b/doc/how-to/generate-models-from-schemas.rst index 3b2a9f33..cd967389 100644 --- a/doc/how-to/generate-models-from-schemas.rst +++ b/doc/how-to/generate-models-from-schemas.rst @@ -65,3 +65,7 @@ Parameterize the resource with the generated extension before parsing its payloa Keep the schema with the generated class. It remains the source of the schema URN and attribute characteristics used when the model validates or serializes a resource. + +:meth:`ScimProvider.from_discovery ` does all of this +at once for a whole service, pairing each schema with the resource type declaring it. See +:doc:`describe-a-scim-service`. diff --git a/doc/how-to/index.rst b/doc/how-to/index.rst index 26415f4b..ccba17cc 100644 --- a/doc/how-to/index.rst +++ b/doc/how-to/index.rst @@ -10,5 +10,6 @@ ends where an application resumes its own work, and assumes the :doc:`../overvie access-resource-values build-filters define-custom-models + describe-a-scim-service generate-models-from-schemas validate-and-serialize diff --git a/doc/integrations/_examples/django_example.py b/doc/integrations/_examples/django_example.py index b2b100fa..9e0c3c19 100644 --- a/doc/integrations/_examples/django_example.py +++ b/doc/integrations/_examples/django_example.py @@ -27,15 +27,13 @@ from .integrations import from_scim_user from .integrations import get_record from .integrations import get_resource_type -from .integrations import get_resource_types from .integrations import get_schema -from .integrations import get_schemas from .integrations import list_records from .integrations import list_group_records from .integrations import page_of from .integrations import make_etag from .integrations import save_record -from .integrations import service_provider_config +from .integrations import provider from .integrations import to_scim_user from .integrations import to_scim_group @@ -428,7 +426,7 @@ def get(self, request): except ValidationError as error: return scim_validation_error(error) - total, page = page_of(get_schemas(), req) + total, page = page_of(provider.schemas, req) response = ListResponse[Schema]( total_results=total, start_index=req.start_index or 1, @@ -469,7 +467,7 @@ def get(self, request): except ValidationError as error: return scim_validation_error(error) - total, page = page_of(get_resource_types(), req) + total, page = page_of(provider.resource_types, req) response = ListResponse[ResourceType]( total_results=total, start_index=req.start_index or 1, @@ -506,7 +504,7 @@ class ServiceProviderConfigView(SCIMView): def get(self, request): return SCIMJsonResponse( - service_provider_config.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + provider.config.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) ) diff --git a/doc/integrations/_examples/fastapi_example.py b/doc/integrations/_examples/fastapi_example.py index 30b760cf..32ca887b 100644 --- a/doc/integrations/_examples/fastapi_example.py +++ b/doc/integrations/_examples/fastapi_example.py @@ -35,15 +35,13 @@ from .integrations import from_scim_user from .integrations import get_record from .integrations import get_resource_type -from .integrations import get_resource_types from .integrations import get_schema -from .integrations import get_schemas from .integrations import list_group_records from .integrations import list_records from .integrations import page_of from .integrations import make_etag from .integrations import save_record -from .integrations import service_provider_config +from .integrations import provider from .integrations import to_scim_group from .integrations import to_scim_user @@ -340,7 +338,7 @@ async def create_user( @router.get("/Schemas") async def list_schemas(req: Annotated[SearchRequest[Schema], Query()]): """Return one page of SCIM schemas the server exposes.""" - total, page = page_of(get_schemas(), req) + total, page = page_of(provider.schemas, req) response = ListResponse[Schema]( total_results=total, start_index=req.start_index or 1, @@ -370,7 +368,7 @@ async def get_schema_by_id(schema_id: str): @router.get("/ResourceTypes") async def list_resource_types(req: Annotated[SearchRequest[ResourceType], Query()]): """Return one page of SCIM resource types the server exposes.""" - total, page = page_of(get_resource_types(), req) + total, page = page_of(provider.resource_types, req) response = ListResponse[ResourceType]( total_results=total, start_index=req.start_index or 1, @@ -404,7 +402,7 @@ async def get_service_provider_config() -> QueryResponseContext[ ServiceProviderConfig ]: """Return the SCIM service provider configuration.""" - return service_provider_config + return provider.config # -- service-provider-config-end -- # -- discovery-end -- diff --git a/doc/integrations/_examples/flask_example.py b/doc/integrations/_examples/flask_example.py index 1823dc78..a917ed4e 100644 --- a/doc/integrations/_examples/flask_example.py +++ b/doc/integrations/_examples/flask_example.py @@ -25,15 +25,13 @@ from .integrations import from_scim_user from .integrations import get_record from .integrations import get_resource_type -from .integrations import get_resource_types from .integrations import get_schema -from .integrations import get_schemas from .integrations import list_records from .integrations import page_of from .integrations import list_group_records from .integrations import make_etag from .integrations import save_record -from .integrations import service_provider_config +from .integrations import provider from .integrations import to_scim_user from .integrations import to_scim_group @@ -325,7 +323,7 @@ def create_user(): def list_schemas(): """Return one page of SCIM schemas the server exposes.""" req = SearchRequest[Schema].model_validate(request.args.to_dict()) - total, page = page_of(get_schemas(), req) + total, page = page_of(provider.schemas, req) response = ListResponse[Schema]( total_results=total, start_index=req.start_index or 1, @@ -352,7 +350,7 @@ def get_schema_by_id(schema_id): def list_resource_types(): """Return one page of SCIM resource types the server exposes.""" req = SearchRequest[ResourceType].model_validate(request.args.to_dict()) - total, page = page_of(get_resource_types(), req) + total, page = page_of(provider.resource_types, req) response = ListResponse[ResourceType]( total_results=total, start_index=req.start_index or 1, @@ -380,7 +378,7 @@ def get_resource_type_by_id(resource_type_id): @bp.get("/ServiceProviderConfig") def get_service_provider_config(): """Return the SCIM service provider configuration.""" - return service_provider_config.model_dump( + return provider.config.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE ) # -- service-provider-config-end -- diff --git a/doc/integrations/_examples/integrations.py b/doc/integrations/_examples/integrations.py index b37de559..4b6228b9 100644 --- a/doc/integrations/_examples/integrations.py +++ b/doc/integrations/_examples/integrations.py @@ -17,6 +17,7 @@ from scim2_models import Path from scim2_models import Patch from scim2_models import ResourceType +from scim2_models import ScimProvider from scim2_models import ServiceProviderConfig from scim2_models import SearchRequest from scim2_models import Sort @@ -228,50 +229,44 @@ def to_scim_group(record): # -- discovery-start -- -RESOURCE_MODELS = [User] - +provider = ScimProvider( + models=[User], + config=ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + filter=Filter(supported=True, max_results=MAX_RESULTS), + change_password=ChangePassword(supported=False), + sort=Sort(supported=True), + etag=ETag(supported=True), + authentication_schemes=[ + AuthenticationScheme( + type=AuthenticationScheme.Type.httpbasic, + name="HTTP Basic", + description="Authentication via HTTP Basic", + ), + ], + ), +) +"""What this server serves, and what it announces of itself. -def get_schemas(): - """Return every :class:`~scim2_models.Schema` the server exposes.""" - return [model.to_schema() for model in RESOURCE_MODELS] +``provider.schemas`` and ``provider.resource_types`` are derived from the +models, so the three discovery endpoints and the resources they describe can +never drift apart. +""" def get_schema(schema_id): """Return the :class:`~scim2_models.Schema` matching *schema_id*, or raise KeyError.""" - for model in RESOURCE_MODELS: - schema = model.to_schema() + for schema in provider.schemas: if schema.id == schema_id: return schema raise KeyError(schema_id) -def get_resource_types(): - """Return every :class:`~scim2_models.ResourceType` the server exposes.""" - return [ResourceType.from_resource(model) for model in RESOURCE_MODELS] - - def get_resource_type(resource_type_id): """Return the :class:`~scim2_models.ResourceType` matching *resource_type_id*, or raise KeyError.""" - for model in RESOURCE_MODELS: - rt = ResourceType.from_resource(model) - if rt.id == resource_type_id: - return rt + for resource_type in provider.resource_types: + if resource_type.id == resource_type_id: + return resource_type raise KeyError(resource_type_id) - - -service_provider_config = ServiceProviderConfig( - patch=Patch(supported=True), - bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), - filter=Filter(supported=True, max_results=MAX_RESULTS), - change_password=ChangePassword(supported=False), - sort=Sort(supported=True), - etag=ETag(supported=True), - authentication_schemes=[ - AuthenticationScheme( - type=AuthenticationScheme.Type.httpbasic, - name="HTTP Basic", - description="Authentication via HTTP Basic", - ), - ], -) # -- discovery-end -- diff --git a/doc/integrations/django.rst b/doc/integrations/django.rst index 71fcd515..9abb3b6b 100644 --- a/doc/integrations/django.rst +++ b/doc/integrations/django.rst @@ -300,8 +300,8 @@ the resources it exposes. The shared :ref:`discovery helpers GET /Schemas and GET /Schemas/ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Return all :class:`~scim2_models.Schema` objects or look one up by its URI. Schemas are built -automatically from resource models with :meth:`~scim2_models.Resource.to_schema`. The collection +Return all :class:`~scim2_models.Schema` objects or look one up by its URI. The +:class:`~scim2_models.ScimProvider` derives them from the resource models it holds. The collection endpoint parses pagination parameters with :class:`~scim2_models.SearchRequest`, following the same pattern as ``GET /Users``. @@ -313,9 +313,8 @@ same pattern as ``GET /Users``. GET /ResourceTypes and GET /ResourceTypes/ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Return all :class:`~scim2_models.ResourceType` objects or look one up by its identifier. Resource -types are built automatically from resource models with -:meth:`~scim2_models.ResourceType.from_resource`. The collection endpoint parses pagination +Return all :class:`~scim2_models.ResourceType` objects or look one up by its identifier. The +:class:`~scim2_models.ScimProvider` derives them from the resource models it holds. The collection endpoint parses pagination parameters with :class:`~scim2_models.SearchRequest`, following the same pattern as ``GET /Users``. diff --git a/doc/integrations/fastapi.rst b/doc/integrations/fastapi.rst index 5909fa66..d64c1424 100644 --- a/doc/integrations/fastapi.rst +++ b/doc/integrations/fastapi.rst @@ -241,8 +241,8 @@ the resources it exposes. The shared :ref:`discovery helpers GET /Schemas and GET /Schemas/ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Return all :class:`~scim2_models.Schema` objects or look one up by its URI. Schemas are built -automatically from resource models with :meth:`~scim2_models.Resource.to_schema`. The collection +Return all :class:`~scim2_models.Schema` objects or look one up by its URI. The +:class:`~scim2_models.ScimProvider` derives them from the resource models it holds. The collection endpoint parses pagination parameters with :class:`~scim2_models.SearchRequest`, following the same pattern as ``GET /Users``. @@ -254,9 +254,8 @@ same pattern as ``GET /Users``. GET /ResourceTypes and GET /ResourceTypes/ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Return all :class:`~scim2_models.ResourceType` objects or look one up by its identifier. Resource -types are built automatically from resource models with -:meth:`~scim2_models.ResourceType.from_resource`. The collection endpoint parses pagination +Return all :class:`~scim2_models.ResourceType` objects or look one up by its identifier. The +:class:`~scim2_models.ScimProvider` derives them from the resource models it holds. The collection endpoint parses pagination parameters with :class:`~scim2_models.SearchRequest`, following the same pattern as ``GET /Users``. diff --git a/doc/integrations/flask.rst b/doc/integrations/flask.rst index 141b47c6..fe0f426e 100644 --- a/doc/integrations/flask.rst +++ b/doc/integrations/flask.rst @@ -233,8 +233,8 @@ the resources it exposes. The shared :ref:`discovery helpers GET /Schemas and GET /Schemas/ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Return all :class:`~scim2_models.Schema` objects or look one up by its URI. Schemas are built -automatically from resource models with :meth:`~scim2_models.Resource.to_schema`. The collection +Return all :class:`~scim2_models.Schema` objects or look one up by its URI. The +:class:`~scim2_models.ScimProvider` derives them from the resource models it holds. The collection endpoint parses pagination parameters with :class:`~scim2_models.SearchRequest`, following the same pattern as ``GET /Users``. @@ -246,9 +246,8 @@ same pattern as ``GET /Users``. GET /ResourceTypes and GET /ResourceTypes/ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Return all :class:`~scim2_models.ResourceType` objects or look one up by its identifier. Resource -types are built automatically from resource models with -:meth:`~scim2_models.ResourceType.from_resource`. The collection endpoint parses pagination +Return all :class:`~scim2_models.ResourceType` objects or look one up by its identifier. The +:class:`~scim2_models.ScimProvider` derives them from the resource models it holds. The collection endpoint parses pagination parameters with :class:`~scim2_models.SearchRequest`, following the same pattern as ``GET /Users``. diff --git a/doc/integrations/helpers.rst b/doc/integrations/helpers.rst index c9fd1662..32000816 100644 --- a/doc/integrations/helpers.rst +++ b/doc/integrations/helpers.rst @@ -113,9 +113,14 @@ Server discovery SCIM clients discover the server capabilities by querying three read-only endpoints: ``/Schemas``, ``/ResourceTypes`` and ``/ServiceProviderConfig`` (:rfc:`RFC 7644 §4 -<7644#section-4>`). The helpers of this section build :class:`~scim2_models.Schema` and -:class:`~scim2_models.ResourceType` objects from the resource models a server exposes, and define -a :class:`~scim2_models.ServiceProviderConfig` describing its capabilities. +<7644#section-4>`). A :class:`~scim2_models.ScimProvider` answers all three: give it the resource +models the server serves and the capabilities it announces, and it derives the +:class:`~scim2_models.Schema` and :class:`~scim2_models.ResourceType` objects the first two +endpoints return. + +Deriving them is what keeps the three endpoints and the resources they describe from drifting +apart. The two helpers below only pick one object out of a collection, which is what +``/Schemas/`` and ``/ResourceTypes/`` serve. .. literalinclude:: _examples/integrations.py :language: python diff --git a/doc/reference.rst b/doc/reference.rst index 2308dfff..6830bbc1 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -71,7 +71,14 @@ SCIM core resources and the complex attributes used by users and groups. Schemas and service discovery ----------------------------- -Models that describe resource schemas, resource types, and a service provider's capabilities. +Models that describe resource schemas, resource types, and a service provider's capabilities, +and the registry gathering them. + +.. autoclass:: scim2_models.ScimProvider + :members: + +.. autoclass:: scim2_models.ScimProviderError + :members: .. autoclass:: scim2_models.Attribute :members: @@ -311,6 +318,11 @@ Type aliases Aliases used where an API accepts more than one SCIM object or resource type. +.. data:: scim2_models.DescribedModel + + A model a schema describes: a resource, or an extension of one. This is what + :class:`~scim2_models.ScimProvider` holds in its catalogue. + .. data:: scim2_models.AnyResource :type: typing.TypeVar diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 5c71109b..c43a0f2f 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -47,6 +47,9 @@ from .path import AttributeBinding from .path import Path from .path import ScimFilter +from .provider import DescribedModel +from .provider import ScimProvider +from .provider import ScimProviderError from .reference import URI from .reference import External from .reference import Reference @@ -105,6 +108,7 @@ "Context", "CreationRequestContext", "CreationResponseContext", + "DescribedModel", "ETag", "Email", "EnterpriseUser", @@ -156,6 +160,8 @@ "SCIMSerializer", "SCIMValidator", "ScimFilter", + "ScimProvider", + "ScimProviderError", "Schema", "SchemaExtension", "ScimObject", diff --git a/scim2_models/provider.py b/scim2_models/provider.py new file mode 100644 index 00000000..4851f18e --- /dev/null +++ b/scim2_models/provider.py @@ -0,0 +1,309 @@ +"""The description of a SCIM service: the resources it serves and what it announces.""" + +import operator +from collections.abc import Iterable +from functools import cached_property +from functools import reduce +from typing import Annotated +from typing import Any +from typing import TypeVar +from typing import cast + +from .annotations import Required +from .resources.resource import Extension +from .resources.resource import Resource +from .resources.resource_type import ResourceType +from .resources.schema import Schema +from .resources.service_provider_config import ServiceProviderConfig +from .scim_object import ScimObject + +DescribedModel = type[Resource[Any]] | type[Extension] +"""A model a schema describes: a resource, or an extension of one.""" + +_AnyDescribed = TypeVar("_AnyDescribed", bound=ScimObject) + +_DISCOVERY_ENDPOINTS: dict[type[Resource[Any]], str] = { + Schema: "/Schemas", + ResourceType: "/ResourceTypes", + ServiceProviderConfig: "/ServiceProviderConfig", +} +"""The endpoints :rfc:`RFC7644 §4 <7644#section-4>` gives every service. + +A service never publishes them among its resource types, so the lookups resolve +them apart from the registry. +""" + + +def _endpoint_key(endpoint: Any) -> str: + """Return the key an endpoint is matched under. + + :rfc:`RFC7643 §6 <7643#section-6>` only says an endpoint is relative to the + base URL, so a service may publish it with or without a leading slash. + """ + return str(endpoint).casefold().lstrip("/") + + +def _schema_key(schema: Any) -> str: + return str(schema).casefold() + + +_DISCOVERY_BY_NAME = { + model.__name__.casefold(): model for model in _DISCOVERY_ENDPOINTS +} +_DISCOVERY_BY_SCHEMA = { + _schema_key(model.__schema__): model for model in _DISCOVERY_ENDPOINTS +} +_DISCOVERY_BY_ENDPOINT = { + _endpoint_key(endpoint): model for model, endpoint in _DISCOVERY_ENDPOINTS.items() +} + + +class ScimProviderError(ValueError): + """A provider cannot describe a coherent service. + + Only the code that builds a provider raises this. It stays out of the + :class:`~scim2_models.SCIMException` hierarchy, where every class maps to a + ``scimType`` and turns into an :class:`~scim2_models.Error` response. + """ + + +class ScimProvider: + """A SCIM service: the resources it serves, and the capabilities it declares. + + A service publishes ``schemas``, ``resource_types`` and ``config`` on the + three discovery endpoints of :rfc:`RFC7644 §4 <7644#section-4>`. A server + describes itself with a provider, and a client describes the peer it + queries; the class carries no notion of either role. + + ``models`` is the catalogue of what the service can build: bare resources + and extensions, each identified by its schema URI. ``resource_types`` binds + extensions to a resource and gives it an endpoint, as :rfc:`RFC7643 §6 + <7643#section-6>` describes. The provider composes the two: + + >>> from scim2_models import EnterpriseUser, ResourceType, ScimProvider, User + >>> provider = ScimProvider( + ... models=[User, EnterpriseUser], + ... resource_types=[ResourceType.from_resource(User[EnterpriseUser])], + ... ) + >>> provider.model_for("User") is User[EnterpriseUser] + True + >>> provider.model_for(str(User.__schema__)) is User + True + + Leave ``resource_types`` out for a service whose resources carry no + extension: the provider derives one per resource. + + A provider is immutable. It validates its whole description at + construction, since nothing can change afterwards. + + :param models: The bare resource and extension models the service builds + its resources from. + :param resource_types: The bindings between a resource, its extensions and + an endpoint, derived from the models when left out. + :param config: The capabilities the service declares. + :raises ScimProviderError: When a model declares no schema or is already + parameterized, when two models share a schema, when two resource types + share a name or an endpoint, or when a resource type names a schema no + model describes. + """ + + def __init__( + self, + models: Iterable[DescribedModel] = (), + resource_types: Iterable[ResourceType] | None = None, + config: ServiceProviderConfig | None = None, + ) -> None: + self._models = tuple(models) + self._config = config + self._models_by_schema = self._index_models() + self._resource_types = ( + tuple(resource_types) + if resource_types is not None + else tuple( + ResourceType.from_resource(model) + for model in self._models + if issubclass(model, Resource) + ) + ) + self._models_by_name: dict[str, type[Resource[Any]]] = {} + self._models_by_endpoint: dict[str, type[Resource[Any]]] = {} + self._index_resource_types() + + def _index_models(self) -> dict[str, DescribedModel]: + by_schema: dict[str, DescribedModel] = {} + for model in self._models: + if not issubclass(model, Resource | Extension): + raise ScimProviderError( + f"{model.__name__} is neither a resource nor an extension" + ) + + if hasattr(model, "__scim_extension_metadata__"): + raise ScimProviderError( + f"{model.__name__} is already parameterized: list the bare " + "resource and its extensions, and bind them with a resource " + "type, as ResourceType.from_resource builds one" + ) + + schema = getattr(model, "__schema__", None) + if not schema: + raise ScimProviderError(f"{model.__name__} declares no schema") + + if _schema_key(schema) in by_schema: + raise ScimProviderError(f"Two models share the schema {schema}") + + by_schema[_schema_key(schema)] = model + return by_schema + + def _index_resource_types(self) -> None: + for resource_type in self._resource_types: + model = self._compose(resource_type) + + name = str(resource_type.name).casefold() + if name in self._models_by_name: + raise ScimProviderError( + f"Two resource types share the name {resource_type.name}" + ) + + endpoint = _endpoint_key(resource_type.endpoint) + if endpoint in self._models_by_endpoint: + raise ScimProviderError( + f"Two resource types share the endpoint {resource_type.endpoint}" + ) + + self._models_by_name[name] = model + self._models_by_endpoint[endpoint] = model + + def _compose(self, resource_type: ResourceType) -> type[Resource[Any]]: + """Build the model a resource type designates, extensions included.""" + model = self._described(resource_type.schema_, Resource) + + parameters = [ + Annotated[extension, Required.true] if declared.required else extension + for declared in resource_type.schema_extensions or [] + for extension in [self._described(declared.schema_, Extension)] + ] + if not parameters: + return model + + # The union is built and subscripted through the calls the syntax stands + # for: mypy reads the index of a generic as a type, not as a value. + return model.__class_getitem__(reduce(operator.or_, parameters)) + + def _described(self, uri: Any, kind: type[_AnyDescribed]) -> type[_AnyDescribed]: + model = self._models_by_schema.get(_schema_key(uri)) + if model is None or not issubclass(model, kind): + raise ScimProviderError(f"No {kind.__name__.lower()} describes {uri}") + return cast("type[_AnyDescribed]", model) + + @property + def models(self) -> tuple[DescribedModel, ...]: + """The bare resource and extension models the service builds from.""" + return self._models + + @property + def resource_types(self) -> tuple[ResourceType, ...]: + """What the service publishes on ``/ResourceTypes``.""" + return self._resource_types + + @property + def config(self) -> ServiceProviderConfig | None: + """What the service publishes on ``/ServiceProviderConfig``.""" + return self._config + + @cached_property + def schemas(self) -> tuple[Schema, ...]: + """What the service publishes on ``/Schemas``. + + Every model contributes one schema, whether a resource type references + it or not: a service may describe a schema before binding it. + """ + return tuple(model.to_schema() for model in self._models) + + def model_for(self, key: str | Schema | ResourceType) -> type[ScimObject] | None: + """Return the model a key designates, or :data:`None`. + + The name a resource type declares, or the + :class:`~scim2_models.ResourceType` itself, answers the composed model, + extensions included. That name is the one ``meta.resourceType`` carries + (:rfc:`RFC7643 §6 <7643#section-6>`), not the name of a Python class and + not an endpoint, which :meth:`model_for_endpoint` takes. + + A schema URI, or a :class:`~scim2_models.Schema`, answers the catalogue + instead: the bare resource, or the extension the URI names. + :rfc:`RFC7643 §3 <7643#section-3>` has ``meta.resourceType``, not + ``schemas``, tell what a resource is. + + An unknown key is not a protocol error, so nothing is raised: a server + answers it with a 404. + + >>> from scim2_models import ScimProvider, User + >>> provider = ScimProvider(models=[User]) + >>> provider.model_for("User") is User + True + >>> provider.model_for("Pet") is None + True + + The derived resource type is named after the last segment of the schema + URI, which is why ``"User"`` answers above. + """ + if isinstance(key, Schema): + return self._model_for_schema(key.id) + + if isinstance(key, ResourceType): + return self._models_by_name.get(str(key.name).casefold()) + + if ":" in key: + return self._model_for_schema(key) + + return self._models_by_name.get(key.casefold()) or _DISCOVERY_BY_NAME.get( + key.casefold() + ) + + def _model_for_schema(self, schema: Any) -> type[ScimObject] | None: + key = _schema_key(schema) + return self._models_by_schema.get(key) or _DISCOVERY_BY_SCHEMA.get(key) + + def model_for_endpoint(self, endpoint: str) -> type[ScimObject] | None: + """Return the composed model an endpoint serves, or :data:`None`. + + >>> from scim2_models import ScimProvider, User + >>> provider = ScimProvider(models=[User]) + >>> provider.model_for_endpoint("/Users") is User + True + """ + key = _endpoint_key(endpoint) + return self._models_by_endpoint.get(key) or _DISCOVERY_BY_ENDPOINT.get(key) + + @classmethod + def from_discovery( + cls, + schemas: Iterable[Schema], + resource_types: Iterable[ResourceType], + config: ServiceProviderConfig | None = None, + ) -> "ScimProvider": + """Build a provider from what a service publishes about itself. + + This is how a client describes the peer it queried, and how a server + describes itself when its resources are configured rather than written + in Python. Each schema becomes a model, a resource or an extension + depending on how the resource types name it. + + :param schemas: What ``/Schemas`` answered. + :param resource_types: What ``/ResourceTypes`` answered. + :param config: What ``/ServiceProviderConfig`` answered. + :raises ScimProviderError: When a resource type names a schema the + service does not publish. + """ + resource_types = tuple(resource_types) + extended = { + _schema_key(declared.schema_) + for resource_type in resource_types + for declared in resource_type.schema_extensions or [] + } + models = [ + Extension.from_schema(schema) + if _schema_key(schema.id) in extended + else Resource.from_schema(schema) + for schema in schemas + ] + return cls(models=models, resource_types=resource_types, config=config) diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 00000000..1fbb9921 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,412 @@ +from typing import Annotated +from typing import Any + +import pytest +from pydantic import ValidationError + +from scim2_models import URI +from scim2_models import URN +from scim2_models import Context +from scim2_models import EnterpriseUser +from scim2_models import Error +from scim2_models import Extension +from scim2_models import Group +from scim2_models import Reference +from scim2_models import Required +from scim2_models import Resource +from scim2_models import ResourceType +from scim2_models import Schema +from scim2_models import SchemaExtension +from scim2_models import ScimProvider +from scim2_models import ScimProviderError +from scim2_models import ServiceProviderConfig +from scim2_models import User + + +class PetOwner(Extension): + __schema__ = URN("urn:example:2.0:PetOwner") + + pet_name: str | None = None + + +def resource_type(name, endpoint, schema, extensions=()): + """Build a resource type binding a resource to the extensions it carries.""" + return ResourceType( + id=name, + name=name, + description=name, + endpoint=Reference[URI](endpoint), + schema_=Reference[URI](schema), + schema_extensions=[ + SchemaExtension(schema_=Reference[URI](str(schema)), required=required) + for schema, required in extensions + ], + ) + + +# The catalogue of models + + +def test_a_provider_publishes_the_schema_of_each_model(): + provider = ScimProvider(models=[User, Group, PetOwner]) + + assert [schema.id for schema in provider.schemas] == [ + User.__schema__, + Group.__schema__, + PetOwner.__schema__, + ] + + +def test_an_extension_no_resource_type_references_is_still_published(): + """A service may describe a schema before binding it to a resource type.""" + provider = ScimProvider(models=[User, PetOwner]) + + assert PetOwner.__schema__ in [schema.id for schema in provider.schemas] + assert provider.model_for(str(PetOwner.__schema__)) is PetOwner + + +def test_two_models_sharing_a_schema_are_refused(): + """A schema URI designates one model, since it is how a payload names it.""" + + class Twin(Resource[Any]): + __schema__ = URN(str(User.__schema__)) + + with pytest.raises(ScimProviderError, match=str(User.__schema__)): + ScimProvider(models=[User, Twin]) + + +def test_a_parameterized_model_is_refused(): + """A resource type binds a resource to its extensions, and nothing else does. + + :rfc:`RFC7643 §6 <7643#section-6>` has ``schemaExtensions`` carry that + binding. Letting a parameterized model carry it too would give a service two + ways to say one thing, and no way to settle a disagreement. + """ + with pytest.raises(ScimProviderError, match="from_resource"): + ScimProvider(models=[User[EnterpriseUser]]) + + +def test_a_model_that_is_neither_a_resource_nor_an_extension_is_refused(): + """A catalogue describes what a service serves, not the messages carrying it.""" + with pytest.raises(ScimProviderError, match="neither a resource nor an extension"): + ScimProvider(models=[Error]) + + +def test_a_model_declaring_no_schema_is_refused(): + class Anonymous(Resource[Any]): + label: str | None = None + + with pytest.raises(ScimProviderError, match="Anonymous declares no schema"): + ScimProvider(models=[Anonymous]) + + +# Composing a resource with its extensions + + +def test_a_resource_type_composes_its_resource_with_its_extensions(): + provider = ScimProvider( + models=[User, EnterpriseUser], + resource_types=[ResourceType.from_resource(User[EnterpriseUser])], + ) + + assert provider.model_for("User") is User[EnterpriseUser] + + +def test_a_required_extension_is_composed_as_required(): + provider = ScimProvider( + models=[User, EnterpriseUser], + resource_types=[ + ResourceType.from_resource(User[Annotated[EnterpriseUser, Required.true]]) + ], + ) + + with pytest.raises(ValidationError, match="is required"): + provider.model_for("User").model_validate( + {"schemas": [User.__schema__], "userName": "bjensen"}, + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + + +def test_two_resource_types_serve_two_variants_of_one_schema(): + """:rfc:`RFC7643 §6 <7643#section-6>` binds extensions and endpoint to the resource type. + + Two resource types may therefore share a base schema and differ by their + extensions, each served under its own endpoint. ``meta.resourceType`` is + what tells the resources apart, as :rfc:`RFC7643 §3 <7643#section-3>` says. + """ + provider = ScimProvider( + models=[User, PetOwner], + resource_types=[ + resource_type("User", "/Users", User.__schema__), + resource_type( + "PetOwner", + "/PetOwners", + User.__schema__, + [(PetOwner.__schema__, False)], + ), + ], + ) + + assert provider.model_for_endpoint("/Users") is User + assert provider.model_for_endpoint("/PetOwners") is User[PetOwner] + + +def test_resource_types_are_derived_without_extensions_when_they_are_left_out(): + provider = ScimProvider(models=[User, Group]) + + assert [(rt.name, rt.endpoint) for rt in provider.resource_types] == [ + ("User", "/Users"), + ("Group", "/Groups"), + ] + assert provider.model_for("User") is User + + +def test_a_resource_type_naming_a_schema_no_model_describes_is_refused(): + with pytest.raises(ScimProviderError, match=str(Group.__schema__)): + ScimProvider(models=[User], resource_types=[ResourceType.from_resource(Group)]) + + +def test_a_resource_type_naming_an_extension_no_model_describes_is_refused(): + with pytest.raises(ScimProviderError, match=str(EnterpriseUser.__schema__)): + ScimProvider( + models=[User], + resource_types=[ResourceType.from_resource(User[EnterpriseUser])], + ) + + +def test_a_resource_type_naming_a_resource_as_its_extension_is_refused(): + """An extension is not a resource, and a resource does not extend another.""" + with pytest.raises(ScimProviderError, match=str(Group.__schema__)): + ScimProvider( + models=[User, Group], + resource_types=[ + resource_type( + "User", "/Users", User.__schema__, [(Group.__schema__, False)] + ) + ], + ) + + +# Lookups + + +def test_a_schema_uri_answers_the_bare_model(): + """A schema URI designates a schema, not a resource type. + + The catalogue answers it: the resource without its extensions, or the + extension itself. + """ + provider = ScimProvider( + models=[User, EnterpriseUser], + resource_types=[ResourceType.from_resource(User[EnterpriseUser])], + ) + + assert provider.model_for(str(User.__schema__)) is User + assert provider.model_for(str(EnterpriseUser.__schema__)) is EnterpriseUser + assert provider.model_for(User.to_schema()) is User + + +def test_a_name_and_an_endpoint_answer_the_composed_model(): + provider = ScimProvider( + models=[User, EnterpriseUser], + resource_types=[ResourceType.from_resource(User[EnterpriseUser])], + ) + composed = User[EnterpriseUser] + + assert provider.model_for("User") is composed + assert provider.model_for_endpoint("/Users") is composed + assert provider.model_for_endpoint("Users") is composed + assert ( + provider.model_for(ResourceType.from_resource(User[EnterpriseUser])) is composed + ) + + +def test_a_name_lookup_takes_the_name_a_resource_type_declares(): + """:rfc:`RFC7643 §6 <7643#section-6>` has that name carried by ``meta.resourceType``. + + It is neither the name of the Python class serving the resource, nor the + endpoint, which ``model_for_endpoint`` takes. They only ever coincide + because a derived resource type is named after the last segment of the + schema URI. + """ + provider = ScimProvider( + models=[User], + resource_types=[resource_type("Staff", "/People", User.__schema__)], + ) + + assert provider.model_for("Staff") is User + assert provider.model_for("User") is None + assert provider.model_for("/People") is None + assert provider.model_for_endpoint("/People") is User + + +def test_a_lookup_ignores_the_case_of_its_key(): + """:rfc:`RFC7643 §2.1 <7643#section-2.1>` makes attribute names case insensitive. + + Names, endpoints and schema URIs are matched the same way, so a peer + spelling them differently is still understood. + """ + provider = ScimProvider(models=[User]) + + assert provider.model_for("user") is User + assert provider.model_for(str(User.__schema__).upper()) is User + assert provider.model_for_endpoint("/USERS") is User + + +def test_a_lookup_answers_none_when_nothing_matches(): + """An unknown key is not a protocol error: a server answers it with a 404.""" + provider = ScimProvider(models=[User]) + + assert provider.model_for("Pet") is None + assert provider.model_for("urn:example:2.0:Pet") is None + assert provider.model_for_endpoint("/Pets") is None + + +def test_the_discovery_resources_are_known_without_being_registered(): + """:rfc:`RFC7644 §4 <7644#section-4>` defines three endpoints every service serves. + + They hold resources like the others, but no service publishes them among + its resource types. + """ + provider = ScimProvider(models=[User]) + + assert provider.model_for("Schema") is Schema + assert provider.model_for(str(ResourceType.__schema__)) is ResourceType + assert ( + provider.model_for_endpoint("/ServiceProviderConfig") is ServiceProviderConfig + ) + + assert provider.models == (User,) + assert [rt.name for rt in provider.resource_types] == ["User"] + assert [schema.id for schema in provider.schemas] == [User.__schema__] + + +def test_two_resource_types_sharing_a_name_are_refused(): + with pytest.raises(ScimProviderError, match="User"): + ScimProvider( + models=[User, Group], + resource_types=[ + resource_type("User", "/Users", User.__schema__), + resource_type("User", "/Groups", Group.__schema__), + ], + ) + + +def test_two_resource_types_sharing_an_endpoint_are_refused(): + with pytest.raises(ScimProviderError, match="/Users"): + ScimProvider( + models=[User, Group], + resource_types=[ + resource_type("User", "/Users", User.__schema__), + resource_type("Group", "/Users", Group.__schema__), + ], + ) + + +# What the service announces + + +def test_a_provider_carries_the_service_provider_config(): + config = ServiceProviderConfig(documentation_uri="https://example.com") + + provider = ScimProvider(models=[User], config=config) + + assert provider.config is config + + +def test_a_provider_serving_nothing_yet_is_built(): + """A server builds its provider before it knows what it serves.""" + provider = ScimProvider() + + assert provider.models == () + assert provider.schemas == () + assert provider.resource_types == () + assert provider.config is None + assert provider.model_for("User") is None + + +# Discovery + + +def test_a_provider_is_rebuilt_from_what_a_service_publishes(): + """What a service publishes is enough to describe it. + + A client reads ``/Schemas`` and ``/ResourceTypes``, and gets back the models + the service would have registered. + """ + served = ScimProvider( + models=[User, EnterpriseUser, Group], + resource_types=[ + ResourceType.from_resource(User[Annotated[EnterpriseUser, Required.true]]), + ResourceType.from_resource(Group), + ], + ) + + discovered = ScimProvider.from_discovery(served.schemas, served.resource_types) + + assert [schema.model_dump() for schema in discovered.schemas] == [ + schema.model_dump() for schema in served.schemas + ] + + +def test_a_required_extension_survives_discovery(): + """``schemaExtensions.required`` is read back, where it used to be dropped.""" + served = ScimProvider( + models=[User, EnterpriseUser], + resource_types=[ + ResourceType.from_resource(User[Annotated[EnterpriseUser, Required.true]]) + ], + ) + + discovered = ScimProvider.from_discovery(served.schemas, served.resource_types) + + with pytest.raises(ValidationError, match="is required"): + discovered.model_for("User").model_validate( + {"schemas": [User.__schema__], "userName": "bjensen"}, + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + + +def test_an_optional_extension_survives_discovery(): + served = ScimProvider( + models=[User, EnterpriseUser], + resource_types=[ResourceType.from_resource(User[EnterpriseUser])], + ) + + discovered = ScimProvider.from_discovery(served.schemas, served.resource_types) + model = discovered.model_for("User") + + assert list(model.get_extension_models()) == [EnterpriseUser.__schema__] + assert model.model_validate( + {"schemas": [User.__schema__], "userName": "bjensen"}, + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + ) + + +def test_discovering_a_resource_type_whose_base_schema_is_missing_is_refused(): + """A dangling reference names what is missing, rather than raising a bare KeyError.""" + served = ScimProvider(models=[User, Group]) + + with pytest.raises(ScimProviderError, match=str(Group.__schema__)): + ScimProvider.from_discovery([User.to_schema()], served.resource_types) + + +def test_a_discovered_provider_carries_the_config_it_is_given(): + config = ServiceProviderConfig(documentation_uri="https://example.com") + served = ScimProvider(models=[User]) + + discovered = ScimProvider.from_discovery( + served.schemas, served.resource_types, config + ) + + assert discovered.config is config + + +def test_a_discovered_provider_serves_the_endpoints_the_service_publishes(): + """The endpoint comes from the resource type, not from a naive derivation.""" + published = resource_type("User", "/People", User.__schema__) + + discovered = ScimProvider.from_discovery([User.to_schema()], [published]) + + assert discovered.model_for_endpoint("/People") is discovered.model_for("User") + assert discovered.model_for_endpoint("/Users") is None From 92fd6658325a4bb46a075d36dd75c88bc249f4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Fri, 11 Sep 2026 11:15:10 +0200 Subject: [PATCH 2/2] feat: implement ScimPolicy --- doc/changelog.rst | 18 + doc/explanation/index.rst | 1 + doc/explanation/patch.rst | 5 +- doc/explanation/policies.rst | 86 +++ doc/how-to/index.rst | 1 + doc/how-to/tolerate-a-nonconformant-peer.rst | 152 +++++ doc/reference.rst | 7 +- scim2_models/__init__.py | 2 + scim2_models/annotated.py | 20 +- scim2_models/base.py | 99 ++- scim2_models/messages/patch_op.py | 72 ++- scim2_models/policy.py | 139 +++++ scim2_models/provider.py | 38 +- scim2_models/resources/resource.py | 5 + tests/test_policy.py | 621 +++++++++++++++++++ tests/test_provider.py | 14 + tests/test_resource_extension.py | 1 - 17 files changed, 1255 insertions(+), 26 deletions(-) create mode 100644 doc/explanation/policies.rst create mode 100644 doc/how-to/tolerate-a-nonconformant-peer.rst create mode 100644 scim2_models/policy.py create mode 100644 tests/test_policy.py diff --git a/doc/changelog.rst b/doc/changelog.rst index e1902bf4..8352ae9e 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -47,6 +47,24 @@ Added provider from what a service publishes, turning each resource type into a model carrying the extensions it declares, required ones included. A resource type naming a schema the service does not publish is refused with an error naming that schema. +- :class:`~scim2_models.ScimPolicy` states how much a payload may depart from + :rfc:`RFC7643 <7643>` and :rfc:`RFC7644 <7644>` and still be read. Pass it as the + ``scim_policy`` argument of :meth:`~scim2_models.BaseModel.model_validate`, + :meth:`~scim2_models.BaseModel.model_dump` and :meth:`PatchOp.patch + `, or open a ``with`` block on a policy or on a + :class:`~scim2_models.ScimProvider` carrying one. Every setting defaults to the strict reading, + so nothing changes until one is chosen. See :doc:`explanation/policies` and + :doc:`how-to/tolerate-a-nonconformant-peer`. :issue:`108` +- :attr:`ScimPolicy.unknown ` reads a payload carrying attributes + no model declares, unmodelled extensions included. ``ignore`` drops them and ``keep`` writes + them back with the spelling the peer used; both leave them readable on + :attr:`~scim2_models.BaseModel.unknown_attributes`, at the level they were found. :issue:`85` +- :attr:`ScimPolicy.remove_value_as_filter + ` reads the ``value`` of a PATCH ``remove`` as + the selection :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` spells in the ``path``, which is + the form `Microsoft Entra ID + `_ + sends to remove a group member. - lark is a new dependency. Changed diff --git a/doc/explanation/index.rst b/doc/explanation/index.rst index fa244a13..8c2ec832 100644 --- a/doc/explanation/index.rst +++ b/doc/explanation/index.rst @@ -10,4 +10,5 @@ written to be read away from the keyboard, rather than consulted while completin filters patch + policies scim-contexts diff --git a/doc/explanation/patch.rst b/doc/explanation/patch.rst index 5fe7a259..260500b7 100644 --- a/doc/explanation/patch.rst +++ b/doc/explanation/patch.rst @@ -145,7 +145,10 @@ Write the selection in the path instead, as ``emails[value eq "work@example.com" non-conformant `_ and the ``aadOptscim062020`` flag, added to the tenant URL of the application, has Entra send - a filter path. + a filter path. An application serving that client without the flag reads the ``value`` as a + selection with + :attr:`ScimPolicy.RemoveValue.apply `; see + :doc:`../how-to/tolerate-a-nonconformant-peer`. Primary values -------------- diff --git a/doc/explanation/policies.rst b/doc/explanation/policies.rst new file mode 100644 index 00000000..88855855 --- /dev/null +++ b/doc/explanation/policies.rst @@ -0,0 +1,86 @@ +Validation policies +=================== + +Vendors implement SCIM from the same specifications and still send payloads those specifications +do not describe. scim2-models refuses such a payload by default. A +:class:`~scim2_models.ScimPolicy` states, for one application, how much of it to accept anyway. + +Why a policy sits beside the context +------------------------------------ + +A :class:`~scim2_models.Context` and a policy answer two different questions, and the difference +decides what may become a policy setting at all. + +The context says what a payload is: a creation request, a query response. Both ends of an exchange +read it the same way, because :rfc:`RFC7644 <7644>` defines what each one contains. It travels +with the message. + +The policy says how the payloads are treated here. Nothing on the wire carries it, and the peer +never learns of it. Two applications talking to each other may hold opposite policies and still +agree on every message they exchange. + +A setting belongs in a policy when it changes how a payload is read or written, and when the +specification leaves the choice open. Page sizes, base URLs and authentication schemes are +configuration too, and they belong to the application that serves the requests. + +Why every default is strict +--------------------------- + +§5.3 of the SCIM interoperability profile asks a service provider to reject the attributes and the +schema URNs it does not define, and the strict reading is what the library applies when it is +given no policy. + +Two consequences follow from that choice. + +A service built on scim2-models keeps refusing the payloads of a client that sends unknown +attributes until its author picks :attr:`~scim2_models.ScimPolicy.Unknown.ignore` or +:attr:`~scim2_models.ScimPolicy.Unknown.keep`. What the library ships is a documented way to +tolerate, and the tolerance itself stays a decision. + +The strict default also applies to a client reading a response, which reaches further than the +profile does: §5.3 addresses service providers receiving requests and says nothing about clients. +Telling the two apart would take a notion of role, and a model has none — a request is validated +by the client that wrote it as readily as by the server that received it, so the context cannot +stand in for one. + +What a policy leaves alone +-------------------------- + +**PATCH paths stay strict.** An operation whose ``path`` names an attribute no model declares +raises :class:`~scim2_models.PathNotFoundException`, whatever the policy says. Path resolution and +unknown attributes are two separate mechanisms, and making them uniform would take a third. The +default that would come out of it is the wrong one: a server would answer 200 to a modification it +never applied, where :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` asks for an error. Inside the body +of a resource the trade is different, since dropping one unknown attribute still lands everything +the peer and the model both knew. + +**Building a model in Python stays strict.** ``User(bogus=1)`` and ``user.bogus = 1`` raise under +every policy. Pydantic only offers a hook for extra keys during validation, so a keyword argument +has nowhere to go. A policy governs payloads, and a payload comes from a peer. + +Where a policy is read +---------------------- + +Three layers answer, in order: + +1. the ``scim_policy`` argument of the call; +2. the policy of the innermost open block, from ``with policy:`` or ``with provider:``; +3. the strict reading. + +The resolution happens for each pass, which gives the layers a visible consequence. A model +validated inside a block and serialized outside it is serialized under the strict reading. Under +:attr:`~scim2_models.ScimPolicy.Unknown.keep` the attributes themselves survive on the instance +and :attr:`~scim2_models.BaseModel.unknown_attributes` still reads them, so the dump can be made +again inside a block; the other settings leave nothing behind. + +Blocks nest, and each one restores what it interrupted. Each thread and each asyncio task carries +its own, so a server may open one per request. + +What a kept attribute costs +--------------------------- + +An attribute no model declares has no :class:`~scim2_models.Returned` and no +:class:`~scim2_models.Mutability` annotation, since those come from a schema. Nothing can filter +it by context, and it is written back in all of them. A client reading from one service under +:attr:`~scim2_models.ScimPolicy.Unknown.keep` and creating on another pushes the first service's +attributes to the second. A proxy wants exactly that, and ``keep`` is a setting an author picks. diff --git a/doc/how-to/index.rst b/doc/how-to/index.rst index ccba17cc..80e9461c 100644 --- a/doc/how-to/index.rst +++ b/doc/how-to/index.rst @@ -12,4 +12,5 @@ ends where an application resumes its own work, and assumes the :doc:`../overvie define-custom-models describe-a-scim-service generate-models-from-schemas + tolerate-a-nonconformant-peer validate-and-serialize diff --git a/doc/how-to/tolerate-a-nonconformant-peer.rst b/doc/how-to/tolerate-a-nonconformant-peer.rst new file mode 100644 index 00000000..3a983dd4 --- /dev/null +++ b/doc/how-to/tolerate-a-nonconformant-peer.rst @@ -0,0 +1,152 @@ +Tolerate a non-conformant peer +============================== + +Use this guide when a real SCIM implementation sends payloads scim2-models refuses. A +:class:`~scim2_models.ScimPolicy` says how much of a payload to accept beyond what +:rfc:`RFC7643 <7643>` and :rfc:`RFC7644 <7644>` describe. See :doc:`../explanation/policies` for +what belongs in one and what does not. + +Accept the attributes a peer adds +--------------------------------- + +A payload carrying an attribute no model declares is refused by default. Set +:attr:`~scim2_models.ScimPolicy.Unknown.ignore` to read the rest of it: + +.. doctest:: + + >>> from scim2_models import ScimPolicy, User + >>> payload = { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": "bjensen", + ... "unknownAttr": "some value", + ... } + >>> tolerant = ScimPolicy(unknown=ScimPolicy.Unknown.ignore) + >>> user = User.model_validate(payload, scim_policy=tolerant) + >>> user.user_name + 'bjensen' + +What was dropped stays readable, spelled as the peer sent it: + +.. doctest:: + + >>> user.unknown_attributes + {'unknownAttr': 'some value'} + +A sub-attribute lands on the complex attribute carrying it, so a caller reads it where it came +from: + +.. doctest:: + + >>> payload["name"] = {"familyName": "Jensen", "bogusSub": 1} + >>> user = User.model_validate(payload, scim_policy=tolerant) + >>> user.name.unknown_attributes + {'bogusSub': 1} + +An unmodelled extension takes the same route, since a payload names one with a root key whose name +is a URN. + +Write unknown attributes back +----------------------------- + +:attr:`~scim2_models.ScimPolicy.Unknown.keep` also writes them to the dump, which is what a proxy +reading from one service and creating on another needs: + +.. doctest:: + + >>> keeping = ScimPolicy(unknown=ScimPolicy.Unknown.keep) + >>> user = User.model_validate(payload, scim_policy=keeping) + >>> user.model_dump(scim_policy=keeping)["unknownAttr"] + 'some value' + +The policy of the dump decides, so a model read under ``keep`` and written under the default keeps +its unknown attributes without sending them: + +.. doctest:: + + >>> "unknownAttr" in user.model_dump() + False + >>> user.unknown_attributes["unknownAttr"] + 'some value' + +Remove a group member the way Entra asks +---------------------------------------- + +:rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` reads the target of a ``remove`` in its ``path`` +alone. Microsoft Entra ID puts it in ``value`` instead, and scim2-models refuses that operation. +Set :attr:`~scim2_models.ScimPolicy.RemoveValue.apply` to read the ``value`` as the selection it +means: + +.. doctest:: + + >>> from scim2_models import Group, PatchOp, PatchOperation + >>> group = Group.model_validate( + ... { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + ... "displayName": "Tour Guides", + ... "members": [ + ... {"value": "2819c223", "display": "Babs Jensen"}, + ... {"value": "902c246b", "display": "Mandy Pepperidge"}, + ... ], + ... } + ... ) + >>> patch = PatchOp[Group]( + ... operations=[ + ... PatchOperation( + ... op=PatchOperation.Op.remove, + ... path="members", + ... value=[{"value": "2819c223"}], + ... ) + ... ] + ... ) + >>> entra = ScimPolicy(remove_value_as_filter=ScimPolicy.RemoveValue.apply) + >>> patch.patch(group, scim_policy=entra) + True + >>> [member.value for member in group.members] + ['902c246b'] + +Each entry becomes a filter on the sub-attributes it names, so a member carrying more than the +entry describes still matches. A selection matching nothing changes nothing and reports success, +which :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` asks for a membership that was not there. + +Entra documents this form as non-conformant, and its ``aadOptscim062020`` tenant flag makes it +send a filter path instead. Setting that flag is the other way out. + +State a policy once per request +------------------------------- + +A server naming the policy at every call repeats itself. Opening a block sets it for everything +inside: + +.. doctest:: + + >>> with tolerant: + ... user = User.model_validate(payload) + >>> user.user_name + 'bjensen' + +An argument named at the call site wins over the block: + +.. doctest:: + + >>> from pydantic import ValidationError + >>> with tolerant: + ... try: + ... User.model_validate(payload, scim_policy=ScimPolicy()) + ... except ValidationError: + ... print("refused") + refused + +A :class:`~scim2_models.ScimProvider` carries a policy alongside the resources it describes, and +opens the same kind of block: + +.. doctest:: + + >>> from scim2_models import ScimProvider + >>> provider = ScimProvider(models=[User], policy=tolerant) + >>> with provider: + ... user = User.model_validate(payload) + >>> user.unknown_attributes["unknownAttr"] + 'some value' + +Blocks nest and each restores the one it interrupted. Each thread and each asyncio task carries +its own, so a server may open one per request it serves. diff --git a/doc/reference.rst b/doc/reference.rst index 6830bbc1..e6af2d3b 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -155,8 +155,11 @@ errors. Validation and serialization ---------------------------- -Contexts, annotations, and Pydantic metadata that control which attributes a SCIM operation -accepts or returns. +The policy, contexts, annotations, and Pydantic metadata that control which attributes a SCIM +operation accepts or returns. + +.. autoclass:: scim2_models.ScimPolicy + :members: .. autoclass:: scim2_models.Context :members: diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index c43a0f2f..62fab75f 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -47,6 +47,7 @@ from .path import AttributeBinding from .path import Path from .path import ScimFilter +from .policy import ScimPolicy from .provider import DescribedModel from .provider import ScimProvider from .provider import ScimProviderError @@ -160,6 +161,7 @@ "SCIMSerializer", "SCIMValidator", "ScimFilter", + "ScimPolicy", "ScimProvider", "ScimProviderError", "Schema", diff --git a/scim2_models/annotated.py b/scim2_models/annotated.py index 9d364fef..a46ab7fc 100644 --- a/scim2_models/annotated.py +++ b/scim2_models/annotated.py @@ -27,10 +27,13 @@ async def create_user( from typing_extensions import TypeAliasType from pydantic import GetCoreSchemaHandler +from pydantic import SerializationInfo +from pydantic import ValidationInfo from pydantic_core import CoreSchema from pydantic_core import core_schema from scim2_models.context import Context +from scim2_models.policy import _policy T = TypeVar("T") @@ -55,12 +58,16 @@ def __get_pydantic_core_schema__( schema = handler(source_type) ctx = self.ctx - def validate_with_context(value: Any, handler: Any) -> Any: + def validate_with_context( + value: Any, handler: Any, info: ValidationInfo + ) -> Any: if isinstance(value, dict): - return source_type.model_validate(value, scim_ctx=ctx) + return source_type.model_validate( + value, scim_ctx=ctx, scim_policy=_policy(info) + ) return handler(value) - return core_schema.no_info_wrap_validator_function( + return core_schema.with_info_wrap_validator_function( validate_with_context, schema ) @@ -85,8 +92,10 @@ def __get_pydantic_core_schema__( schema = handler(source_type) ctx = self.ctx - def serialize_with_context(value: Any, _handler: Any) -> Any: - return value.model_dump(scim_ctx=ctx) + def serialize_with_context( + value: Any, _handler: Any, info: SerializationInfo + ) -> Any: + return value.model_dump(scim_ctx=ctx, scim_policy=_policy(info)) return core_schema.no_info_wrap_validator_function( lambda v, h: h(v), @@ -94,6 +103,7 @@ def serialize_with_context(value: Any, _handler: Any) -> Any: serialization=core_schema.wrap_serializer_function_ser_schema( serialize_with_context, schema=schema, + info_arg=True, ), ) diff --git a/scim2_models/base.py b/scim2_models/base.py index 9b4a89ea..d512d5b9 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -16,6 +16,7 @@ from pydantic import Base64Bytes from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict +from pydantic import PrivateAttr from pydantic import SerializationInfo from pydantic import SerializerFunctionWrapHandler from pydantic import ValidationError @@ -33,6 +34,8 @@ from scim2_models.annotations import Returned from scim2_models.context import Context from scim2_models.exceptions import MutabilityException +from scim2_models.policy import ScimPolicy +from scim2_models.policy import _policy from scim2_models.reference import Reference from scim2_models.utils import UNION_TYPES from scim2_models.utils import _normalize_attribute_name @@ -127,6 +130,13 @@ class _SCIMClassInfo(NamedTuple): extensions: frozenset[str] = frozenset() """Field names whose root type is a ``Extension`` subclass.""" + known_keys: frozenset[str] = frozenset() + """Every payload key the class accepts, normalized. + + Field names and aliases alike: an extension is named by its URN in a + payload and by its class name as a field, and both name the same thing. + """ + def _holds_reference(model: type["BaseModel"], field_name: str) -> bool: """Say whether a field holds a reference URI, which is compared apart. @@ -172,6 +182,19 @@ class BaseModel(PydanticBaseModel): __scim_info__: ClassVar[_SCIMClassInfo] = _SCIMClassInfo() """Cached model metadata""" + _unknown_attributes: dict[str, Any] = PrivateAttr(default_factory=dict) + + @property + def unknown_attributes(self) -> dict[str, Any]: + """The attributes of the payload that no field of this model declares. + + Keyed by the spelling the peer used. Empty unless the + :class:`~scim2_models.ScimPolicy` the validation ran under tolerated + them, and filled at the level they were found: a sub-attribute lands on + the complex attribute that carries it, not on the resource above. + """ + return self._unknown_attributes + @classmethod def get_field_annotation(cls, field_name: str, annotation_type: type) -> Any: """Return the annotation of type 'annotation_type' of the field 'field_name'. @@ -362,6 +385,10 @@ def __pydantic_on_complete__(cls) -> None: attribute_urns=attribute_urns, complex_fields=frozenset(complex_fields), extensions=frozenset(extensions), + known_keys=frozenset( + _normalize_attribute_name(key) + for key in (*cls.model_fields, *alias_to_field) + ), ) @model_validator(mode="wrap") @@ -369,15 +396,35 @@ def __pydantic_on_complete__(cls) -> None: def normalize_attribute_names( cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo ) -> Self: - """Normalize payload attribute names. + """Normalize payload attribute names, and set aside the ones no field declares. :rfc:`RFC7643 §2.1 <7643#section-2.1>` indicate that attribute names should be case-insensitive. Any attribute name is transformed in lowercase so any case is handled the same way. + + Unless the policy forbids them, unknown keys are taken out of the + payload with the spelling the peer used. Pydantic never sees them, so + the ``extra="forbid"`` of the class has nothing to refuse. """ + unknown: dict[str, Any] = {} if isinstance(value, dict): - value = {_normalize_attribute_name(k): v for k, v in value.items()} - return cast(Self, handler(value)) + if _policy(info).unknown == ScimPolicy.Unknown.forbid: + value = {_normalize_attribute_name(k): v for k, v in value.items()} + else: + known = cls.__scim_info__.known_keys + normalized = {} + for key, item in value.items(): + name = _normalize_attribute_name(key) + if name in known: + normalized[name] = item + else: + unknown[key] = item + value = normalized + + obj = cast(Self, handler(value)) + if unknown: + obj._unknown_attributes = unknown + return obj @model_validator(mode="after") def enforce_scim_context(self, info: ValidationInfo) -> Self: @@ -638,7 +685,7 @@ def scim_serializer( serialized: dict[str, Any] = handler(self) if not scim_ctx: - return serialized + return self._restore_unknown_attributes(serialized, info) # Delete empty extensions for extension_field in self.__scim_info__.extensions: @@ -668,6 +715,19 @@ def scim_serializer( # Must be request self._scim_request_serializer(serialized, scim_ctx) + return self._restore_unknown_attributes(serialized, info) + + def _restore_unknown_attributes( + self, serialized: dict[str, Any], info: SerializationInfo + ) -> dict[str, Any]: + """Put back the attributes no field declares, as the peer spelled them. + + This runs after the context filters, which map every key back to the + field that carries it: an unknown key has none, and + :meth:`get_attribute_urn` would raise on it. + """ + if _policy(info).unknown == ScimPolicy.Unknown.keep: + serialized.update(self._unknown_attributes) return serialized def _scim_request_serializer( @@ -735,11 +795,13 @@ def _prepare_model_validate( cls, scim_ctx: Context | None = Context.DEFAULT, original: Optional["BaseModel"] = None, + scim_policy: ScimPolicy | None = None, **kwargs: Any, ) -> dict[str, Any]: context = kwargs.setdefault("context", {}) context.setdefault("scim", scim_ctx) context.setdefault("original", original) + context.setdefault("scim_policy", scim_policy) return kwargs @classmethod @@ -748,11 +810,14 @@ def model_validate( *args: Any, scim_ctx: Context | None = Context.DEFAULT, original: Optional["BaseModel"] = None, + scim_policy: ScimPolicy | None = None, **kwargs: Any, ) -> Self: """Validate SCIM payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate`. :param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens. + :param scim_policy: The :class:`~scim2_models.ScimPolicy` the validation + runs under. Defaults to the strict reading of the specification. :param original: If this parameter is set during :attr:`~Context.RESOURCE_REPLACEMENT_REQUEST`, :attr:`~scim2_models.Mutability.immutable` parameters will be compared against the *original* model value. An exception is raised if values are different. @@ -770,7 +835,9 @@ def model_validate( stacklevel=2, ) - validate_kwargs = cls._prepare_model_validate(scim_ctx, original, **kwargs) + validate_kwargs = cls._prepare_model_validate( + scim_ctx, original, scim_policy, **kwargs + ) return super().model_validate(*args, **validate_kwargs) @classmethod @@ -778,6 +845,7 @@ def model_validate_json( cls, *args: Any, scim_ctx: Context | None = Context.DEFAULT, + scim_policy: ScimPolicy | None = None, **kwargs: Any, ) -> Self: """Validate SCIM JSON payloads and generate model representation by using Pydantic :meth:`~pydantic.BaseModel.model_validate_json`. @@ -786,8 +854,12 @@ def model_validate_json( any other SCIM validation failure. :param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens. + :param scim_policy: The :class:`~scim2_models.ScimPolicy` the validation + runs under. Defaults to the strict reading of the specification. """ - validate_kwargs = cls._prepare_model_validate(scim_ctx, **kwargs) + validate_kwargs = cls._prepare_model_validate( + scim_ctx, scim_policy=scim_policy, **kwargs + ) return super().model_validate_json(*args, **validate_kwargs) def _prepare_model_dump( @@ -795,9 +867,12 @@ def _prepare_model_dump( scim_ctx: Context | None = Context.DEFAULT, attributes: list["str | Path[Any]"] | None = None, excluded_attributes: list["str | Path[Any]"] | None = None, + scim_policy: ScimPolicy | None = None, **kwargs: Any, ) -> dict[str, Any]: - kwargs.setdefault("context", {}).setdefault("scim", scim_ctx) + context = kwargs.setdefault("context", {}) + context.setdefault("scim", scim_ctx) + context.setdefault("scim_policy", scim_policy) if scim_ctx: kwargs.setdefault("exclude_none", True) @@ -818,6 +893,7 @@ def model_dump( scim_ctx: Context | None = Context.DEFAULT, attributes: list["str | Path[Any]"] | None = None, excluded_attributes: list["str | Path[Any]"] | None = None, + scim_policy: ScimPolicy | None = None, **kwargs: Any, ) -> dict[str, Any]: """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`. @@ -830,11 +906,15 @@ def model_dump( would be returned by default. Invalid values are ignored. :param excluded_attributes: A multi-valued list of strings indicating the names of resource attributes to be removed from the default set of attributes to return. Invalid values are ignored. + :param scim_policy: The :class:`~scim2_models.ScimPolicy` the + serialization runs under. Defaults to the strict reading of the + specification. """ dump_kwargs = self._prepare_model_dump( scim_ctx, attributes=attributes, excluded_attributes=excluded_attributes, + scim_policy=scim_policy, **kwargs, ) if scim_ctx: @@ -847,6 +927,7 @@ def model_dump_json( scim_ctx: Context | None = Context.DEFAULT, attributes: list["str | Path[Any]"] | None = None, excluded_attributes: list["str | Path[Any]"] | None = None, + scim_policy: ScimPolicy | None = None, **kwargs: Any, ) -> str: """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`. @@ -859,11 +940,15 @@ def model_dump_json( would be returned by default. Invalid values are ignored. :param excluded_attributes: A multi-valued list of strings indicating the names of resource attributes to be removed from the default set of attributes to return. Invalid values are ignored. + :param scim_policy: The :class:`~scim2_models.ScimPolicy` the + serialization runs under. Defaults to the strict reading of the + specification. """ dump_kwargs = self._prepare_model_dump( scim_ctx, attributes=attributes, excluded_attributes=excluded_attributes, + scim_policy=scim_policy, **kwargs, ) return super().model_dump_json(*args, **dump_kwargs) diff --git a/scim2_models/messages/patch_op.py b/scim2_models/messages/patch_op.py index 200d13cf..eb5424ef 100644 --- a/scim2_models/messages/patch_op.py +++ b/scim2_models/messages/patch_op.py @@ -21,7 +21,11 @@ from ..exceptions import MutabilityException from ..exceptions import NoTargetException from ..path import Path +from ..path import ScimFilter from ..path import attribute_host +from ..policy import ScimPolicy +from ..policy import _effective_policy +from ..policy import _policy from ..resources.resource import Resource from ..urn import URN from ..utils import _find_field_name @@ -174,7 +178,11 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: # spelled as a filter there. An operation carrying a value is thus # incompatible with the schema of the attribute it targets, which # Section 3.5.2 answers with an error. - if self.op == PatchOperation.Op.remove and self.value is not None: + if ( + self.op == PatchOperation.Op.remove + and self.value is not None + and _policy(info).remove_value_as_filter != ScimPolicy.RemoveValue.apply + ): raise InvalidValueException( detail="a remove operation carries no value, " "a filter in the path selects what to remove" @@ -354,7 +362,7 @@ def validate_operations(self, info: ValidationInfo) -> Self: return self - def patch(self, resource: ResourceT) -> bool: + def patch(self, resource: ResourceT, scim_policy: ScimPolicy | None = None) -> bool: """Apply all PATCH operations to the given SCIM resource in sequence. The resource is modified in-place. @@ -369,6 +377,8 @@ def patch(self, resource: ResourceT) -> bool: attribute will have their ``primary`` set to ``False`` automatically. :param resource: The SCIM resource to patch. This object is modified in-place. + :param scim_policy: The :class:`~scim2_models.ScimPolicy` the patch is + applied under. Defaults to the strict reading of the specification. :return: True if the resource was modified by any operation, False otherwise. :raises InvalidValueException: If multiple values are marked as primary in a single operation, or if multiple primary values already exist before the patch. @@ -377,10 +387,13 @@ def patch(self, resource: ResourceT) -> bool: return False modified = False - # RFC 7644 Section 3.5.2: "Apply each operation in sequence" - for operation in self.operations: - if self._apply_operation(resource, operation): - modified = True + # The policy is made ambient for the whole application: the passes it + # governs below are revalidations that start from no call of ours. + with _effective_policy(scim_policy): + # RFC 7644 Section 3.5.2: "Apply each operation in sequence" + for operation in self.operations: + if self._apply_operation(resource, operation): + modified = True return modified @@ -565,9 +578,50 @@ def _apply_remove( # Checked again here, a PatchOp built in Python reaching no validator. if operation.value is not None: - raise InvalidValueException( - detail="a remove operation carries no value, " - "a filter in the path selects what to remove" + if ( + _effective_policy().remove_value_as_filter + != ScimPolicy.RemoveValue.apply + ): + raise InvalidValueException( + detail="a remove operation carries no value, " + "a filter in the path selects what to remove" + ) + return self._remove_selected_values( + resource, operation.path, operation.value ) return operation.path.delete(resource) # type: ignore[arg-type] + + def _remove_selected_values( + self, resource: Resource[Any], path: Path[ResourceT], value: Any + ) -> bool: + """Remove the entries the value of a remove operation selects. + + Microsoft Entra puts the selection in ``value`` where + :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` puts it in ``path``. + Each entry becomes a filter on the sub-attributes it names, which is + the path the operation should have carried. + """ + if path.value_filter is not None: + raise InvalidValueException( + detail="a remove operation carrying a value cannot also " + "select values in its path" + ) + + entries = value if isinstance(value, list) else [value] + if not all(isinstance(entry, dict) and entry for entry in entries): + raise InvalidValueException( + detail="the value of a remove operation names the " + "sub-attributes selecting what to remove" + ) + + removed = False + for entry in entries: + conditions = " and ".join( + f"{name} eq {ScimFilter.quote(item)}" for name, item in entry.items() + ) + # Subscripted through the call the syntax stands for: mypy reads + # the index of a generic as a type, not as a value. + selection = Path.__class_getitem__(type(resource))(f"{path}[{conditions}]") + removed = selection.delete(resource) or removed + return removed diff --git a/scim2_models/policy.py b/scim2_models/policy.py new file mode 100644 index 00000000..0fa22191 --- /dev/null +++ b/scim2_models/policy.py @@ -0,0 +1,139 @@ +"""How a peer's deviations from the specification are treated.""" + +from contextvars import ContextVar +from enum import Enum +from types import TracebackType + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import SerializationInfo +from pydantic import ValidationInfo + +_AMBIENT_POLICIES: ContextVar[tuple["ScimPolicy", ...]] = ContextVar( + "scim2_models_policies", default=() +) +"""The policies of the blocks a call is running inside, innermost last.""" + + +class ScimPolicy(BaseModel): + """What a service tolerates from the payloads it reads. + + A :class:`~scim2_models.Context` says what a payload *is*: a creation + request, a query response. A policy says how much its author is allowed to + depart from :rfc:`RFC7643 <7643>` and :rfc:`RFC7644 <7644>`. The first is + on the wire, symmetric, and defined by the specification; the second is + local, and nothing on the wire announces it. + + Every setting defaults to the strict reading of the specification. + + >>> from scim2_models import ScimPolicy + >>> ScimPolicy().unknown is ScimPolicy.Unknown.forbid + True + + Name a policy at the call that reads or writes a payload: + + >>> from scim2_models import User + >>> payload = {"schemas": [str(User.__schema__)], "userName": "bjensen"} + >>> tolerant = ScimPolicy(unknown=ScimPolicy.Unknown.ignore) + >>> User.model_validate(payload, scim_policy=tolerant).user_name + 'bjensen' + + Or open a block, which is how a server states once per request what it + tolerates instead of naming it at every call: + + >>> with tolerant: + ... user = User.model_validate(payload) + >>> user.user_name + 'bjensen' + + An argument named at the call site wins over the block, and leaving the + block restores what it interrupted. + + A policy is settled once built: the pass running under one cannot be + contradicted halfway through. + """ + + model_config = ConfigDict(frozen=True) + + class Unknown(str, Enum): + """What becomes of an attribute no model declares.""" + + forbid = "forbid" + """The payload is refused with a validation error. + + §5.3 of the SCIM interoperability profile asks a service provider to + reject the attributes and the schema URNs it does not define. + """ + + ignore = "ignore" + """The payload is accepted and the attribute is dropped. + + It stays readable on + :attr:`~scim2_models.BaseModel.unknown_attributes`, and no dump + restores it. + """ + + keep = "keep" + """The payload is accepted and the attribute is dumped back. + + The original spelling is preserved. No attribute characteristic is + declared for it, so no context filters it out. + """ + + class RemoveValue(str, Enum): + """What becomes of a PATCH ``remove`` operation carrying a ``value``.""" + + forbid = "forbid" + """The operation is refused with ``invalidValue``. + + :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` reads the target of a + ``remove`` in its ``path`` alone. + """ + + apply = "apply" + """The ``value`` selects what to remove, as Microsoft Entra sends it.""" + + unknown: Unknown = Unknown.forbid + """What becomes of an attribute no model declares.""" + + remove_value_as_filter: RemoveValue = RemoveValue.forbid + """What becomes of a PATCH ``remove`` operation carrying a ``value``.""" + + def __enter__(self) -> "ScimPolicy": + """Make this policy the one every call in the block runs under.""" + _AMBIENT_POLICIES.set(_AMBIENT_POLICIES.get() + (self,)) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Restore the policy the block interrupted.""" + _AMBIENT_POLICIES.set(_AMBIENT_POLICIES.get()[:-1]) + + +_DEFAULT_POLICY = ScimPolicy() + + +def _ambient_policy() -> ScimPolicy | None: + """Return the policy of the innermost open block, if any.""" + policies = _AMBIENT_POLICIES.get() + return policies[-1] if policies else None + + +def _effective_policy(explicit: ScimPolicy | None = None) -> ScimPolicy: + """Return the policy a call runs under, outside of a validation pass.""" + return explicit or _ambient_policy() or _DEFAULT_POLICY + + +def _policy(info: ValidationInfo | SerializationInfo) -> ScimPolicy: + """Return the policy a validation or a serialization runs under. + + Passes that no call of ours started carry no context — an assignment + revalidated under ``validate_assignment``, a round trip made by the PATCH + machinery — and fall back on the ambient policy. + """ + context = getattr(info, "context", None) or {} + return context.get("scim_policy") or _ambient_policy() or _DEFAULT_POLICY diff --git a/scim2_models/provider.py b/scim2_models/provider.py index 4851f18e..380217d2 100644 --- a/scim2_models/provider.py +++ b/scim2_models/provider.py @@ -4,12 +4,14 @@ from collections.abc import Iterable from functools import cached_property from functools import reduce +from types import TracebackType from typing import Annotated from typing import Any from typing import TypeVar from typing import cast from .annotations import Required +from .policy import ScimPolicy from .resources.resource import Extension from .resources.resource import Resource from .resources.resource_type import ResourceType @@ -112,9 +114,11 @@ def __init__( models: Iterable[DescribedModel] = (), resource_types: Iterable[ResourceType] | None = None, config: ServiceProviderConfig | None = None, + policy: ScimPolicy | None = None, ) -> None: self._models = tuple(models) self._config = config + self._policy = policy or ScimPolicy() self._models_by_schema = self._index_models() self._resource_types = ( tuple(resource_types) @@ -210,6 +214,29 @@ def config(self) -> ServiceProviderConfig | None: """What the service publishes on ``/ServiceProviderConfig``.""" return self._config + @property + def policy(self) -> ScimPolicy: + """How much the payloads the service reads may depart from the specification. + + Unlike :attr:`config`, this is never :data:`None`: a policy always + applies, and a provider given none declares the strict reading. + """ + return self._policy + + def __enter__(self) -> "ScimProvider": + """Make the policy of this provider the one the block runs under.""" + self._policy.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Restore the policy the block interrupted.""" + self._policy.__exit__(exc_type, exc_value, traceback) + @cached_property def schemas(self) -> tuple[Schema, ...]: """What the service publishes on ``/Schemas``. @@ -280,6 +307,7 @@ def from_discovery( schemas: Iterable[Schema], resource_types: Iterable[ResourceType], config: ServiceProviderConfig | None = None, + policy: ScimPolicy | None = None, ) -> "ScimProvider": """Build a provider from what a service publishes about itself. @@ -291,6 +319,9 @@ def from_discovery( :param schemas: What ``/Schemas`` answered. :param resource_types: What ``/ResourceTypes`` answered. :param config: What ``/ServiceProviderConfig`` answered. + :param policy: How much the answers of the peer are allowed to depart + from the specification. This is a choice, not something a service + publishes about itself. :raises ScimProviderError: When a resource type names a schema the service does not publish. """ @@ -306,4 +337,9 @@ def from_discovery( else Resource.from_schema(schema) for schema in schemas ] - return cls(models=models, resource_types=resource_types, config=config) + return cls( + models=models, + resource_types=resource_types, + config=config, + policy=policy, + ) diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 42a81899..c94113dd 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -37,6 +37,8 @@ from ..lookup import get_model_by_payload from ..lookup import get_model_by_schema from ..path import Path +from ..policy import ScimPolicy +from ..policy import _policy from ..scim_object import AnyScimObject from ..scim_object import ScimObject from ..utils import UNION_TYPES @@ -418,6 +420,9 @@ def _validate_extension_schemas( if scim_ctx is None or scim_ctx == Context.DEFAULT: return obj + if _policy(info).unknown != ScimPolicy.Unknown.forbid: + return obj + base_schema = getattr(cls, "__schema__", None) if not base_schema: return obj diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 00000000..44e0c76e --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,621 @@ +"""The policy that says how a peer's deviations from the specification are treated.""" + +import asyncio +import threading +from typing import Any + +import pytest +from pydantic import SerializationInfo +from pydantic import ValidationError +from pydantic import ValidationInfo +from pydantic import ValidatorFunctionWrapHandler +from pydantic import field_serializer +from pydantic import model_validator + +from scim2_models import URN +from scim2_models import ComplexAttribute +from scim2_models import Context +from scim2_models import CreationRequestContext +from scim2_models import CreationResponseContext +from scim2_models import EnterpriseUser +from scim2_models import Group +from scim2_models import InvalidValueException +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import Resource +from scim2_models import ScimPolicy +from scim2_models import ScimProvider +from scim2_models import User +from scim2_models.policy import _policy + +IGNORE = ScimPolicy(unknown=ScimPolicy.Unknown.ignore) + +VALIDATION_POLICIES: list[ScimPolicy] = [] +SERIALIZATION_POLICIES: list[ScimPolicy] = [] + + +class Probe(ComplexAttribute): + """Record the policy each pass over this attribute runs under.""" + + label: str | None = None + + @model_validator(mode="wrap") + @classmethod + def _record_validation_policy( + cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo + ) -> "Probe": + VALIDATION_POLICIES.append(_policy(info)) + return handler(value) # type: ignore[no-any-return] + + @field_serializer("label") + def _record_serialization_policy(self, value: Any, info: SerializationInfo) -> Any: + SERIALIZATION_POLICIES.append(_policy(info)) + return value + + +class Probed(Resource): + __schema__ = URN("urn:org:example:Probed") + + probe: Probe | None = None + + +PAYLOAD = {"schemas": [str(Probed.__schema__)], "probe": {"label": "a"}} + + +@pytest.fixture(autouse=True) +def forget_recorded_policies(): + VALIDATION_POLICIES.clear() + SERIALIZATION_POLICIES.clear() + + +def test_a_new_policy_forbids_every_deviation(): + """Every setting starts at the strict reading of the specification.""" + policy = ScimPolicy() + assert policy.unknown == ScimPolicy.Unknown.forbid + assert policy.remove_value_as_filter == ScimPolicy.RemoveValue.forbid + + +def test_a_policy_cannot_be_modified_once_built(): + """A settled policy cannot contradict the pass that is already running under it.""" + with pytest.raises(ValidationError): + ScimPolicy().unknown = ScimPolicy.Unknown.ignore + + +def test_a_validation_given_no_policy_runs_under_the_default(): + """A caller that names no policy gets the strict reading of the specification.""" + Probed.model_validate(PAYLOAD) + assert VALIDATION_POLICIES == [ScimPolicy()] + + +def test_a_policy_reaches_the_validator_of_a_nested_attribute(): + """A policy given at the root governs the whole tree, not only its top.""" + Probed.model_validate(PAYLOAD, scim_policy=IGNORE) + assert VALIDATION_POLICIES == [IGNORE] + + +def test_a_policy_reaches_the_validator_of_a_nested_attribute_from_json(): + """A JSON payload takes the same route as a mapping.""" + Probed.model_validate_json( + '{"schemas": ["urn:org:example:Probed"], "probe": {"label": "a"}}', + scim_policy=IGNORE, + ) + assert VALIDATION_POLICIES == [IGNORE] + + +def test_a_policy_reaches_the_serializer_of_a_nested_attribute(): + """A dump carries the policy down the tree the way a validation does.""" + Probed.model_validate(PAYLOAD).model_dump(scim_policy=IGNORE) + assert SERIALIZATION_POLICIES == [IGNORE] + + +def test_a_policy_reaches_the_serializer_of_a_nested_attribute_for_json(): + """A JSON dump takes the same route as a mapping dump.""" + Probed.model_validate(PAYLOAD).model_dump_json(scim_policy=IGNORE) + assert SERIALIZATION_POLICIES == [IGNORE] + + +def test_a_serialization_given_no_policy_runs_under_the_default(): + """A dump that names no policy gets the strict reading of the specification.""" + Probed.model_validate(PAYLOAD).model_dump() + assert SERIALIZATION_POLICIES == [ScimPolicy()] + + +def test_a_policy_passed_in_the_pydantic_context_is_left_alone(): + """A caller reaching for the pydantic context directly is not overridden.""" + Probed.model_validate(PAYLOAD, context={"scim_policy": IGNORE}) + assert VALIDATION_POLICIES == [IGNORE] + + +def test_a_policy_crosses_a_validation_context_annotation(): + """The context annotations of :mod:`scim2_models.annotated` restart a validation.""" + + class Outer(Resource): + __schema__ = URN("urn:org:example:Outer") + + probed: CreationRequestContext[Probed] | None = None + + Outer.model_validate( + {"schemas": [str(Outer.__schema__)], "probed": PAYLOAD}, + scim_policy=IGNORE, + ) + assert VALIDATION_POLICIES == [IGNORE] + + +def test_a_policy_crosses_a_serialization_context_annotation(): + """The context annotations of :mod:`scim2_models.annotated` restart a dump.""" + + class Outer(Resource): + __schema__ = URN("urn:org:example:Outer") + + probed: CreationResponseContext[Probed] | None = None + + outer = Outer.model_validate( + {"schemas": [str(Outer.__schema__)], "probed": PAYLOAD}, + scim_ctx=Context.DEFAULT, + ) + VALIDATION_POLICIES.clear() + outer.model_dump(scim_ctx=Context.RESOURCE_CREATION_RESPONSE, scim_policy=IGNORE) + assert SERIALIZATION_POLICIES == [IGNORE] + + +# The ambient policy + + +def test_a_policy_set_around_a_block_reaches_a_validation(): + """A server wraps a request in one block instead of naming the policy everywhere.""" + with IGNORE: + Probed.model_validate(PAYLOAD) + + assert VALIDATION_POLICIES == [IGNORE] + + +def test_a_policy_set_around_a_block_reaches_a_serialization(): + """A dump reads the ambient policy the way a validation does.""" + resource = Probed.model_validate(PAYLOAD) + + with IGNORE: + resource.model_dump() + + assert SERIALIZATION_POLICIES == [IGNORE] + + +def test_an_explicit_policy_wins_over_the_ambient_one(): + """The call names what it wants, and the block only says what is otherwise meant.""" + with IGNORE: + Probed.model_validate(PAYLOAD, scim_policy=ScimPolicy()) + + assert VALIDATION_POLICIES == [ScimPolicy()] + + +def test_leaving_a_block_restores_the_strict_reading(): + """A model validated under a policy is not validated under it forever.""" + with IGNORE: + pass + Probed.model_validate(PAYLOAD) + + assert VALIDATION_POLICIES == [ScimPolicy()] + + +def test_a_nested_block_restores_the_policy_it_interrupted(): + """Blocks stack, so an inner tolerance does not outlive itself.""" + keep = ScimPolicy(unknown=ScimPolicy.Unknown.keep) + + with IGNORE: + with keep: + Probed.model_validate(PAYLOAD) + Probed.model_validate(PAYLOAD) + + assert VALIDATION_POLICIES == [keep, IGNORE] + + +def test_a_provider_lends_its_policy_to_the_block_it_opens(): + """A service describes what it serves and what it tolerates in one object.""" + provider = ScimProvider(models=[Probed], policy=IGNORE) + + with provider: + Probed.model_validate(PAYLOAD) + + assert VALIDATION_POLICIES == [IGNORE] + + +def test_a_provider_given_no_policy_declares_the_strict_reading(): + """Unlike its config, a provider always has a policy: one always applies.""" + assert ScimProvider().policy == ScimPolicy() + + +def test_a_discovered_provider_carries_the_policy_it_is_given(): + """How a client treats its peer is its own choice, not something it discovers.""" + served = ScimProvider(models=[Probed]) + + discovered = ScimProvider.from_discovery( + served.schemas, served.resource_types, policy=IGNORE + ) + + assert discovered.policy is IGNORE + + +def test_an_ambient_policy_reaches_the_round_trips_of_a_patch(): + """Applying a patch revalidates in plain Python, out of reach of a call argument.""" + resource = Probed.model_validate(PAYLOAD) + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="probe", value={"label": "b"} + ) + VALIDATION_POLICIES.clear() + + with IGNORE: + PatchOp[Probed](operations=[operation]).patch(resource) + + assert IGNORE in VALIDATION_POLICIES + + +def test_two_threads_do_not_share_an_ambient_policy(): + """A server handling requests in a thread pool must not leak one into another.""" + seen: dict[str, ScimPolicy] = {} + entered = threading.Barrier(2) + + def record(name: str, policy: ScimPolicy) -> None: + with policy: + entered.wait() + Probed.model_validate(PAYLOAD) + seen[name] = VALIDATION_POLICIES[-1] + + threads = [ + threading.Thread(target=record, args=("tolerant", IGNORE)), + threading.Thread(target=record, args=("strict", ScimPolicy())), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert seen == {"tolerant": IGNORE, "strict": ScimPolicy()} + + +def test_two_asyncio_tasks_do_not_share_an_ambient_policy(): + """Two requests served by one event loop each keep their own policy.""" + + async def record(policy: ScimPolicy) -> ScimPolicy: + with policy: + await asyncio.sleep(0) + Probed.model_validate(PAYLOAD) + return VALIDATION_POLICIES[-1] + + async def both() -> list[ScimPolicy]: + return list(await asyncio.gather(record(IGNORE), record(ScimPolicy()))) + + assert asyncio.run(both()) == [IGNORE, ScimPolicy()] + + +# Unknown attributes, dropped + + +def unknown_payload(**extra: Any) -> dict[str, Any]: + """Build a User payload carrying whatever a peer added to it.""" + return { + "schemas": [str(User.__schema__)], + "userName": "bjensen", + **extra, + } + + +def test_an_unknown_attribute_is_refused_by_default(): + """§5.3 of the interoperability profile asks a provider to reject what it does not define.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + User.model_validate(unknown_payload(unknownAttr="x")) + + +def test_an_ignored_attribute_leaves_the_rest_of_the_payload_readable(): + """An Authentik payload lands in a model only once its extras stop refusing it.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=IGNORE) + + assert user.user_name == "bjensen" + + +def test_an_ignored_attribute_keeps_the_spelling_the_peer_used(): + """Reporting an unknown attribute is useless once its name has been mangled.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=IGNORE) + + assert user.unknown_attributes == {"unknownAttr": "x"} + + +def test_an_ignored_attribute_is_captured_where_it_was_found(): + """Each complex attribute answers for what it was sent, not the resource above it.""" + user = User.model_validate( + unknown_payload(name={"familyName": "Jensen", "bogusSub": 1}), + scim_policy=IGNORE, + ) + + assert user.unknown_attributes == {} + assert user.name.unknown_attributes == {"bogusSub": 1} + + +def test_a_model_that_was_sent_nothing_unknown_reports_an_empty_mapping(): + """The accessor answers on every model, so a caller needs no guard.""" + assert User.model_validate(unknown_payload()).unknown_attributes == {} + + +def test_an_ignored_attribute_is_not_dumped_back(): + """``ignore`` reports what was dropped; it does not carry it further.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=IGNORE) + + assert "unknownAttr" not in user.model_dump(scim_policy=IGNORE) + + +def test_an_unmodelled_extension_is_ignored(): + """Half of what makes a payload unreadable is an extension URN, not an attribute.""" + payload = unknown_payload(**{"urn:example:2.0:Pet": {"petName": "Mochi"}}) + payload["schemas"] = [str(User.__schema__), "urn:example:2.0:Pet"] + + user = User.model_validate( + payload, + scim_ctx=Context.RESOURCE_CREATION_REQUEST, + scim_policy=IGNORE, + ) + + assert user.unknown_attributes == {"urn:example:2.0:Pet": {"petName": "Mochi"}} + + +def test_a_declared_extension_is_not_taken_for_an_unknown_attribute(): + """An extension is named by its URN in the payload and by its class name as a field.""" + urn = str(EnterpriseUser.__schema__) + + user = User[EnterpriseUser].model_validate( + { + **unknown_payload(), + "schemas": [str(User.__schema__), urn], + urn: {"employeeNumber": "701984"}, + }, + scim_policy=IGNORE, + ) + + assert user.unknown_attributes == {} + assert user[EnterpriseUser].employee_number == "701984" + + +def test_the_keys_microsoft_entra_adds_to_a_patch_are_ignored(): + """Entra labels its operations and repeats the resource id, which no message declares.""" + payload = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "id": "2819c223", + "Operations": [ + {"name": "addMember", "op": "add", "path": "displayName", "value": "g"} + ], + } + + patch_op = PatchOp[Group].model_validate(payload, scim_policy=IGNORE) + + assert patch_op.unknown_attributes == {"id": "2819c223"} + assert patch_op.operations[0].unknown_attributes == {"name": "addMember"} + + +# Unknown attributes, carried back + + +KEEP = ScimPolicy(unknown=ScimPolicy.Unknown.keep) + + +def test_a_kept_attribute_is_dumped_back_as_the_peer_spelled_it(): + """A proxy reading from one service and writing to another loses nothing.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=KEEP) + + assert user.model_dump(scim_policy=KEEP)["unknownAttr"] == "x" + + +def test_a_kept_attribute_is_dumped_back_from_the_level_it_was_found(): + """A sub-attribute goes back where it came from, not to the resource above.""" + payload = unknown_payload(name={"familyName": "Jensen", "bogusSub": 1}) + + user = User.model_validate(payload, scim_policy=KEEP) + + assert user.model_dump(scim_policy=KEEP)["name"]["bogusSub"] == 1 + + +def test_a_kept_attribute_is_dumped_in_every_context(): + """No attribute characteristic is declared for it, so no context can filter it out.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=KEEP) + + for context in ( + Context.DEFAULT, + Context.RESOURCE_CREATION_REQUEST, + Context.RESOURCE_QUERY_RESPONSE, + ): + dumped = user.model_dump(scim_ctx=context, scim_policy=KEEP) + assert dumped["unknownAttr"] == "x", context + + +def test_a_kept_attribute_is_dumped_outside_of_any_scim_context(): + """A plain pydantic dump takes the same route.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=KEEP) + + assert user.model_dump(scim_ctx=None, scim_policy=KEEP)["unknownAttr"] == "x" + + +def test_a_kept_attribute_is_left_out_when_the_dump_forbids_it(): + """The policy governs the pass that is running, and a dump is a pass of its own.""" + user = User.model_validate(unknown_payload(unknownAttr="x"), scim_policy=KEEP) + + assert "unknownAttr" not in user.model_dump() + assert user.unknown_attributes == {"unknownAttr": "x"} + + +def test_an_unmodelled_extension_survives_a_round_trip(): + """An unknown extension is a root key whose name is a URN, and its URN is in ``schemas``.""" + payload = unknown_payload(**{"urn:example:2.0:Pet": {"petName": "Mochi"}}) + payload["schemas"] = [str(User.__schema__), "urn:example:2.0:Pet"] + + with KEEP: + dumped = User.model_validate( + payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ).model_dump(scim_ctx=Context.RESOURCE_CREATION_RESPONSE) + + assert dumped["urn:example:2.0:Pet"] == {"petName": "Mochi"} + assert "urn:example:2.0:Pet" in dumped["schemas"] + + +def test_a_pathless_patch_operation_keeps_the_unknown_attributes_it_merges_over(): + """A pathless operation dumps the resource and revalidates it, in plain Python.""" + with KEEP: + user = User.model_validate(unknown_payload(unknownAttr="x")) + operation = PatchOperation( + op=PatchOperation.Op.replace_, value={"displayName": "Barbara"} + ) + PatchOp[User](operations=[operation]).patch(user) + + assert user.display_name == "Barbara" + assert user.unknown_attributes == {"unknownAttr": "x"} + + +def test_replacing_a_complex_attribute_drops_the_unknowns_it_carried(): + """The whole attribute is replaced, so what was unknown in it goes with the rest.""" + with KEEP: + user = User.model_validate( + unknown_payload(name={"familyName": "Jensen", "bogusSub": 1}) + ) + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="name", value={"givenName": "Barbara"} + ) + PatchOp[User](operations=[operation]).patch(user) + + assert user.name.family_name is None + assert user.name.unknown_attributes == {} + + +# A remove operation carrying a value + + +APPLY = ScimPolicy(remove_value_as_filter=ScimPolicy.RemoveValue.apply) + + +def group_with_members() -> Group: + """Build a group whose members carry more than the value naming them.""" + return Group.model_validate( + { + "schemas": [str(Group.__schema__)], + "displayName": "Tour Guides", + "members": [ + {"value": "s-foobar", "display": "Foo Bar"}, + {"value": "autre", "display": "Autre"}, + ], + } + ) + + +def entra_remove(value: Any) -> PatchOp[Group]: + """Build the remove operation Microsoft Entra sends to drop a member.""" + return PatchOp[Group]( + operations=[ + PatchOperation(op=PatchOperation.Op.remove, path="members", value=value) + ] + ) + + +def test_a_remove_carrying_a_value_is_refused_by_default(): + """:rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` reads the target of a remove in its path alone.""" + with pytest.raises(InvalidValueException): + entra_remove([{"value": "s-foobar"}]).patch(group_with_members()) + + +def test_a_remove_carrying_a_value_is_refused_at_validation_by_default(): + """The refusal answers the peer at parse time, before anything is applied.""" + with pytest.raises(ValidationError, match="carries no value"): + PatchOp[Group].model_validate( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "remove", "path": "members", "value": [{"value": "x"}]} + ], + }, + scim_ctx=Context.RESOURCE_PATCH_REQUEST, + ) + + +def test_a_remove_carrying_a_value_passes_validation_under_apply(): + """Reading the value as a selection means accepting the payload that carries one.""" + patch_op = PatchOp[Group].model_validate( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "remove", "path": "members", "value": [{"value": "x"}]} + ], + }, + scim_ctx=Context.RESOURCE_PATCH_REQUEST, + scim_policy=APPLY, + ) + + assert patch_op.operations[0].value == [{"value": "x"}] + + +def test_the_entra_remove_form_drops_the_member_it_names(): + """Entra sends the selection as a list of objects, which is scim2-server issue 13.""" + group = group_with_members() + + assert entra_remove([{"value": "s-foobar"}]).patch(group, scim_policy=APPLY) + assert [member.value for member in group.members] == ["autre"] + + +def test_a_remove_value_given_as_a_bare_object_drops_the_member_it_names(): + """The same selection written without its enclosing list means the same thing.""" + group = group_with_members() + + assert entra_remove({"value": "s-foobar"}).patch(group, scim_policy=APPLY) + assert [member.value for member in group.members] == ["autre"] + + +def test_a_selection_matches_a_member_carrying_more_than_it_names(): + """Entra names a member by its value alone, where the member also carries a display.""" + group = group_with_members() + + entra_remove([{"value": "s-foobar"}]).patch(group, scim_policy=APPLY) + + assert all(member.display is not None for member in group.members) + + +def test_a_selection_naming_several_sub_attributes_matches_on_all_of_them(): + """Every sub-attribute the selection names must match, as a filter joined by ``and``.""" + group = group_with_members() + + entra_remove([{"value": "s-foobar", "display": "Autre"}]).patch( + group, scim_policy=APPLY + ) + + assert [member.value for member in group.members] == ["s-foobar", "autre"] + + +def test_a_selection_that_matches_nothing_is_a_success(): + """:rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` answers a membership that was not there with a success.""" + group = group_with_members() + + assert entra_remove([{"value": "absent"}]).patch(group, scim_policy=APPLY) is False + assert len(group.members) == 2 + + +def test_a_path_that_already_selects_refuses_a_value_even_under_apply(): + """Two selections are two intentions, and nothing says which one to honour.""" + patch_op = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path='members[display eq "Foo Bar"]', + value=[{"value": "s-foobar"}], + ) + ] + ) + + with pytest.raises(InvalidValueException): + patch_op.patch(group_with_members(), scim_policy=APPLY) + + +def test_a_remove_value_that_names_no_sub_attribute_is_refused(): + """A selection is built from sub-attribute names, which a bare value does not carry.""" + with pytest.raises(InvalidValueException): + entra_remove("s-foobar").patch(group_with_members(), scim_policy=APPLY) + + +def test_an_ambient_policy_reaches_a_remove_carrying_a_value(): + """A server states once per request what it tolerates, patches included.""" + group = group_with_members() + + with APPLY: + entra_remove([{"value": "s-foobar"}]).patch(group) + + assert [member.value for member in group.members] == ["autre"] diff --git a/tests/test_provider.py b/tests/test_provider.py index 1fbb9921..997750ff 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -48,6 +48,7 @@ def resource_type(name, endpoint, schema, extensions=()): def test_a_provider_publishes_the_schema_of_each_model(): + """Every model contributes one schema to ``/Schemas``, in the order it was listed.""" provider = ScimProvider(models=[User, Group, PetOwner]) assert [schema.id for schema in provider.schemas] == [ @@ -93,6 +94,8 @@ def test_a_model_that_is_neither_a_resource_nor_an_extension_is_refused(): def test_a_model_declaring_no_schema_is_refused(): + """A model with no ``__schema__`` cannot be indexed, nor named by a resource type.""" + class Anonymous(Resource[Any]): label: str | None = None @@ -104,6 +107,7 @@ class Anonymous(Resource[Any]): def test_a_resource_type_composes_its_resource_with_its_extensions(): + """The catalogue holds bare models, and the resource type says how to assemble them.""" provider = ScimProvider( models=[User, EnterpriseUser], resource_types=[ResourceType.from_resource(User[EnterpriseUser])], @@ -113,6 +117,7 @@ def test_a_resource_type_composes_its_resource_with_its_extensions(): def test_a_required_extension_is_composed_as_required(): + """``schemaExtensions.required`` survives composition and refuses a creation without it.""" provider = ScimProvider( models=[User, EnterpriseUser], resource_types=[ @@ -152,6 +157,7 @@ def test_two_resource_types_serve_two_variants_of_one_schema(): def test_resource_types_are_derived_without_extensions_when_they_are_left_out(): + """A service whose resources carry no extension needs to declare nothing.""" provider = ScimProvider(models=[User, Group]) assert [(rt.name, rt.endpoint) for rt in provider.resource_types] == [ @@ -162,11 +168,13 @@ def test_resource_types_are_derived_without_extensions_when_they_are_left_out(): def test_a_resource_type_naming_a_schema_no_model_describes_is_refused(): + """A resource type the provider cannot compose is a broken description.""" with pytest.raises(ScimProviderError, match=str(Group.__schema__)): ScimProvider(models=[User], resource_types=[ResourceType.from_resource(Group)]) def test_a_resource_type_naming_an_extension_no_model_describes_is_refused(): + """An extension is looked up in the catalogue like the resource it extends.""" with pytest.raises(ScimProviderError, match=str(EnterpriseUser.__schema__)): ScimProvider( models=[User], @@ -207,6 +215,7 @@ def test_a_schema_uri_answers_the_bare_model(): def test_a_name_and_an_endpoint_answer_the_composed_model(): + """The four ways of naming a resource type all reach the same composed class.""" provider = ScimProvider( models=[User, EnterpriseUser], resource_types=[ResourceType.from_resource(User[EnterpriseUser])], @@ -282,6 +291,7 @@ def test_the_discovery_resources_are_known_without_being_registered(): def test_two_resource_types_sharing_a_name_are_refused(): + """The name is a lookup key, and ``meta.resourceType`` could not tell the two apart.""" with pytest.raises(ScimProviderError, match="User"): ScimProvider( models=[User, Group], @@ -293,6 +303,7 @@ def test_two_resource_types_sharing_a_name_are_refused(): def test_two_resource_types_sharing_an_endpoint_are_refused(): + """The endpoint is a lookup key, and a URL could not tell the two apart.""" with pytest.raises(ScimProviderError, match="/Users"): ScimProvider( models=[User, Group], @@ -307,6 +318,7 @@ def test_two_resource_types_sharing_an_endpoint_are_refused(): def test_a_provider_carries_the_service_provider_config(): + """What a service announces travels with what it serves.""" config = ServiceProviderConfig(documentation_uri="https://example.com") provider = ScimProvider(models=[User], config=config) @@ -368,6 +380,7 @@ def test_a_required_extension_survives_discovery(): def test_an_optional_extension_survives_discovery(): + """An extension declared optional stays optional once the models are rebuilt.""" served = ScimProvider( models=[User, EnterpriseUser], resource_types=[ResourceType.from_resource(User[EnterpriseUser])], @@ -392,6 +405,7 @@ def test_discovering_a_resource_type_whose_base_schema_is_missing_is_refused(): def test_a_discovered_provider_carries_the_config_it_is_given(): + """``/ServiceProviderConfig`` is part of what a client learns about its peer.""" config = ServiceProviderConfig(documentation_uri="https://example.com") served = ScimProvider(models=[User]) diff --git a/tests/test_resource_extension.py b/tests/test_resource_extension.py index 0c16dc13..4b7d552b 100644 --- a/tests/test_resource_extension.py +++ b/tests/test_resource_extension.py @@ -372,7 +372,6 @@ def test_class_getitem(): UserEnt2 = UserEnt[EnterpriseUser] assert UserEnt is UserEnt2 - # Test line 178: invalid extension type raises TypeError with pytest.raises(TypeError, match="is not a valid Extension type"): User[str]