Skip to content

Commit 978b25c

Browse files
authored
Merge pull request #24 from python-scim/tenants
Serve several tenants under a URL prefix
2 parents 834d358 + e5013fe commit 978b25c

4 files changed

Lines changed: 302 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ they are lost once the process exits.
1414
- [x] HTTP PATCH (Add/Remove/Replace)
1515
- [x] Sorting
1616
- [x] Bulk operations
17+
- [x] Multi-tenancy with a URL prefix
1718

1819
## Usage
1920

2021
```shell
21-
$ 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]
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]
2223
```
2324

2425
- `-h`/`--help`: Show help message
@@ -29,9 +30,31 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service
2930
- `--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.
3031
- `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
3132
- `--port`: The port to listen on. Defaults to `8080`.
32-
- `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
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).
3336
- `--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.
3437

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.
44+
45+
```shell
46+
$ scim2-server --tenant a --tenant b
47+
$ curl http://localhost:8080/a/v2/Users
48+
```
49+
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+
55+
In Python, `scim2_server.tenants.TenantDispatcher` builds one `SCIMApplication` per tenant from a factory.
56+
Override its `select_tenant` method to read the tenant from a header or a sub-domain instead.
57+
3558
### Container
3659

3760
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
5174
- Implement your own Backend as a subclass of `scim2_server.backend.Backend`
5275
- Implement proper authorization with OAuth instead of public access or static bearer tokens
5376
- Support the `/Me` endpoint, if it applies in your use case
54-
- Add support for using either a static URL prefix or improve the support for usage behind a reverse proxy
5577

5678
The provider in its current state has been tested successfully against a live
5779
[Microsoft Entra](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/scim-validator-tutorial)

‎scim2_server/cli.py‎

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pprint
55
from collections.abc import Iterable
66
from typing import TYPE_CHECKING
7+
from typing import Any
78

89
from scim2_models import AuthenticationScheme
910
from scim2_models import External
@@ -16,6 +17,7 @@
1617

1718
from scim2_server.backend import InMemoryBackend
1819
from scim2_server.provider import SCIMApplication
20+
from scim2_server.tenants import TenantDispatcher
1921
from scim2_server.utils import load_default_resource_types
2022
from scim2_server.utils import load_default_schemas
2123
from scim2_server.utils import load_default_service_provider_config
@@ -45,6 +47,11 @@ def _inner(
4547
return _inner
4648

4749

50+
def dump_resources(backend: InMemoryBackend) -> list[dict[str, Any]]:
51+
"""Return the JSON representation of the resources of a backend."""
52+
return [r.model_dump() for r in backend.resources]
53+
54+
4855
def main() -> None:
4956
parser = argparse.ArgumentParser()
5057
parser.add_argument(
@@ -71,6 +78,17 @@ def main() -> None:
7178
type=argparse.FileType("w"),
7279
help="Dump resources to a JSON file on exit",
7380
)
81+
parser.add_argument(
82+
"--tenant",
83+
action="append",
84+
help="Serve a tenant under /TENANT, with its own resources",
85+
)
86+
parser.add_argument(
87+
"--dynamic-tenants",
88+
action="store_true",
89+
help="Create a tenant on the first request to /TENANT. "
90+
"Any client can then create tenants, and they stay in memory until exit",
91+
)
7492
parser.add_argument(
7593
"--debug",
7694
action="store_true",
@@ -112,16 +130,26 @@ def main() -> None:
112130
BEARER_TOKEN_SCHEME,
113131
]
114132

115-
backend = InMemoryBackend()
116-
app = SCIMApplication(
117-
backend, ScimProvider.from_discovery(schemas, resource_types, config=config)
118-
)
133+
provider = ScimProvider.from_discovery(schemas, resource_types, config=config)
119134

120-
if args.bearer_token is not None:
121-
for bearer_token in args.bearer_token:
135+
backends: dict[str | None, InMemoryBackend] = {}
136+
137+
def make_application(tenant: str | None = None) -> SCIMApplication:
138+
backends[tenant] = InMemoryBackend()
139+
app = SCIMApplication(backends[tenant], provider)
140+
for bearer_token in args.bearer_token or []:
122141
app.register_bearer_token(bearer_token)
142+
return app
143+
144+
use_tenants = bool(args.tenant or args.dynamic_tenants)
145+
wsgi_app: WSGIApplication
146+
if use_tenants:
147+
wsgi_app = TenantDispatcher(
148+
make_application, args.tenant or [], dynamic=args.dynamic_tenants
149+
)
150+
else:
151+
wsgi_app = make_application()
123152

124-
wsgi_app: WSGIApplication = app
125153
if args.debug:
126154
wsgi_app = log_environ(wsgi_app)
127155
if args.reverse_proxy:
@@ -139,8 +167,13 @@ def main() -> None:
139167
)
140168

