scim2-server 0.3.1__tar.gz → 0.3.2__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.1
3
+ Version: 0.3.2
4
4
  Summary: Lightweight SCIM2 server prototype
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -59,6 +59,7 @@ dev = [
59
59
  "prek>=0.1.0",
60
60
  "time-machine>=2.14.2",
61
61
  "tox-uv>=1.16.0",
62
+ "mypy>=2.3.1",
62
63
  ]
63
64
 
64
65
  [tool.uv]
@@ -75,6 +76,15 @@ pytest-scim2-server = false
75
76
  [tool.uv.build-backend]
76
77
  module-root = ""
77
78
 
79
+ [tool.mypy]
80
+ plugins = ["pydantic.mypy"]
81
+ files = ["scim2_server"]
82
+ strict = true
83
+
84
+ [tool.pydantic-mypy]
85
+ init_forbid_extra = true
86
+ init_typed = true
87
+
78
88
  [tool.coverage.run]
79
89
  source = [
80
90
  "scim2_server",
@@ -93,6 +103,7 @@ exclude_lines = [
93
103
  "@pytest.mark.xfail",
94
104
  "pragma: no cover",
95
105
  "raise NotImplementedError",
106
+ "if TYPE_CHECKING:",
96
107
  '\.\.\.\s*$',
97
108
  ]
98
109
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -48,6 +48,7 @@ dev = [
48
48
  "prek>=0.1.0",
49
49
  "time-machine>=2.14.2",
50
50
  "tox-uv>=1.16.0",
51
+ "mypy>=2.3.1",
51
52
  ]
52
53
 
53
54
  [tool.uv]
@@ -57,6 +58,17 @@ exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-serv
57
58
  [tool.uv.build-backend]
58
59
  module-root = ""
59
60
 
61
+ [tool.mypy]
62
+ plugins = [
63
+ "pydantic.mypy"
64
+ ]
65
+ files = ["scim2_server"]
66
+ strict = true
67
+
68
+ [tool.pydantic-mypy]
69
+ init_forbid_extra = true
70
+ init_typed = true
71
+
60
72
  [tool.coverage.run]
61
73
  source = [
62
74
  "scim2_server",
@@ -71,6 +83,7 @@ exclude_lines = [
71
83
  "@pytest.mark.xfail",
72
84
  "pragma: no cover",
73
85
  "raise NotImplementedError",
86
+ "if TYPE_CHECKING:",
74
87
  "\\.\\.\\.\\s*$", # ignore ellipsis
75
88
  ]
76
89
 
@@ -2,7 +2,9 @@ import datetime
2
2
  import pickle
3
3
  import uuid
4
4
  from threading import Lock
5
+ from types import TracebackType
5
6
  from typing import Any
7
+ from typing import Self
6
8
  from typing import Union
7
9
  from typing import cast
8
10
 
@@ -17,6 +19,8 @@ from scim2_models import Uniqueness
17
19
  from scim2_models import UniquenessException
18
20
  from werkzeug.http import generate_etag
19
21
 
22
+ from scim2_server.utils import parametrize
23
+
20
24
 
21
25
  class Backend:
22
26
  """The base class for a SCIM provider backend.
@@ -25,22 +29,27 @@ class Backend:
25
29
  :class:`~scim2_models.ScimProvider` of the application.
26
30
  """
27
31
 
28
- def __enter__(self):
32
+ def __enter__(self) -> Self:
29
33
  """Allow the backend to be used as a context manager.
30
34
 
31
35
  This enables support for transactions.
32
36
  """
33
37
  return self
34
38
 
35
- def __exit__(self, exc_type, exc_val, exc_tb):
39
+ def __exit__(
40
+ self,
41
+ exc_type: type[BaseException] | None,
42
+ exc_val: BaseException | None,
43
+ exc_tb: TracebackType | None,
44
+ ) -> None:
36
45
  """Exit the transaction."""
37
46
  pass
38
47
 
39
48
  def query_resources(
40
49
  self,
41
- search_request: SearchRequest,
50
+ search_request: SearchRequest[Any],
42
51
  resource_type: ResourceType | None = None,
43
- ) -> tuple[int, list[Resource]]:
52
+ ) -> tuple[int, list[Resource[Any]]]:
44
53
  """Query the backend for a set of resources.
45
54
 
46
55
  :param search_request: SearchRequest instance describing the
@@ -59,7 +68,7 @@ class Backend:
59
68
 
60
69
  def get_resource(
61
70
  self, resource_type: ResourceType, object_id: str
62
- ) -> Resource | None:
71
+ ) -> Resource[Any] | None:
63
72
  """Query the backend for a resources by its ID.
64
73
 
65
74
  :param resource_type: The resource type to get the object from.
@@ -81,8 +90,8 @@ class Backend:
81
90
  raise NotImplementedError
82
91
 
83
92
  def create_resource(
84
- self, resource_type: ResourceType, resource: Resource
85
- ) -> Resource | None:
93
+ self, resource_type: ResourceType, resource: Resource[Any]
94
+ ) -> Resource[Any]:
86
95
  """Create a resource.
87
96
 
88
97
  :param resource_type: The resource type to create.
@@ -94,15 +103,15 @@ class Backend:
94
103
  raise NotImplementedError
95
104
 
96
105
  def update_resource(
97
- self, resource_type: ResourceType, resource: Resource
98
- ) -> Resource | None:
106
+ self, resource_type: ResourceType, resource: Resource[Any]
107
+ ) -> Resource[Any] | None:
99
108
  """Update a resource. The resource is identified by its ID.
100
109
 
101
110
  :param resource_type: The resource type to update.
102
111
  :param resource: Resource to update.
103
- :return: The updated resource. Updating should update the
104
- "meta.lastModified" data. May be the same object that is
105
- passed in.
112
+ :return: The updated resource, or None if no resource has its ID.
113
+ Updating should update the "meta.lastModified" data. May be
114
+ the same object that is passed in.
106
115
  """
107
116
  raise NotImplementedError
108
117
 
@@ -116,12 +125,12 @@ class InMemoryBackend(Backend):
116
125
  implementation simple.
117
126
  """
