Django¶
This guide shows a minimal SCIM integration with Django
and scim2_models.
It focuses on the integration points that matter most:
validating incoming SCIM payloads with the right
Context;serializing resources and collections as SCIM responses;
exposing responses with the
application/scim+jsonmedia type;parsing pagination parameters with
SearchRequest;handling Django-specific concerns such as URLconfs, custom path converters, and CSRF.
The example uses User as a concrete resource type, but the same
pattern applies to any other resource such as Group.
The storage and mapping layers are defined in the Integrations section and shared across
all integration examples.
The complete runnable file is available in the Complete example section.
pip install django scim2-models
Application setup¶
Start with a SCIMView base class that all SCIM views inherit from.
It overrides dispatch() to centralise
cross-cutting concerns: the application/scim+json content type,
ETag extraction from meta.version, If-None-Match (304) on reads,
and If-Match (412) on writes.
The @csrf_exempt decorator is applied once on the base class.
class SCIMJsonResponse(JsonResponse):
"""JSON response with the ``application/scim+json`` media type.
Keeps a reference to the original data dict in :attr:`scim_data` so that
``dispatch()`` can inspect it without re-parsing the serialised body.
"""
def __init__(self, data, **kwargs):
self.scim_data = data
kwargs.setdefault("content_type", "application/scim+json")
super().__init__(data, **kwargs)
@method_decorator(csrf_exempt, name="dispatch")
class SCIMView(View):
"""Base view for SCIM endpoints.
Extracts the ``ETag`` header from ``meta.version``, handles
``If-None-Match`` (304) on GET, and checks ``If-Match`` (412) on
write operations.
"""
# -- etag-start --
def dispatch(self, request, *args, **kwargs):
"""Dispatch with ETag handling."""
if request.method in ("PUT", "PATCH", "DELETE"):
app_record = kwargs.get("app_record")
if app_record is not None:
if_match = request.META.get("HTTP_IF_MATCH")
if if_match and if_match.strip() != "*":
etag = make_etag(app_record)
if etag not in parse_etags(if_match):
scim_error = Error(status=412, detail="ETag mismatch")
return SCIMJsonResponse(
scim_error.model_dump(), status=412
)
response = super().dispatch(request, *args, **kwargs)
data = getattr(response, "scim_data", None)
if data is None:
return response
if meta := data.get("meta"):
if version := meta.get("version"):
response["ETag"] = version
if request.method == "GET" and (etag := response.get("ETag")):
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
if if_none_match and etag in parse_etags(if_none_match):
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
return response
# -- etag-end --
def resource_location(request, app_record):
"""Return the canonical URL for a user record."""
return request.build_absolute_uri(
reverse("scim_user", kwargs={"app_record": app_record})
)
Optional Django refinements¶
The core SCIM flow only needs the endpoints below. Django also offers a few convenient integration patterns that can keep the views shorter and help keep framework-level errors aligned with SCIM responses.
Django converters¶
Custom path converters let Django resolve route parameters before the view function is called. Define one converter per resource type: it maps a resource identifier to an application record and lets Django turn lookup failures into a 404 during URL resolution.
class UserConverter:
regex = "[^/]+"
def to_python(self, id):
try:
return get_record(id)
except KeyError:
raise ValueError
def to_url(self, record):
return record["id"]
register_converter(UserConverter, "user")
Validation helper¶
The validation helper keeps Pydantic validation errors aligned with SCIM responses.
def scim_validation_error(error):
"""Turn Pydantic validation errors into a SCIM error response."""
scim_error = Error.from_validation_error(error.errors()[0])
return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
If model_validate() or
model_validate() fails, the views below catch the
ValidationError and return a SCIM Error
response.
SCIM exception helper¶
scim_exception_error converts any SCIMException
(uniqueness, mutability, …) into a SCIM error response.
def scim_exception_error(error):
"""Turn SCIM exceptions into a SCIM error response."""
scim_error = error.to_error()
return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
Error handler¶
Django does not produce SCIM-formatted 404 responses by default.
Defining handler404 in the URLconf module overrides this behaviour.
Note that Django only calls handler404 when DEBUG is False.
def handler404(request, exception):
"""Turn Django 404 errors into SCIM error responses."""
scim_error = Error(status=404, detail=str(exception))
return SCIMJsonResponse(
scim_error.model_dump(),
status=HTTPStatus.NOT_FOUND,
)
Endpoints¶
Django’s CSRF middleware is enabled by default.
SCIMView applies @csrf_exempt to accept JSON API requests directly.
The views serve /Users, but the same structure applies to any resource type:
replace the mapping helpers, the model class, and the URL prefix to expose /Groups or
any other collection.
Single resource¶
UserView handles GET, PUT, PATCH and DELETE on /Users/<id>.
For GET, parse query parameters with ResponseParameters to honour the
attributes and excludedAttributes query parameters, convert the native record to a
SCIM resource, and serialize with RESOURCE_QUERY_RESPONSE.
For DELETE, remove the record and return an empty 204 response.
For PUT, validate the full replacement payload with
RESOURCE_REPLACEMENT_REQUEST, then call
replace() to verify that immutable attributes
have not been modified.
Convert back to native and persist, then serialize with
RESOURCE_REPLACEMENT_RESPONSE.
For PATCH, validate the payload with RESOURCE_PATCH_REQUEST,
apply it with patch() (generic, works with any resource type),
convert back to native and persist, then serialize with
RESOURCE_PATCH_RESPONSE.
class UserView(SCIMView):
"""Handle GET, PUT, PATCH and DELETE on one SCIM user resource."""
def get(self, request, app_record):
try:
req = ResponseParameters.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
scim_user = to_scim_user(app_record, resource_location(request, app_record))
return SCIMJsonResponse(
scim_user.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
def delete(self, request, app_record):
delete_record(app_record["id"])
return HttpResponse(status=HTTPStatus.NO_CONTENT)
def put(self, request, app_record):
req = ResponseParameters.model_validate(request.GET.dict())
existing_user = to_scim_user(app_record, resource_location(request, app_record))
try:
replacement = User.model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
)
replacement.replace(existing_user)
except ValidationError as error:
return scim_validation_error(error)
except SCIMException as error:
return scim_exception_error(error)
updated_record = from_scim_user(replacement)
try:
save_record(updated_record)
except SCIMException as error:
return scim_exception_error(error)
response_user = to_scim_user(
updated_record, resource_location(request, updated_record)
)
return SCIMJsonResponse(
response_user.model_dump(
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
def patch(self, request, app_record):
req = ResponseParameters.model_validate(request.GET.dict())
try:
patch = PatchOp[User].model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
)
except ValidationError as error:
return scim_validation_error(error)
scim_user = to_scim_user(app_record, resource_location(request, app_record))
patch.patch(scim_user)
updated_record = from_scim_user(scim_user)
try:
save_record(updated_record)
except SCIMException as error:
return scim_exception_error(error)
return SCIMJsonResponse(
scim_user.model_dump(
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
Collection¶
UsersView handles GET /Users and POST /Users.
For GET, parse pagination and filtering parameters with
SearchRequest, slice the store, then wrap the page in a
ListResponse serialized with
RESOURCE_QUERY_RESPONSE.
req.attributes and req.excluded_attributes are passed to
model_dump() to apply the attributes and
excludedAttributes query parameters to each embedded resource.
For POST, validate the creation payload with
RESOURCE_CREATION_REQUEST, persist the record, then serialize
with RESOURCE_CREATION_RESPONSE.
The urlpatterns list wires both views to their routes.
class UsersView(SCIMView):
"""Handle GET and POST on the SCIM users collection."""
def get(self, request):
try:
req = SearchRequest.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
total, page = list_records(req.start_index_0, req.stop_index_0)
resources = [
to_scim_user(record, resource_location(request, record)) for record in page
]
response = ListResponse[User](
total_results=total,
start_index=req.start_index or 1,
items_per_page=len(resources),
resources=resources,
)
return SCIMJsonResponse(
response.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
def post(self, request):
req = ResponseParameters.model_validate(request.GET.dict())
try:
request_user = User.model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
)
except ValidationError as error:
return scim_validation_error(error)
app_record = from_scim_user(request_user)
try:
save_record(app_record)
except SCIMException as error:
return scim_exception_error(error)
response_user = to_scim_user(app_record, resource_location(request, app_record))
return SCIMJsonResponse(
response_user.model_dump(
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
),
status=HTTPStatus.CREATED,
)
urlpatterns = [
path("scim/v2/Users", UsersView.as_view(), name="scim_users"),
path("scim/v2/Users/<user:app_record>", UserView.as_view(), name="scim_user"),
]
Discovery endpoints¶
SCIM defines three read-only endpoints that let clients discover the server’s capabilities
and the resources it exposes (RFC 7644 §4).
The shared discovery helpers that build
Schema, ResourceType and
ServiceProviderConfig objects are defined in the Integrations section.
GET /Schemas and GET /Schemas/<id>¶
Return all Schema objects or look one up by its URI.
Schemas are built automatically from resource models with
to_schema().
The collection endpoint parses pagination parameters with
SearchRequest, following the same pattern as GET /Users.
class SchemasView(SCIMView):
"""Handle GET on the SCIM schemas collection."""
def get(self, request):
try:
req = SearchRequest.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
total, page = get_schemas(req.start_index_0, req.stop_index_0)
response = ListResponse[Schema](
total_results=total,
start_index=req.start_index or 1,
items_per_page=len(page),
resources=page,
)
return SCIMJsonResponse(
response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
class SchemaView(SCIMView):
"""Handle GET on a single SCIM schema."""
def get(self, request, schema_id):
try:
schema = get_schema(schema_id)
except KeyError:
scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
return SCIMJsonResponse(
scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
)
return SCIMJsonResponse(
schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
GET /ResourceTypes and GET /ResourceTypes/<id>¶
Return all ResourceType objects or look one up by its identifier.
Resource types are built automatically from resource models with
from_resource().
The collection endpoint parses pagination parameters with
SearchRequest, following the same pattern as GET /Users.
class ResourceTypesView(SCIMView):
"""Handle GET on the SCIM resource types collection."""
def get(self, request):
try:
req = SearchRequest.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
total, page = get_resource_types(req.start_index_0, req.stop_index_0)
response = ListResponse[ResourceType](
total_results=total,
start_index=req.start_index or 1,
items_per_page=len(page),
resources=page,
)
return SCIMJsonResponse(
response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
class ResourceTypeView(SCIMView):
"""Handle GET on a single SCIM resource type."""
def get(self, request, resource_type_id):
try:
rt = get_resource_type(resource_type_id)
except KeyError:
scim_error = Error(
status=404, detail=f"ResourceType {resource_type_id!r} not found"
)
return SCIMJsonResponse(
scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
)
return SCIMJsonResponse(
rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
GET /ServiceProviderConfig¶
Return the ServiceProviderConfig singleton that describes the
features the server supports (patch, bulk, filtering, etc.).
class ServiceProviderConfigView(SCIMView):
"""Handle GET on the SCIM service provider configuration."""
def get(self, request):
return SCIMJsonResponse(
service_provider_config.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
)
)
The discovery_urlpatterns list wires the discovery views to their routes.
Merge it with the resource urlpatterns in your root URLconf.
Complete example¶
import json
from http import HTTPStatus
from django.http import HttpResponse
from django.http import JsonResponse
from django.urls import path
from django.urls import register_converter
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import parse_etags
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from pydantic import ValidationError
from scim2_models import Context
from scim2_models import Error
from scim2_models import ListResponse
from scim2_models import PatchOp
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import Schema
from scim2_models import SCIMException
from scim2_models import SearchRequest
from scim2_models import User
from .integrations import delete_record
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 make_etag
from .integrations import save_record
from .integrations import service_provider_config
from .integrations import to_scim_user
# -- setup-start --
class SCIMJsonResponse(JsonResponse):
"""JSON response with the ``application/scim+json`` media type.
Keeps a reference to the original data dict in :attr:`scim_data` so that
``dispatch()`` can inspect it without re-parsing the serialised body.
"""
def __init__(self, data, **kwargs):
self.scim_data = data
kwargs.setdefault("content_type", "application/scim+json")
super().__init__(data, **kwargs)
@method_decorator(csrf_exempt, name="dispatch")
class SCIMView(View):
"""Base view for SCIM endpoints.
Extracts the ``ETag`` header from ``meta.version``, handles
``If-None-Match`` (304) on GET, and checks ``If-Match`` (412) on
write operations.
"""
# -- etag-start --
def dispatch(self, request, *args, **kwargs):
"""Dispatch with ETag handling."""
if request.method in ("PUT", "PATCH", "DELETE"):
app_record = kwargs.get("app_record")
if app_record is not None:
if_match = request.META.get("HTTP_IF_MATCH")
if if_match and if_match.strip() != "*":
etag = make_etag(app_record)
if etag not in parse_etags(if_match):
scim_error = Error(status=412, detail="ETag mismatch")
return SCIMJsonResponse(
scim_error.model_dump(), status=412
)
response = super().dispatch(request, *args, **kwargs)
data = getattr(response, "scim_data", None)
if data is None:
return response
if meta := data.get("meta"):
if version := meta.get("version"):
response["ETag"] = version
if request.method == "GET" and (etag := response.get("ETag")):
if_none_match = request.META.get("HTTP_IF_NONE_MATCH")
if if_none_match and etag in parse_etags(if_none_match):
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
return response
# -- etag-end --
def resource_location(request, app_record):
"""Return the canonical URL for a user record."""
return request.build_absolute_uri(
reverse("scim_user", kwargs={"app_record": app_record})
)
# -- setup-end --
# -- refinements-start --
# -- converters-start --
class UserConverter:
regex = "[^/]+"
def to_python(self, id):
try:
return get_record(id)
except KeyError:
raise ValueError
def to_url(self, record):
return record["id"]
register_converter(UserConverter, "user")
# -- converters-end --
# -- validation-helper-start --
def scim_validation_error(error):
"""Turn Pydantic validation errors into a SCIM error response."""
scim_error = Error.from_validation_error(error.errors()[0])
return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
# -- validation-helper-end --
# -- scim-exception-helper-start --
def scim_exception_error(error):
"""Turn SCIM exceptions into a SCIM error response."""
scim_error = error.to_error()
return SCIMJsonResponse(scim_error.model_dump(), status=scim_error.status)
# -- scim-exception-helper-end --
# -- error-handler-start --
def handler404(request, exception):
"""Turn Django 404 errors into SCIM error responses."""
scim_error = Error(status=404, detail=str(exception))
return SCIMJsonResponse(
scim_error.model_dump(),
status=HTTPStatus.NOT_FOUND,
)
# -- error-handler-end --
# -- refinements-end --
# -- endpoints-start --
# -- single-resource-start --
class UserView(SCIMView):
"""Handle GET, PUT, PATCH and DELETE on one SCIM user resource."""
def get(self, request, app_record):
try:
req = ResponseParameters.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
scim_user = to_scim_user(app_record, resource_location(request, app_record))
return SCIMJsonResponse(
scim_user.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
def delete(self, request, app_record):
delete_record(app_record["id"])
return HttpResponse(status=HTTPStatus.NO_CONTENT)
def put(self, request, app_record):
req = ResponseParameters.model_validate(request.GET.dict())
existing_user = to_scim_user(app_record, resource_location(request, app_record))
try:
replacement = User.model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
)
replacement.replace(existing_user)
except ValidationError as error:
return scim_validation_error(error)
except SCIMException as error:
return scim_exception_error(error)
updated_record = from_scim_user(replacement)
try:
save_record(updated_record)
except SCIMException as error:
return scim_exception_error(error)
response_user = to_scim_user(
updated_record, resource_location(request, updated_record)
)
return SCIMJsonResponse(
response_user.model_dump(
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
def patch(self, request, app_record):
req = ResponseParameters.model_validate(request.GET.dict())
try:
patch = PatchOp[User].model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_PATCH_REQUEST,
)
except ValidationError as error:
return scim_validation_error(error)
scim_user = to_scim_user(app_record, resource_location(request, app_record))
patch.patch(scim_user)
updated_record = from_scim_user(scim_user)
try:
save_record(updated_record)
except SCIMException as error:
return scim_exception_error(error)
return SCIMJsonResponse(
scim_user.model_dump(
scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
# -- single-resource-end --
# -- collection-start --
class UsersView(SCIMView):
"""Handle GET and POST on the SCIM users collection."""
def get(self, request):
try:
req = SearchRequest.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
total, page = list_records(req.start_index_0, req.stop_index_0)
resources = [
to_scim_user(record, resource_location(request, record)) for record in page
]
response = ListResponse[User](
total_results=total,
start_index=req.start_index or 1,
items_per_page=len(resources),
resources=resources,
)
return SCIMJsonResponse(
response.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
)
)
def post(self, request):
req = ResponseParameters.model_validate(request.GET.dict())
try:
request_user = User.model_validate(
json.loads(request.body),
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
)
except ValidationError as error:
return scim_validation_error(error)
app_record = from_scim_user(request_user)
try:
save_record(app_record)
except SCIMException as error:
return scim_exception_error(error)
response_user = to_scim_user(app_record, resource_location(request, app_record))
return SCIMJsonResponse(
response_user.model_dump(
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
attributes=req.attributes,
excluded_attributes=req.excluded_attributes,
),
status=HTTPStatus.CREATED,
)
urlpatterns = [
path("scim/v2/Users", UsersView.as_view(), name="scim_users"),
path("scim/v2/Users/<user:app_record>", UserView.as_view(), name="scim_user"),
]
# -- collection-end --
# -- discovery-start --
# -- schemas-start --
class SchemasView(SCIMView):
"""Handle GET on the SCIM schemas collection."""
def get(self, request):
try:
req = SearchRequest.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
total, page = get_schemas(req.start_index_0, req.stop_index_0)
response = ListResponse[Schema](
total_results=total,
start_index=req.start_index or 1,
items_per_page=len(page),
resources=page,
)
return SCIMJsonResponse(
response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
class SchemaView(SCIMView):
"""Handle GET on a single SCIM schema."""
def get(self, request, schema_id):
try:
schema = get_schema(schema_id)
except KeyError:
scim_error = Error(status=404, detail=f"Schema {schema_id!r} not found")
return SCIMJsonResponse(
scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
)
return SCIMJsonResponse(
schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
# -- schemas-end --
# -- resource-types-start --
class ResourceTypesView(SCIMView):
"""Handle GET on the SCIM resource types collection."""
def get(self, request):
try:
req = SearchRequest.model_validate(request.GET.dict())
except ValidationError as error:
return scim_validation_error(error)
total, page = get_resource_types(req.start_index_0, req.stop_index_0)
response = ListResponse[ResourceType](
total_results=total,
start_index=req.start_index or 1,
items_per_page=len(page),
resources=page,
)
return SCIMJsonResponse(
response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
class ResourceTypeView(SCIMView):
"""Handle GET on a single SCIM resource type."""
def get(self, request, resource_type_id):
try:
rt = get_resource_type(resource_type_id)
except KeyError:
scim_error = Error(
status=404, detail=f"ResourceType {resource_type_id!r} not found"
)
return SCIMJsonResponse(
scim_error.model_dump(), status=HTTPStatus.NOT_FOUND
)
return SCIMJsonResponse(
rt.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
)
# -- resource-types-end --
# -- service-provider-config-start --
class ServiceProviderConfigView(SCIMView):
"""Handle GET on the SCIM service provider configuration."""
def get(self, request):
return SCIMJsonResponse(
service_provider_config.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE
)
)
# -- service-provider-config-end --
discovery_urlpatterns = [
path("scim/v2/Schemas", SchemasView.as_view(), name="scim_schemas"),
path("scim/v2/Schemas/<path:schema_id>", SchemaView.as_view(), name="scim_schema"),
path(
"scim/v2/ResourceTypes",
ResourceTypesView.as_view(),
name="scim_resource_types",
),
path(
"scim/v2/ResourceTypes/<resource_type_id>",
ResourceTypeView.as_view(),
name="scim_resource_type",
),
path(
"scim/v2/ServiceProviderConfig",
ServiceProviderConfigView.as_view(),
name="scim_service_provider_config",
),
]
# -- discovery-end --
# -- endpoints-end --