scim2-server 0.6.2__tar.gz → 0.7.1__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.
Files changed (30) hide show
  1. {scim2_server-0.6.2 → scim2_server-0.7.1}/PKG-INFO +4 -2
  2. {scim2_server-0.6.2 → scim2_server-0.7.1}/pyproject.toml +5 -3
  3. {scim2_server-0.6.2 → scim2_server-0.7.1}/pyproject.toml.orig +7 -3
  4. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/handler.py +7 -2
  5. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/memory.py +27 -6
  6. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/resources/default-service-provider-config.json +1 -2
  7. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/service.py +0 -1
  8. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/storage.py +71 -2
  9. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/testing.py +63 -2
  10. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/testserver/cli.py +20 -1
  11. scim2_server-0.7.1/scim2_server/testserver/precis.py +29 -0
  12. {scim2_server-0.6.2 → scim2_server-0.7.1}/LICENSE +0 -0
  13. {scim2_server-0.6.2 → scim2_server-0.7.1}/README.md +0 -0
  14. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/__init__.py +0 -0
  15. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/applications/__init__.py +0 -0
  16. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/applications/asgi.py +0 -0
  17. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/applications/base.py +0 -0
  18. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/applications/wsgi.py +0 -0
  19. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/bulk.py +0 -0
  20. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/conditions.py +0 -0
  21. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/errors.py +0 -0
  22. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/py.typed +0 -0
  23. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/requests.py +0 -0
  24. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/resources/default-resource-types.json +0 -0
  25. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/resources/default-schemas.json +0 -0
  26. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/responses.py +0 -0
  27. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/routing.py +0 -0
  28. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/testserver/__init__.py +0 -0
  29. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/testserver/application.py +0 -0
  30. {scim2_server-0.6.2 → scim2_server-0.7.1}/scim2_server/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.6.2
3
+ Version: 0.7.1
4
4
  Summary: SCIM server core for any storage and web framework
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -217,11 +217,13 @@ Classifier: License :: OSI Approved :: Apache Software License
217
217
  Classifier: Environment :: Web Environment
218
218
  Classifier: Programming Language :: Python
219
219
  Classifier: Operating System :: OS Independent
220
- Requires-Dist: scim2-models>=0.10.6
220
+ Requires-Dist: scim2-models>=0.12.1
221
+ Requires-Dist: precis-i18n>=1.1.2 ; extra == 'precis'
221
222
  Requires-Dist: pytest>=9.1.1 ; extra == 'testing'
222
223
  Requires-Python: >=3.11
223
224
  Project-URL: repository, https://github.com/python-scim/scim2-server
224
225
  Project-URL: funding, https://github.com/sponsors/python-scim
226
+ Provides-Extra: precis
225
227
  Provides-Extra: testing
226
228
  Description-Content-Type: text/markdown
227
229
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.6.2"
7
+ version = "0.7.1"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -28,7 +28,7 @@ classifiers = [
28
28
  "Operating System :: OS Independent",
29
29
  ]
30
30
  requires-python = ">= 3.11"
31
- dependencies = ["scim2-models>=0.10.6"]
31
+ dependencies = ["scim2-models>=0.12.1"]
32
32
 
33
33
  [[project.authors]]
34
34
  name = "Yaal Coop"
@@ -43,6 +43,7 @@ file = "LICENSE"
43
43
 
44
44
  [project.optional-dependencies]
45
45
  testing = ["pytest>=9.1.1"]
46
+ precis = ["precis-i18n>=1.1.2"]
46
47
 
47
48
  [project.urls]
48
49
  repository = "https://github.com/python-scim/scim2-server"
@@ -60,8 +61,9 @@ dev = [
60
61
  "time-machine>=2.14.2",
61
62
  "tox-uv>=1.16.0",
62
63
  "aiosqlite>=0.22.1",
64
+ "precis-i18n>=1.1.2",
63
65
  "mypy>=2.3.1",
64
- "scim2-client[werkzeug]>=0.11.2",
66
+ "scim2-client>=0.12.0",
65
67
  "scim2-tester>=0.5.1",
66
68
  "werkzeug>=3.0.3",
67
69
  ]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.6.2"