118
127
 
119
- def __init__(self):
128
+ def __init__(self) -> None:
120
129
  super().__init__()
121
- self.resources: list[Resource] = []
130
+ self.resources: list[Resource[Any]] = []
122
131
  self.lock: Lock = Lock()
123
132
 
124
- def __enter__(self):
133
+ def __enter__(self) -> Self:
125
134
  """See super docs.
126
135
 
127
136
  The InMemoryBackend uses a simple Lock to synchronize all
@@ -131,15 +140,20 @@ class InMemoryBackend(Backend):
131
140
  self.lock.acquire()
132
141
  return self
133
142
 
134
- def __exit__(self, exc_type, exc_val, exc_tb):
143
+ def __exit__(
144
+ self,
145
+ exc_type: type[BaseException] | None,
146
+ exc_val: BaseException | None,
147
+ exc_tb: TracebackType | None,
148
+ ) -> None:
135
149
  super().__exit__(exc_type, exc_val, exc_tb)
136
150
  self.lock.release()
137
151
 
138
152
  def query_resources(
139
153
  self,
140
- search_request: SearchRequest,
154
+ search_request: SearchRequest[Any],
141
155
  resource_type: ResourceType | None = None,
142
- ) -> tuple[int, list[Resource]]:
156
+ ) -> tuple[int, list[Resource[Any]]]:
143
157
  start_index = (search_request.start_index or 1) - 1
144
158
 
145
159
  candidates = [
@@ -151,7 +165,7 @@ class InMemoryBackend(Backend):
151
165
  scim_filter = search_request.filter
152
166
  if scim_filter is not None and not scim_filter.models and candidates:
153
167
  models = tuple(dict.fromkeys(type(r) for r in candidates))
154
- scim_filter = ScimFilter[Union[models]](str(scim_filter)) # noqa: UP007
168
+ scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
155
169
 
156
170
  found_resources = [
157
171
  r for r in candidates if scim_filter is None or scim_filter.match(r)
@@ -165,16 +179,17 @@ class InMemoryBackend(Backend):
165
179
  found_resources = found_resources[: search_request.count]
166
180
  return total_results, found_resources
167
181
 
168
- def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
182
+ def _is_of_type(self, resource: Resource[Any], resource_type: ResourceType) -> bool:
169
183
  """Tell whether a resource belongs to a resource type.
170
184
 
171
185
  RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
172
186
  which may differ from its id.
173
187
  """
188
+ assert resource.meta is not None
174
189
  return resource.meta.resource_type == resource_type.name
175
190
 
176
191
  def _get_resource_idx(
177
- self, resource_type: ResourceType, object_id: str
192
+ self, resource_type: ResourceType, object_id: str | None
178
193
  ) -> int | None:
179
194
  return next(
180
195
  (
@@ -187,7 +202,7 @@ class InMemoryBackend(Backend):
187
202
 
188
203
  def get_resource(
189
204
  self, resource_type: ResourceType, object_id: str
190
- ) -> Resource | None:
205
+ ) -> Resource[Any] | None:
191
206
  resource_dict_idx = self._get_resource_idx(resource_type, object_id)
192
207
  if resource_dict_idx is not None:
193
208
  return self.resources[resource_dict_idx].model_copy(deep=True)
@@ -205,11 +220,12 @@ class InMemoryBackend(Backend):
205
220
  return False
206
221
 
207
222
  def create_resource(
208
- self, resource_type: ResourceType, resource: Resource
209
- ) -> Resource | None:
223
+ self, resource_type: ResourceType, resource: Resource[Any]
224
+ ) -> Resource[Any]:
210
225
  resource = resource.model_copy(deep=True)
211
226
  resource.id = uuid.uuid4().hex
212
227
  utcnow = datetime.datetime.now(datetime.UTC)
228
+ assert resource_type.endpoint is not None
213
229
  resource.meta = Meta(
214
230
  resource_type=resource_type.name,
215
231
  created=utcnow,
@@ -221,14 +237,14 @@ class InMemoryBackend(Backend):
221
237
  self.resources.append(resource)
222
238
  return resource
223
239
 
224
- def _check_uniqueness(self, resource: Resource):
240
+ def _check_uniqueness(self, resource: Resource[Any]) -> None:
225
241
  """Refuse a resource sharing a unique value with another one of the same schema.
226
242
 
227
243
  RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
228
244
  schema that declares the attribute, whatever their resource type. A
229
245
  missing value never clashes, as a SQL NULL does not.
230
246
  """
231
- unique_paths = Path[type(resource)].iter_paths(
247
+ unique_paths = parametrize(Path, type(resource)).iter_paths(
232
248
  include_subattributes=False,
233
249
  uniqueness=[Uniqueness.server, Uniqueness.global_],
234
250
  )
@@ -245,30 +261,35 @@ class InMemoryBackend(Backend):
245
261
  raise UniquenessException()
246
262
 
247
263
  @staticmethod
248
- def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
264
+ def _unique_value(resource: Resource[Any], attribute: AttributeBinding) -> Any:
249
265
  """Return the value a resource holds for a unique attribute, in the form it is compared in.
250
266
 
251
267
  A resource whose schemas do not declare the attribute holds no value.
252
268
  """
253
- value = Path[type(resource)](attribute.urn).get(resource, strict=False)
269
+ value = parametrize(Path, type(resource))(attribute.urn).get(
270
+ resource, strict=False
271
+ )
254
272
  if isinstance(value, str) and not attribute.case_exact:
255
273
  return value.casefold()
256
274
  return value
257
275
 
258
276
  @staticmethod
259
- def _touch_resource(resource: Resource, last_modified: datetime.datetime):
277
+ def _touch_resource(
278
+ resource: Resource[Any], last_modified: datetime.datetime
279
+ ) -> None:
260
280
  """Touches a resource (updates last_modified and version).
