Source code for scim2_server.memory

import datetime
import itertools
import uuid
from collections.abc import Callable
from collections.abc import Generator
from contextlib import contextmanager
from threading import RLock
from typing import Any
from typing import Union
from typing import cast

from scim2_models import AttributeBinding
from scim2_models import InvalidCursorException
from scim2_models import Meta
from scim2_models import NotFoundException
from scim2_models import Path
from scim2_models import PreconditionFailedException
from scim2_models import Resource
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import ScimFilter
from scim2_models import SearchRequest
from scim2_models import Uniqueness
from scim2_models import UniquenessException
from scim2_models.path import CompareOperator
from scim2_models.path import Comparison

from scim2_server.storage import AsyncScimStorage
from scim2_server.storage import ScimStorage
from scim2_server.storage import SearchPage
from scim2_server.storage import projection
from scim2_server.storage import search_page
from scim2_server.utils import parametrize


def utcnow() -> datetime.datetime:
    """Return the current date, in UTC."""
    return datetime.datetime.now(datetime.UTC)


def _page(
    search_request: SearchRequest[Any], position: Any, resources: list[Resource[Any]]
) -> SearchPage:
    """Sort every matching resource by its sort value then its identifier, and return the page a search asks for.

    A cursor position holds the sort value and the identifier of the resource
    the page starts after, or ends before, so the pages stay stable.
    """
    descending = (
        search_request.sort_by is not None
        and search_request.sort_order == SearchRequest.SortOrder.descending
    )
    pairs = sorted(
        ((_sort_key(search_request, resource), resource) for resource in resources),
        key=lambda pair: pair[0],
        reverse=descending,
    )
    found = [resource for _, resource in pairs]
    if search_request.cursor is None:
        start = search_request.start_index_0 or 0
        stop = None if search_request.count is None else start + search_request.count
        return SearchPage(len(found), found[start:stop])

    if search_request.count == 0:
        return SearchPage(len(found), [])

    start, stop = _bounds(
        [key for key, _ in pairs], position, search_request.count, descending
    )
    page = SearchPage(len(found), found[start:stop])
    if page.resources and stop < len(found):
        page.next = _position("next", pairs[stop - 1][0])
    if page.resources and start > 0:
        page.previous = _position("previous", pairs[start][0])
    return page


SortKey = tuple[bool, Any, str]


def _sort_key(search_request: SearchRequest[Any], resource: Resource[Any]) -> SortKey:
    """Return the key that orders a resource: its sort value, missing values last, then its identifier."""
    value = search_request.sort_value(resource)
    return value is None, value if value is not None else "", cast(str, resource.id)


def _bounds(
    keys: list[SortKey], position: Any, count: int | None, descending: bool
) -> tuple[int, int]:
    """Return the slice of the sorted keys that a cursor page covers."""
    if position is None:
        return 0, len(keys) if count is None else min(count, len(keys))

    direction, anchor = _read_position(position)

    def before(key: SortKey) -> bool:
        return key > anchor if descending else key < anchor

    if direction == "next":
        start = sum(1 for key in keys if before(key) or key == anchor)
        stop = len(keys) if count is None else min(start + count, len(keys))
        return start, stop

    stop = sum(1 for key in keys if before(key))
    start = 0 if count is None else max(stop - count, 0)
    return start, stop


def _position(direction: str, key: SortKey) -> dict[str, Any]:
    """Return the position of the page that starts after, or ends before, a sort key."""
    missing, value, identifier = key
    return {"d": direction, "k": None if missing else _encode(value), "i": identifier}


def _read_position(position: Any) -> tuple[str, SortKey]:
    """Return the direction and the sort key of a position that _page gave."""
    try:
        direction, value, identifier = position["d"], position["k"], position["i"]
    except (TypeError, KeyError):
        raise InvalidCursorException from None
    if direction not in ("next", "previous") or not isinstance(identifier, str):
        raise InvalidCursorException
    if value is None:
        return direction, (True, "", identifier)
    return direction, (False, _decode(value), identifier)


