scim2-server 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.0
4
4
  Summary: Lightweight SCIM2 server prototype
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -208,7 +208,6 @@ License: Apache License
208
208
  limitations under the License.
209
209
  Classifier: Intended Audience :: Developers
210
210
  Classifier: Development Status :: 4 - Beta
211
- Classifier: Programming Language :: Python :: 3.10
212
211
  Classifier: Programming Language :: Python :: 3.11
213
212
  Classifier: Programming Language :: Python :: 3.12
214
213
  Classifier: Programming Language :: Python :: 3.13
@@ -218,10 +217,9 @@ Classifier: License :: OSI Approved :: Apache Software License
218
217
  Classifier: Environment :: Web Environment
219
218
  Classifier: Programming Language :: Python
220
219
  Classifier: Operating System :: OS Independent
221
- Requires-Dist: scim2-filter-parser>=0.7.0
222
- Requires-Dist: scim2-models>=0.6.1
220
+ Requires-Dist: scim2-models>=0.9.0
223
221
  Requires-Dist: werkzeug>=3.0.3
224
- Requires-Python: >=3.10
222
+ Requires-Python: >=3.11
225
223
  Project-URL: repository, https://github.com/python-scim/scim2-server
226
224
  Project-URL: funding, https://github.com/sponsors/python-scim
227
225
  Description-Content-Type: text/markdown
@@ -229,7 +227,7 @@ Description-Content-Type: text/markdown
229
227
  # scim2-server
230
228
 
231
229
  This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
232
- It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and [scim2-filter-parser](https://github.com/15five/scim2-filter-parser) and keeps all resources in-memory,
230
+ It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
233
231
  they are lost once the process exits.
234
232
 
235
233
  ## Features
@@ -241,20 +239,20 @@ they are lost once the process exits.
241
239
  - [x] Unique Constraints
242
240
  - [x] HTTP PATCH (Add/Remove/Replace)
243
241
  - [x] Sorting
244
-
245
- The only optional feature currently missing is support for Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7)).
242
+ - [x] Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
246
243
 
247
244
  ## Usage
248
245
 
249
246
  ```shell
250
- $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
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]
251
248
  ```
252
249
 
253
250
  - `-h`/`--help`: Show help message
254
251
  - `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
255
252
  - `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
256
253
  - `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
257
- - `--bearer-token`: Registers a bearer token that can be used for accessing the service. If no tokens are provided, anonymous access without authentication is allowed.
254
+ - `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
255
+ - `--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.
258
256
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
259
257
  - `--port`: The port to listen on. Defaults to `8080`.
260
258
  - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
@@ -1,7 +1,7 @@
1
1
  # scim2-server
2
2
 
3
3
  This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
4
- It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and [scim2-filter-parser](https://github.com/15five/scim2-filter-parser) and keeps all resources in-memory,
4
+ It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
5
5
  they are lost once the process exits.
6
6
 
7
7
  ## Features
@@ -13,20 +13,20 @@ they are lost once the process exits.
13
13
  - [x] Unique Constraints
14
14
  - [x] HTTP PATCH (Add/Remove/Replace)
15
15
  - [x] Sorting
16
-
17
- The only optional feature currently missing is support for Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7)).
16
+ - [x] Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
18
17
 
19
18
  ## Usage
20
19
 
21
20
  ```shell
22
- $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
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]
23
22
  ```
24
23
 
25
24
  - `-h`/`--help`: Show help message
26
25
  - `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
27
26
  - `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
28
27
  - `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
29
- - `--bearer-token`: Registers a bearer token that can be used for accessing the service. If no tokens are provided, anonymous access without authentication is allowed.
28
+ - `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
29
+ - `--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
30
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
31
31
  - `--port`: The port to listen on. Defaults to `8080`.
32
32
  - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.12.16,<0.13.0"]