261
281
 
262
282
  Version is generated by hashing last_modified. Another option
263
283
  would be to hash the entire resource instead.
264
284
  """
285
+ assert resource.meta is not None
265
286
  resource.meta.last_modified = last_modified
266
287
  etag = generate_etag(pickle.dumps(resource.meta.last_modified))
267
288
  resource.meta.version = f'W/"{etag}"'
268
289
 
269
290
  def update_resource(
270
- self, resource_type: ResourceType, resource: Resource
271
- ) -> Resource | None:
291
+ self, resource_type: ResourceType, resource: Resource[Any]
292
+ ) -> Resource[Any] | None:
272
293
  found_res_idx = self._get_resource_idx(resource_type, resource.id)
273
294
  if found_res_idx is not None:
274
295
  updated_resource = type(resource).model_validate(resource.model_dump())
@@ -9,11 +9,12 @@ from werkzeug.exceptions import Conflict
9
9
 
10
10
  BULK_ID_PREFIX = "bulkId:"
11
11
 
12
- Resolver = Callable[[BulkOperation], BulkOperation]
12
+ Resolver = Callable[[BulkOperation[Resource[Any]]], BulkOperation[Resource[Any]]]
13
13
  """Replaces the bulkId references of an operation."""
14
14
 
15
15
  OperationRunner = Callable[
16
- [BulkOperation, Resolver], tuple[dict[str, Any], Resource | None]
16
+ [BulkOperation[Resource[Any]], Resolver],
17
+ tuple[dict[str, Any], Resource[Any] | None],
17
18
  ]
18
19
  """Applies an operation once resolved, and returns its outcome and the resource it acted on."""
19
20
 
@@ -50,8 +51,8 @@ def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
50
51
 
51
52
 
52
53
  def resolve_operation(
53
- operation: BulkOperation, replace: Callable[[str], str]
54
- ) -> BulkOperation:
54
+ operation: BulkOperation[Resource[Any]], replace: Callable[[str], str]
55
+ ) -> BulkOperation[Resource[Any]]:
55
56
  """Replace the "bulkId:" references of the path and the data of a bulk operation."""
56
57
  updates: dict[str, Any] = {}
57
58
  if operation.path is not None:
@@ -79,7 +80,7 @@ class BulkJob:
79
80
 
80
81
  def __init__(
81
82
  self,
82
- operations: list[BulkOperation],
83
+ operations: list[BulkOperation[Resource[Any]]],
83
84
  fail_on_errors: int | None,
84
85
  run: OperationRunner,
85
86
  ):
@@ -87,7 +88,7 @@ class BulkJob:
87
88
  self.operations = operations
88
89
  self.fail_on_errors = fail_on_errors
89
90
  self.results: dict[int, dict[str, Any]] = {}
90
- self.created: dict[str, Resource] = {}
91
+ self.created: dict[str, Resource[Any]] = {}
91
92
  self.running: set[int] = set()
92
93
  self.errors = 0
93
94
 
@@ -145,7 +146,7 @@ class BulkJob:
145
146
  return bulk_id is not None and self.creations.get(bulk_id) == index
146
147
 
147
148
  @staticmethod
148
- def references(operation: BulkOperation) -> list[str]:
149
+ def references(operation: BulkOperation[Resource[Any]]) -> list[str]:
149
150
  """Return the bulkIds an operation references."""
150
151
  bulk_ids: list[str] = []
151
152
 
@@ -156,7 +157,9 @@ class BulkJob:
156
157
  resolve_operation(operation, collect)
157
158
  return bulk_ids
158
159
 
159
- def resolve(self, index: int, operation: BulkOperation) -> BulkOperation:
160
+ def resolve(
161
+ self, index: int, operation: BulkOperation[Resource[Any]]
162
+ ) -> BulkOperation[Resource[Any]]:
160
163
  """Replace the bulkId references of an operation with the identifiers of the created resources.
161
164
 
162
165
  :raises Conflict: When a referenced resource was not created, as
@@ -2,8 +2,12 @@ import argparse
2
2
  import json
3
3
  import logging
4
4
  import pprint
5
+ from collections.abc import Iterable
6
+ from typing import TYPE_CHECKING
5
7
 
6
8
  from scim2_models import AuthenticationScheme
9
+ from scim2_models import External
10
+ from scim2_models import Reference
7
11
  from scim2_models import ResourceType
8
12
  from scim2_models import Schema
9
13
  from scim2_models import ScimProvider
@@ -16,25 +20,32 @@ from scim2_server.utils import load_default_resource_types
16
20
  from scim2_server.utils import load_default_schemas
17
21
  from scim2_server.utils import load_default_service_provider_config
18
22
 
23
+ if TYPE_CHECKING:
24
+ from _typeshed.wsgi import StartResponse
25
+ from _typeshed.wsgi import WSGIApplication
26
+ from _typeshed.wsgi import WSGIEnvironment
27
+
19
28
  BEARER_TOKEN_SCHEME = AuthenticationScheme(
20
- type="oauthbearertoken",
29
+ type=AuthenticationScheme.Type.oauthbearertoken,
21
30
  name="bearer_token",
22
31
  description="HTTP Bearer Token",
23
- spec_uri="https://datatracker.ietf.org/doc/html/rfc6750",
32
+ spec_uri=Reference[External]("https://datatracker.ietf.org/doc/html/rfc6750"),
24
33
  )
25
34
 
26
35
 
27
- def log_environ(handler):
36
+ def log_environ(handler: "WSGIApplication") -> "WSGIApplication":
28
37
  """Build a simple decorator to log all WSGI environment variables."""
29
38
 
30
- def _inner(environ, start_fn):
39
+ def _inner(
40
+ environ: "WSGIEnvironment", start_fn: "StartResponse"
41
+ ) -> Iterable[bytes]:
31
42
  logging.getLogger("log_environ").debug(pprint.pformat(environ))
