Skip to content

Commit 635bed0

Browse files
committed
doc: add the documentation
1 parent 15a1411 commit 635bed0

50 files changed

Lines changed: 4685 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.readthedocs.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
version: 2
3+
sphinx:
4+
configuration: doc/conf.py
5+
build:
6+
os: "ubuntu-22.04"
7+
tools:
8+
python: "3.14"
9+
python:
10+
install:
11+
- method: uv
12+
command: sync
13+
groups:
14+
- doc
15+
extras: all

‎README.md‎

Lines changed: 70 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,98 @@
11
# scim2-server
22

3-
This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
4-
It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
5-
they are lost once the process exits.
3+
A Python library that serves the SCIM protocol over any storage, built upon
4+
[scim2-models](https://scim2-models.readthedocs.io), following the
5+
[RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and
6+
[RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications.
7+
It validates the requests, applies them to the resources and builds the responses.
8+
The application only reads and writes the resources.
9+
10+
It comes with an in-memory storage, WSGI and ASGI applications with no dependency, and a
11+
`scim2-server` command that serves a test server.
12+
13+
## Who is it for?
14+
15+
scim2-server is for projects that need to:
16+
17+
- serve a standalone SCIM server;
18+
- add SCIM to an existing application, with SCIM models that describe its data;
19+
- test a SCIM client against a working server, from the `scim2-server` command, a container, or
20+
the [pytest-scim2-server](https://github.com/pytest-dev/pytest-scim2-server) fixture.
21+
22+
## What's SCIM anyway?
23+
24+
SCIM stands for System for Cross-domain Identity Management, and it is a provisioning protocol.
25+
Provisioning is the action of managing a set of resources across different services, usually users and groups.
26+
SCIM is often used between Identity Providers and applications in completion of standards like OAuth2 and OpenID Connect.
27+
It allows users and groups creations, modifications and deletions to be synchronized between applications.
628

729
## Features
830

9-
- [x] Discovery endpoints (`/v2/ServiceProviderConfig`, `/v2/ResourceTypes`, `/v2/Schemas`)
10-
- [x] Create/Read/Update/Delete resources (`POST`, `GET`, `PUT`, `DELETE`)
11-
- [x] Searching & Filtering
12-
- [x] Support for ETags
13-
- [x] Unique Constraints
14-
- [x] HTTP PATCH (Add/Remove/Replace)
15-
- [x] Sorting
16-
- [x] Bulk operations
17-
- [x] Multi-tenancy with a URL prefix
31+
- **Resources**: creation, read, replacement, PATCH and deletion
32+
- **Search**: filters, sorting, paging and attribute selection, on a resource type or at the root
33+
- **Bulk requests**: with `bulkId` references and `failOnErrors`
34+
- **ETags**: conditional requests and protection against concurrent writes
35+
- **Discovery**: `ServiceProviderConfig`, `ResourceTypes` and `Schemas`
36+
- **Storages**: an interface to implement, an in-memory storage, and a test suite that checks a storage
37+
- **Sync & Async**: a synchronous and an asynchronous handler, over the same rules
38+
- **Multi-tenancy**: one set of resources per URL prefix
1839

19-
## Usage
40+
## Installation
2041

2142
```shell
22-
$ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service-provider-config SERVICE_PROVIDER_CONFIG] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--tenant TENANT] [--dynamic-tenants] [--debug]
43+
pip install scim2-server
2344
```
2445

25-
- `-h`/`--help`: Show help message
26-
- `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
27-
- `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
28-
- `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
29-
- `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
30-
- `--bearer-token`: Registers a bearer token that can be used for accessing the service, and announces the bearer token authentication scheme. If no tokens are provided, anonymous access without authentication is allowed.
31-
- `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
32-
- `--port`: The port to listen on. Defaults to `8080`.
33-
- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally. With tenants, the document has one entry per tenant.
34-
- `--tenant`: Serve a tenant under `/<tenant>`, for example `/<tenant>/v2/Users`. Can be repeated. See [Multi-tenancy](#multi-tenancy).
35-
- `--dynamic-tenants`: Create a tenant on the first request to `/<tenant>`. See [Multi-tenancy](#multi-tenancy).
36-
- `--debug`: Enable the interactive Werkzeug debugger, the reloader and the logging of the WSGI environment of each request. The debugger allows arbitrary code execution and the environment contains the bearer tokens, so never use this option on a server reachable by others.
37-
38-
### Multi-tenancy
39-
40-
With `--tenant` or `--dynamic-tenants`, the first segment of the URL path selects a tenant (RFC 7644 §6.1).
41-
Each tenant has its own resources: `/a/v2/Users` and `/b/v2/Users` are separate, and uniqueness, filters and pagination only consider the resources of the tenant.
42-
The schemas, the resource types and the service provider configuration are the same for every tenant, and so are the bearer tokens.
43-
A request without a known tenant gets a 404 answer, and `v2` cannot be a tenant name.
46+
## Usage
47+
48+
Serve a test server from the command line:
4449

4550
```shell
46-
$ scim2-server --tenant a --tenant b
47-
$ curl http://localhost:8080/a/v2/Users
51+
scim2-server --port 8080
4852
```
4953

50-
With `--dynamic-tenants`, the first request to an unknown tenant creates it with no resources.
51-
This is useful for tests: each test can pick a random tenant and get an empty server.
52-
Any client can then create tenants, even without a valid bearer token, and every tenant stays in memory until the server exits.
53-
Do not use this option on a server reachable by untrusted clients.
54+
Or build the WSGI application in Python:
5455

55-
In Python, `scim2_server.tenants.TenantDispatcher` builds one `SCIMApplication` per tenant from a factory, on the first request to the tenant.
56-
The factory gets the tenant name and returns `None` when the tenant does not exist.
57-
Override its `select_tenant` method to read the tenant from a header or a sub-domain instead.
56+
```python
57+
from scim2_server.applications.wsgi import WSGIApplication
58+
from scim2_server.memory import InMemoryStorage
59+
from scim2_server.utils import load_default_provider
5860

59-
### Container
61+
app = WSGIApplication(InMemoryStorage(), load_default_provider())
62+
```
6063

61-
A container image is published on the GitHub container registry for each release.
62-
The server listens on `0.0.0.0:8080` inside the container, and the command line arguments are passed to `scim2-server`:
64+
A container image is published on the GitHub container registry for each release:
6365

6466
```shell
65-
$ docker run --publish 8080:8080 ghcr.io/python-scim/scim2-server --bearer-token secret
67+
docker run --publish 8080:8080 ghcr.io/python-scim/scim2-server --bearer-token secret
6668
```
6769

68-
To build the image yourself, use `docker build --file Containerfile .` or `podman build .`.
70+
The server has been tested against a live
71+
[Microsoft Entra](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/scim-validator-tutorial)
72+
system and a live
73+
[Okta](https://developer.okta.com/docs/guides/scim-provisioning-integration-test/main/) system.
6974

70-
## Notes
75+
## Documentation
7176

72-
This provider can be used as a starting point if you want to implement a SCIM provider. You should probably change the following things, if you want to use it in production:
77+
- [Overview](https://scim2-server.readthedocs.io/en/latest/overview.html) introduces the parts of
78+
a SCIM server, from the description of the service to the test server.
79+
- [How-to guides](https://scim2-server.readthedocs.io/en/latest/how-to/index.html) cover focused
80+
tasks, such as writing a storage or integrating a web framework.
81+
- [Explanation](https://scim2-server.readthedocs.io/en/latest/explanation/index.html) covers the
82+
layers of the server, the writes of resources and the bulk requests.
83+
- [Reference](https://scim2-server.readthedocs.io/en/latest/reference.html) lists the public API
84+
and the options of the `scim2-server` command.
7385

74-
- Use a proper production WSGI server instead of the one provided by Werkzeug
75-
- Implement your own storage as a subclass of `scim2_server.storage.ScimStorage`, and check it with `scim2_server.testing.ScimStorageContract`
76-
- Implement proper authorization with OAuth instead of public access or static bearer tokens
77-
- Support the `/Me` endpoint, if it applies in your use case
86+
## Contributing
7887

79-
The provider in its current state has been tested successfully against a live
80-
[Microsoft Entra](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/scim-validator-tutorial)
81-
system as well as a live
82-
[Okta](https://developer.okta.com/docs/guides/scim-provisioning-integration-test/main/) system.
88+
The [contribution page](https://scim2-server.readthedocs.io/en/latest/contributing.html)
89+
describes how to run the tests, the style checks and the documentation build.
90+
91+
scim2-server belongs in a collection of SCIM tools developed by [Yaal Coop](https://yaal.coop),
92+
with [scim2-models](https://github.com/python-scim/scim2-models),
93+
[scim2-client](https://github.com/python-scim/scim2-client),
94+
[scim2-tester](https://github.com/python-scim/scim2-tester) and
95+
[scim2-cli](https://github.com/python-scim/scim2-cli).
8396

8497
## Origins
8598

‎doc/__init__.py‎

Whitespace-only changes.

‎doc/_examples/__init__.py‎

Whitespace-only changes.

‎doc/_examples/asgi_integration.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import json
2+
from urllib.parse import parse_qsl
3+
4+
from scim2_models import SCIMException
5+
6+
from scim2_server.handler import AsyncScimHandler
7+
from scim2_server.memory import AsyncInMemoryStorage
8+
from scim2_server.requests import ScimRequest
9+
from scim2_server.responses import ScimResponse
10+
from scim2_server.service import ScimService
11+
from scim2_server.utils import load_default_provider
12+
13+
service = ScimService(load_default_provider())
14+
handler = AsyncScimHandler(service, AsyncInMemoryStorage())
15+
16+
17+
async def asgi_to_scim_request(scope, receive):
18+
"""Turn an ASGI request into a SCIM request."""
19+
headers = [(name.decode(), value.decode()) for name, value in scope["headers"]]
20+
body = b""
21+
while True:
22+
message = await receive()
23+
body += message.get("body", b"")
24+
if not message.get("more_body"):
25+
break
26+
return ScimRequest(
27+
method=scope["method"],
28+
base_url=f"{scope['scheme']}://{dict(headers)['host']}{scope['root_path']}",
29+
path=scope["path"],
30+
query=dict(parse_qsl(scope["query_string"].decode())),
31+
headers=headers,
32+
body=body,
33+
)
34+
35+
36+
async def scim_to_asgi_response(response: ScimResponse, send):
37+
"""Send a SCIM response as an ASGI response."""
38+
body = b"" if response.body is None else json.dumps(response.body).encode()
39+
await send(
40+
{
41+
"type": "http.response.start",
42+
"status": response.status,
43+
"headers": [(k.encode(), v.encode()) for k, v in response.headers.items()],
44+
}
45+
)
46+
await send({"type": "http.response.body", "body": body})
47+
48+
49+
async def application(scope, receive, send):
50+
"""Serve the SCIM endpoints at the root of the application."""
51+
try:
52+
response = await handler.handle(await asgi_to_scim_request(scope, receive))
53+
except SCIMException as exception:
54+
response = service.error_response(exception)
55+
await scim_to_asgi_response(response, send)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import sqlite3
2+
from collections.abc import AsyncIterator
3+
from contextlib import asynccontextmanager
4+
from datetime import UTC
5+
from datetime import datetime
6+
from typing import Any
7+
8+
import aiosqlite
9+
from scim2_models import NotFoundException
10+
from scim2_models import PreconditionFailedException
11+
from scim2_models import Resource
12+
from scim2_models import ResourceType
13+
from scim2_models import SearchRequest
14+
from scim2_models import UniquenessException
15+
16+
from doc._examples.library_storage import TABLES
17+
from scim2_server.storage import AsyncScimStorage
18+
19+
20+
class AsyncLibraryStorage(AsyncScimStorage):
21+
"""Serve the members and the books of the library, with aiosqlite."""
22+
23+
def __init__(self, path: str):
24+
self.path = path
25+
self.connection: aiosqlite.Connection | None = None
26+
27+
async def connect(self) -> aiosqlite.Connection:
28+
"""Open the database on the first call."""
29+
if self.connection is None:
30+
self.connection = await aiosqlite.connect(self.path)
31+
self.connection.row_factory = aiosqlite.Row
32+
return self.connection
33+
34+
async def close(self) -> None:
35+
"""Close the database, when the application stops."""
36+
if self.connection is not None:
37+
await self.connection.close()
38+
self.connection = None
39+
40+
async def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
41+
table = TABLES[resource_type.name]
42+
connection = await self.connect()
43+
row = None
44+
if resource_id.isdigit():
45+
cursor = await connection.execute(
46+
f"SELECT * FROM {table.name} WHERE id = ?", (int(resource_id),)
47+
)
48+
row = await cursor.fetchone()
49+
if row is None:
50+
raise NotFoundException(
51+
detail=f"{resource_type.name} {resource_id} not found"
52+
)
53+
return table.to_resource(row)
54+
55+
async def create(
56+
self, resource_type: ResourceType, resource: Resource[Any]
57+
) -> Resource[Any]:
58+
table = TABLES[resource_type.name]
59+
now = datetime.now(UTC).isoformat()
60+
row = {
61+
**table.to_row(resource),
62+
**table.assigned(),
63+
"created_at": now,
64+
"updated_at": now,
65+
}
66+
columns = ", ".join(row)
67+
values = ", ".join(f":{column}" for column in row)
68+
async with self.writing() as connection:
69+
cursor = await connection.execute(
70+
f"INSERT INTO {table.name} ({columns}) VALUES ({values})", row
71+
)
72+
return await self.get(resource_type, str(cursor.lastrowid))
73+
74+
async def update(
75+
self,
76+
resource_type: ResourceType,
77+
resource: Resource[Any],
78+
*,
79+
expected_version: str | None = None,
80+
) -> Resource[Any]:
81+
table = TABLES[resource_type.name]
82+
row = {**table.to_row(resource), "updated_at": datetime.now(UTC).isoformat()}
83+
assignments = ", ".join(f"{column} = :{column}" for column in row)
84+
query = f"UPDATE {table.name} SET {assignments} WHERE id = :id"
85+
parameters = {**row, "id": resource.id}
86+
if expected_version is not None:
87+
query += " AND updated_at = :version"
88+
parameters["version"] = expected_version.removeprefix("W/").strip('"')
89+
async with self.writing() as connection:
90+
cursor = await connection.execute(query, parameters)
91+
if cursor.rowcount == 0:
92+
await self.get(resource_type, str(resource.id))
93+
raise PreconditionFailedException
94+
return await self.get(resource_type, str(resource.id))
95+
96+
async def delete(
97+
self,
98+
resource_type: ResourceType,
99+
resource_id: str,
100+
*,
101+
expected_version: str | None = None,
102+
) -> None:
103+
table = TABLES[resource_type.name]
104+
query = f"DELETE FROM {table.name} WHERE id = :id"
105+
parameters = {"id": resource_id}
106+
if expected_version is not None:
107+
query += " AND updated_at = :version"
108+
parameters["version"] = expected_version.removeprefix("W/").strip('"')
109+
async with self.writing() as connection:
110+
cursor = await connection.execute(query, parameters)
111+
if cursor.rowcount == 0:
112+
await self.get(resource_type, resource_id)
113+
raise PreconditionFailedException
114+
115+
@asynccontextmanager
116+
async def writing(self) -> AsyncIterator[aiosqlite.Connection]:
117+
"""Commit a write, and refuse a value that a UNIQUE column already holds."""
118+
connection = await self.connect()
119+
try:
120+
yield connection
121+
await connection.commit()
122+
except sqlite3.IntegrityError:
123+
await connection.rollback()
124+
raise UniquenessException from None
125+
126+
async def search(
127+
self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
128+
) -> tuple[int, list[Resource[Any]]]:
129+
connection = await self.connect()
130+
found: list[Resource[Any]] = []
131+
for resource_type in resource_types:
132+
table = TABLES[resource_type.name]
133+
cursor = await connection.execute(f"SELECT * FROM {table.name} ORDER BY id")
134+
found += [table.to_resource(row) for row in await cursor.fetchall()]
135+
if search_request.filter is not None:
136+
found = [
137+
resource for resource in found if search_request.filter.match(resource)
138+
]
139+
found = search_request.sort(found)
140+
start = (search_request.start_index or 1) - 1
141+
stop = None if search_request.count is None else start + search_request.count
142+
return len(found), found[start:stop]

0 commit comments

Comments
 (0)