7
+ version = "0.7.1"
8
8
  description = "SCIM server core for any storage and web framework"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -29,13 +29,16 @@ classifiers = [
29
29
 
30
30
  requires-python = ">= 3.11"
31
31
  dependencies = [
32
- "scim2-models>=0.10.6",
32
+ "scim2-models>=0.12.1",
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
36
36
  testing = [
37
37
  "pytest>=9.1.1",
38
38
  ]
39
+ precis = [
40
+ "precis-i18n>=1.1.2",
41
+ ]
39
42
 
40
43
  [project.urls]
41
44
  repository = "https://github.com/python-scim/scim2-server"
@@ -53,8 +56,9 @@ dev = [
53
56
  "time-machine>=2.14.2",
54
57
  "tox-uv>=1.16.0",
55
58
  "aiosqlite>=0.22.1",
59
+ "precis-i18n>=1.1.2",
56
60
  "mypy>=2.3.1",
57
- "scim2-client[werkzeug]>=0.11.2",
61
+ "scim2-client>=0.12.0",
58
62
  "scim2-tester>=0.5.1",
59
63
  "werkzeug>=3.0.3",
60
64
  ]
@@ -15,6 +15,7 @@ from scim2_server.routing import Operation
15
15
  from scim2_server.service import ScimService
16
16
  from scim2_server.storage import AsyncScimStorage
17
17
  from scim2_server.storage import ScimStorage
18
+ from scim2_server.storage import projection
18
19
 
19
20
 
20
21
  class ScimHandler:
@@ -75,7 +76,9 @@ class ScimHandler:
75
76
  )
76
77
  with self.storage.operation():
77
78
  resource = self.storage.get(
78
- resource_type, cast(str, target.resource_id)
79
+ resource_type,
80
+ cast(str, target.resource_id),
81
+ **projection(self.storage, response_parameters),
79
82
  )
80
83
  response = self.service.query_response(
81
84
  request.base_url,
@@ -398,7 +401,9 @@ class AsyncScimHandler:
398
401
  )
399
402
  async with self.storage.operation():
400
403
  resource = await self.storage.get(
401
- resource_type, cast(str, target.resource_id)
404
+ resource_type,
405
+ cast(str, target.resource_id),
406
+ **projection(self.storage, response_parameters),
402
407
  )
403
408
  response = self.service.query_response(
404
409
  request.base_url,
@@ -16,6 +16,7 @@ from scim2_models import Path
16
16
  from scim2_models import PreconditionFailedException
17
17
  from scim2_models import Resource
18
18
  from scim2_models import ResourceType
19
+ from scim2_models import ResponseParameters
19
20
  from scim2_models import ScimFilter
20
21
  from scim2_models import SearchRequest
21
22
  from scim2_models import Uniqueness
@@ -23,6 +24,7 @@ from scim2_models import UniquenessException
23
24
 
24
25
  from scim2_server.storage import AsyncScimStorage
25
26
  from scim2_server.storage import ScimStorage
27
+ from scim2_server.storage import projection
26
28
  from scim2_server.utils import parametrize
27
29
 
28
30
 
@@ -68,7 +70,13 @@ class InMemoryStorage(ScimStorage):
68
70
  with self.lock:
69
71
  yield
70
72
 
71
- def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
73
+ def get(
74
+ self,
75
+ resource_type: ResourceType,
76
+ resource_id: str,
77
+ *,
78
+ response_parameters: ResponseParameters[Any] | None = None,
79
+ ) -> Resource[Any]:
72
80
  with self.lock:
73
81
  return self.resources[self._index(resource_type, resource_id)].model_copy(
74
82
  deep=True
@@ -203,13 +211,16 @@ class InMemoryStorage(ScimStorage):
203
211
  """Return the value a resource holds for a unique attribute, in the form it is compared in.
204
212
 
205
213
  A resource whose schemas do not declare the attribute holds no value.
214
+ A string the policy cannot compare is equal to no other value, so it
215
+ is held as missing too.
206
216
  """
207
217
  value = parametrize(Path, type(resource))(attribute.urn).get(
208
218
  resource, strict=False
209
219
  )
210
- if isinstance(value, str) and not attribute.case_exact:
211
- return value.casefold()
212
- return value
220
+ try:
221
+ return attribute.comparable(value)
222
+ except ValueError:
223
+ return None
213
224
 
214
225
 
215
226
  class AsyncInMemoryStorage(AsyncScimStorage):
@@ -233,8 +244,18 @@ class AsyncInMemoryStorage(AsyncScimStorage):
233
244
  """The stored resources."""
234
245
  return self.storage.resources
235
246
 
236
- async def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
237
- return self.storage.get(resource_type, resource_id)
247
+ async def get(
248
+ self,
249
+ resource_type: ResourceType,
250
+ resource_id: str,
251
+ *,
252
+ response_parameters: ResponseParameters[Any] | None = None,
253
+ ) -> Resource[Any]:
254
+ return self.storage.get(
255
+ resource_type,
256
+ resource_id,
257
+ **projection(self.storage, response_parameters),
258
+ )
238
259
 
239
260
  async def search(
240
261
  self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
@@ -22,6 +22,5 @@
22
22
  },
23
23
  "etag": {
24
24
  "supported": true
25
- },
26
- "authenticationSchemes": []
25
+ }
27
26
  }
@@ -122,7 +122,6 @@ def patch_only_config() -> ServiceProviderConfig:
122
122
  change_password=ChangePassword(supported=False),
123
123
  sort=Sort(supported=False),
124
124
  etag=ETag(supported=False),
125
- authentication_schemes=[],
126
125
  )