141169
if args.dump_resources:
170+
dump: Any = (
171+
{tenant: dump_resources(backend) for tenant, backend in backends.items()}
172+
if use_tenants
173+
else dump_resources(backends[None])
174+
)
142175
with args.dump_resources as f:
143-
f.write(json.dumps([r.model_dump() for r in backend.resources], indent=2))
176+
f.write(json.dumps(dump, indent=2))
144177

145178

146179
if __name__ == "__main__":

‎scim2_server/tenants.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from collections.abc import Callable
2+
from collections.abc import Iterable
3+
from threading import Lock
4+
from typing import TYPE_CHECKING
5+
6+
from scim2_models import Error
7+
8+
from scim2_server.provider import SCIMApplication
9+
10+
if TYPE_CHECKING:
11+
from _typeshed.wsgi import StartResponse
12+
from _typeshed.wsgi import WSGIEnvironment
13+
14+
RESERVED_TENANTS = frozenset({"v2"})
15+
16+
17+
class TenantDispatcher:
18+
"""A WSGI application serving each tenant with its own SCIM application.
19+
20+
The tenant is the first segment of the request path, as in the URL prefix
21+
method of RFC 7644 §6.1: a request to ``/<tenant>/v2/Users`` is served by
22+
the application of ``<tenant>``, mounted under ``/<tenant>``.
23+
24+
:param factory: Build the application of a tenant from its name.
25+
:param tenants: The tenants created at startup.
26+
:param dynamic: Whether a request to an unknown tenant creates it. Any
27+
client can then create tenants, and each one stays in memory.
28+
"""
29+
30+
def __init__(
31+
self,
32+
factory: Callable[[str], SCIMApplication],
33+
tenants: Iterable[str] = (),
34+
dynamic: bool = False,
35+
):
36+
self.factory = factory
37+
self.dynamic = dynamic
38+
self.applications: dict[str, SCIMApplication] = {}
39+
self.lock = Lock()
40+
for tenant in tenants:
41+
if not self.is_valid_tenant(tenant):
42+
raise ValueError(f"Invalid tenant name: {tenant!r}")
43+
self.applications[tenant] = factory(tenant)
44+
45+
@staticmethod
46+
def is_valid_tenant(tenant: str) -> bool:
47+
"""Tell whether a name can identify a tenant.
48+
49+
The version segment is refused, so that a request without a tenant is
50+
not served by a tenant named ``v2``.
51+
"""
52+
return bool(tenant) and "/" not in tenant and tenant not in RESERVED_TENANTS
53+
54+
def select_tenant(self, environ: "WSGIEnvironment") -> str | None:
55+
"""Return the tenant of a request, and move it from the path to the mount prefix.
56+
57+
Override this method to read the tenant from somewhere else, such as
58+
a header or a sub-domain (RFC 7644 §6.1).
59+
"""
60+
path_info: str = environ.get("PATH_INFO", "")
61+
_, _, path = path_info.partition("/")
62+
tenant, separator, rest = path.partition("/")
63+
if not self.is_valid_tenant(tenant):
64+
return None
65+
environ["SCRIPT_NAME"] = f"{environ.get('SCRIPT_NAME', '')}/{tenant}"
66+
environ["PATH_INFO"] = separator + rest
67+
return tenant
68+
69+
def get_application(self, tenant: str) -> SCIMApplication | None:
70+
"""Return the application of a tenant, creating it if tenants are dynamic."""
71+
with self.lock:
72+
if tenant not in self.applications and self.dynamic:
73+
self.applications[tenant] = self.factory(tenant)
74+
return self.applications.get(tenant)
75+
76+
def __call__(
77+
self, environ: "WSGIEnvironment", start_response: "StartResponse"
78+
) -> Iterable[bytes]:
79+
"""Dispatch a request to the application of its tenant."""
80+
tenant = self.select_tenant(environ)
81+
application = self.get_application(tenant) if tenant is not None else None
82+
if application is None:
83+
response = SCIMApplication.make_response(
84+
Error(status=404, detail="Unknown tenant").model_dump(), status=404
85+
)
86+
return response(environ, start_response)
87+
return application(environ, start_response)