32
43
  return handler(environ, start_fn)
33
44
 
34
45
  return _inner
35
46
 
36
47
 
37
- def main():
48
+ def main() -> None:
38
49
  parser = argparse.ArgumentParser()
39
50
  parser.add_argument(
40
51
  "--schema", type=argparse.FileType("r"), help="Schema definitions"
@@ -71,12 +82,14 @@ def main():
71
82
 
72
83
  from werkzeug.serving import run_simple
73
84
 
85
+ schemas: Iterable[Schema]
74
86
  if args.schema is None:
75
87
  schemas = load_default_schemas().values()
76
88
  else:
77
89
  with args.schema:
78
90
  schemas = [Schema.model_validate(sc) for sc in json.load(args.schema)]
79
91
 
92
+ resource_types: Iterable[ResourceType]
80
93
  if args.resource_type is None:
81
94
  resource_types = load_default_resource_types().values()
82
95
  else:
@@ -108,17 +121,21 @@ def main():
108
121
  for bearer_token in args.bearer_token:
109
122
  app.register_bearer_token(bearer_token)
110
123
 
124
+ wsgi_app: WSGIApplication = app
111
125
  if args.debug:
112
- app = log_environ(app)
126
+ wsgi_app = log_environ(wsgi_app)
113
127
  if args.reverse_proxy:
114
- app = ProxyFix(app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
128
+ wsgi_app = ProxyFix(
129
+ wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
130
+ )
115
131
 
116
132
  run_simple(
117
133
  args.hostname,
118
134
  args.port,
119
- app,
135
+ wsgi_app,
120
136
  use_debugger=args.debug,
121
137
  use_reloader=args.debug,
138
+ threaded=True,
122
139
  )
123
140
 
124
141
  if args.dump_resources:
@@ -1,7 +1,10 @@
1
1
  import itertools
2
2
  import json
3
3
  import logging
4
+ from collections.abc import Iterable
5
+ from typing import TYPE_CHECKING
4
6
  from typing import Any
7
+ from typing import TypeVar
5
8
  from typing import Union
6
9
  from typing import cast
7
10
  from urllib.parse import urljoin
@@ -48,6 +51,11 @@ from scim2_server.backend import Backend
48
51
  from scim2_server.bulk import BulkJob
49
52
  from scim2_server.bulk import Resolver
50
53
  from scim2_server.utils import load_default_service_provider_config
54
+ from scim2_server.utils import parametrize
55
+
56
+ if TYPE_CHECKING:
57
+ from _typeshed.wsgi import StartResponse
58
+ from _typeshed.wsgi import WSGIEnvironment
51
59
 
52
60
  SEARCH_REQUEST_PARAMETERS = (
53
61
  "attributes",
@@ -59,6 +67,10 @@ SEARCH_REQUEST_PARAMETERS = (
59
67
  "count",
60
68
  )
61
69
 
70
+ DiscoveryResourceT = TypeVar(
71
+ "DiscoveryResourceT", ResourceType, Schema, ServiceProviderConfig
72
+ )
73
+
62
74
  BULK_SUCCESS_STATUS = {
63
75
  BulkOperation.Method.post: 201,
64
76
  BulkOperation.Method.put: 200,
@@ -71,7 +83,7 @@ class SCIMApplication:
71
83
  """A WSGI application implementing a SCIM provider (server)."""
72
84
 
73
85
  def __init__(self, backend: Backend, provider: ScimProvider):
74
- self.bearer_tokens = set()
86
+ self.bearer_tokens: set[str] = set()
75
87
  self.backend = backend
76
88
  self.provider = provider
77
89
  self.config = provider.config or load_default_service_provider_config()
@@ -138,11 +150,11 @@ class SCIMApplication:
138
150
 
139
151
  self.url_map = Map(rules)
140
152
 
141
- def get_model(self, resource_type: ResourceType) -> type[Resource]:
153
+ def get_model(self, resource_type: ResourceType) -> type[Resource[Any]]:
142
154
  """Return the model of a resource type, its extensions included."""
143
- return cast(type[Resource], self.provider.model_for(resource_type))
155
+ return cast(type[Resource[Any]], self.provider.model_for(resource_type))
144
156
 
145
- def get_models(self) -> list[type[Resource]]:
157
+ def get_models(self) -> list[type[Resource[Any]]]:
146
158
  """Return the models of every resource type."""
147
159
  return [self.get_model(rt) for rt in self.provider.resource_types]
148
160
 
@@ -152,7 +164,7 @@ class SCIMApplication:
152
164
  (
153
165
  resource_type
154
166
  for resource_type in self.provider.resource_types
155
- if resource_type.endpoint.lstrip("/").casefold()
167
+ if (resource_type.endpoint or "").lstrip("/").casefold()
156
168
  == endpoint.lstrip("/").casefold()
157
169
  ),
158
170
  None,
@@ -163,12 +175,13 @@ class SCIMApplication:
163
175
  """Whether the configuration declares the resources versioned with ETags."""
164
176
  return bool(self.config.etag and self.config.etag.supported)
165
177
 
166
- def publish(self, request: Request, resource: Resource) -> Resource:
178
+ def publish(self, request: Request, resource: Resource[Any]) -> Resource[Any]:
167
179
  """Return a copy of a resource in the form sent to the client.
168
180
 
169
181
  Its location is made absolute from the URL the client requested, and
170
182
  its version is left out when the service does not support ETags.
171
183
  """
184
+ assert resource.meta is not None
172
185
  update: dict[str, Any] = {
173
186
  "location": urljoin(request.url + "/", resource.meta.location)
174
187
  }
@@ -179,13 +192,14 @@ class SCIMApplication:
179
192
  )
180
193
 
181
194
  @staticmethod
182
- def etag_header(resource: Resource) -> dict[str, str]:
195
+ def etag_header(resource: Resource[Any]) -> dict[str, str]:
183
196
  """Return the ETag header of a published resource, if it has a version."""
197
+ assert resource.meta is not None
184
198
  return {"ETag": resource.meta.version} if resource.meta.version else {}
185
199
 
186
200
  def check_preconditions(
187
201
  self,
188
- resource: Resource,
202
+ resource: Resource[Any],
189
203
  method: str,
190
204
  if_match: ETags | None = None,
191
205
  if_none_match: ETags | None = None,
@@ -199,11 +213,13 @@ class SCIMApplication:
199
213
  :return: :data:`False` when a GET should answer 304 Not Modified.
200
214
  :raises PreconditionFailed: When the method must not be performed.
201
215
  """
216
+ assert resource.meta is not None
202
217
  # A service that does not support ETags has no tag to match: RFC 7232
203
218
  # §3.1 fails an If-Match listing tags, and lets "*" pass.
204
219
  version, _ = (
205
220
  unquote_etag(resource.meta.version) if self.etag_supported else (None, None)
206
221
  )
222
+ version = version or ""
207
223
  # RFC 7232 §3.1 compares If-Match strongly, which would never match
208
224
  # the weak ETags RFC 7644 §3.14 recommends and sends in its example.
209
225
  if if_match and not if_match.contains_weak(version):
@@ -218,7 +234,7 @@ class SCIMApplication:
218
234
 
219
235
  def get_existing_resource(
220
236
  self, resource_type: ResourceType, resource_id: str
221
- ) -> Resource:
237
+ ) -> Resource[Any]:
222
238
  """Return a stored resource.
223
239
 
224
240
  :raises NotFound: When no resource of this type has this identifier.
@@ -228,7 +244,7 @@ class SCIMApplication:
228
244
  raise NotFound
229
245
  return resource
230
246
 
231
- def create(self, resource_type: ResourceType, payload: Any) -> Resource:
247
+ def create(self, resource_type: ResourceType, payload: Any) -> Resource[Any]:
232
248
  """Validate a creation payload and store the new resource."""
233
249
  resource = self.get_model(resource_type).model_validate(
234
250
  payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST
@@ -242,7 +258,7 @@ class SCIMApplication:
242
258
  payload: Any,
243
259
  if_match: ETags | None = None,
244
260
  if_none_match: ETags | None = None,
245
- ) -> Resource:
261
+ ) -> Resource[Any]:
246
262
  """Replace a stored resource with a payload and return the stored result."""
247
263
  resource = self.get_existing_resource(resource_type, resource_id)
248
264
  self.check_preconditions(resource, "PUT", if_match, if_none_match)
@@ -253,7 +269,7 @@ class SCIMApplication:
253
269
  # A PUT that changes nothing keeps meta.lastModified and the ETag.
254
270
  if not replacement.replace(resource):
255
271
  return resource
256
- return self.backend.update_resource(resource_type, replacement)
272
+ return self.update(resource_type, replacement)
257
273
 
258
274
  def patch(
259
275
  self,
@@ -262,17 +278,31 @@ class SCIMApplication:
262
278
  payload: Any,
263
279
  if_match: ETags | None = None,
264
280
  if_none_match: ETags | None = None,
265
- ) -> Resource:
281
+ ) -> Resource[Any]:
266
282
  """Apply a PATCH payload to a stored resource and return the stored result."""
267
283
  self.ensure_supported(self.config.patch, "PATCH")
268
- patch_operation = PatchOp[self.get_model(resource_type)].model_validate(payload)
284
+ patch_operation = parametrize(
285
+ PatchOp, self.get_model(resource_type)
286
+ ).model_validate(payload)
269
287
  resource = self.get_existing_resource(resource_type, resource_id)
270
288
  self.check_preconditions(resource, "PATCH", if_match, if_none_match)
271
289
 
272
290
  # A PATCH that changes nothing keeps meta.lastModified and the ETag.
273
291
  if not patch_operation.patch(resource):
274
292
  return resource
275
- return self.backend.update_resource(resource_type, resource)
293
+ return self.update(resource_type, resource)
294
+
295
+ def update(
296
+ self, resource_type: ResourceType, resource: Resource[Any]
297
+ ) -> Resource[Any]:
298
+ """Store an updated resource and return the stored result.
299
+
300
+ :raises NotFound: When the backend no longer has the resource.
301
+ """
302
+ updated = self.backend.update_resource(resource_type, resource)
303
+ if updated is None:
304
+ raise NotFound
305
+ return updated
276
306
 
277
307
  def delete(
278
308
  self,
@@ -287,7 +317,7 @@ class SCIMApplication:
287
317
  self.backend.delete_resource(resource_type, resource_id)
288
318
 
289
319
  def call_single_resource(
290
- self, request: Request, resource_endpoint: str, resource_id: str, **kwargs
320
+ self, request: Request, resource_endpoint: str, resource_id: str, **kwargs: Any
291
321
  ) -> Response:
292
322
  resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
293
323
  if not resource_type:
@@ -369,10 +399,10 @@ class SCIMApplication:
369
399
 
370
400
  @staticmethod
371
401
  def get_response_parameters(
372
- request: Request, model: type[Resource]
373
- ) -> ResponseParameters:
402
+ request: Request, model: type[Resource[Any]]
403
+ ) -> ResponseParameters[Any]:
374
404
  """Parse the "attributes" and "excludedAttributes" HTTP request parameters."""
375
- return ResponseParameters[model].model_validate(
405
+ return parametrize(ResponseParameters, model).model_validate(
376
406
  {
377
407
  key: request.args[key]
378
408
  for key in ("attributes", "excludedAttributes")
@@ -381,8 +411,8 @@ class SCIMApplication:
381
411
  )
382
412
 
383
413
  def build_search_request(
384
- self, request: Request, models: list[type[Resource]]
385
- ) -> SearchRequest:
414
+ self, request: Request, models: list[type[Resource[Any]]]
415
+ ) -> SearchRequest[Any]:
386
416
  """Construct a SearchRequest object from a werkzeug request.
387
417
 
388
418
  :param request: werkzeug request
@@ -410,9 +440,10 @@ class SCIMApplication:
410
440
  if parameters & {"sortby", "sortorder"}:
411
441
  self.ensure_supported(self.config.sort, "Sorting")
412
442
 
413
- search_request = SearchRequest[Union[tuple(models)]].model_validate( # noqa: UP007
414
- payload, scim_ctx=Context.SEARCH_REQUEST
415
- )
443
+ search_request = parametrize(
444
+ SearchRequest,
445
+ Union[tuple(models)], # noqa: UP007
446
+ ).model_validate(payload, scim_ctx=Context.SEARCH_REQUEST)
416
447
  search_request.start_index = search_request.start_index or 1
417
448
  max_results = self.config.filter.max_results if self.config.filter else None
418
449
  if max_results is not None and (
@@ -421,7 +452,9 @@ class SCIMApplication:
421
452
  search_request.count = max_results
422
453
  return search_request
423
454
 
424
- def query_resource(self, request: Request, resource: ResourceType | None):
455
+ def query_resource(
456
+ self, request: Request, resource: ResourceType | None
457
+ ) -> ListResponse[Resource[Any]]:
425
458
  models = self.get_models() if resource is None else [self.get_model(resource)]
426
459
  search_request = self.build_search_request(request, models)
427
460
 
@@ -438,7 +471,7 @@ class SCIMApplication:
438
471
  for s in results
439
472
  ]
440
473
 
441
- return ListResponse[Union[tuple(self.get_models())]]( # noqa: UP007
474
+ return parametrize(ListResponse, Union[tuple(self.get_models())])( # noqa: UP007
442
475
  total_results=total_results,
443
476
  items_per_page=len(resources),
444
477
  start_index=search_request.start_index,
@@ -446,7 +479,7 @@ class SCIMApplication:
446
479
  )
447
480
 
448
481
  def call_resource(
449
- self, request: Request, resource_endpoint: str, **kwargs
482
+ self, request: Request, resource_endpoint: str, **kwargs: Any
450
483
  ) -> Response:
451
484
  resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
452
485
  if not resource_type:
@@ -463,6 +496,7 @@ class SCIMApplication:
463
496
  created_resource = self.publish(
464
497
  request, self.create(resource_type, request.json)
465
498
  )
499
+ assert created_resource.meta is not None
466
500
  return self.make_response(
467
501
  created_resource.model_dump(
468
502
  scim_ctx=Context.RESOURCE_CREATION_RESPONSE
@@ -471,7 +505,7 @@ class SCIMApplication:
471
505
  headers={"Location": created_resource.meta.location},
472
506
  )
473
507
 
474
- def call_query_all(self, request: Request, **kwargs) -> Response:
508
+ def call_query_all(self, request: Request, **kwargs: Any) -> Response:
475
509
  return self.make_response(
476
510
  self.query_resource(request, None).model_dump(
477
511
  scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
@@ -479,7 +513,7 @@ class SCIMApplication:
479
513
  )
480
514
 
481
515
  def call_resource_search(
482
- self, request: Request, resource_endpoint: str, **kwargs
516
+ self, request: Request, resource_endpoint: str, **kwargs: Any
483
517
  ) -> Response:
484
518
  resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
485
519
  if not resource_type:
@@ -493,7 +527,7 @@ class SCIMApplication:
493
527
  @staticmethod
494
528
  def ensure_supported(
495
529
  capability: Patch | Bulk | Filter | Sort | None, operation: str
496
- ):
530
+ ) -> None:
497
531
  """Refuse with a 501 an operation the configuration does not declare supported.
498
532
 
499
533
  RFC 7644 §3.12 answers 501 when the service provider does not support
@@ -502,23 +536,38 @@ class SCIMApplication:
502
536
  if capability is None or not capability.supported:
503
537
  raise WerkzeugNotImplemented(f"{operation} is not supported")
504
538
 
505
- def call_bulk(self, request: Request, **kwargs) -> Response:
539
+ @staticmethod
540
+ def ensure_payload_size(request: Request, max_payload_size: int) -> None:
541
+ """Refuse with a 413 a payload larger than max_payload_size, without reading more than that.
542
+
543
+ :raises RequestEntityTooLarge: When the payload is too large.
544
+ """
545
+ # Werkzeug refuses a Content-Length above the limit, but silently cuts a
546
+ # streamed body at the limit. One extra byte tells a cut body apart.
547
+ request.max_content_length = max_payload_size + 1
548
+ try:
549
+ too_large = len(request.get_data()) > max_payload_size
550
+ except RequestEntityTooLarge:
551
+ too_large = True
552
+
553
+ if too_large:
554
+ raise RequestEntityTooLarge(
555
+ f"The payload exceeds the maxPayloadSize ({max_payload_size} bytes)"
556
+ )
557
+
558
+ def call_bulk(self, request: Request, **kwargs: Any) -> Response:
506
559
  """Implement the /Bulk endpoint (RFC 7644 §3.7)."""
507
560
  self.ensure_supported(self.config.bulk, "Bulk")
508
561
  bulk = cast(Bulk, self.config.bulk)
509
562
 
510
- if (
511
- bulk.max_payload_size is not None
512
- and len(request.get_data()) > bulk.max_payload_size
513
- ):
514
- raise RequestEntityTooLarge(
515
- f"The payload exceeds the maxPayloadSize ({bulk.max_payload_size} bytes)"
516
- )
563
+ if bulk.max_payload_size is not None:
564
+ self.ensure_payload_size(request, bulk.max_payload_size)
517
565
 
518
- bulk_request = BulkRequest[Union[tuple(self.get_models())]].model_validate( # noqa: UP007
519
- request.json, scim_ctx=Context.BULK_REQUEST
520
- )
521
- operations = cast(list[BulkOperation], bulk_request.operations)
566
+ bulk_request = parametrize(
567
+ BulkRequest,
568
+ Union[tuple(self.get_models())], # noqa: UP007
569
+ ).model_validate(request.json, scim_ctx=Context.BULK_REQUEST)
570
+ operations = cast(list[BulkOperation[Resource[Any]]], bulk_request.operations)
522
571
  if bulk.max_operations is not None and len(operations) > bulk.max_operations:
523
572
  raise RequestEntityTooLarge(
524
573
  f"The number of operations exceeds the maxOperations ({bulk.max_operations})"
@@ -532,14 +581,17 @@ class SCIMApplication:
532
581
  ),
533
582
  ).run()
534
583
  return self.make_response(
535
- BulkResponse[Union[tuple(self.get_models())]]( # noqa: UP007
536
- operations=results
537
- ).model_dump(scim_ctx=Context.BULK_RESPONSE)
584
+ parametrize(BulkResponse, Union[tuple(self.get_models())]) # noqa: UP007
585
+ .model_validate({"operations": results})
586
+ .model_dump(scim_ctx=Context.BULK_RESPONSE)
538
587
  )
539
588
 
540
589
  def run_bulk_operation(
541
- self, request: Request, operation: BulkOperation, resolve: Resolver
542
- ) -> tuple[dict[str, Any], Resource | None]:
590
+ self,
591
+ request: Request,
592
+ operation: BulkOperation[Resource[Any]],
593
+ resolve: Resolver,
594
+ ) -> tuple[dict[str, Any], Resource[Any] | None]:
543
595
  """Apply one operation of a bulk job.
544
596
 
545
597
  An operation that failed its validation keeps its error, once its
@@ -556,6 +608,7 @@ class SCIMApplication:
556
608
  operation = resolve(operation)
557
609
  resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
558
610
  if resource_type is not None and operation.resource_id:
611
+ assert resource_type.endpoint is not None
559
612
  result["location"] = urljoin(
560
613
  request.url,
561
614
  f"{resource_type.endpoint.strip('/')}/{operation.resource_id}",
@@ -573,18 +626,20 @@ class SCIMApplication:
573
626
  error = self.error_from(exception)
574
627
  return {**result, "status": error.status, "response": error}, None
575
628
 
629
+ assert operation.method is not None
576
630
  result["status"] = BULK_SUCCESS_STATUS[operation.method]
577
631
  if resource is None:
578
632
  return result, None
579
633
 
580
634
  resource = self.publish(request, resource)
635
+ assert resource.meta is not None
581
636
  result["location"] = resource.meta.location
582
637
  result["version"] = resource.meta.version
583
638
  return result, resource
584
639
 
585
640
  def apply_bulk_operation(
586
- self, resource_type: ResourceType, operation: BulkOperation
587
- ) -> Resource | None:
641
+ self, resource_type: ResourceType, operation: BulkOperation[Resource[Any]]
642
+ ) -> Resource[Any] | None:
588
643
  """Apply a validated bulk operation, and return the resource it acted on.
589
644
 
590
645
  The data of the operation is already validated, and the resource
@@ -596,10 +651,11 @@ class SCIMApplication:
596
651
  detail="A POST path must target a resource type endpoint, other methods a resource"
597
652
  )