def _encode(value: Any) -> Any:
    """Return a sort value in a form JSON can hold."""
    if isinstance(value, datetime.datetime):
        return {"datetime": value.isoformat()}
    if isinstance(value, str | bool | int | float):
        return value
    raise TypeError(f"A cursor cannot hold a sort value of type {type(value).__name__}")


def _decode(value: Any) -> Any:
    """Return the sort value that _encode gave."""
    if not isinstance(value, dict):
        return value
    try:
        return datetime.datetime.fromisoformat(value["datetime"])
    except (KeyError, TypeError, ValueError):
        raise InvalidCursorException from None


Key = tuple[str | None, str]


def _key(resource: Resource[Any]) -> Key:
    """Return the resource type and the identifier a resource is stored under.

    Per RFC 7643 §3.1, meta.resourceType holds the name of the resource
    type, which may differ from its id.
    """
    assert resource.meta is not None
    return resource.meta.resource_type, cast(str, resource.id)


def _equality(scim_filter: ScimFilter[Any] | None) -> AttributeBinding | None:
    """Return the attribute a filter compares to a string with eq, if it holds a single string."""
    if scim_filter is None or scim_filter.model is None:
        return None
    node = scim_filter.ast
    if (
        not isinstance(node, Comparison)
        or node.op != CompareOperator.eq
        or not isinstance(node.value, str)
    ):
        return None
    binding = scim_filter.resolve_comparison(node.attr_path, strict=False)
    if binding is None or binding.is_multivalued or binding.target_type is not str:
        return None
    return binding


def _hashable(value: Any) -> Any:
    """Return a form of a value that a dict can hold as a key.

    Lists and models cannot be hashed, so they are held by their
    representation.
    """
    try:
        hash(value)
    except TypeError:
        return repr(value)
    return value


