Source code for scim2_server.storage

import inspect
import warnings
from abc import ABC
from abc import abstractmethod
from contextlib import AbstractAsyncContextManager
from contextlib import AbstractContextManager
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Any
from typing import cast

from scim2_models import Resource
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import SearchRequest


def _accepts(storage_class: type, method: str, parameter: str) -> bool:
    """Tell whether a method of a storage accepts a keyword argument, and warn when it does not."""
    parameters = inspect.signature(getattr(storage_class, method)).parameters.values()
    if any(
        candidate.name == parameter or candidate.kind is inspect.Parameter.VAR_KEYWORD
        for candidate in parameters
    ):
        return True
    warnings.warn(
        f"{storage_class.__qualname__}.{method} should accept a {parameter} "
        "keyword argument. scim2-server 0.9 will require it.",
        DeprecationWarning,
        # _accepts, __init_subclass__, ABCMeta.__new__, then the class statement
        stacklevel=4,
    )
    return False


def projection(
    storage: "ScimStorage | AsyncScimStorage",
    response_parameters: ResponseParameters[Any] | None,
) -> dict[str, Any]:
    """Return the arguments that pass the response parameters to the get method of a storage, if it accepts them."""
    if not storage._get_takes_response_parameters:
        return {}
    return {"response_parameters": response_parameters}