598
653
 
654
+ if operation.method == BulkOperation.Method.post or resource_id is None:
655
+ return self.create(resource_type, operation.data)
656
+
599
657
  if_match = parse_etags(operation.version) if operation.version else None
600
658
  match operation.method:
601
- case BulkOperation.Method.post:
602
- return self.create(resource_type, operation.data)
603
659
  case BulkOperation.Method.put:
604
660
  return self.replace(
605
661
  resource_type, resource_id, operation.data, if_match
@@ -610,7 +666,7 @@ class SCIMApplication:
610
666
  self.delete(resource_type, resource_id, if_match)
611
667
  return None
612
668
 
613
- def call_me(self, request: Request, **kwargs):
669
+ def call_me(self, request: Request, **kwargs: Any) -> Response:
614
670
  """Implement the /Me endpoint.
615
671
 
616
672
  RFC 7644, Section 3.11 allows raising a 501 (Not Implemented) if
@@ -618,14 +674,14 @@ class SCIMApplication:
618
674
  """
619
675
  raise WerkzeugNotImplemented
620
676
 
621
- def register_bearer_token(self, token: str):
677
+ def register_bearer_token(self, token: str) -> None:
622
678
  """Register a static bearer token for authentication.
623
679
 
624
680
  :param token: Bearer token
625
681
  """
626
682
  self.bearer_tokens.add(token)
627
683
 
628
- def check_auth(self, request: Request):
684
+ def check_auth(self, request: Request) -> None:
629
685
  """Check the authorization headers."""
630
686
  if not self.bearer_tokens:
631
687
  return
@@ -636,7 +692,7 @@ class SCIMApplication:
636
692
  raise Unauthorized
637
693
 
638
694
  @staticmethod
639
- def make_response(content, status=200, **kwargs) -> Response:
695
+ def make_response(content: Any, status: int = 200, **kwargs: Any) -> Response:
640
696
  """Construct a werkzeug response from any JSON-serializable content."""
641
697
  etag = None
642
698
  if content is not None:
@@ -667,17 +723,17 @@ class SCIMApplication:
667
723
  case _:
668
724
  return Error(status=500, detail="Internal server error")
669
725
 
670
- def make_error(self, error: Error):
726
+ def make_error(self, error: Error) -> Response:
671
727
  """Construct a werkzeug response from a SCIM Error."""
672
- return self.make_response(error.model_dump(), status=int(error.status))
728
+ return self.make_response(error.model_dump(), status=int(error.status or 500))
673
729
 
674
730
  @staticmethod
675
- def forbid_filter(request: Request):
731
+ def forbid_filter(request: Request) -> None:
676
732
  """RFC 7644, Section 4: "If a "filter" is provided, the service provider SHOULD respond with HTTP status code 403 (Forbidden)"."""
677
733
  if "filter" in request.args:
678
734
  raise Forbidden
679
735
 
680
- def call_service_provider_config(self, request: Request, **kwargs):
736
+ def call_service_provider_config(self, request: Request, **kwargs: Any) -> Response:
681
737
  """Return the ServiceProviderConfig."""
682
738
  self.forbid_filter(request)
683
739
  return self.make_response(
@@ -685,12 +741,14 @@ class SCIMApplication:
685
741
  )
686
742
 
687
743
  @staticmethod
688
- def locate(resource: ResourceType | Schema | ServiceProviderConfig, location: str):
744
+ def locate(resource: DiscoveryResourceT, location: str) -> DiscoveryResourceT:
689
745
  """Return a copy of a discovery resource carrying its meta."""
690
746
  meta = Meta(resource_type=type(resource).__name__, location=location)
691
747
  return resource.model_copy(update={"meta": meta})
692
748
 
693
- def call_resource_type(self, request: Request, resource_type: str, **kwargs):
749
+ def call_resource_type(
750
+ self, request: Request, resource_type: str, **kwargs: Any
751
+ ) -> Response:
694
752
  """Return a single resource type."""
695
753
  self.forbid_filter(request)
696
754
  for res in self.provider.resource_types:
@@ -700,7 +758,7 @@ class SCIMApplication:
700
758
  )
