scim2-server 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Lightweight SCIM2 server prototype
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -240,11 +240,12 @@ they are lost once the process exits.
240
240
  - [x] HTTP PATCH (Add/Remove/Replace)
241
241
  - [x] Sorting
242
242
  - [x] Bulk operations
243
+ - [x] Multi-tenancy with a URL prefix
243
244
 
244
245
  ## Usage
245
246
 
246
247
  ```shell
247
- $ 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]
248
+ $ 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]
248
249
  ```
249
250
 
250
251
  - `-h`/`--help`: Show help message
@@ -255,9 +256,31 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service
255
256
  - `--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.
256
257
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
257
258
  - `--port`: The port to listen on. Defaults to `8080`.
258
- - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
259
+ - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally. With tenants, the document has one entry per tenant.
260
+ - `--tenant`: Serve a tenant under `/<tenant>`, for example `/<tenant>/v2/Users`. Can be repeated. See [Multi-tenancy](#multi-tenancy).
261
+ - `--dynamic-tenants`: Create a tenant on the first request to `/<tenant>`. See [Multi-tenancy](#multi-tenancy).
259
262
  - `--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.
260
263
 
264
+ ### Multi-tenancy
265
+
266
+ With `--tenant` or `--dynamic-tenants`, the first segment of the URL path selects a tenant (RFC 7644 §6.1).
267
+ 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.
268
+ The schemas, the resource types and the service provider configuration are the same for every tenant, and so are the bearer tokens.
269
+ A request without a known tenant gets a 404 answer, and `v2` cannot be a tenant name.
270
+
271
+ ```shell
272
+ $ scim2-server --tenant a --tenant b
273
+ $ curl http://localhost:8080/a/v2/Users
274
+ ```
275
+
276
+ With `--dynamic-tenants`, the first request to an unknown tenant creates it with no resources.
277
+ This is useful for tests: each test can pick a random tenant and get an empty server.
278
+ Any client can then create tenants, even without a valid bearer token, and every tenant stays in memory until the server exits.
279
+ Do not use this option on a server reachable by untrusted clients.
280
+
281
+ In Python, `scim2_server.tenants.TenantDispatcher` builds one `SCIMApplication` per tenant from a factory.
282
+ Override its `select_tenant` method to read the tenant from a header or a sub-domain instead.
283
+
261
284
  ### Container
262
285
 
263
286
  A container image is published on the GitHub container registry for each release.
@@ -277,7 +300,6 @@ This provider can be used as a starting point if you want to implement a SCIM pr
277
300
  - Implement your own Backend as a subclass of `scim2_server.backend.Backend`
278
301
  - Implement proper authorization with OAuth instead of public access or static bearer tokens
279
302
  - Support the `/Me` endpoint, if it applies in your use case
280
- - Add support for using either a static URL prefix or improve the support for usage behind a reverse proxy
281
303
 
282
304
  The provider in its current state has been tested successfully against a live
283
305
  [Microsoft Entra](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/scim-validator-tutorial)
@@ -14,11 +14,12 @@ they are lost once the process exits.
14
14
  - [x] HTTP PATCH (Add/Remove/Replace)
15
15
  - [x] Sorting
16
16
  - [x] Bulk operations
17
+ - [x] Multi-tenancy with a URL prefix
17
18
 
18
19
  ## Usage
19
20
 
20
21
  ```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]
22
23
  ```
23
24
 
24
25
  - `-h`/`--help`: Show help message