127
126
 
128
127
 
@@ -1,3 +1,5 @@
1
+ import inspect
2
+ import warnings
1
3
  from abc import ABC
2
4
  from abc import abstractmethod
3
5
  from contextlib import AbstractAsyncContextManager
@@ -7,9 +9,39 @@ from typing import Any
7
9
 
8
10
  from scim2_models import Resource
9
11
  from scim2_models import ResourceType
12
+ from scim2_models import ResponseParameters
10
13
  from scim2_models import SearchRequest
11
14
 
12
15
 
16
+ def takes_response_parameters(storage_class: type) -> bool:
17
+ """Tell whether the ``get`` method of a storage accepts ``response_parameters``, and warn when it does not."""
18
+ parameters = inspect.signature(storage_class.get).parameters.values() # type: ignore[attr-defined]
19
+ if any(
20
+ parameter.name == "response_parameters"
21
+ or parameter.kind is inspect.Parameter.VAR_KEYWORD
22
+ for parameter in parameters
23
+ ):
24
+ return True
25
+ warnings.warn(
26
+ f"{storage_class.__qualname__}.get should accept a response_parameters "
27
+ "keyword argument. scim2-server 0.9 will require it.",
28
+ DeprecationWarning,
29
+ # takes_response_parameters, __init_subclass__, ABCMeta.__new__, then the class statement
30
+ stacklevel=4,
31
+ )
32
+ return False
33
+
34
+
35
+ def projection(
36
+ storage: "ScimStorage | AsyncScimStorage",
37
+ response_parameters: ResponseParameters[Any] | None,
38
+ ) -> dict[str, Any]:
39
+ """Return the arguments that pass the response parameters to the get method of a storage, if it accepts them."""
40
+ if not storage._get_takes_response_parameters:
41
+ return {}
42
+ return {"response_parameters": response_parameters}
43
+
44
+
13
45
  class ScimStorage(ABC):
14
46
  """Where a SCIM server reads and writes its resources.
15
47
 
@@ -40,10 +72,35 @@ class ScimStorage(ABC):
40
72
  calling the storage.
41
73
  """
42
74
 
75
+ _get_takes_response_parameters = True
76
+
77
+ def __init_subclass__(cls, **kwargs: Any) -> None:
78
+ super().__init_subclass__(**kwargs)
79
+ cls._get_takes_response_parameters = takes_response_parameters(cls)
80
+
43
81
  @abstractmethod
