Source code for scim2_server.service

from collections.abc import Mapping
from dataclasses import replace
from http import HTTPStatus
from typing import Any
from typing import TypeVar
from typing import Union
from typing import cast

from pydantic import ValidationError
from pydantic_core import from_json
from scim2_models import AuthenticationScheme
from scim2_models import BaseModel
from scim2_models import Bulk
from scim2_models import BulkOperation
from scim2_models import BulkRequest
from scim2_models import BulkResponse
from scim2_models import Context
from scim2_models import Error
from scim2_models import Filter
from scim2_models import ForbiddenException
from scim2_models import InvalidSyntaxException
from scim2_models import InvalidValueException
from scim2_models import ListResponse
from scim2_models import Meta
from scim2_models import NotFoundException
from scim2_models import NotImplementedException
from scim2_models import Patch
from scim2_models import PatchOp
from scim2_models import PayloadTooLargeException
from scim2_models import Resource
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import Schema
from scim2_models import SCIMException
from scim2_models import ScimProvider
from scim2_models import SearchRequest
from scim2_models import ServiceProviderConfig
from scim2_models import Sort

from scim2_server.bulk import BulkPlan
from scim2_server.conditions import Conditions
from scim2_server.errors import MethodNotAllowedException
from scim2_server.errors import UnsupportedMediaTypeException
from scim2_server.requests import ScimRequest
from scim2_server.responses import ScimResponse
from scim2_server.routing import Operation
from scim2_server.routing import Target
from scim2_server.routing import match
from scim2_server.utils import load_default_service_provider_config
from scim2_server.utils import parametrize


def scim_exception_of(exception: ValidationError) -> SCIMException:
    """Return the SCIM exception of an invalid request."""
    return SCIMException.from_error(Error.from_validation_errors(exception)[0])


BULK_SUCCESS_STATUS = {
    BulkOperation.Method.post: HTTPStatus.CREATED,
    BulkOperation.Method.put: HTTPStatus.OK,
    BulkOperation.Method.patch: HTTPStatus.OK,
    BulkOperation.Method.delete: HTTPStatus.NO_CONTENT,
}

BULK_OPERATIONS = {
    BulkOperation.Method.post: Operation.create,
    BulkOperation.Method.put: Operation.replace,
    BulkOperation.Method.patch: Operation.patch,
    BulkOperation.Method.delete: Operation.delete,
}

CHALLENGES = {
    AuthenticationScheme.Type.oauthbearertoken: 'Bearer realm="SCIM"',
    AuthenticationScheme.Type.oauth2: 'Bearer realm="SCIM"',
    AuthenticationScheme.Type.httpbasic: 'Basic realm="SCIM"',
}

SEARCH_REQUEST_PARAMETERS = (
    "attributes",
    "excludedAttributes",
    "filter",
    "sortBy",
    "sortOrder",
    "startIndex",
    "count",
)

ModelT = TypeVar("ModelT", bound=BaseModel)
DiscoveryResourceT = TypeVar(
    "DiscoveryResourceT", ResourceType, Schema, ServiceProviderConfig
)