@@ -29,9 +30,31 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service
29
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.
30
31
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
31
32
  - `--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).
33
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.
34
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.
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
+
35
58
  ### Container
36
59
 
37
60
  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
51
74
  - Implement your own Backend as a subclass of `scim2_server.backend.Backend`
52
75
  - Implement proper authorization with OAuth instead of public access or static bearer tokens
53
76
  - 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
55
77
 
56
78
  The provider in its current state has been tested successfully against a live
57
79
  [Microsoft Entra](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/scim-validator-tutorial)
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.2"
7
+ version = "0.4.0"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -98,7 +98,8 @@ class Backend:
98
98
  :param resource: Resource to create.
99
99
  :return: The created resource. Creation should set system-
100
100
  defined attributes (ID, Metadata). May be the same object
101
- that is passed in.
101
+ that is passed in. A relative ``meta.location`` is relative to
102
+ the root of the application.
102
103
  """
103
104
  raise NotImplementedError
104
105
 
@@ -230,7 +231,7 @@ class InMemoryBackend(Backend):
230
231
  resource_type=resource_type.name,
231
232
  created=utcnow,
232
233
  last_modified=utcnow,
233
- location="/v2" + resource_type.endpoint + "/" + resource.id,
234
+ location=f"v2/{resource_type.endpoint.strip('/')}/{resource.id}",
234
235
  )
235
236
  self._touch_resource(resource, utcnow)
236
237
  self._check_uniqueness(resource)
@@ -4,6 +4,7 @@ import logging
4
4
  import pprint
5
5
  from collections.abc import Iterable
6
6
  from typing import TYPE_CHECKING
7
+ from typing import Any
7
8
 
8
9
  from scim2_models import AuthenticationScheme
9
10
  from scim2_models import External
@@ -16,6 +17,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
16
17
 
17
18
  from scim2_server.backend import InMemoryBackend
18
19
  from scim2_server.provider import SCIMApplication
20
+ from scim2_server.tenants import TenantDispatcher
19
21
  from scim2_server.utils import load_default_resource_types
20
22
  from scim2_server.utils import load_default_schemas
21
23
  from scim2_server.utils import load_default_service_provider_config
@@ -45,6 +47,11 @@ def log_environ(handler: "WSGIApplication") -> "WSGIApplication":
45
47
  return _inner
46
48
 
47
49
 
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
+
48
55
  def main() -> None:
49
56
  parser = argparse.ArgumentParser()
50
57
  parser.add_argument(
@@ -71,6 +78,17 @@ def main() -> None:
71
78
  type=argparse.FileType("w"),
72
79
  help="Dump resources to a JSON file on exit",
73
80
  )
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
+ )
74
92
  parser.add_argument(
75
93
  "--debug",
76
94
  action="store_true",
@@ -112,16 +130,26 @@ def main() -> None:
112
130
  BEARER_TOKEN_SCHEME,
113
131
  ]
114
132
 
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)
119
134
 
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 []:
122
141
  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()
123
152
 
124
- wsgi_app: WSGIApplication = app
125
153
  if args.debug:
126
154
  wsgi_app = log_environ(wsgi_app)
127
155
  if args.reverse_proxy:
@@ -139,8 +167,13 @@ def main() -> None:
139
167
  )
140
168
 
141
169
  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
+ )
142
175
  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))
144
177
 
145
178
 
146
179
  if __name__ == "__main__":
@@ -36,6 +36,7 @@ from werkzeug import Response
36
36
  from werkzeug.datastructures import ETags
37
37
  from werkzeug.exceptions import Forbidden
38
38
  from werkzeug.exceptions import HTTPException
39
+ from werkzeug.exceptions import MethodNotAllowed
39
40
  from werkzeug.exceptions import NotFound
40
41
  from werkzeug.exceptions import NotImplemented as WerkzeugNotImplemented
41
42
  from werkzeug.exceptions import PreconditionFailed
@@ -43,6 +44,7 @@ from werkzeug.exceptions import RequestEntityTooLarge
43
44
  from werkzeug.exceptions import Unauthorized
44
45
  from werkzeug.http import parse_etags
45
46
  from werkzeug.http import unquote_etag
47
+ from werkzeug.routing import BaseConverter
46
48
  from werkzeug.routing import Map
47
49
  from werkzeug.routing import Rule
48
50
  from werkzeug.routing.exceptions import RequestRedirect
@@ -79,6 +81,20 @@ BULK_SUCCESS_STATUS = {
79
81
  }
80
82
 
81
83
 
84
+ class ResourceEndpointConverter(BaseConverter):
85
+ """Match a resource endpoint, but not the endpoints RFC 7644 reserves nor the version prefix.
86
+
87
+ A request with a method a reserved endpoint does not support then gets a
88
+ 405 answer, instead of being routed to a resource type of that name.
89
+ """
90
+
91
+ # A reserved name followed by the end of the path segment is refused.
92
+ regex = (
93
+ r"(?!(?:ServiceProviderConfig|ResourceTypes|Schemas|Bulk|Me|v2)(?![^/]))[^/]+"
94
+ )
95
+ part_isolating = True
96
+
97
+
82
98
  class SCIMApplication:
83
99
  """A WSGI application implementing a SCIM provider (server)."""
84
100
 
@@ -123,17 +139,17 @@ class SCIMApplication:
123
139
  methods=("GET", "POST", "PUT", "PATCH", "DELETE"),
124
140
  ),
125
141
  Rule(
126
- f"{prefix}/<string:resource_endpoint>",
142
+ f"{prefix}/<resource_endpoint:resource_endpoint>",
127
143
  endpoint="resource",
128
144
  methods=("GET", "POST"),
129
145
  ),
130
146
  Rule(
131
- f"{prefix}/<string:resource_endpoint>/.search",
147
+ f"{prefix}/<resource_endpoint:resource_endpoint>/.search",
132
148
  endpoint="resource_search",
133
149
  methods=("POST",),
134
150
  ),
135
151
  Rule(
136
- f"{prefix}/<string:resource_endpoint>/<string:resource_id>",
152
+ f"{prefix}/<resource_endpoint:resource_endpoint>/<string:resource_id>",
137
153
  endpoint="single_resource",
138
154
  methods=("GET", "PUT", "PATCH", "DELETE"),
139
155
  ),
@@ -148,7 +164,9 @@ class SCIMApplication:
148
164
  for prefix in ("", "/v2")
149
165
  )
150
166
 
151
- self.url_map = Map(rules)
167
+ self.url_map = Map(
168
+ rules, converters={"resource_endpoint": ResourceEndpointConverter}
169
+ )
152
170
 
153
171
  def get_model(self, resource_type: ResourceType) -> type[Resource[Any]]:
154
172
  """Return the model of a resource type, its extensions included."""
@@ -178,12 +196,12 @@ class SCIMApplication:
178
196
  def publish(self, request: Request, resource: Resource[Any]) -> Resource[Any]:
179
197
  """Return a copy of a resource in the form sent to the client.
180
198
 
181
- Its location is made absolute from the URL the client requested, and
199
+ Its location is made absolute from the root URL of the application, and
182
200
  its version is left out when the service does not support ETags.
183
201
  """
184
202
  assert resource.meta is not None
185
203
  update: dict[str, Any] = {
186
- "location": urljoin(request.url + "/", resource.meta.location)
204
+ "location": urljoin(request.url_root, resource.meta.location)
187
205
  }
188
206
  if not self.etag_supported:
189
207
  update["version"] = None
@@ -811,7 +829,11 @@ class SCIMApplication:
811
829
  self.log.exception(e)
812
830
  return e.get_response(environ)
813
831
  except Exception as e:
814
- return self.make_error(self.error_from(e))
832
+ response = self.make_error(self.error_from(e))
833
+ if isinstance(e, MethodNotAllowed) and e.valid_methods:
834
+ # RFC 9110 §15.5.6: a 405 answer lists the supported methods.
835
+ response.headers["Allow"] = ", ".join(sorted(e.valid_methods))
836
+ return response
815
837
 
816
838
  def __call__(
817
839
  self, environ: "WSGIEnvironment", start_response: "StartResponse"
@@ -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)
File without changes