44
- def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
82
+ def get(
83
+ self,
84
+ resource_type: ResourceType,
85
+ resource_id: str,
86
+ *,
87
+ response_parameters: ResponseParameters[Any] | None = None,
88
+ ) -> Resource[Any]:
45
89
  """Return a resource.
46
90
 
91
+ The server passes ``response_parameters`` when it returns the resource
92
+ to the client, for a GET request. The storage may then leave out the
93
+ attributes the response does not keep, for instance to avoid loading
94
+ the members of a group. It must return every attribute the response
95
+ keeps. :meth:`Path.iter_paths <scim2_models.Path.iter_paths>` gives
96
+ them. The server applies the parameters to the response anyway.
97
+ Without ``response_parameters``, the storage returns the whole
98
+ resource.
99
+
100
+ A ``get`` method without ``response_parameters`` is deprecated, and
101
+ raises a :class:`DeprecationWarning`. The server then does not pass
102
+ the parameters. scim2-server 0.9 will require it.
103
+
47
104
  :raises ~scim2_models.NotFoundException: When no resource of this type
48
105
  has this identifier.
49
106
  """
@@ -135,8 +192,20 @@ class AsyncScimStorage(ABC):
135
192
  which :class:`~scim2_server.testing.AsyncScimStorageContract` checks.