[docs] @dataclass class SearchPage: """A page of found resources, as :meth:`ScimStorage.search` returns it.""" total: int | None """The number of matching resources, on every page. It may be :data:`None` when the resources are paged with a cursor (:rfc:`RFC 9865 §2 <9865#section-2>`). """ resources: list[Resource[Any]] """The resources of the page.""" next: Any = None """The position of the next page, or :data:`None` on the last page.""" previous: Any = None """The position of the previous page, or :data:`None` on the first page."""
def search_page( storage: "ScimStorage", resource_types: list[ResourceType], search_request: SearchRequest[Any], position: Any, ) -> SearchPage: """Search a storage, whether its search method accepts a position or not. This function goes away with the deprecated search methods in scim2-server 0.9. """ if storage._search_takes_position: result = storage.search(resource_types, search_request, position=position) else: result = storage.search(resource_types, search_request) return _page_of(result) async def async_search_page( storage: "AsyncScimStorage", resource_types: list[ResourceType], search_request: SearchRequest[Any], position: Any, ) -> SearchPage: """Search an asynchronous storage, as search_page does. This function goes away with the deprecated search methods in scim2-server 0.9. """ if storage._search_takes_position: result = await storage.search(resource_types, search_request, position=position) else: result = await storage.search(resource_types, search_request) return _page_of(result) def _page_of(result: Any) -> SearchPage: """Return the page a search returned, as a deprecated search returns the number of resources and the page.""" if isinstance(result, tuple): return SearchPage(*result) return cast(SearchPage, result)
[docs] class ScimStorage(ABC): """Where a SCIM server reads and writes its resources. Subclass it to connect the server to a data source, such as a SQL database or a directory. The server handles the SCIM protocol, and calls these methods to read, search, create, update and delete resources. Every method receives the :class:`~scim2_models.ResourceType` it applies to, so a single storage can serve several resource types. A storage follows these rules, which :class:`~scim2_server.testing.ScimStorageContract` checks: - Every resource it returns is a copy. Changing it does not change the stored resource, and the resources it receives are not changed either. - It fills ``id``, ``meta.resourceType``, ``meta.created``, ``meta.lastModified`` and ``meta.version``. It leaves ``meta.location`` to the server, which knows the URLs. For the same reason, a reference to another resource, such as ``members.$ref``, can be relative to the SCIM root, such as ``Users/2819c223``. The server returns its URL. - The version changes whenever the resource changes. An update that changes nothing may keep it. - A resource that does not exist raises :class:`~scim2_models.NotFoundException`. - A value already taken by an attribute whose uniqueness is ``server`` or ``global`` raises :class:`~scim2_models.UniquenessException`. - It supports what the :class:`~scim2_models.ServiceProviderConfig` of the server announces, such as sorting. The server refuses the rest before calling the storage. """ supports_cursors: bool = False """Whether :meth:`search` pages the resources with cursors that give stable pages. Set it to :data:`True` once :meth:`search` follows the rules of the cursors. The server refuses to start when its configuration announces cursor pagination and the storage does not support it. """ _get_takes_response_parameters = True _search_takes_position = True def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) cls._get_takes_response_parameters = _accepts(cls, "get", "response_parameters") cls._search_takes_position = _accepts(cls, "search", "position")
[docs] @abstractmethod def get( self, resource_type: ResourceType, resource_id: str, *, response_parameters: ResponseParameters[Any] | None = None, ) -> Resource[Any]: """Return a resource. The server passes ``response_parameters`` when it returns the resource to the client, for a GET request. The storage may then leave out the attributes the response does not keep, for instance to avoid loading the members of a group. It must return every attribute the response keeps. :meth:`Path.iter_paths <scim2_models.Path.iter_paths>` gives them. The server applies the parameters to the response anyway. Without ``response_parameters``, the storage returns the whole resource. A ``get`` method without ``response_parameters`` is deprecated, and raises a :class:`DeprecationWarning`. The server then does not pass the parameters. scim2-server 0.9 will require it. :raises ~scim2_models.NotFoundException: When no resource of this type has this identifier. """
[docs] @abstractmethod def search( self, resource_types: list[ResourceType], search_request: SearchRequest[Any], *, position: Any = None, ) -> SearchPage: """Return one page of the matching resources, and their number. The search request is already validated, and its ``count`` is already bounded by the ``maxResults`` of the server. The storage filters, sorts and pages the resources of every given resource type as a single collection. Several resource types mean a search at the server root. An attribute that a resource type does not declare matches none of its resources (:rfc:`RFC 7644 §3.4.2.1 <7644#section-3.4.2.1>`). A :attr:`~scim2_models.SearchRequest.start_index` pages the resources by index. A :attr:`~scim2_models.SearchRequest.cursor` that is not :data:`None` pages them with a cursor (:rfc:`RFC 9865 <9865>`), when the storage :attr:`supports_cursors`. The storage then returns the ``next`` and ``previous`` positions of the page, as values that JSON can hold. The server hides them in the cursors it gives the client, and passes them back as ``position``. ``position`` is :data:`None` on the first page. The cursors give stable pages. A position holds the sort value and the identifier of the last resource of the page, or of the first one for the previous page. A resource that exists during the whole paging, and whose sort value does not change, is returned exactly once. A resource created or deleted meanwhile is returned or not, depending on where it sorts. A ``search`` method without ``position`` is deprecated, and raises a :class:`DeprecationWarning`. It returns the number of matching resources and the page, and pages by index only. scim2-server 0.9 will require ``position``. :raises ~scim2_models.InvalidFilterException: When the storage cannot evaluate the filter. Filtering the page afterwards would make ``totalResults`` and the paging wrong. :raises ~scim2_models.InvalidCursorException: When the storage cannot read ``position``. :raises ~scim2_models.NotImplementedException: When the storage does not support searching several resource types at once. """
[docs] @abstractmethod def create( self, resource_type: ResourceType, resource: Resource[Any] ) -> Resource[Any]: """Store a new resource, and return the stored resource. :raises ~scim2_models.UniquenessException: When a unique value is taken. """
[docs] @abstractmethod def update( self, resource_type: ResourceType, resource: Resource[Any], *, expected_version: str | None = None, ) -> Resource[Any]: """Replace the stored resource that has the identifier of ``resource``, and return the stored resource. The server calls it for PUT and PATCH requests. It applies the request to the stored resource first, so ``resource`` is the whole new state of the resource. :param expected_version: The version the stored resource must still have, when given. :raises ~scim2_models.NotFoundException: When the resource does not exist. :raises ~scim2_models.PreconditionFailedException: When the stored version is not ``expected_version``. :raises ~scim2_models.UniquenessException: When a unique value is taken. """
[docs] @abstractmethod def delete( self, resource_type: ResourceType, resource_id: str, *, expected_version: str | None = None, ) -> None: """Delete a resource. :param expected_version: The version the stored resource must still have, when given. :raises ~scim2_models.NotFoundException: When the resource does not exist. :raises ~scim2_models.PreconditionFailedException: When the stored version is not ``expected_version``. """
[docs] def operation(self) -> AbstractContextManager[None]: """Enclose one SCIM operation: a single request, or one operation of a bulk request. It does nothing by default. A SQL storage can open a savepoint here, so that a failed operation does not prevent the next ones of a bulk request (:rfc:`RFC 7644 §3.7 <7644#section-3.7>`). Committing the request is left to the application. """ return nullcontext()
[docs] class AsyncScimStorage(ABC): """The asynchronous variant of :class:`ScimStorage`. Its methods are coroutines, and follow the rules of :class:`ScimStorage`, which :class:`~scim2_server.testing.AsyncScimStorageContract` checks. """ supports_cursors: bool = False """Whether :meth:`search` pages the resources with cursors that give stable pages. See :attr:`ScimStorage.supports_cursors`.""" _get_takes_response_parameters = True _search_takes_position = True def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) cls._get_takes_response_parameters = _accepts(cls, "get", "response_parameters") cls._search_takes_position = _accepts(cls, "search", "position")
[docs] @abstractmethod async def get( self, resource_type: ResourceType, resource_id: str, *, response_parameters: ResponseParameters[Any] | None = None, ) -> Resource[Any]: """Return a resource. See :meth:`ScimStorage.get`."""
[docs] @abstractmethod async def search( self, resource_types: list[ResourceType], search_request: SearchRequest[Any], *, position: Any = None, ) -> SearchPage: """Return one page of the matching resources, and their number. See :meth:`ScimStorage.search`."""
[docs] @abstractmethod async def create( self, resource_type: ResourceType, resource: Resource[Any] ) -> Resource[Any]: """Store a new resource. See :meth:`ScimStorage.create`."""
[docs] @abstractmethod async def update( self, resource_type: ResourceType, resource: Resource[Any], *, expected_version: str | None = None, ) -> Resource[Any]: """Replace a stored resource. See :meth:`ScimStorage.update`."""
[docs] @abstractmethod async def delete( self, resource_type: ResourceType, resource_id: str, *, expected_version: str | None = None, ) -> None: """Delete a resource. See :meth:`ScimStorage.delete`."""
[docs] def operation(self) -> AbstractAsyncContextManager[None]: """Enclose one SCIM operation. See :meth:`ScimStorage.operation`.""" return nullcontext()