701
759
  raise NotFound
702
760
 
703
- def call_schema(self, request: Request, schema_id: str):
761
+ def call_schema(self, request: Request, schema_id: str) -> Response:
704
762
  """Return a single schema."""
705
763
  self.forbid_filter(request)
706
764
  for res in self.provider.schemas:
@@ -710,7 +768,7 @@ class SCIMApplication:
710
768
  )
711
769
  raise NotFound
712
770
 
713
- def call_resource_types(self, request: Request, **kwargs):
771
+ def call_resource_types(self, request: Request, **kwargs: Any) -> Response:
714
772
  """Return a ListResponse of all known resource types."""
715
773
  self.forbid_filter(request)
716
774
  results = self.provider.resource_types
@@ -722,7 +780,7 @@ class SCIMApplication:
722
780
  ).model_dump()
723
781
  return self.make_response(resp)
724
782
 
725
- def call_schemas(self, request: Request, **kwargs):
783
+ def call_schemas(self, request: Request, **kwargs: Any) -> Response:
726
784
  """Return a ListResponse of all known schemas."""
727
785
  self.forbid_filter(request)
728
786
  results = self.provider.schemas
@@ -734,7 +792,7 @@ class SCIMApplication:
734
792
  ).model_dump()
735
793
  return self.make_response(resp)