[docs] def is_json_media_type(content_type: str | None) -> bool: """Tell whether a Content-Type header designates JSON. ``application/scim+json``, ``application/json`` and any other ``application/*+json`` are accepted, whatever their parameters. """ if not content_type: return False media_type = content_type.partition(";")[0].strip().lower() return media_type == "application/json" or ( media_type.startswith("application/") and media_type.endswith("+json") )
[docs] class ScimService: """The SCIM protocol, without input or output. A service validates the requests, applies the PUT and PATCH requests to the resources, evaluates the conditional headers and builds the responses. It neither reads nor writes resources: :class:`~scim2_server.handler.ScimHandler` calls the storage between its steps. It knows nothing about the web framework either: the integration reads the request, and turns the :class:`~scim2_server.responses.ScimResponse` into a response of the framework. Every public method can be overridden. ``base_url`` is the root URL of the SCIM endpoints, as the client sees it, such as ``https://example.com/scim/v2``. """ def __init__(self, provider: ScimProvider): self.provider = provider self.config = provider.config or load_default_service_provider_config() # -- Resource types and models --------------------------------------
[docs] def get_model(self, resource_type: ResourceType) -> type[Resource[Any]]: """Return the model of a resource type, its extensions included.""" return cast(type[Resource[Any]], self.provider.model_for(resource_type))
[docs] def get_models(self) -> list[type[Resource[Any]]]: """Return the models of every resource type.""" return [self.get_model(rt) for rt in self.provider.resource_types]
[docs] def get_resource_type_by_endpoint(self, endpoint: str) -> ResourceType | None: """Return the resource type an endpoint serves, if any.""" return next( ( resource_type for resource_type in self.provider.resource_types if (resource_type.endpoint or "").lstrip("/").casefold() == endpoint.lstrip("/").casefold() ), None, )
# -- Requests -------------------------------------------------------
[docs] @staticmethod def match(request: ScimRequest) -> Target: """Return the operation a request asks for (:rfc:`RFC 7644 §3.2 <7644#section-3.2>`). The target of a ``/Me`` request is not resolved yet. :raises ~scim2_models.NotFoundException: When no endpoint has the path of the request. :raises ~scim2_server.errors.MethodNotAllowedException: When the endpoint does not support the method of the request. """ return match(request.method.upper(), request.path)
[docs] def route(self, request: ScimRequest, operation: Operation) -> Target: """Return what a request for an operation acts on. A ``/Me`` request acts on the resource of :meth:`me_target`, or creates a resource of the type of :meth:`me_creation_type`. :param operation: The operation the caller serves. A request for another operation is a routing error of the integration. :raises ~scim2_models.NotFoundException: When no endpoint has the path of the request. :raises ~scim2_server.errors.MethodNotAllowedException: When the endpoint does not support the method of the request. """ target = self.match(request) if target.operation is not operation: raise RuntimeError( f"{request.method} {request.path} asks for {target.operation.value}, " f"not for {operation.value}" ) if not target.me: return target if operation is Operation.create: resource_type = self.me_creation_type(request) return replace(target, endpoint=self.endpoint_of(resource_type)) resource_type, resource_id = self.me_target(request) return replace( target, endpoint=self.endpoint_of(resource_type), resource_id=resource_id )
[docs] @staticmethod def endpoint_of(resource_type: ResourceType) -> str: """Return the endpoint of a resource type, without its slashes.""" assert resource_type.endpoint is not None return resource_type.endpoint.strip("/")
[docs] def me_target(self, request: ScimRequest) -> tuple[ResourceType, str]: """Return the type and the identifier of the resource ``/Me`` stands for (:rfc:`RFC 7644 §3.11 <7644#section-3.11>`). Override this method to serve ``/Me``. It reads the authenticated subject in :attr:`ScimRequest.subject <scim2_server.requests.ScimRequest.subject>`. The exceptions it raises answer the request. :raises ~scim2_models.NotImplementedException: By default, so that ``/Me`` answers 501. :raises ~scim2_models.UnauthorizedException: When the request has no authenticated subject, for an application that accepts anonymous requests. It answers 401. :raises ~scim2_models.ForbiddenException: When the subject may not use ``/Me``, for a 403. :raises ~scim2_models.NotFoundException: When the subject has no resource, for a 404. """ raise NotImplementedException(detail="/Me is not supported")
[docs] def me_creation_type(self, request: ScimRequest) -> ResourceType: """Return the type of the resource a POST on ``/Me`` creates (:rfc:`RFC 7644 §3.11 <7644#section-3.11>`). Override this method to let the clients register themselves (:rfc:`RFC 7644 §7.6 <7644#section-7.6>`). Linking the created resource to the subject is left to the application. The exceptions it raises answer the request. :raises ~scim2_models.NotImplementedException: By default, so that a POST on ``/Me`` answers 501. :raises ~scim2_models.UnauthorizedException: When the request has no authenticated subject, for an application that accepts anonymous requests. It answers 401. :raises ~scim2_models.ForbiddenException: When the subject may not register, for a 403. :raises ~scim2_models.UniquenessException: When the subject already has a resource, for a 409. """ raise NotImplementedException(detail="POST /Me is not supported")
[docs] def me_response( self, request: ScimRequest, target: Target, response: ScimResponse ) -> ScimResponse: """Add the location of the resource ``/Me`` stands for to a response (:rfc:`RFC 7644 §3.11 <7644#section-3.11>`).""" if target.me and target.resource_id is not None: response.headers["Location"] = self.resource_location( request.base_url, self.resource_type_at(cast(str, target.endpoint)), target.resource_id, ) return response
[docs] def authorize( self, request: ScimRequest, target: Target, resource_type: ResourceType ) -> None: """Accept or refuse an operation on a resource type (:rfc:`RFC 7644 §2 <7644#section-2>`). Override this method to check the rights of the client. It reads the authenticated subject in :attr:`ScimRequest.subject <scim2_server.requests.ScimRequest.subject>`. By default, every operation is accepted. The service calls this method before it validates the request body, and before the handler calls the storage. For a ``/Me`` request, the service calls it after :meth:`me_target` or :meth:`me_creation_type` finds the resource, and :attr:`Target.me <scim2_server.routing.Target.me>` is :data:`True`. The service can call this method several times for one request: once per operation of a bulk request, and once per resource type of a search at the root. The method must not do any input or output. The subject must arrive with everything the decision needs, such as its permissions or the scopes of its token. In a bulk request, ``target`` describes the operation, and ``request`` holds the bulk request. Read the operation in ``target``, never in the method or the path of ``request``. In a search at the root, ``target.endpoint`` is :data:`None`, and ``resource_type`` is one of the searched types. A :class:`~scim2_models.ForbiddenException` removes this type from the search. The search answers 403 when every type is removed. :raises ~scim2_models.ForbiddenException: When the client may not perform the operation, for a 403. :raises ~scim2_models.UnauthorizedException: When the request has no authenticated subject, for an application that accepts anonymous requests. It answers 401. """
[docs] def authorize_bulk_operation( self, request: ScimRequest, operation: BulkOperation[Resource[Any]], resource_type: ResourceType | None, ) -> None: """Authorize a resolved bulk operation with :meth:`authorize`. An operation without a known resource type or a valid method keeps its validation error, without authorization. """ if resource_type is None or operation.method is None: return target = Target( BULK_OPERATIONS[operation.method], endpoint=self.endpoint_of(resource_type), resource_id=operation.resource_id, ) self.authorize(request, target, resource_type)
[docs] def authorized_types( self, request: ScimRequest, target: Target, resource_types: list[ResourceType] ) -> list[ResourceType]: """Return the resource types the client may search, according to :meth:`authorize`. At the root, the refused types are left out of the search (:rfc:`RFC 7644 §3.4.2.1 <7644#section-3.4.2.1>`). :raises ~scim2_models.ForbiddenException: When the client may search none of the types. """ if target.endpoint is not None: for resource_type in resource_types: self.authorize(request, target, resource_type) return resource_types authorized = [] for resource_type in resource_types: try: self.authorize(request, target, resource_type) except ForbiddenException: continue authorized.append(resource_type) if not authorized: raise ForbiddenException(detail="No resource type may be searched") return authorized
[docs] @staticmethod def read_conditions(request: ScimRequest) -> Conditions: """Return the conditional headers of a request.""" return Conditions( if_match=request.header("If-Match"), if_none_match=request.header("If-None-Match"), )
[docs] def max_body_size(self, request: ScimRequest) -> int | None: """Return the largest body the service accepts for a request, in bytes. An integration reads at most one byte more, and leaves the 413 answer to the service. A bulk request is limited by maxPayloadSize (:rfc:`RFC 7644 §3.7.4 <7644#section-3.7.4>`). Other requests have no limit. """ if self.match(request).operation is Operation.bulk: return self.bulk_max_payload_size() return None
[docs] def resource_type_at(self, endpoint: str) -> ResourceType: """Return the resource type an endpoint serves. :raises ~scim2_models.NotFoundException: When no resource type is served at this endpoint. """ resource_type = self.get_resource_type_by_endpoint(endpoint) if resource_type is None: raise NotFoundException(detail=f"No resource type found at {endpoint!r}") return resource_type
[docs] def get_resource_type(self, name: str) -> ResourceType: """Return the resource type of a name, such as ``User``. :raises ValueError: When the provider serves no resource type of this name. """ for resource_type in self.provider.resource_types: if resource_type.name == name: return resource_type raise ValueError(f"No resource type named {name!r}")
[docs] def get_resource_type_of(self, resource: Resource[Any]) -> ResourceType: """Return the resource type of a stored resource, from its meta.resourceType. :raises ValueError: When the storage returned a resource of a type the provider does not serve. """ name = resource.meta.resource_type if resource.meta else None for resource_type in self.provider.resource_types: if resource_type.name == name: return resource_type raise ValueError( f"The storage returned a resource of an unknown resource type: {name!r}" )
# -- Capabilities --------------------------------------------------- @property def etag_supported(self) -> bool: """Whether the configuration declares the resources versioned with ETags.""" return bool(self.config.etag and self.config.etag.supported)
[docs] @staticmethod def ensure_supported( capability: Patch | Bulk | Filter | Sort | None, operation: str ) -> None: """Refuse with a 501 an operation the configuration does not declare supported. :rfc:`RFC 7644 §3.12 <7644#section-3.12>` answers 501 when the service provider does not support the request operation. """ if capability is None or not capability.supported: raise NotImplementedException(detail=f"{operation} is not supported")
[docs] def bulk_max_payload_size(self) -> int | None: """Return the largest bulk request body the service accepts, in bytes. An integration can use it to stop reading a larger body early. """ return self.config.bulk.max_payload_size if self.config.bulk else None
[docs] def ensure_bulk_payload_size(self, size: int) -> None: """Refuse with a 413 a bulk request body larger than maxPayloadSize (:rfc:`RFC 7644 §3.7.4 <7644#section-3.7.4>`). :param size: The size of the body, in bytes. """ limit = self.bulk_max_payload_size() if limit is not None and size > limit: raise self.payload_too_large(limit)
[docs] @staticmethod def payload_too_large(limit: int) -> PayloadTooLargeException: """Return the 413 error of a bulk request body larger than maxPayloadSize.""" return PayloadTooLargeException( detail=f"The payload exceeds the maxPayloadSize ({limit} bytes)" )
# -- Request bodies -------------------------------------------------
[docs] @staticmethod def ensure_json(content_type: str | None) -> None: """Refuse with a 415 a request body that is not JSON.""" if not is_json_media_type(content_type): raise UnsupportedMediaTypeException
[docs] def read_body( self, model: type[ModelT], body: bytes, content_type: str | None, scim_ctx: Context, ) -> ModelT: """Validate a request body with a model, in the context of its operation.""" self.ensure_json(content_type) try: return model.model_validate_json(body, scim_ctx=scim_ctx) except ValidationError as exception: raise scim_exception_of(exception) from exception
[docs] def decode_body(self, body: bytes, content_type: str | None) -> Any: """Return the JSON value of a request body, unvalidated.""" self.ensure_json(content_type) try: return from_json(body) except ValueError as exception: raise InvalidSyntaxException(detail=str(exception)) from exception
# -- Locations ------------------------------------------------------
[docs] def resource_location( self, base_url: str, resource_type: ResourceType, resource_id: str ) -> str: """Return the URL of a resource. Override this method to serve the resources at other URLs. """ assert resource_type.endpoint is not None return ( f"{base_url.rstrip('/')}/{resource_type.endpoint.strip('/')}/{resource_id}" )
[docs] def publish(self, base_url: str, resource: Resource[Any]) -> Resource[Any]: """Return a copy of a stored resource in the form sent to the client. Its location is set, and its version is left out when the service does not support ETags. """ assert resource.meta is not None assert resource.id is not None update: dict[str, Any] = { "location": self.resource_location( base_url, self.get_resource_type_of(resource), resource.id ) } if not self.etag_supported: update["version"] = None return resource.model_copy( update={"meta": resource.meta.model_copy(update=update)} )
[docs] @staticmethod def resource_response( resource: Resource[Any], scim_ctx: Context, response_parameters: ResponseParameters[Any] | None = None, status: HTTPStatus = HTTPStatus.OK, ) -> ScimResponse: """Return the response carrying a published resource, with its ETag.""" assert resource.meta is not None headers = {"ETag": resource.meta.version} if resource.meta.version else {} body = resource.model_dump( scim_ctx=scim_ctx, response_parameters=response_parameters ) return ScimResponse(status, body, headers)
# -- Conditional requests -------------------------------------------
[docs] def check_preconditions( self, resource: Resource[Any], method: str, conditions: Conditions ) -> bool: """Evaluate the conditional headers of a request against a resource. :return: :data:`False` when a GET should answer 304 Not Modified. :raises ~scim2_models.PreconditionFailedException: When the method must not be performed. """ assert resource.meta is not None version = resource.meta.version if self.etag_supported else None return conditions.check(version, method)
# -- Creation -------------------------------------------------------
[docs] def read_creation( self, resource_type: ResourceType, body: bytes, content_type: str | None ) -> Resource[Any]: """Validate the body of a creation request.""" return self.read_body( self.get_model(resource_type), body, content_type, Context.RESOURCE_CREATION_REQUEST, )
[docs] def creation_response(self, base_url: str, created: Resource[Any]) -> ScimResponse: """Return the 201 response to a creation, with the location of the resource.""" resource = self.publish(base_url, created) assert resource.meta is not None and resource.meta.location is not None result = self.resource_response( resource, Context.RESOURCE_CREATION_RESPONSE, status=HTTPStatus.CREATED ) result.headers["Location"] = resource.meta.location return result
# -- Query ----------------------------------------------------------
[docs] def read_response_parameters( self, resource_type: ResourceType, query: Mapping[str, str] ) -> ResponseParameters[Any]: """Read the "attributes" and "excludedAttributes" query parameters.""" parameters = { key: query[key] for key in ("attributes", "excludedAttributes") if key in query } try: return parametrize( ResponseParameters, self.get_model(resource_type) ).model_validate(parameters) except ValidationError as exception: raise scim_exception_of(exception) from exception
[docs] def query_response( self, base_url: str, resource: Resource[Any], response_parameters: ResponseParameters[Any], conditions: Conditions, ) -> ScimResponse: """Return the response to the read of a resource, or a 304.""" published = self.publish(base_url, resource) if not self.check_preconditions(published, "GET", conditions): assert published.meta is not None # RFC 9110 §15.4.5: a 304 carries the ETag a 200 would have headers = {"ETag": published.meta.version} if published.meta.version else {} return ScimResponse(HTTPStatus.NOT_MODIFIED, None, headers) return self.resource_response( published, Context.RESOURCE_QUERY_RESPONSE, response_parameters )
# -- Search --------------------------------------------------------- def search_models( self, resource_types: list[ResourceType] ) -> list[type[Resource[Any]]]: return [self.get_model(rt) for rt in resource_types]
[docs] def read_search_query( self, resource_types: list[ResourceType], query: Mapping[str, str] ) -> SearchRequest[Any]: """Read the query parameters of a search with GET (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`).""" return self.read_search( resource_types, {key: query[key] for key in SEARCH_REQUEST_PARAMETERS if key in query}, )
[docs] def read_search_body( self, resource_types: list[ResourceType], body: bytes, content_type: str | None, ) -> SearchRequest[Any]: """Read the body of a search with POST on ".search" (:rfc:`RFC 7644 §3.4.3 <7644#section-3.4.3>`).""" return self.read_search(resource_types, self.decode_body(body, content_type))
[docs] def searched_types(self, endpoint: str | None) -> list[ResourceType]: """Return the resource types a search covers: those of the endpoint, or all of them at the root.""" if endpoint is None: return list(self.provider.resource_types) return [self.resource_type_at(endpoint)]
[docs] def search_response( self, base_url: str, total_results: int, resources: list[Resource[Any]], search_request: SearchRequest[Any], ) -> ScimResponse: """Return the response listing a page of found resources.""" dumped = [ self.publish(base_url, resource).model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, response_parameters=search_request, ) for resource in resources ] response = parametrize(ListResponse, Union[tuple(self.get_models())])( # noqa: UP007 total_results=total_results, items_per_page=len(dumped), start_index=search_request.start_index, resources=dumped, ) return ScimResponse( HTTPStatus.OK, response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) )
# -- Replacement ----------------------------------------------------
[docs] def read_replacement( self, resource_type: ResourceType, body: bytes, content_type: str | None ) -> Resource[Any]: """Validate the body of a replacement request.""" return self.read_body( self.get_model(resource_type), body, content_type, Context.RESOURCE_REPLACEMENT_REQUEST, )
[docs] def apply_replacement( self, current: Resource[Any], replacement: Resource[Any], conditions: Conditions, ) -> Resource[Any] | None: """Apply a replacement to a stored resource. :return: The new state of the resource, or :data:`None` when the replacement changes nothing. Such a PUT keeps meta.lastModified and the ETag. """ self.check_preconditions(current, "PUT", conditions) return replacement if replacement.replace(current) else None
[docs] def replacement_response( self, base_url: str, resource: Resource[Any], response_parameters: ResponseParameters[Any], ) -> ScimResponse: """Return the response to a replacement.""" return self.resource_response( self.publish(base_url, resource), Context.RESOURCE_REPLACEMENT_RESPONSE, response_parameters, )
# -- Patch ----------------------------------------------------------
[docs] def ensure_patch_supported(self) -> None: """Refuse with a 501 a PATCH when the configuration does not support it.""" self.ensure_supported(self.config.patch, "PATCH")
[docs] def read_patch( self, resource_type: ResourceType, body: bytes, content_type: str | None ) -> "PatchOp[Any]": """Validate the body of a PATCH request.""" self.ensure_patch_supported() return self.read_body( parametrize(PatchOp, self.get_model(resource_type)), body, content_type, Context.RESOURCE_PATCH_REQUEST, )
[docs] def apply_patch( self, current: Resource[Any], patch_op: "PatchOp[Any]", conditions: Conditions ) -> Resource[Any] | None: """Apply a PATCH to a stored resource. :return: The new state of the resource, or :data:`None` when the PATCH changes nothing. Such a PATCH keeps meta.lastModified and the ETag. """ self.check_preconditions(current, "PATCH", conditions) return current if patch_op.patch(current) else None
[docs] def patch_response( self, base_url: str, resource: Resource[Any], response_parameters: ResponseParameters[Any], ) -> ScimResponse: """Return the response to a PATCH. :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: a PATCH MAY answer 204 when no attributes were requested. """ published = self.publish(base_url, resource) if ( not response_parameters.attributes and not response_parameters.excluded_attributes ): assert published.meta is not None headers = {"ETag": published.meta.version} if published.meta.version else {} return ScimResponse(HTTPStatus.NO_CONTENT, None, headers) return self.resource_response( published, Context.RESOURCE_REPLACEMENT_RESPONSE, response_parameters )
# -- Deletion -------------------------------------------------------
[docs] def check_deletion(self, current: Resource[Any], conditions: Conditions) -> None: """Evaluate the conditional headers of a deletion.""" self.check_preconditions(current, "DELETE", conditions)
[docs] @staticmethod def deletion_response() -> ScimResponse: """Return the 204 response to a deletion.""" return ScimResponse(HTTPStatus.NO_CONTENT)
# -- Bulk -----------------------------------------------------------
[docs] def read_bulk(self, body: bytes, content_type: str | None) -> BulkPlan: """Validate a bulk request against the limits of the service, and plan its operations (:rfc:`RFC 7644 §3.7 <7644#section-3.7>`).""" self.ensure_supported(self.config.bulk, "Bulk") bulk = cast(Bulk, self.config.bulk) self.ensure_bulk_payload_size(len(body)) bulk_request = self.read_body( parametrize(BulkRequest, Union[tuple(self.get_models())]), # noqa: UP007 body, content_type, Context.BULK_REQUEST, ) operations = bulk_request.operations or [] if bulk.max_operations is not None and len(operations) > bulk.max_operations: raise PayloadTooLargeException( detail=f"The number of operations exceeds the maxOperations ({bulk.max_operations})" ) return BulkPlan( cast(list[BulkOperation[Resource[Any]]], operations), bulk_request.fail_on_errors, )
[docs] @staticmethod def bulk_outcome(operation: BulkOperation[Resource[Any]]) -> dict[str, Any]: """Start the outcome of a bulk operation, before it runs.""" return {"method": operation.method, "bulk_id": operation.bulk_id}
[docs] def locate_bulk_operation( self, base_url: str, operation: BulkOperation[Resource[Any]], outcome: dict[str, Any], ) -> ResourceType | None: """Return the resource type a resolved bulk operation targets, and locate its resource. The location is set before the operation runs, so a failure still knows it. :rfc:`RFC 7644 §3.7.3 <7644#section-3.7.3>` requires it for every operation but a failed POST. """ resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "") if resource_type is not None and operation.resource_id: outcome["location"] = self.resource_location( base_url, resource_type, operation.resource_id ) return resource_type
[docs] @staticmethod def bulk_validation_failure( operation: BulkOperation[Resource[Any]], outcome: dict[str, Any] ) -> dict[str, Any] | None: """Return the outcome of a bulk operation that failed its validation, if it did.""" if not isinstance(operation.response, Error): return None return {**outcome, "status": operation.status, "response": operation.response}
[docs] @staticmethod def check_bulk_target(operation: BulkOperation[Resource[Any]]) -> str | None: """Check that the path of a bulk operation fits its method. :return: The identifier of the targeted resource, or :data:`None` for a POST. :raises ~scim2_models.InvalidValueException: When a POST targets a resource, or another method a resource type endpoint. """ resource_id = operation.resource_id if (operation.method == BulkOperation.Method.post) == bool(resource_id): raise InvalidValueException( detail="A POST path must target a resource type endpoint, other methods a resource" ) return resource_id
[docs] def bulk_failure( self, outcome: dict[str, Any], exception: Exception ) -> dict[str, Any]: """Return the outcome of a bulk operation that raised an exception.""" error = self.error_of(exception) return {**outcome, "status": error.status, "response": error}
[docs] def bulk_success( self, base_url: str, operation: BulkOperation[Resource[Any]], outcome: dict[str, Any], resource: Resource[Any] | None, ) -> dict[str, Any]: """Return the outcome of a bulk operation that succeeded.""" assert operation.method is not None outcome["status"] = BULK_SUCCESS_STATUS[operation.method] if resource is None: return outcome published = self.publish(base_url, resource) assert published.meta is not None outcome["location"] = published.meta.location outcome["version"] = published.meta.version return outcome
[docs] def bulk_response(self, plan: BulkPlan) -> ScimResponse: """Return the response listing the outcome of each operation that ran.""" response = parametrize(BulkResponse, Union[tuple(self.get_models())]) # noqa: UP007 return ScimResponse( HTTPStatus.OK, response.model_validate({"operations": plan.outcomes()}).model_dump( scim_ctx=Context.BULK_RESPONSE ), )
# -- Discovery ------------------------------------------------------
[docs] @staticmethod def forbid_filter(query: Mapping[str, str]) -> None: """Refuse a filter on a discovery endpoint. :rfc:`RFC 7644 §4 <7644#section-4>`: "If a "filter" is provided, the service provider SHOULD respond with HTTP status code 403 (Forbidden)". """ if "filter" in query: raise ForbiddenException( detail="Discovery endpoints do not support filtering" )
[docs] @staticmethod def locate(resource: DiscoveryResourceT, location: str) -> DiscoveryResourceT: """Return a copy of a discovery resource carrying its meta.""" meta = Meta(resource_type=type(resource).__name__, location=location) return resource.model_copy(update={"meta": meta})
[docs] def service_provider_config( self, location: str, query: Mapping[str, str] ) -> ScimResponse: """Return the ServiceProviderConfig. :param location: The URL of the endpoint. """ self.forbid_filter(query) return ScimResponse( HTTPStatus.OK, self.locate(self.config, location).model_dump() )
[docs] def resource_types(self, location: str, query: Mapping[str, str]) -> ScimResponse: """Return the list of the resource types.""" self.forbid_filter(query) resource_types = self.provider.resource_types response = ListResponse[ResourceType]( total_results=len(resource_types), items_per_page=len(resource_types), start_index=1, resources=[self.locate(r, f"{location}/{r.id}") for r in resource_types], ) return ScimResponse(HTTPStatus.OK, response.model_dump())
[docs] def resource_type( self, location: str, resource_type_id: str, query: Mapping[str, str] ) -> ScimResponse: """Return a single resource type.""" self.forbid_filter(query) for resource_type in self.provider.resource_types: if resource_type.id == resource_type_id: return ScimResponse( HTTPStatus.OK, self.locate(resource_type, location).model_dump() ) raise NotFoundException(detail=f"Resource type {resource_type_id!r} not found")
[docs] def schemas(self, location: str, query: Mapping[str, str]) -> ScimResponse: """Return the list of the schemas.""" self.forbid_filter(query) schemas = self.provider.schemas response = ListResponse[Schema]( total_results=len(schemas), items_per_page=len(schemas), start_index=1, resources=[self.locate(s, f"{location}/{s.id}") for s in schemas], ) return ScimResponse(HTTPStatus.OK, response.model_dump())
[docs] def schema( self, location: str, schema_id: str, query: Mapping[str, str] ) -> ScimResponse: """Return a single schema.""" self.forbid_filter(query) for schema in self.provider.schemas: if schema.id == schema_id: return ScimResponse( HTTPStatus.OK, self.locate(schema, location).model_dump() ) raise NotFoundException(detail=f"Schema {schema_id!r} not found")
# -- Errors ---------------------------------------------------------
[docs] @staticmethod def error_of(exception: Exception) -> Error: """Return the SCIM error of an exception raised while serving a request. Any other exception than a :class:`~scim2_models.SCIMException` is unexpected, and gives a 500, without its message nor its traceback. """ if isinstance(exception, SCIMException): return exception.to_error() return Error(status=500, detail="Internal server error")
[docs] def error_response(self, exception: Exception) -> ScimResponse: """Return the SCIM error response of an exception raised while serving a request. A 401 response carries the :mdn:`WWW-Authenticate` header of :meth:`www_authenticate`. """ error = self.error_of(exception) response = ScimResponse(HTTPStatus(error.status or 500), error.model_dump()) if isinstance(exception, MethodNotAllowedException): # RFC 9110 §15.5.6: a 405 answer lists the supported methods. response.headers["Allow"] = ", ".join(exception.allowed) if isinstance(exception, SCIMException) and exception.status == 401: challenge = self.www_authenticate(exception) if challenge: response.headers["WWW-Authenticate"] = challenge return response
[docs] def www_authenticate(self, exception: SCIMException) -> str | None: """Return the :mdn:`WWW-Authenticate` header of a 401 response (:rfc:`RFC 7644 §2 <7644#section-2>`). It holds one challenge per authentication scheme of the service provider configuration, for the Bearer and Basic schemes. Override this method to announce other schemes, or to add parameters to the challenges, such as ``resource_metadata`` (:rfc:`RFC 9728 §5.1 <9728#section-5.1>`). """ schemes = self.config.authentication_schemes or [] challenges = dict.fromkeys( CHALLENGES[scheme.type] for scheme in schemes if scheme.type in CHALLENGES ) return ", ".join(challenges) or None