From e5013fedf287a14f095af67be8effb2a6fa72953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 1 Oct 2026 23:07:01 +0200 Subject: [PATCH] feat: serve several tenants under a URL prefix --- README.md | 28 +++++++- scim2_server/cli.py | 49 ++++++++++--- scim2_server/tenants.py | 87 +++++++++++++++++++++++ tests/test_tenants.py | 149 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 scim2_server/tenants.py create mode 100644 tests/test_tenants.py diff --git a/README.md b/README.md index 0071168..5e85fcd 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,12 @@ they are lost once the process exits. - [x] HTTP PATCH (Add/Remove/Replace) - [x] Sorting - [x] Bulk operations +- [x] Multi-tenancy with a URL prefix ## Usage ```shell -$ 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] [--debug] +$ 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] ``` - `-h`/`--help`: Show help message @@ -29,9 +30,31 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service - `--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. - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`. - `--port`: The port to listen on. Defaults to `8080`. -- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally. +- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally. With tenants, the document has one entry per tenant. +- `--tenant`: Serve a tenant under `/`, for example `//v2/Users`. Can be repeated. See [Multi-tenancy](#multi-tenancy). +- `--dynamic-tenants`: Create a tenant on the first request to `/`. See [Multi-tenancy](#multi-tenancy). - `--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. +### Multi-tenancy + +With `--tenant` or `--dynamic-tenants`, the first segment of the URL path selects a tenant (RFC 7644 §6.1). +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. +The schemas, the resource types and the service provider configuration are the same for every tenant, and so are the bearer tokens. +A request without a known tenant gets a 404 answer, and `v2` cannot be a tenant name. + +```shell +$ scim2-server --tenant a --tenant b +$ curl http://localhost:8080/a/v2/Users +``` + +With `--dynamic-tenants`, the first request to an unknown tenant creates it with no resources. +This is useful for tests: each test can pick a random tenant and get an empty server. +Any client can then create tenants, even without a valid bearer token, and every tenant stays in memory until the server exits. +Do not use this option on a server reachable by untrusted clients. + +In Python, `scim2_server.tenants.TenantDispatcher` builds one `SCIMApplication` per tenant from a factory. +Override its `select_tenant` method to read the tenant from a header or a sub-domain instead. + ### Container A container image is published on the GitHub container registry for each release. @@ -51,7 +74,6 @@ This provider can be used as a starting point if you want to implement a SCIM pr - Implement your own Backend as a subclass of `scim2_server.backend.Backend` - Implement proper authorization with OAuth instead of public access or static bearer tokens - Support the `/Me` endpoint, if it applies in your use case -- Add support for using either a static URL prefix or improve the support for usage behind a reverse proxy The provider in its current state has been tested successfully against a live [Microsoft Entra](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/scim-validator-tutorial) diff --git a/scim2_server/cli.py b/scim2_server/cli.py index 4ea3f78..cf0851b 100644 --- a/scim2_server/cli.py +++ b/scim2_server/cli.py @@ -4,6 +4,7 @@ import pprint from collections.abc import Iterable from typing import TYPE_CHECKING +from typing import Any from scim2_models import AuthenticationScheme from scim2_models import External @@ -16,6 +17,7 @@ from scim2_server.backend import InMemoryBackend from scim2_server.provider import SCIMApplication +from scim2_server.tenants import TenantDispatcher from scim2_server.utils import load_default_resource_types from scim2_server.utils import load_default_schemas from scim2_server.utils import load_default_service_provider_config @@ -45,6 +47,11 @@ def _inner( return _inner +def dump_resources(backend: InMemoryBackend) -> list[dict[str, Any]]: + """Return the JSON representation of the resources of a backend.""" + return [r.model_dump() for r in backend.resources] + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( @@ -71,6 +78,17 @@ def main() -> None: type=argparse.FileType("w"), help="Dump resources to a JSON file on exit", ) + parser.add_argument( + "--tenant", + action="append", + help="Serve a tenant under /TENANT, with its own resources", + ) + parser.add_argument( + "--dynamic-tenants", + action="store_true", + help="Create a tenant on the first request to /TENANT. " + "Any client can then create tenants, and they stay in memory until exit", + ) parser.add_argument( "--debug", action="store_true", @@ -112,16 +130,26 @@ def main() -> None: BEARER_TOKEN_SCHEME, ] - backend = InMemoryBackend() - app = SCIMApplication( - backend, ScimProvider.from_discovery(schemas, resource_types, config=config) - ) + provider = ScimProvider.from_discovery(schemas, resource_types, config=config) - if args.bearer_token is not None: - for bearer_token in args.bearer_token: + backends: dict[str | None, InMemoryBackend] = {} + + def make_application(tenant: str | None = None) -> SCIMApplication: + backends[tenant] = InMemoryBackend() + app = SCIMApplication(backends[tenant], provider) + for bearer_token in args.bearer_token or []: app.register_bearer_token(bearer_token) + return app + + use_tenants = bool(args.tenant or args.dynamic_tenants) + wsgi_app: WSGIApplication + if use_tenants: + wsgi_app = TenantDispatcher( + make_application, args.tenant or [], dynamic=args.dynamic_tenants + ) + else: + wsgi_app = make_application() - wsgi_app: WSGIApplication = app if args.debug: wsgi_app = log_environ(wsgi_app) if args.reverse_proxy: @@ -139,8 +167,13 @@ def main() -> None: ) if args.dump_resources: + dump: Any = ( + {tenant: dump_resources(backend) for tenant, backend in backends.items()} + if use_tenants + else dump_resources(backends[None]) + ) with args.dump_resources as f: - f.write(json.dumps([r.model_dump() for r in backend.resources], indent=2)) + f.write(json.dumps(dump, indent=2)) if __name__ == "__main__": diff --git a/scim2_server/tenants.py b/scim2_server/tenants.py new file mode 100644 index 0000000..a7aaadc --- /dev/null +++ b/scim2_server/tenants.py @@ -0,0 +1,87 @@ +from collections.abc import Callable +from collections.abc import Iterable +from threading import Lock +from typing import TYPE_CHECKING + +from scim2_models import Error + +from scim2_server.provider import SCIMApplication + +if TYPE_CHECKING: + from _typeshed.wsgi import StartResponse + from _typeshed.wsgi import WSGIEnvironment + +RESERVED_TENANTS = frozenset({"v2"}) + + +class TenantDispatcher: + """A WSGI application serving each tenant with its own SCIM application. + + The tenant is the first segment of the request path, as in the URL prefix + method of RFC 7644 §6.1: a request to ``//v2/Users`` is served by + the application of ````, mounted under ``/``. + + :param factory: Build the application of a tenant from its name. + :param tenants: The tenants created at startup. + :param dynamic: Whether a request to an unknown tenant creates it. Any + client can then create tenants, and each one stays in memory. + """ + + def __init__( + self, + factory: Callable[[str], SCIMApplication], + tenants: Iterable[str] = (), + dynamic: bool = False, + ): + self.factory = factory + self.dynamic = dynamic + self.applications: dict[str, SCIMApplication] = {} + self.lock = Lock() + for tenant in tenants: + if not self.is_valid_tenant(tenant): + raise ValueError(f"Invalid tenant name: {tenant!r}") + self.applications[tenant] = factory(tenant) + + @staticmethod + def is_valid_tenant(tenant: str) -> bool: + """Tell whether a name can identify a tenant. + + The version segment is refused, so that a request without a tenant is + not served by a tenant named ``v2``. + """ + return bool(tenant) and "/" not in tenant and tenant not in RESERVED_TENANTS + + def select_tenant(self, environ: "WSGIEnvironment") -> str | None: + """Return the tenant of a request, and move it from the path to the mount prefix. + + Override this method to read the tenant from somewhere else, such as + a header or a sub-domain (RFC 7644 §6.1). + """ + path_info: str = environ.get("PATH_INFO", "") + _, _, path = path_info.partition("/") + tenant, separator, rest = path.partition("/") + if not self.is_valid_tenant(tenant): + return None + environ["SCRIPT_NAME"] = f"{environ.get('SCRIPT_NAME', '')}/{tenant}" + environ["PATH_INFO"] = separator + rest + return tenant + + def get_application(self, tenant: str) -> SCIMApplication | None: + """Return the application of a tenant, creating it if tenants are dynamic.""" + with self.lock: + if tenant not in self.applications and self.dynamic: + self.applications[tenant] = self.factory(tenant) + return self.applications.get(tenant) + + def __call__( + self, environ: "WSGIEnvironment", start_response: "StartResponse" + ) -> Iterable[bytes]: + """Dispatch a request to the application of its tenant.""" + tenant = self.select_tenant(environ) + application = self.get_application(tenant) if tenant is not None else None + if application is None: + response = SCIMApplication.make_response( + Error(status=404, detail="Unknown tenant").model_dump(), status=404 + ) + return response(environ, start_response) + return application(environ, start_response) diff --git a/tests/test_tenants.py b/tests/test_tenants.py new file mode 100644 index 0000000..b847b3b --- /dev/null +++ b/tests/test_tenants.py @@ -0,0 +1,149 @@ +import httpx2 +import pytest + +from scim2_server.backend import InMemoryBackend +from scim2_server.provider import SCIMApplication +from scim2_server.tenants import TenantDispatcher + +BASE_URL = "https://scim.example.com" + + +@pytest.fixture +def factory(scim_provider): + return lambda tenant: SCIMApplication(InMemoryBackend(), scim_provider) + + +def make_client(dispatcher, script_name=""): + transport = httpx2.WSGITransport(app=dispatcher, script_name=script_name) + return httpx2.Client(transport=transport, base_url=BASE_URL) + + +@pytest.fixture +def dispatcher(factory): + return TenantDispatcher(factory, dynamic=True) + + +@pytest.fixture +def client(dispatcher): + with make_client(dispatcher) as client: + yield client + + +def test_resources_are_isolated_between_tenants(client, fake_user_data): + """A resource created in a tenant is not visible from another tenant.""" + user_id = client.post("/a/v2/Users", json=fake_user_data[0]).json()["id"] + + assert client.get(f"/a/v2/Users/{user_id}").status_code == 200 + assert client.get(f"/b/v2/Users/{user_id}").status_code == 404 + assert client.get("/a/v2/Users").json()["totalResults"] == 1 + assert client.get("/b/v2/Users").json()["totalResults"] == 0 + + +def test_same_user_name_in_two_tenants(client, fake_user_data): + """Uniqueness only considers the resources of the tenant.""" + assert client.post("/a/v2/Users", json=fake_user_data[0]).status_code == 201 + assert client.post("/b/v2/Users", json=fake_user_data[0]).status_code == 201 + assert client.post("/a/v2/Users", json=fake_user_data[0]).status_code == 409 + + +def test_location_includes_the_tenant(client, fake_user_data): + """The location of a resource starts with the prefix of its tenant.""" + r = client.post("/a/v2/Users", json=fake_user_data[0]) + location = f"{BASE_URL}/a/v2/Users/{r.json()['id']}" + assert r.headers["Location"] == location + assert r.json()["meta"]["location"] == location + assert client.get("/a/Users").json()["Resources"][0]["meta"]["location"] == ( + location + ) + + +def test_bulk_location_includes_the_tenant(client, fake_user_data): + """The location of a bulk operation starts with the prefix of its tenant.""" + r = client.post( + "/a/v2/Bulk", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], + "Operations": [ + { + "method": "POST", + "path": "/Users", + "bulkId": "u", + "data": fake_user_data[0], + } + ], + }, + ) + assert r.json()["Operations"][0]["location"].startswith(f"{BASE_URL}/a/v2/Users/") + + +def test_discovery_is_served_in_every_tenant(client): + """The discovery endpoints are available in each tenant, under its prefix.""" + r = client.get("/a/v2/ServiceProviderConfig") + assert r.status_code == 200 + assert r.json()["meta"]["location"] == f"{BASE_URL}/a/v2/ServiceProviderConfig" + + +def test_tenant_under_a_mount_prefix(dispatcher, fake_user_data): + """The tenant comes after the prefix the dispatcher is mounted under.""" + with make_client(dispatcher, script_name="/scim") as client: + r = client.post("/a/v2/Users", json=fake_user_data[0]) + assert r.json()["meta"]["location"].startswith(f"{BASE_URL}/scim/a/v2/Users/") + + +def test_dynamic_tenant_is_created_once(client, dispatcher): + """The first request to an unknown tenant creates it, later ones reuse it.""" + assert dispatcher.applications == {} + client.get("/a/v2/Users") + application = dispatcher.applications["a"] + client.get("/a/v2/Users") + assert dispatcher.applications == {"a": application} + + +def test_static_tenants(factory, fake_user_data): + """Without dynamic tenants, only the declared tenants exist.""" + dispatcher = TenantDispatcher(factory, tenants=["a"]) + with make_client(dispatcher) as client: + assert client.post("/a/v2/Users", json=fake_user_data[0]).status_code == 201 + r = client.get("/b/v2/Users") + assert r.status_code == 404 + assert r.headers["Content-Type"] == "application/scim+json" + assert r.json() == { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "404", + "detail": "Unknown tenant", + } + assert list(dispatcher.applications) == ["a"] + + +@pytest.mark.parametrize("path", ["/", "/v2/Users", "//Users"]) +def test_request_without_tenant(client, dispatcher, path): + """A request whose path has no valid tenant gets a 404 and creates nothing.""" + r = client.get(path) + assert r.status_code == 404 + assert r.json()["detail"] == "Unknown tenant" + assert dispatcher.applications == {} + + +@pytest.mark.parametrize("tenant", ["", "v2", "a/b"]) +def test_invalid_static_tenant(factory, tenant): + """A declared tenant must be a single path segment other than the version.""" + with pytest.raises(ValueError, match="Invalid tenant name"): + TenantDispatcher(factory, tenants=[tenant]) + + +def test_tenant_from_a_header(factory, fake_user_data): + """A subclass can read the tenant from a header and keep the path unchanged.""" + + class HeaderTenantDispatcher(TenantDispatcher): + def select_tenant(self, environ): + return environ.get("HTTP_X_TENANT") + + dispatcher = HeaderTenantDispatcher(factory, dynamic=True) + with make_client(dispatcher) as client: + r = client.post("/v2/Users", json=fake_user_data[0], headers={"X-Tenant": "a"}) + assert r.json()["meta"]["location"].startswith(f"{BASE_URL}/v2/Users/") + assert ( + client.get("/v2/Users", headers={"X-Tenant": "b"}).json()["totalResults"] + == 0 + ) + assert client.get("/v2/Users").status_code == 404