736
794
 
737
- def wsgi_app(self, request: Request, environ):
795
+ def wsgi_app(self, request: Request, environ: "WSGIEnvironment") -> Response:
738
796
  try:
739
797
  urls = self.url_map.bind_to_environ(environ)
740
798
  endpoint, args = urls.match()
@@ -746,7 +804,7 @@ class SCIMApplication:
746
804
  # Wrap the entire call in a transaction. Should probably be optimized (use transaction only when necessary).
747
805
  # The provider makes its policy the one every payload is read under.
748
806
  with self.provider, self.backend:
749
- response = getattr(self, f"call_{endpoint}")(request, **args)
807
+ response: Response = getattr(self, f"call_{endpoint}")(request, **args)
750
808
  return response
751
809
  except RequestRedirect as e:
752
810
  # urls.match may cause a redirect, handle it as a special case of HTTPException
@@ -755,7 +813,9 @@ class SCIMApplication:
755
813
  except Exception as e:
756
814
  return self.make_error(self.error_from(e))
757
815
 
758
- def __call__(self, environ, start_response):
816
+ def __call__(
817
+ self, environ: "WSGIEnvironment", start_response: "StartResponse"
818
+ ) -> Iterable[bytes]:
759
819
  """Return the actual WSGI server implementation."""
