from typing import Any
from typing import cast
from scim2_models import BulkOperation
from scim2_models import PatchOp
from scim2_models import Resource
from scim2_models import ResourceType
from scim2_server.bulk import BulkPlan
from scim2_server.bulk import BulkStep
from scim2_server.conditions import Conditions
from scim2_server.requests import ScimRequest
from scim2_server.responses import ScimResponse
from scim2_server.routing import Operation
from scim2_server.service import ScimService
from scim2_server.storage import AsyncScimStorage
from scim2_server.storage import ScimStorage
[docs]
class ScimHandler:
"""Serve the SCIM operations, by calling the steps of a service and a storage in turn.
Each method serves one SCIM operation: it takes a
:class:`~scim2_server.requests.ScimRequest`, and returns its
:class:`~scim2_server.responses.ScimResponse`. :meth:`handle` serves any
request, by calling the method of its operation. A failure raises a
:class:`~scim2_models.SCIMException`, that :meth:`ScimService.error_response
<scim2_server.service.ScimService.error_response>` turns into a response.
Any other exception is a bug.
"""
def __init__(self, service: ScimService, storage: ScimStorage):
self.service = service
self.storage = storage
[docs]
def handle(self, request: ScimRequest) -> ScimResponse:
"""Serve a request with the method of its operation (:rfc:`RFC 7644 §3.2 <7644#section-3.2>`).
:raises ~scim2_models.NotFoundException: When the path matches no
route.
:raises ~scim2_server.errors.MethodNotAllowedException: When the
endpoint does not support the method.
:raises ~scim2_models.NotImplementedException: When the request is
on ``/Me`` and the service does not override
:meth:`~scim2_server.service.ScimService.me_target`.
"""
with self.service.provider:
target = self.service.match(request)
response: ScimResponse = getattr(self, target.operation.value)(request)
return response
# -- Resources ------------------------------------------------------
[docs]
def create(self, request: ScimRequest) -> ScimResponse:
"""Create a resource (:rfc:`RFC 7644 §3.3 <7644#section-3.3>`)."""
with self.service.provider:
target = self.service.route(request, Operation.create)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
resource = self.service.read_creation(
resource_type, request.body, request.header("Content-Type")
)
with self.storage.operation():
created = self.storage.create(resource_type, resource)
return self.service.creation_response(request.base_url, created)
[docs]
def query(self, request: ScimRequest) -> ScimResponse:
"""Read a resource (:rfc:`RFC 7644 §3.4.1 <7644#section-3.4.1>`)."""
with self.service.provider:
target = self.service.route(request, Operation.query)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
response_parameters = self.service.read_response_parameters(
resource_type, request.query
)
with self.storage.operation():
resource = self.storage.get(
resource_type, cast(str, target.resource_id)
)
response = self.service.query_response(
request.base_url,
resource,
response_parameters,
self.service.read_conditions(request),
)
return self.service.me_response(request, target, response)
[docs]
def replace(self, request: ScimRequest) -> ScimResponse:
"""Replace a resource (:rfc:`RFC 7644 §3.5.1 <7644#section-3.5.1>`)."""
with self.service.provider:
target = self.service.route(request, Operation.replace)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
response_parameters = self.service.read_response_parameters(
resource_type, request.query
)
with self.storage.operation():
current = self.storage.get(resource_type, cast(str, target.resource_id))
replacement = self.service.read_replacement(
resource_type, request.body, request.header("Content-Type")
)
resource = self.replace_resource(
resource_type,
current,
replacement,
self.service.read_conditions(request),
)
response = self.service.replacement_response(
request.base_url, resource, response_parameters
)
return self.service.me_response(request, target, response)
[docs]
def patch(self, request: ScimRequest) -> ScimResponse:
"""Modify a resource (:rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`)."""
with self.service.provider:
target = self.service.route(request, Operation.patch)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
response_parameters = self.service.read_response_parameters(
resource_type, request.query
)
patch_op = self.service.read_patch(
resource_type, request.body, request.header("Content-Type")
)
with self.storage.operation():
resource = self.patch_resource(
resource_type,
cast(str, target.resource_id),
patch_op,
self.service.read_conditions(request),
)
response = self.service.patch_response(
request.base_url, resource, response_parameters
)
return self.service.me_response(request, target, response)
[docs]
def delete(self, request: ScimRequest) -> ScimResponse:
"""Delete a resource (:rfc:`RFC 7644 §3.6 <7644#section-3.6>`)."""
with self.service.provider:
target = self.service.route(request, Operation.delete)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
with self.storage.operation():
self.delete_resource(
resource_type,
cast(str, target.resource_id),
self.service.read_conditions(request),
)
return self.service.me_response(
request, target, self.service.deletion_response()
)
[docs]
def replace_resource(
self,
resource_type: ResourceType,
current: Resource[Any],
replacement: Resource[Any],
conditions: Conditions,
) -> Resource[Any]:
"""Apply a validated replacement to a stored resource, and return the stored result."""
assert current.meta is not None
version = current.meta.version
replaced = self.service.apply_replacement(current, replacement, conditions)
if replaced is None:
return current
return self.storage.update(resource_type, replaced, expected_version=version)
[docs]
def patch_resource(
self,
resource_type: ResourceType,
resource_id: str,
patch_op: "PatchOp[Any]",
conditions: Conditions,
) -> Resource[Any]:
"""Apply a validated PATCH to a stored resource, and return the stored result."""
current = self.storage.get(resource_type, resource_id)
assert current.meta is not None
version = current.meta.version
patched = self.service.apply_patch(current, patch_op, conditions)
if patched is None:
return current
return self.storage.update(resource_type, patched, expected_version=version)
[docs]
def delete_resource(
self, resource_type: ResourceType, resource_id: str, conditions: Conditions
) -> None:
"""Delete a stored resource once its conditions are met."""
current = self.storage.get(resource_type, resource_id)
self.service.check_deletion(current, conditions)
assert current.meta is not None
self.storage.delete(
resource_type, resource_id, expected_version=current.meta.version
)
# -- Search ---------------------------------------------------------
[docs]
def search(self, request: ScimRequest) -> ScimResponse:
"""Search with GET, on a resource type or at the root (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`)."""
with self.service.provider:
target = self.service.route(request, Operation.search)
resource_types = self.service.searched_types(target.endpoint)
authorized_types = self.service.authorized_types(
request, target, resource_types
)
search_request = self.service.read_search_query(
resource_types, request.query
)
with self.storage.operation():
total, resources = self.storage.search(authorized_types, search_request)
return self.service.search_response(
request.base_url, total, resources, search_request
)
[docs]
def search_with_body(self, request: ScimRequest) -> ScimResponse:
"""Search with POST on ".search", on a resource type or at the root (:rfc:`RFC 7644 §3.4.3 <7644#section-3.4.3>`)."""
with self.service.provider:
target = self.service.route(request, Operation.search_with_body)
resource_types = self.service.searched_types(target.endpoint)
authorized_types = self.service.authorized_types(
request, target, resource_types
)
search_request = self.service.read_search_body(
resource_types, request.body, request.header("Content-Type")
)
with self.storage.operation():
total, resources = self.storage.search(authorized_types, search_request)
return self.service.search_response(
request.base_url, total, resources, search_request
)
# -- Bulk -----------------------------------------------------------
[docs]
def bulk(self, request: ScimRequest) -> ScimResponse:
"""Run a bulk request (:rfc:`RFC 7644 §3.7 <7644#section-3.7>`)."""
with self.service.provider:
self.service.route(request, Operation.bulk)
plan = self.service.read_bulk(request.body, request.header("Content-Type"))
for step in plan:
result, resource = self.run_bulk_step(request, plan, step)
plan.record(step, result, resource)
return self.service.bulk_response(plan)
[docs]
def run_bulk_step(
self, request: ScimRequest, plan: BulkPlan, step: BulkStep
) -> tuple[dict[str, Any], Resource[Any] | None]:
"""Apply one step of a bulk request.
The step resolves the references of the operation, locates it and
authorizes it. An operation that failed its validation then keeps its
error.
:return: The outcome of the step, and the resource it created or updated.
"""
operation = plan.operation(step)
outcome = self.service.bulk_outcome(operation)
try:
operation = plan.resolve(step)
resource_type = self.service.locate_bulk_operation(
request.base_url, operation, outcome
)
self.service.authorize_bulk_operation(request, operation, resource_type)
failure = self.service.bulk_validation_failure(operation, outcome)
if failure is not None:
return failure, None
with self.storage.operation():
resource = self.apply_bulk_operation(
cast(ResourceType, resource_type), operation
)
except Exception as exception:
return self.service.bulk_failure(outcome, exception), None
return self.service.bulk_success(
request.base_url, operation, outcome, resource
), resource
[docs]
def apply_bulk_operation(
self, resource_type: ResourceType, operation: BulkOperation[Resource[Any]]
) -> Resource[Any] | None:
"""Apply a validated bulk operation, and return the resource it acted on.
The data of the operation is already validated, in the context of the
request the operation stands for.
"""
resource_id = self.service.check_bulk_target(operation)
if resource_id is None:
return self.storage.create(
resource_type, cast(Resource[Any], operation.data)
)
conditions = Conditions(if_match=operation.version)
match operation.method:
case BulkOperation.Method.put:
return self.replace_resource(
resource_type,
self.storage.get(resource_type, resource_id),
cast(Resource[Any], operation.data),
conditions,
)
case BulkOperation.Method.patch:
self.service.ensure_patch_supported()
return self.patch_resource(
resource_type,
resource_id,
cast("PatchOp[Any]", operation.data),
conditions,
)
case _: # DELETE
self.delete_resource(resource_type, resource_id, conditions)
return None
# -- Discovery ------------------------------------------------------
[docs]
def service_provider_config(self, request: ScimRequest) -> ScimResponse:
"""Serve the ServiceProviderConfig endpoint (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
self.service.route(request, Operation.service_provider_config)
return self.service.service_provider_config(request.url, request.query)
[docs]
def resource_types(self, request: ScimRequest) -> ScimResponse:
"""Serve the ResourceTypes endpoint (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
self.service.route(request, Operation.resource_types)
return self.service.resource_types(request.url, request.query)
[docs]
def resource_type(self, request: ScimRequest) -> ScimResponse:
"""Serve one resource type (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
target = self.service.route(request, Operation.resource_type)
return self.service.resource_type(
request.url, cast(str, target.resource_id), request.query
)
[docs]
def schemas(self, request: ScimRequest) -> ScimResponse:
"""Serve the Schemas endpoint (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
self.service.route(request, Operation.schemas)
return self.service.schemas(request.url, request.query)
[docs]
def schema(self, request: ScimRequest) -> ScimResponse:
"""Serve one schema (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
target = self.service.route(request, Operation.schema)
return self.service.schema(
request.url, cast(str, target.resource_id), request.query
)
[docs]
class AsyncScimHandler:
"""The asynchronous variant of :class:`ScimHandler`, over an :class:`~scim2_server.storage.AsyncScimStorage`.
It calls the same steps of the same service, and awaits the storage
between them.
"""
def __init__(self, service: ScimService, storage: AsyncScimStorage):
self.service = service
self.storage = storage
[docs]
async def handle(self, request: ScimRequest) -> ScimResponse:
"""Serve a request with the method of its operation (:rfc:`RFC 7644 §3.2 <7644#section-3.2>`).
:raises ~scim2_models.NotFoundException: When the path matches no
route.
:raises ~scim2_server.errors.MethodNotAllowedException: When the
endpoint does not support the method.
:raises ~scim2_models.NotImplementedException: When the request is
on ``/Me`` and the service does not override
:meth:`~scim2_server.service.ScimService.me_target`.
"""
with self.service.provider:
target = self.service.match(request)
response: ScimResponse = await getattr(self, target.operation.value)(request)
return response
# -- Resources ------------------------------------------------------
[docs]
async def create(self, request: ScimRequest) -> ScimResponse:
"""Create a resource (:rfc:`RFC 7644 §3.3 <7644#section-3.3>`)."""
with self.service.provider:
target = self.service.route(request, Operation.create)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
resource = self.service.read_creation(
resource_type, request.body, request.header("Content-Type")
)
async with self.storage.operation():
created = await self.storage.create(resource_type, resource)
return self.service.creation_response(request.base_url, created)
[docs]
async def query(self, request: ScimRequest) -> ScimResponse:
"""Read a resource (:rfc:`RFC 7644 §3.4.1 <7644#section-3.4.1>`)."""
with self.service.provider:
target = self.service.route(request, Operation.query)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
response_parameters = self.service.read_response_parameters(
resource_type, request.query
)
async with self.storage.operation():
resource = await self.storage.get(
resource_type, cast(str, target.resource_id)
)
response = self.service.query_response(
request.base_url,
resource,
response_parameters,
self.service.read_conditions(request),
)
return self.service.me_response(request, target, response)
[docs]
async def replace(self, request: ScimRequest) -> ScimResponse:
"""Replace a resource (:rfc:`RFC 7644 §3.5.1 <7644#section-3.5.1>`)."""
with self.service.provider:
target = self.service.route(request, Operation.replace)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
response_parameters = self.service.read_response_parameters(
resource_type, request.query
)
async with self.storage.operation():
current = await self.storage.get(
resource_type, cast(str, target.resource_id)
)
replacement = self.service.read_replacement(
resource_type, request.body, request.header("Content-Type")
)
resource = await self.replace_resource(
resource_type,
current,
replacement,
self.service.read_conditions(request),
)
response = self.service.replacement_response(
request.base_url, resource, response_parameters
)
return self.service.me_response(request, target, response)
[docs]
async def patch(self, request: ScimRequest) -> ScimResponse:
"""Modify a resource (:rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`)."""
with self.service.provider:
target = self.service.route(request, Operation.patch)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
response_parameters = self.service.read_response_parameters(
resource_type, request.query
)
patch_op = self.service.read_patch(
resource_type, request.body, request.header("Content-Type")
)
async with self.storage.operation():
resource = await self.patch_resource(
resource_type,
cast(str, target.resource_id),
patch_op,
self.service.read_conditions(request),
)
response = self.service.patch_response(
request.base_url, resource, response_parameters
)
return self.service.me_response(request, target, response)
[docs]
async def delete(self, request: ScimRequest) -> ScimResponse:
"""Delete a resource (:rfc:`RFC 7644 §3.6 <7644#section-3.6>`)."""
with self.service.provider:
target = self.service.route(request, Operation.delete)
resource_type = self.service.resource_type_at(cast(str, target.endpoint))
self.service.authorize(request, target, resource_type)
async with self.storage.operation():
await self.delete_resource(
resource_type,
cast(str, target.resource_id),
self.service.read_conditions(request),
)
return self.service.me_response(
request, target, self.service.deletion_response()
)
[docs]
async def replace_resource(
self,
resource_type: ResourceType,
current: Resource[Any],
replacement: Resource[Any],
conditions: Conditions,
) -> Resource[Any]:
"""Apply a validated replacement to a stored resource, and return the stored result."""
assert current.meta is not None
version = current.meta.version
replaced = self.service.apply_replacement(current, replacement, conditions)
if replaced is None:
return current
return await self.storage.update(
resource_type, replaced, expected_version=version
)
[docs]
async def patch_resource(
self,
resource_type: ResourceType,
resource_id: str,
patch_op: "PatchOp[Any]",
conditions: Conditions,
) -> Resource[Any]:
"""Apply a validated PATCH to a stored resource, and return the stored result."""
current = await self.storage.get(resource_type, resource_id)
assert current.meta is not None
version = current.meta.version
patched = self.service.apply_patch(current, patch_op, conditions)
if patched is None:
return current
return await self.storage.update(
resource_type, patched, expected_version=version
)
[docs]
async def delete_resource(
self, resource_type: ResourceType, resource_id: str, conditions: Conditions
) -> None:
"""Delete a stored resource once its conditions are met."""
current = await self.storage.get(resource_type, resource_id)
self.service.check_deletion(current, conditions)
assert current.meta is not None
await self.storage.delete(
resource_type, resource_id, expected_version=current.meta.version
)
# -- Search ---------------------------------------------------------
[docs]
async def search(self, request: ScimRequest) -> ScimResponse:
"""Search with GET, on a resource type or at the root (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`)."""
with self.service.provider:
target = self.service.route(request, Operation.search)
resource_types = self.service.searched_types(target.endpoint)
authorized_types = self.service.authorized_types(
request, target, resource_types
)
search_request = self.service.read_search_query(
resource_types, request.query
)
async with self.storage.operation():
total, resources = await self.storage.search(
authorized_types, search_request
)
return self.service.search_response(
request.base_url, total, resources, search_request
)
[docs]
async def search_with_body(self, request: ScimRequest) -> ScimResponse:
"""Search with POST on ".search", on a resource type or at the root (:rfc:`RFC 7644 §3.4.3 <7644#section-3.4.3>`)."""
with self.service.provider:
target = self.service.route(request, Operation.search_with_body)
resource_types = self.service.searched_types(target.endpoint)
authorized_types = self.service.authorized_types(
request, target, resource_types
)
search_request = self.service.read_search_body(
resource_types, request.body, request.header("Content-Type")
)
async with self.storage.operation():
total, resources = await self.storage.search(
authorized_types, search_request
)
return self.service.search_response(
request.base_url, total, resources, search_request
)
# -- Bulk -----------------------------------------------------------
[docs]
async def bulk(self, request: ScimRequest) -> ScimResponse:
"""Run a bulk request (:rfc:`RFC 7644 §3.7 <7644#section-3.7>`)."""
with self.service.provider:
self.service.route(request, Operation.bulk)
plan = self.service.read_bulk(request.body, request.header("Content-Type"))
for step in plan:
result, resource = await self.run_bulk_step(request, plan, step)
plan.record(step, result, resource)
return self.service.bulk_response(plan)
[docs]
async def run_bulk_step(
self, request: ScimRequest, plan: BulkPlan, step: BulkStep
) -> tuple[dict[str, Any], Resource[Any] | None]:
"""Apply one step of a bulk request.
The step resolves the references of the operation, locates it and
authorizes it. An operation that failed its validation then keeps its
error.
:return: The outcome of the step, and the resource it created or updated.
"""
operation = plan.operation(step)
outcome = self.service.bulk_outcome(operation)
try:
operation = plan.resolve(step)
resource_type = self.service.locate_bulk_operation(
request.base_url, operation, outcome
)
self.service.authorize_bulk_operation(request, operation, resource_type)
failure = self.service.bulk_validation_failure(operation, outcome)
if failure is not None:
return failure, None
async with self.storage.operation():
resource = await self.apply_bulk_operation(
cast(ResourceType, resource_type), operation
)
except Exception as exception:
return self.service.bulk_failure(outcome, exception), None
return self.service.bulk_success(
request.base_url, operation, outcome, resource
), resource
[docs]
async def apply_bulk_operation(
self, resource_type: ResourceType, operation: BulkOperation[Resource[Any]]
) -> Resource[Any] | None:
"""Apply a validated bulk operation, and return the resource it acted on.
The data of the operation is already validated, in the context of the
request the operation stands for.
"""
resource_id = self.service.check_bulk_target(operation)
if resource_id is None:
return await self.storage.create(
resource_type, cast(Resource[Any], operation.data)
)
conditions = Conditions(if_match=operation.version)
match operation.method:
case BulkOperation.Method.put:
return await self.replace_resource(
resource_type,
await self.storage.get(resource_type, resource_id),
cast(Resource[Any], operation.data),
conditions,
)
case BulkOperation.Method.patch:
self.service.ensure_patch_supported()
return await self.patch_resource(
resource_type,
resource_id,
cast("PatchOp[Any]", operation.data),
conditions,
)
case _: # DELETE
await self.delete_resource(resource_type, resource_id, conditions)
return None
# -- Discovery ------------------------------------------------------
[docs]
async def service_provider_config(self, request: ScimRequest) -> ScimResponse:
"""Serve the ServiceProviderConfig endpoint (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
self.service.route(request, Operation.service_provider_config)
return self.service.service_provider_config(request.url, request.query)
[docs]
async def resource_types(self, request: ScimRequest) -> ScimResponse:
"""Serve the ResourceTypes endpoint (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
self.service.route(request, Operation.resource_types)
return self.service.resource_types(request.url, request.query)
[docs]
async def resource_type(self, request: ScimRequest) -> ScimResponse:
"""Serve one resource type (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
target = self.service.route(request, Operation.resource_type)
return self.service.resource_type(
request.url, cast(str, target.resource_id), request.query
)
[docs]
async def schemas(self, request: ScimRequest) -> ScimResponse:
"""Serve the Schemas endpoint (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
self.service.route(request, Operation.schemas)
return self.service.schemas(request.url, request.query)
[docs]
async def schema(self, request: ScimRequest) -> ScimResponse:
"""Serve one schema (:rfc:`RFC 7644 §4 <7644#section-4>`)."""
with self.service.provider:
target = self.service.route(request, Operation.schema)
return self.service.schema(
request.url, cast(str, target.resource_id), request.query
)