[docs] class InMemoryStorage(ScimStorage): """A storage keeping the resources in memory, for tests, debugging and demos. It indexes the resources by identifier and the unique values. A search walks every resource of the searched types, unless it compares a single string attribute to a string with ``eq``. A lock serializes every access, so it can serve a threaded server. It indexes the values under the policy in effect when they were written, so it serves the resources of a single policy. :param clock: Return the date of a write, for ``meta.created`` and ``meta.lastModified``. Pass a fixed clock to get predictable dates. """ supports_cursors = True def __init__(self, clock: Callable[[], datetime.datetime] = utcnow) -> None: self._resources: dict[str | None, dict[str, Resource[Any]]] = {} self._owners: dict[str, dict[Any, Key]] = {} self._unique_values: dict[Key, dict[str, Any]] = {} self._equalities: dict[ str | None, dict[AttributeBinding, dict[Any, set[str]]] ] = {} self._equal_values: dict[Key, dict[AttributeBinding, Any]] = {} self.clock = clock self.lock = RLock() self.versions = itertools.count(1) @property def resources(self) -> list[Resource[Any]]: """The stored resources.""" return [ resource for resources in self._resources.values() for resource in resources.values() ] @resources.setter def resources(self, resources: list[Resource[Any]]) -> None: self._resources = {} self._owners = {} self._unique_values = {} self._equalities = {} self._equal_values = {} for resource in resources: self._store(resource, self._values_of(resource))
[docs] def generate_id(self, resource_type: ResourceType, resource: Resource[Any]) -> str: """Return the identifier of a new resource. Override this method to get predictable identifiers. """ return uuid.uuid4().hex
[docs] def next_version(self) -> str: """Return the version of a new write. Versions come from a counter, so two writes never share a version, even within the same microsecond. """ return f'W/"{next(self.versions)}"'
[docs] @contextmanager def operation(self) -> Generator[None]: """Hold the lock for the whole operation.""" with self.lock: yield
def get( self, resource_type: ResourceType, resource_id: str, *, response_parameters: ResponseParameters[Any] | None = None, ) -> Resource[Any]: with self.lock: return self._stored(resource_type, resource_id).model_copy(deep=True) def search( self, resource_types: list[ResourceType], search_request: SearchRequest[Any], *, position: Any = None, ) -> SearchPage: with self.lock: names = list(dict.fromkeys(rt.name for rt in resource_types)) scim_filter = search_request.filter candidates = self._candidates(names, scim_filter) if scim_filter is not None and not scim_filter.models and candidates: models = tuple(dict.fromkeys(type(r) for r in candidates)) scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007 found = [ r for r in candidates if scim_filter is None or scim_filter.match(r) ] page = _page(search_request, position, found) page.resources = [r.model_copy(deep=True) for r in page.resources] return page def create( self, resource_type: ResourceType, resource: Resource[Any] ) -> Resource[Any]: with self.lock: resource = resource.model_copy(deep=True) resource.id = self.generate_id(resource_type, resource) now = self.clock() resource.meta = Meta( resource_type=resource_type.name, created=now, last_modified=now, version=self.next_version(), ) values = self._values_of(resource) self._check_uniqueness(resource, values) self._store(resource, values) return resource.model_copy(deep=True) def update( self, resource_type: ResourceType, resource: Resource[Any], *, expected_version: str | None = None, ) -> Resource[Any]: with self.lock: stored = self._stored(resource_type, resource.id) self._check_version(stored, expected_version) updated = type(resource).model_validate(resource.model_dump()) assert stored.meta is not None updated.meta = stored.meta.model_copy( update={ "location": None, "last_modified": self.clock(), "version": self.next_version(), } ) values = self._values_of(updated) self._check_uniqueness(updated, values) self._store(updated, values) return updated.model_copy(deep=True) def delete( self, resource_type: ResourceType, resource_id: str, *, expected_version: str | None = None, ) -> None: with self.lock: stored = self._stored(resource_type, resource_id) self._check_version(stored, expected_version) self._remove(_key(stored)) def _candidates( self, names: list[str | None], scim_filter: ScimFilter[Any] | None ) -> list[Resource[Any]]: """Return the resources a search walks. A search on a single resource type, comparing a single string attribute to a string with eq, walks only the resources holding an equal value. The values are indexed on the first such search. """ binding = _equality(scim_filter) if len(names) == 1 else None if binding is None: return [ resource for name in names for resource in self._resources.get(name, {}).values() ] node = cast(Comparison, cast(ScimFilter[Any], scim_filter).ast) try: value = binding.comparable(node.value) except ValueError: return [] resources = self._resources.get(names[0], {}) ids = self._equality_index(names[0], binding).get(value, set()) return [resources[resource_id] for resource_id in ids] def _equality_index( self, name: str | None, binding: AttributeBinding ) -> dict[Any, set[str]]: """Return the identifiers of the resources of a type, by the value of an attribute.""" indexes = self._equalities.setdefault(name, {}) if binding not in indexes: indexes[binding] = {} for resource in self._resources.get(name, {}).values(): self._index_equality(resource, binding) return indexes[binding] def _index_equality( self, resource: Resource[Any], binding: AttributeBinding ) -> None: key = _key(resource) value = self._comparable_value(resource, binding) self._equal_values.setdefault(key, {})[binding] = value self._equalities[key[0]][binding].setdefault(value, set()).add(key[1]) def _store(self, resource: Resource[Any], values: dict[str, Any]) -> None: """Keep a resource under its resource type and its identifier, and index its values.""" key = _key(resource) self._remove(key) self._resources.setdefault(key[0], {})[key[1]] = resource self._unique_values[key] = values for urn, value in values.items(): self._owners.setdefault(urn, {})[value] = key for binding in self._equalities.get(key[0], {}): self._index_equality(resource, binding) def _remove(self, key: Key) -> None: """Forget a resource and its indexed values.""" self._resources.get(key[0], {}).pop(key[1], None) for urn, value in self._unique_values.pop(key, {}).items(): del self._owners[urn][value] for binding, value in self._equal_values.pop(key, {}).items(): self._equalities[key[0]][binding][value].discard(key[1]) def _stored( self, resource_type: ResourceType, resource_id: str | None ) -> Resource[Any]: try: return self._resources[resource_type.name][cast(str, resource_id)] except KeyError: raise NotFoundException( detail=f"{resource_type.name} {resource_id!r} not found" ) from None @staticmethod def _check_version(resource: Resource[Any], expected_version: str | None) -> None: assert resource.meta is not None if expected_version is not None and resource.meta.version != expected_version: raise PreconditionFailedException def _check_uniqueness( self, resource: Resource[Any], values: dict[str, Any] ) -> None: """Refuse a resource sharing a unique value with another resource.""" key = _key(resource) for urn, value in values.items(): if self._owners.get(urn, {}).get(value, key) != key: raise UniquenessException() def _values_of(self, resource: Resource[Any]) -> dict[str, Any]: """Return the unique values of a resource, by the URN of their attribute. Per RFC 7643 erratum 8279, the uniqueness applies to the resources using the schema that declares the attribute, whatever their resource type. The URN of an attribute holds its schema, so two resources clash when they hold the same value under the same URN. A missing value never clashes, as a SQL NULL does not. The values are compared under the policy of the write that stored them, so a storage serves the resources of a single policy. """ unique_paths = parametrize(Path, type(resource)).iter_paths( include_subattributes=False, uniqueness=[Uniqueness.server, Uniqueness.global_], ) values = {} for path in unique_paths: attribute = cast(AttributeBinding, path.resolve()) value = self._comparable_value(resource, attribute) if value is not None: values[attribute.urn] = _hashable(value) return values @staticmethod def _comparable_value(resource: Resource[Any], attribute: AttributeBinding) -> Any: """Return the value a resource holds for an attribute, in the form it is compared in. A resource whose schemas do not declare the attribute holds no value. A string the policy cannot compare is equal to no other value, so it is held as missing too. """ value = parametrize(Path, type(resource))(attribute.urn).get( resource, strict=False ) try: return attribute.comparable(value) except ValueError: return None
[docs] class AsyncInMemoryStorage(AsyncScimStorage): """The asynchronous variant of :class:`InMemoryStorage`. It serves the resources of an :class:`InMemoryStorage`. Every call runs without awaiting anything, so a call is never interrupted by another coroutine. :meth:`~scim2_server.storage.AsyncScimStorage.operation` takes no lock: holding the lock of the storage across an ``await`` would block the event loop. Two concurrent updates of a resource are still told apart by ``expected_version``. :param storage: The storage to serve. Pass a subclass of :class:`InMemoryStorage` to change how identifiers are generated. """ supports_cursors = True def __init__(self, storage: InMemoryStorage | None = None) -> None: self.storage = storage if storage is not None else InMemoryStorage() @property def resources(self) -> list[Resource[Any]]: """The stored resources.""" return self.storage.resources async def get( self, resource_type: ResourceType, resource_id: str, *, response_parameters: ResponseParameters[Any] | None = None, ) -> Resource[Any]: return self.storage.get( resource_type, resource_id, **projection(self.storage, response_parameters), ) async def search( self, resource_types: list[ResourceType], search_request: SearchRequest[Any], *, position: Any = None, ) -> SearchPage: return search_page(self.storage, resource_types, search_request, position) async def create( self, resource_type: ResourceType, resource: Resource[Any] ) -> Resource[Any]: return self.storage.create(resource_type, resource) async def update( self, resource_type: ResourceType, resource: Resource[Any], *, expected_version: str | None = None, ) -> Resource[Any]: return self.storage.update( resource_type, resource, expected_version=expected_version ) async def delete( self, resource_type: ResourceType, resource_id: str, *, expected_version: str | None = None, ) -> None: self.storage.delete( resource_type, resource_id, expected_version=expected_version )