Overview#
scim2-client builds the requests of the System for Cross-domain Identity Management (SCIM) protocol and reads the responses to them. It represents the payloads with scim2-models, and hands the HTTP request itself to an engine.
It performs no network call of its own, holds no credentials, and retries nothing: those belong to the engine and to the application that configures it.
The SCIM data model and SCIM protocol specifications define the vocabulary used here.
Install scim2-client with the engine it will use:
pip install scim2-client[httpx2]
This page introduces the operations a client performs, in the order an application meets them. Follow it in order for a first tour. The how-to guides cover focused tasks, the explanations cover what the client checks and what it needs to know about a server, and the Reference lists the complete API.
Instantiate a client#
An engine performs the requests. The one shipped with scim2-client is built upon httpx2, and comes in a synchronous and an asynchronous flavour. Both take a configured client, which is where the server root endpoint and the authorization headers belong:
from httpx2 import Client
from scim2_client.engines.httpx2 import SyncSCIMClient
client = Client(
base_url="https://auth.example/scim/v2",
headers={"Authorization": "Bearer foobar"},
)
scim = SyncSCIMClient(client)
from httpx2 import AsyncClient
from scim2_client.engines.httpx2 import AsyncSCIMClient
client = AsyncClient(
base_url="https://auth.example/scim/v2",
headers={"Authorization": "Bearer foobar"},
)
scim = AsyncSCIMClient(client)
The two have the same API, and every example below shows both. Choose or write a request engine covers the engine serving a WSGI application without a network, and how to write one for another HTTP library.
Describe the server#
A client needs to know which resources the server serves, under which endpoints, and what it is
capable of. A ScimProvider gathers the three, and
discover() fills it from the endpoints the server
publishes:
scim.discover()
User = scim.get_resource_model("User")
await scim.discover()
User = scim.get_resource_model("User")
The models are built from the schemas the server publishes, so they carry the attributes that server actually declares. Describe the server covers describing a server by hand, and What a client knows about a server covers what each part of the description is used for.
Create a resource#
create() issues a POST and returns the resource the
server stored, with the attributes it filled:
request = User(user_name="bjensen@example.com")
response = scim.create(request)
print(f"User {response.id} has been created!")
request = User(user_name="bjensen@example.com")
response = await scim.create(request)
print(f"User {response.id} has been created!")
Read a resource#
query() issues a GET. It reads one resource,
designated either by a model and an id or by an object carrying that id, and it lists the
resources of a type when given no id:
from scim2_models import SearchRequest
user = scim.query(User, "my-user-id")
user = scim.query(User(id="my-user-id"))
response = scim.query(User, query_parameters=SearchRequest(filter='userName sw "john"'))
for user in response.resources:
print(user.user_name)
from scim2_models import SearchRequest
user = await scim.query(User, "my-user-id")
user = await scim.query(User(id="my-user-id"))
response = await scim.query(
User, query_parameters=SearchRequest(filter='userName sw "john"')
)
for user in response.resources:
print(user.user_name)
A listing returns a ListResponse, whose resources attribute holds the
objects. The query_parameters argument carries the filter, the sorting, the paging and the
attribute projection RFC7644 §3.4.2 defines.
Search across resource types#
search() issues a POST on the /.search endpoint,
which queries every resource type at once and takes its parameters in the request body rather
than in the query string:
response = scim.search(SearchRequest(filter='id co "admin"'))
response = await scim.search(SearchRequest(filter='id co "admin"'))
Replace a resource#
replace() issues a PUT, which overwrites a resource
with the object it is given:
user = scim.query(User, "my-user-id")
user.display_name = "Fancy New Name"
updated_user = scim.replace(user)
user = await scim.query(User, "my-user-id")
user.display_name = "Fancy New Name"
updated_user = await scim.replace(user)
Patch a resource#
modify() issues a PATCH, which carries the
modifications rather than the whole resource, as RFC7644 §3.5.2
defines them:
from scim2_models import PatchOp, PatchOperation
patch = PatchOp[User](
operations=[
PatchOperation(
op=PatchOperation.Op.replace_, path="displayName", value="New Name"
),
PatchOperation(
op=PatchOperation.Op.add,
path="emails",
value=[{"value": "new@example.com"}],
),
]
)
user = scim.query(User, "my-user-id")
response = scim.modify(user, patch)
from scim2_models import PatchOp, PatchOperation
patch = PatchOp[User](
operations=[
PatchOperation(
op=PatchOperation.Op.replace_, path="displayName", value="New Name"
),
PatchOperation(
op=PatchOperation.Op.add,
path="emails",
value=[{"value": "new@example.com"}],
),
]
)
user = await scim.query(User, "my-user-id")
response = await scim.modify(user, patch)
A server may answer a patch with the modified resource or with an empty 204, so response
is None when it returned nothing.
Delete a resource#
delete() issues a DELETE and returns None on
success:
scim.delete(User, "my-user-id")
user = scim.query(User, "my-user-id")
scim.delete(user)
await scim.delete(User, "my-user-id")
user = await scim.query(User, "my-user-id")
await scim.delete(user)
Version resources with ETags covers the conditional headers a write carries when the server supports them, which keep a modification from overwriting one made in between.
Send operations in bulk#
bulk() issues a POST on the /Bulk endpoint, which
carries several operations in one request and lets one of them refer to a resource another one
creates:
from scim2_models import BulkOperation, BulkRequest, Group, GroupMember, User
request = BulkRequest[User | Group](
operations=[
BulkOperation[User](
method="POST",
path="/Users",
bulk_id="qwerty",
data=User(user_name="Alice"),
),
BulkOperation[Group](
method="POST",
path="/Groups",
bulk_id="ytrewq",
data=Group(
display_name="Tour Guides",
members=[GroupMember(type="User", value="bulkId:qwerty")],
),
),
]
)
response = scim.bulk(request)
from scim2_models import BulkOperation, BulkRequest, Group, GroupMember, User
request = BulkRequest[User | Group](
operations=[
BulkOperation[User](
method="POST",
path="/Users",
bulk_id="qwerty",
data=User(user_name="Alice"),
),
BulkOperation[Group](
method="POST",
path="/Groups",
bulk_id="ytrewq",
data=Group(
display_name="Tour Guides",
members=[GroupMember(type="User", value="bulkId:qwerty")],
),
),
]
)
response = await scim.bulk(request)
Send operations in bulk covers reading the outcome of each operation, and the limits a request is checked against before it is sent.
Read the errors a server returns#
A payload that does not comply, and an Error the server returned, both
raise. to_error()
gives the error object back:
from scim2_models import SCIMException
try:
response = scim.create(request)
except SCIMException as exc:
error = exc.to_error()
print(f"SCIM error [{error.status}] {error.scim_type}: {error.detail}")
from scim2_models import SCIMException
try:
response = await scim.create(request)
except SCIMException as exc:
error = exc.to_error()
print(f"SCIM error [{error.status}] {error.scim_type}: {error.detail}")
Handle errors covers the exception hierarchy and what each family means. Tolerate a non-conformant peer covers the checks an application may relax when the server it talks to departs from the specification.