760
820
  if environ.get("PATH_INFO", "").endswith(".scim"):
761
821
  # RFC 7644, Section 3.8
File without changes
@@ -1,6 +1,8 @@
1
1
  import importlib.resources
2
2
  import json
3
3
  from typing import Any
4
+ from typing import TypeVar
5
+ from typing import cast
4
6
 
5
7
  from scim2_models import Resource
6
8
  from scim2_models import ResourceType
@@ -8,20 +10,29 @@ from scim2_models import Schema
8
10
  from scim2_models import ScimProvider
9
11
  from scim2_models import ServiceProviderConfig
10
12
 
13
+ GenericT = TypeVar("GenericT")
14
+ ResourceT = TypeVar("ResourceT", bound=Resource[Any])
15
+
16
+
17
+ def parametrize(generic: type[GenericT], parameter: Any) -> type[GenericT]:
18
+ """Parametrize a generic class with a type only known at runtime."""
19
+ return cast("type[GenericT]", cast(Any, generic)[parameter])
20
+
11
21
 
12
22
  def load_json_resource(json_name: str) -> Any:
13
23
  """Load a JSON document from the scim2_server package resources."""
14
24
  fp = importlib.resources.files("scim2_server") / "resources" / json_name
15
- with open(fp) as f:
25
+ with fp.open() as f:
16
26
  return json.load(f)
17
27
 
18
28
 
19
- def load_scim_resource(json_name: str, type_: type[Resource]):
29
+ def load_scim_resource(json_name: str, type_: type[ResourceT]) -> dict[str, ResourceT]:
20
30
  """Load and validates a JSON document from the scim2_server package resources."""
21
31
  ret = {}
22
32
  definitions = load_json_resource(json_name)
23
33
  for d in definitions:
24
34
  model = type_.model_validate(d)
35
+ assert model.id is not None
25
36
  ret[model.id] = model
26
37
  return ret
27
38
 
File without changes
File without changes