136
193
  """
137
194
 
195
+ _get_takes_response_parameters = True
196
+
197
+ def __init_subclass__(cls, **kwargs: Any) -> None:
198
+ super().__init_subclass__(**kwargs)
199
+ cls._get_takes_response_parameters = takes_response_parameters(cls)
200
+
138
201
  @abstractmethod
139
- async def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
202
+ async def get(
203
+ self,
204
+ resource_type: ResourceType,
205
+ resource_id: str,
206
+ *,
207
+ response_parameters: ResponseParameters[Any] | None = None,
208
+ ) -> Resource[Any]:
140
209
  """Return a resource. See :meth:`ScimStorage.get`."""
141
210
 
142
211
  @abstractmethod
@@ -29,16 +29,19 @@ from typing import Union
29
29
  from typing import cast
30
30
 
31
31
  import pytest
32
+ from scim2_models import Context
32
33
  from scim2_models import NotFoundException
33
34
  from scim2_models import PreconditionFailedException
34
35
  from scim2_models import Resource
35
36
  from scim2_models import ResourceType
37
+ from scim2_models import ResponseParameters
36
38
  from scim2_models import ScimProvider
37
39
  from scim2_models import SearchRequest
38
40
  from scim2_models import UniquenessException
39
41
 
40
42
  from scim2_server.storage import AsyncScimStorage
41
43
  from scim2_server.storage import ScimStorage
44
+ from scim2_server.storage import projection
42
45
  from scim2_server.utils import load_default_provider
43
46
  from scim2_server.utils import parametrize
44
47
 
@@ -198,6 +201,52 @@ class ScimStorageContract:
198
201
  with pytest.raises(NotFoundException):
199
202
  storage.get(group_type, user.id)
200
203
 
204
+ @pytest.mark.parametrize(
205
+ "parameters",
206
+ [
207
+ {"excludedAttributes": "members"},
208
+ {"excludedAttributes": "members.display"},
209
+ {"attributes": "displayName"},
210
+ {"attributes": "members.value"},
211
+ ],
212
+ )
213
+ def test_get_keeps_what_the_response_returns(
214
+ self,
215
+ storage: Any,
216
+ user_type: ResourceType,
217
+ group_type: ResourceType,
218
+ user_model: Any,
219
+ group_model: Any,
220
+ parameters: dict[str, str],
221
+ ) -> None:
222
+ """A resource read with response parameters gives the same response as the whole resource."""
223
+ (user,) = self.create_users(storage, user_type, user_model, "bjensen")
224
+ group = storage.create(
225
+ group_type,
226
+ group_model.model_validate(
227
+ {
228
+ "displayName": "Admins",
229
+ "members": [{"value": user.id, "display": "bjensen"}],
230
+ }
231
+ ),
232
+ )
233
+ response_parameters = parametrize(
234
+ ResponseParameters, group_model
235
+ ).model_validate(parameters)
236
+
237
+ whole = storage.get(group_type, group.id)
238
+ projected = storage.get(
239
+ group_type, group.id, **projection(storage, response_parameters)
240
+ )
241
+
242
+ assert projected.model_dump(
243
+ scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
244
+ response_parameters=response_parameters,
245
+ ) == whole.model_dump(
246
+ scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
247
+ response_parameters=response_parameters,
248
+ )
249
+
201
250
  def test_update_replaces_the_stored_resource(
202
251
  self, storage: Any, user_type: ResourceType, user_model: Any
203
252
  ) -> None:
@@ -492,8 +541,20 @@ class BlockingStorage(ScimStorage):
492
541
  def run(self, coroutine: Coroutine[Any, Any, T]) -> T:
493
542
  return self.runner.run(coroutine)
494
543
 
495
- def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]:
496
- return self.run(self.storage.get(resource_type, resource_id))
544
+ def get(
545
+ self,
546
+ resource_type: ResourceType,
547
+ resource_id: str,
548
+ *,
549
+ response_parameters: ResponseParameters[Any] | None = None,
550
+ ) -> Resource[Any]:
551
+ return self.run(
552
+ self.storage.get(
553
+ resource_type,
554
+ resource_id,
555
+ **projection(self.storage, response_parameters),
556
+ )
557
+ )
497
558
 
498
559
  def search(
499
560
  self, resource_types: list[ResourceType], search_request: SearchRequest[Any]
@@ -87,6 +87,12 @@ def build_parser() -> argparse.ArgumentParser:
87
87
  help="Accept a static bearer token, and announce the bearer token scheme. "
88
88
  "Can be repeated. Without it, the server accepts every request.",
89
89
  )
90
+ parser.add_argument(
91
+ "--precis",
92
+ action="store_true",
93
+ help="Compare the usernames and the passwords with the PRECIS profiles of "
94
+ "RFC 8265, and refuse the values they do not allow. Needs the precis extra.",
95
+ )
90
96
  parser.add_argument(
91
97
  "--hostname",
92
98
  default="127.0.0.1",
@@ -169,7 +175,20 @@ def main() -> None:
169
175
  BEARER_TOKEN_SCHEME,
170
176
  ]
171
177
 
172
- provider = ScimProvider.from_discovery(schemas, resource_types, config=config)
178
+ policy = None
179
+ if args.precis:
180
+ try:
181
+ # Imported on demand, so that the server runs without the precis extra.
182
+ from scim2_server.testserver.precis import PRECIS_POLICY
183
+ except ModuleNotFoundError:
184
+ parser.error(
185
+ "--precis needs the precis extra: pip install 'scim2-server[precis]'"
186
+ )
187
+ policy = PRECIS_POLICY
188
+
189
+ provider = ScimProvider.from_discovery(
190
+ schemas, resource_types, config=config, policy=policy
191
+ )
173
192
 
174
193
  storages: dict[str | None, InMemoryStorage] = {}
175
194
 
@@ -0,0 +1,29 @@
1
+ """The PRECIS comparison of the ``--precis`` option of the ``scim2-server`` command."""
2
+
3
+ from typing import Any
4
+
5
+ from precis_i18n import get_profile
6
+ from scim2_models import ScimPolicy
7
+ from scim2_models import default_comparison_key
8
+
9
+ USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
10
+
11
+ PROFILES = {
12
+ f"{USER_SCHEMA}:userName": get_profile("UsernameCaseMapped"),
13
+ f"{USER_SCHEMA}:password": get_profile("OpaqueString"),
14
+ }
15
+
16
+
17
+ def precis_comparison_key(binding: Any, value: str) -> str:
18
+ """Compare the usernames and the passwords with the PRECIS profiles of RFC 8265.
19
+
20
+ Per RFC 7644 §5, the usernames and the passwords are prepared with PRECIS
21
+ before they are compared. The other strings are compared as by default.
22
+ """
23
+ profile = PROFILES.get(binding.urn)
24
+ if profile is None:
25
+ return default_comparison_key(binding, value)
26
+ return str(profile.enforce(value))
27
+
28
+
29
+ PRECIS_POLICY = ScimPolicy(comparison_key=precis_comparison_key)
File without changes
File without changes