‎tests/test_tenants.py‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import httpx2
2+
import pytest
3+
4+
from scim2_server.backend import InMemoryBackend
5+
from scim2_server.provider import SCIMApplication
6+
from scim2_server.tenants import TenantDispatcher
7+
8+
BASE_URL = "https://scim.example.com"
9+
10+
11+
@pytest.fixture
12+
def factory(scim_provider):
13+
return lambda tenant: SCIMApplication(InMemoryBackend(), scim_provider)
14+
15+
16+
def make_client(dispatcher, script_name=""):
17+
transport = httpx2.WSGITransport(app=dispatcher, script_name=script_name)
18+
return httpx2.Client(transport=transport, base_url=BASE_URL)
19+
20+
21+
@pytest.fixture
22+
def dispatcher(factory):
23+
return TenantDispatcher(factory, dynamic=True)
24+
25+
26+
@pytest.fixture
27+
def client(dispatcher):
28+
with make_client(dispatcher) as client:
29+
yield client
30+
31+
32+
def test_resources_are_isolated_between_tenants(client, fake_user_data):
33+
"""A resource created in a tenant is not visible from another tenant."""
34+
user_id = client.post("/a/v2/Users", json=fake_user_data[0]).json()["id"]
35+
36+
assert client.get(f"/a/v2/Users/{user_id}").status_code == 200
37+
assert client.get(f"/b/v2/Users/{user_id}").status_code == 404
38+
assert client.get("/a/v2/Users").json()["totalResults"] == 1
39+
assert client.get("/b/v2/Users").json()["totalResults"] == 0
40+
41+
42+
def test_same_user_name_in_two_tenants(client, fake_user_data):
43+
"""Uniqueness only considers the resources of the tenant."""
44+
assert client.post("/a/v2/Users", json=fake_user_data[0]).status_code == 201
45+
assert client.post("/b/v2/Users", json=fake_user_data[0]).status_code == 201
46+
assert client.post("/a/v2/Users", json=fake_user_data[0]).status_code == 409
47+
48+
49+
def test_location_includes_the_tenant(client, fake_user_data):
50+
"""The location of a resource starts with the prefix of its tenant."""
51+
r = client.post("/a/v2/Users", json=fake_user_data[0])
52+
location = f"{BASE_URL}/a/v2/Users/{r.json()['id']}"
53+
assert r.headers["Location"] == location
54+
assert r.json()["meta"]["location"] == location
55+
assert client.get("/a/Users").json()["Resources"][0]["meta"]["location"] == (
56+
location
57+
)
58+
59+
60+
def test_bulk_location_includes_the_tenant(client, fake_user_data):
61+
"""The location of a bulk operation starts with the prefix of its tenant."""
62+
r = client.post(
63+
"/a/v2/Bulk",
64+
json={
65+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"],
66+
"Operations": [
67+
{
68+
"method": "POST",
69+
"path": "/Users",
70+
"bulkId": "u",
71+
"data": fake_user_data[0],
72+
}
73+
],
74+
},
75+
)
76+
assert r.json()["Operations"][0]["location"].startswith(f"{BASE_URL}/a/v2/Users/")
77+
78+
79+
def test_discovery_is_served_in_every_tenant(client):
80+
"""The discovery endpoints are available in each tenant, under its prefix."""
81+
r = client.get("/a/v2/ServiceProviderConfig")
82+
assert r.status_code == 200
83+
assert r.json()["meta"]["location"] == f"{BASE_URL}/a/v2/ServiceProviderConfig"
84+
85+
86+
def test_tenant_under_a_mount_prefix(dispatcher, fake_user_data):
87+
"""The tenant comes after the prefix the dispatcher is mounted under."""
88+
with make_client(dispatcher, script_name="/scim") as client:
89+
r = client.post("/a/v2/Users", json=fake_user_data[0])
90+
assert r.json()["meta"]["location"].startswith(f"{BASE_URL}/scim/a/v2/Users/")
91+
92+
93+
def test_dynamic_tenant_is_created_once(client, dispatcher):
94+
"""The first request to an unknown tenant creates it, later ones reuse it."""
95+
assert dispatcher.applications == {}
96+
client.get("/a/v2/Users")
97+
application = dispatcher.applications["a"]
98+
client.get("/a/v2/Users")
99+
assert dispatcher.applications == {"a": application}
100+
101+
102+
def test_static_tenants(factory, fake_user_data):
103+
"""Without dynamic tenants, only the declared tenants exist."""
104+
dispatcher = TenantDispatcher(factory, tenants=["a"])
105+
with make_client(dispatcher) as client:
106+
assert client.post("/a/v2/Users", json=fake_user_data[0]).status_code == 201
107+
r = client.get("/b/v2/Users")
108+
assert r.status_code == 404
109+
assert r.headers["Content-Type"] == "application/scim+json"
110+
assert r.json() == {
111+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
112+
"status": "404",
113+
"detail": "Unknown tenant",
114+
}
115+
assert list(dispatcher.applications) == ["a"]
116+
117+
118+
@pytest.mark.parametrize("path", ["/", "/v2/Users", "//Users"])
119+
def test_request_without_tenant(client, dispatcher, path):
120+
"""A request whose path has no valid tenant gets a 404 and creates nothing."""
121+
r = client.get(path)
122+
assert r.status_code == 404
123+
assert r.json()["detail"] == "Unknown tenant"
124+
assert dispatcher.applications == {}
125+
126+
127+
@pytest.mark.parametrize("tenant", ["", "v2", "a/b"])
128+
def test_invalid_static_tenant(factory, tenant):
129+
"""A declared tenant must be a single path segment other than the version."""
130+
with pytest.raises(ValueError, match="Invalid tenant name"):
131+
TenantDispatcher(factory, tenants=[tenant])
132+
133+
134+
def test_tenant_from_a_header(factory, fake_user_data):
135+
"""A subclass can read the tenant from a header and keep the path unchanged."""
136+
137+
class HeaderTenantDispatcher(TenantDispatcher):
138+
def select_tenant(self, environ):
139+
return environ.get("HTTP_X_TENANT")
140+
141+
dispatcher = HeaderTenantDispatcher(factory, dynamic=True)
142+
with make_client(dispatcher) as client:
143+
r = client.post("/v2/Users", json=fake_user_data[0], headers={"X-Tenant": "a"})
144+
assert r.json()["meta"]["location"].startswith(f"{BASE_URL}/v2/Users/")
145+
assert (
146+
client.get("/v2/Users", headers={"X-Tenant": "b"}).json()["totalResults"]
147+
== 0
148+
)
149+
assert client.get("/v2/Users").status_code == 404

0 commit comments

Comments
 (0)