2
+ requires = ["uv_build>=0.12.13,<0.13.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -17,7 +17,6 @@ keywords = [
17
17
  classifiers = [
18
18
  "Intended Audience :: Developers",
19
19
  "Development Status :: 4 - Beta",
20
- "Programming Language :: Python :: 3.10",
21
20
  "Programming Language :: Python :: 3.11",
22
21
  "Programming Language :: Python :: 3.12",
23
22
  "Programming Language :: Python :: 3.13",
@@ -28,10 +27,9 @@ classifiers = [
28
27
  "Programming Language :: Python",
29
28
  "Operating System :: OS Independent",
30
29
  ]
31
- requires-python = ">= 3.10"
30
+ requires-python = ">= 3.11"
32
31
  dependencies = [
33
- "scim2-filter-parser>=0.7.0",
34
- "scim2-models>=0.6.1",
32
+ "scim2-models>=0.9.0",
35
33
  "werkzeug>=3.0.3",
36
34
  ]
37
35
 
@@ -55,14 +53,25 @@ scim2-server = "scim2_server.cli:main"
55
53
 
56
54
  [dependency-groups]
57
55
  dev = [
58
- "httpx>=0.27.0",
56
+ "httpx2>=2.0.0",
59
57
  "pytest>=8.2.2",
60
- "pytest-coverage>=0.0",
58
+ "pytest-cov>=6.0.0",
61
59
  "prek>=0.1.0",
62
60
  "time-machine>=2.14.2",
63
61
  "tox-uv>=1.16.0",
64
62
  ]
65
63
 
64
+ [tool.uv]
65
+ exclude-newer = "14 days"
66
+
67
+ [tool.uv.exclude-newer-package]
68
+ scim2-models = false
69
+ scim2-client = false
70
+ scim2-server = false
71
+ scim2-cli = false
72
+ scim2-tester = false
73
+ pytest-scim2-server = false
74
+
66
75
  [tool.uv.build-backend]
67
76
  module-root = ""
68
77
 
@@ -125,7 +134,6 @@ doctest_optionflags = "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
125
134
  requires = ["tox>=4.19"]
126
135
  env_list = [
127
136
  "style",
128
- "py310",
129
137
  "py311",
130
138
  "py312",
131
139
  "py313",
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.12.16,<0.13.0"]
2
+ requires = ["uv_build>=0.12.13,<0.13.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -16,7 +16,6 @@ keywords = ["scim", "scim2", "provisioning", "rfc7643", "rfc7644"]
16
16
  classifiers = [
17
17
  "Intended Audience :: Developers",
18
18
  "Development Status :: 4 - Beta",
19
- "Programming Language :: Python :: 3.10",
20
19
  "Programming Language :: Python :: 3.11",
21
20
  "Programming Language :: Python :: 3.12",
22
21
  "Programming Language :: Python :: 3.13",
@@ -28,10 +27,9 @@ classifiers = [
28
27
  "Operating System :: OS Independent",
29
28
  ]
30
29
 
31
- requires-python = ">= 3.10"
30
+ requires-python = ">= 3.11"
32
31
  dependencies = [
33
- "scim2-filter-parser>=0.7.0",
34
- "scim2-models>=0.6.1",
32
+ "scim2-models>=0.9.0",
35
33
  "werkzeug>=3.0.3",
36
34
  ]
37
35
 
@@ -44,14 +42,18 @@ scim2-server = "scim2_server.cli:main"
44
42
 
45
43
  [dependency-groups]
46
44
  dev = [
47
- "httpx>=0.27.0",
45
+ "httpx2>=2.0.0",
48
46
  "pytest>=8.2.2",
49
- "pytest-coverage>=0.0",
47
+ "pytest-cov>=6.0.0",
50
48
  "prek>=0.1.0",
51
49
  "time-machine>=2.14.2",
52
50
  "tox-uv>=1.16.0",
53
51
  ]
54
52
 
53
+ [tool.uv]
54
+ exclude-newer = "14 days"
55
+ exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-server = false, scim2-cli = false, scim2-tester = false, pytest-scim2-server = false }
56
+
55
57
  [tool.uv.build-backend]
56
58
  module-root = ""
57
59
 
@@ -111,7 +113,6 @@ doctest_optionflags= "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
111
113
  requires = ["tox>=4.19"]
112
114
  env_list = [
113
115
  "style",
114
- "py310",
115
116
  "py311",
116
117
  "py312",
117
118
  "py313",
@@ -0,0 +1,315 @@
1
+ import dataclasses
2
+ import datetime
3
+ import pickle
4
+ import uuid
5
+ from inspect import isclass
6
+ from threading import Lock
7
+ from typing import Any
8
+ from typing import Union
9
+
10
+ from scim2_models import BaseModel
11
+ from scim2_models import CaseExact
12
+ from scim2_models import Extension
13
+ from scim2_models import Meta
14
+ from scim2_models import Resource
15
+ from scim2_models import ResourceType
16
+ from scim2_models import ScimFilter
17
+ from scim2_models import SearchRequest
18
+ from scim2_models import Uniqueness
19
+ from scim2_models import UniquenessException
20
+ from werkzeug.http import generate_etag
21
+
22
+
23
+ class Backend:
24
+ """The base class for a SCIM provider backend.
25
+
26
+ A backend only stores resources: what the service serves is described by the
27
+ :class:`~scim2_models.ScimProvider` of the application.
28
+ """
29
+
30
+ def __enter__(self):
31
+ """Allow the backend to be used as a context manager.
32
+
33
+ This enables support for transactions.
34
+ """
35
+ return self
36
+
37
+ def __exit__(self, exc_type, exc_val, exc_tb):
38
+ """Exit the transaction."""
39
+ pass
40
+
41
+ def query_resources(
42
+ self,
43
+ search_request: SearchRequest,
44
+ resource_type: ResourceType | None = None,
45
+ ) -> tuple[int, list[Resource]]:
46
+ """Query the backend for a set of resources.
47
+
48
+ :param search_request: SearchRequest instance describing the
49
+ query.
50
+ :param resource_type: The resource type to query. If None, all
51
+ resource types are queried.
52
+ :return: A tuple of "total results" and a List of found
53
+ Resources. The List must contain a copy of resources.
54
+ Mutating elements in the List must not modify the data
55
+ stored in the backend.
56
+ :raises TooManyException: If the backend only supports querying
57
+ for one resource type at a time, setting resource_type to
58
+ None the backend may raise TooManyException.
59
+ """
60
+ raise NotImplementedError
61
+
62
+ def get_resource(
63
+ self, resource_type: ResourceType, object_id: str
64
+ ) -> Resource | None:
65
+ """Query the backend for a resources by its ID.
66
+
67
+ :param resource_type: The resource type to get the object from.
68
+ :param object_id: ID of the object to get.
69
+ :return: The resource object if it exists, None otherwise. The
70
+ resource must be a copy, modifying it must not change the
71
+ data stored in the backend.
72
+ """
73
+ raise NotImplementedError
74
+
75
+ def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
76
+ """Delete a resource.
77
+
78
+ :param resource_type: The resource type to delete the object
79
+ from.
80
+ :param object_id: ID of the object to delete.
81
+ :return: True if the resource was deleted, False otherwise.
82
+ """
83
+ raise NotImplementedError
84
+
85
+ def create_resource(
86
+ self, resource_type: ResourceType, resource: Resource
87
+ ) -> Resource | None:
88
+ """Create a resource.
89
+
90
+ :param resource_type: The resource type to create.
91
+ :param resource: Resource to create.
92
+ :return: The created resource. Creation should set system-
93
+ defined attributes (ID, Metadata). May be the same object
94
+ that is passed in.
95
+ """
96
+ raise NotImplementedError
97
+
98
+ def update_resource(
99
+ self, resource_type: ResourceType, resource: Resource
100
+ ) -> Resource | None:
101
+ """Update a resource. The resource is identified by its ID.
102
+
103
+ :param resource_type: The resource type to update.
104
+ :param resource: Resource to update.
105
+ :return: The updated resource. Updating should update the
106
+ "meta.lastModified" data. May be the same object that is
107
+ passed in.
108
+ """
109
+ raise NotImplementedError
110
+
111
+
112
+ class InMemoryBackend(Backend):
113
+ """An example in-memory backend for the SCIM provider.
114
+
115
+ It is not optimized for performance. Many operations are O(n) or
116
+ worse, whereas they would perform better with an actual production
117
+ database in the backend. This is intentional to keep the
118
+ implementation simple.
119
+ """
120
+
121
+ @dataclasses.dataclass(frozen=True)
122
+ class UniquenessDescriptor:
123
+ """Used to mimic uniqueness constraints e.g. from a SQL database."""
124
+
125
+ extension: str | None
126
+ field_name: str
127
+ case_exact: bool
128
+ schema: str
129
+
130
+ def is_declared_by(self, resource: Resource) -> bool:
131
+ """Tell whether the model of a resource holds the schema of the attribute."""
132
+ model = type(resource)
133
+ return self.schema == model.__schema__ or (
134
+ self.schema in model.get_extension_models()
135
+ )
136
+
137
+ def get_attribute(self, resource: Resource) -> Any:
138
+ holder = getattr(resource, self.extension) if self.extension else resource
139
+ value = getattr(holder, self.field_name, None) if holder else None
140
+ if isinstance(value, str) and not self.case_exact:
141
+ return value.casefold()
142
+ return value
143
+
144
+ @classmethod
145
+ def collect_unique_attrs(
146
+ cls, model: type[BaseModel], extension: str | None = None
147
+ ) -> list[UniquenessDescriptor]:
148
+ """Return the uniqueness constraints the annotations of a model declare.
149
+
150
+ The ``id`` is left out: the backend issues it, so it cannot clash.
151
+ """
152
+ descriptors = [
153
+ cls.UniquenessDescriptor(
154
+ extension,
155
+ field_name,
156
+ model.get_field_annotation(field_name, CaseExact) == CaseExact.true,
157
+ str(model.__schema__),
158
+ )
159
+ for field_name in model.model_fields
160
+ if field_name != "id"
161
+ and model.get_field_annotation(field_name, Uniqueness) != Uniqueness.none
162
+ ]
163
+ for field_name in model.model_fields:
164
+ root_type = model.get_field_root_type(field_name)
165
+ if isclass(root_type) and issubclass(root_type, Extension):
166
+ descriptors.extend(cls.collect_unique_attrs(root_type, field_name))
167
+ return descriptors
168
+
169
+ def __init__(self):
170
+ super().__init__()
171
+ self.resources: list[Resource] = []
172
+ self.lock: Lock = Lock()
173
+
174
+ def __enter__(self):
175
+ """See super docs.
176
+
177
+ The InMemoryBackend uses a simple Lock to synchronize all
178
+ access.
179
+ """
180
+ super().__enter__()
181
+ self.lock.acquire()
182
+ return self
183
+
184
+ def __exit__(self, exc_type, exc_val, exc_tb):
185
+ super().__exit__(exc_type, exc_val, exc_tb)
186
+ self.lock.release()
187
+
188
+ def query_resources(
189
+ self,
190
+ search_request: SearchRequest,
191
+ resource_type: ResourceType | None = None,
192
+ ) -> tuple[int, list[Resource]]:
193
+ start_index = (search_request.start_index or 1) - 1
194
+
195
+ candidates = [
196
+ r
197
+ for r in self.resources
198
+ if resource_type is None or self._is_of_type(r, resource_type)
199
+ ]
200
+
201
+ scim_filter = search_request.filter
202
+ if scim_filter is not None and not scim_filter.models and candidates:
203
+ models = tuple(dict.fromkeys(type(r) for r in candidates))
204
+ scim_filter = ScimFilter[Union[models]](str(scim_filter)) # noqa: UP007
205
+
206
+ found_resources = [
207
+ r for r in candidates if scim_filter is None or scim_filter.match(r)
208
+ ]
209
+
210
+ found_resources = search_request.sort(found_resources)
211
+
212
+ total_results = len(found_resources)
213
+ found_resources = found_resources[start_index:]
214
+ if search_request.count is not None:
215
+ found_resources = found_resources[: search_request.count]
216
+ return total_results, found_resources
217
+
218
+ def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
219
+ """Tell whether a resource belongs to a resource type.
220
+
221
+ RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
222
+ which may differ from its id.
223
+ """
224
+ return resource.meta.resource_type == resource_type.name
225
+
226
+ def _get_resource_idx(
227
+ self, resource_type: ResourceType, object_id: str
228
+ ) -> int | None:
229
+ return next(
230
+ (
231
+ idx
232
+ for idx, r in enumerate(self.resources)
233
+ if self._is_of_type(r, resource_type) and r.id == object_id
234
+ ),
235
+ None,
236
+ )
237
+
238
+ def get_resource(
239
+ self, resource_type: ResourceType, object_id: str
240
+ ) -> Resource | None:
241
+ resource_dict_idx = self._get_resource_idx(resource_type, object_id)
242
+ if resource_dict_idx is not None:
243
+ return self.resources[resource_dict_idx].model_copy(deep=True)
244
+ return None
245
+
246
+ def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
247
+ found = self.get_resource(resource_type, object_id)
248
+ if found:
249
+ self.resources = [
250
+ r
251
+ for r in self.resources
252
+ if not (self._is_of_type(r, resource_type) and r.id == object_id)
253
+ ]
254
+ return True
255
+ return False
256
+
257
+ def create_resource(
258
+ self, resource_type: ResourceType, resource: Resource
259
+ ) -> Resource | None:
260
+ resource = resource.model_copy(deep=True)
261
+ resource.id = uuid.uuid4().hex
262
+ utcnow = datetime.datetime.now(datetime.UTC)
263
+ resource.meta = Meta(
264
+ resource_type=resource_type.name,
265
+ created=utcnow,
266
+ last_modified=utcnow,
267
+ location="/v2" + resource_type.endpoint + "/" + resource.id,
268
+ )
269
+ self._touch_resource(resource, utcnow)
270
+ self._check_uniqueness(resource)
271
+ self.resources.append(resource)
272
+ return resource
273
+
274
+ def _check_uniqueness(self, resource: Resource):
275
+ """Refuse a resource sharing a unique value with another one of the same schema.
276
+
277
+ RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
278
+ schema that declares the attribute, whatever their resource type. A
279
+ missing value never clashes, as a SQL NULL does not.
280
+ """
281
+ for unique_attribute in self.collect_unique_attrs(type(resource)):
282
+ value = unique_attribute.get_attribute(resource)
283
+ if value is None:
284
+ continue
285
+ for existing_resource in self.resources:
286
+ if (
287
+ unique_attribute.is_declared_by(existing_resource)
288
+ and existing_resource.id != resource.id
289
+ and unique_attribute.get_attribute(existing_resource) == value
290
+ ):
291
+ raise UniquenessException()
292
+
293
+ @staticmethod
294
+ def _touch_resource(resource: Resource, last_modified: datetime.datetime):
295
+ """Touches a resource (updates last_modified and version).
296
+
297
+ Version is generated by hashing last_modified. Another option
298
+ would be to hash the entire resource instead.
299
+ """
300
+ resource.meta.last_modified = last_modified
301
+ etag = generate_etag(pickle.dumps(resource.meta.last_modified))
302
+ resource.meta.version = f'W/"{etag}"'
303
+
304
+ def update_resource(
305
+ self, resource_type: ResourceType, resource: Resource
306
+ ) -> Resource | None:
307
+ found_res_idx = self._get_resource_idx(resource_type, resource.id)
308
+ if found_res_idx is not None:
309
+ updated_resource = type(resource).model_validate(resource.model_dump())
310
+ self._touch_resource(updated_resource, datetime.datetime.now(datetime.UTC))
311
+
312
+ self._check_uniqueness(updated_resource)
313
+ self.resources[found_res_idx] = updated_resource
314
+ return updated_resource
315
+ return None