scim2-server 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,478 @@
1
+ import re
2
+ from typing import Dict
3
+ from typing import List
4
+ from typing import Optional
5
+ from typing import Tuple
6
+ from typing import Type
7
+
8
+ from scim2_filter_parser.lexer import SCIMLexer
9
+ from scim2_filter_parser.parser import SCIMParser
10
+ from scim2_models import BaseModel
11
+ from scim2_models import CaseExact
12
+ from scim2_models import Error
13
+ from scim2_models import Mutability
14
+ from scim2_models import PatchOperation
15
+ from scim2_models import Required
16
+ from scim2_models import Resource
17
+ from scim2_models import Returned
18
+
19
+ from scim2_server.filter import evaluate_filter
20
+ from scim2_server.utils import SCIMException
21
+ from scim2_server.utils import get_by_alias
22
+ from scim2_server.utils import get_or_create
23
+ from scim2_server.utils import handle_extension
24
+ from scim2_server.utils import is_multi_valued
25
+ from scim2_server.utils import parse_new_value
26
+
27
+ ATTRIBUTE_PATH_REGEX = re.compile(
28
+ r"^(?P<attribute>\w+)(\[(?P<condition>.*)\])?(\.(?P<sub_attribute>\w+))?$"
29
+ )
30
+
31
+
32
+ def patch_resource(resource: Resource, operation: PatchOperation):
33
+ """Runs a patch operation against a resource."""
34
+ match operation.op:
35
+ case PatchOperation.Op.add:
36
+ operator = AddOperator(operation.path, operation.value)
37
+ operator(resource)
38
+ case PatchOperation.Op.remove:
39
+ operator = RemoveOperator(operation.path, None)
40
+ operator(resource)
41
+ case _: # PatchOperation.Op.replace
42
+ operator = ReplaceOperator(operation.path, operation.value)
43
+ operator(resource)
44
+
45
+
46
+ def parse_attribute_path(attribute_path: Optional[str]) -> Optional[Dict[str, any]]:
47
+ """Parses an attribute path and returns a dictionary of attributes.
48
+
49
+ The attributes are the named captures in the regex
50
+ ATTRIBUTE_PATH_REGEX.
51
+ """
52
+ if not attribute_path:
53
+ return {}
54
+
55
+ if not hasattr(parse_attribute_path, "cache"):
56
+ parse_attribute_path.cache = {}
57
+ if attribute_path in parse_attribute_path.cache:
58
+ return parse_attribute_path.cache[attribute_path]
59
+
60
+ match = ATTRIBUTE_PATH_REGEX.match(attribute_path)
61
+ if not match:
62
+ raise SCIMException(Error.make_invalid_path_error())
63
+ parse_attribute_path.cache[attribute_path] = match.groupdict()
64
+ return match.groupdict()
65
+
66
+
67
+ class Operator:
68
+ """An operator operates on a resource and is constructed using a path and a
69
+ value."""
70
+
71
+ OPERATE_ON_ROOT = True # Whether the operator may operate on the root of a resource (e.g. remove may not)
72
+ REQUIRES_VALUE = True # Whether the operator modifies the resource or not
73
+ RETURNS_VALUE = False # Whether the operator returns a result or not
74
+
75
+ def __init__(self, path: Optional[str], value: Optional[any]):
76
+ self.path = path
77
+ self.value = value
78
+
79
+ @classmethod
80
+ def init_return(
81
+ cls, model: BaseModel, attribute: str, sub_attribute: Optional[str], value: any
82
+ ):
83
+ """Initializes the return value if the operator returns something."""
84
+ pass
85
+
86
+ def do_return(self):
87
+ """Returns the return value for the operator."""
88
+ return None
89
+
90
+ @classmethod
91
+ def operation(
92
+ cls, model: BaseModel, attribute: str, value: any, index: Optional[int] = None
93
+ ):
94
+ """Performs the actual operation of the operator."""
95
+ raise NotImplementedError
96
+
97
+ def parse_path(self, model: BaseModel):
98
+ """Parses a path and optionally handles model extensions.
99
+
100
+ :return: A tuple of the model to operate on and the parsed path.
101
+ """
102
+ path = self.path
103
+ if isinstance(model, Resource):
104
+ model, path = handle_extension(model, self.path)
105
+ return model, parse_attribute_path(path)
106
+
107
+ def __call__(self, model: BaseModel):
108
+ """Executes the operator against a model."""
109
+ if not self.path:
110
+ self.call_on_root(model)
111
+ return self.do_return()
112
+ else:
113
+ model, path = self.parse_path(model)
114
+
115
+ match path:
116
+ case {
117
+ "attribute": attribute,
118
+ "condition": None,
119
+ "sub_attribute": None,
120
+ }:
121
+ self.match_attribute(attribute, model)
122
+ return self.do_return()
123
+ case {
124
+ "attribute": attribute,
125
+ "condition": None,
126
+ "sub_attribute": sub_path,
127
+ }:
128
+ self.match_complex_attribute(attribute, model, sub_path)
129
+ return self.do_return()
130
+ case {
131
+ "attribute": attribute,
132
+ "condition": condition,
133
+ "sub_attribute": None,
134
+ }:
135
+ self.match_multi_valued_attribute(attribute, condition, model)
136
+ return self.do_return()
137
+ case {
138
+ "attribute": attribute,
139
+ "condition": condition,
140
+ "sub_attribute": sub_attribute,
141
+ }:
142
+ self.match_multi_valued_attribute_sub(
143
+ attribute, condition, model, sub_attribute
144
+ )
145
+ return self.do_return()
146
+ case _:
147
+ raise SCIMException(Error.make_invalid_path_error())
148
+
149
+ def match_multi_valued_attribute_sub(
150
+ self, attribute: str, condition: str, model: BaseModel, sub_attribute: str
151
+ ):
152
+ attribute_name = get_by_alias(model, attribute)
153
+ multi_valued_attribute = get_or_create(model, attribute_name, True)
154
+ if not isinstance(multi_valued_attribute, list):
155
+ raise SCIMException(Error.make_invalid_path_error())
156
+ token_stream = SCIMLexer().tokenize(condition)
157
+ condition = SCIMParser().parse(token_stream)
158
+ self.init_return(model, attribute_name, sub_attribute, self.value)
159
+ for value in multi_valued_attribute:
160
+ if evaluate_filter(value, condition):
161
+ self.operation(value, sub_attribute, self.value)
162
+
163
+ def match_multi_valued_attribute(
164
+ self, attribute: str, condition: str, model: BaseModel
165
+ ):
166
+ if self.REQUIRES_VALUE and not isinstance(self.value, dict):
167
+ raise SCIMException(Error.make_invalid_value_error())
168
+ attribute_name = get_by_alias(model, attribute)
169
+ multi_valued_attribute = get_or_create(
170
+ model, attribute_name, self.REQUIRES_VALUE
171
+ )
172
+ if not isinstance(multi_valued_attribute, list):
173
+ raise SCIMException(Error.make_invalid_path_error())
174
+ token_stream = SCIMLexer().tokenize(condition)
175
+ condition = SCIMParser().parse(token_stream)
176
+ if self.REQUIRES_VALUE:
177
+ for value in multi_valued_attribute:
178
+ if evaluate_filter(value, condition):
179
+ for k, v in self.value.items():
180
+ self.operation(value, k, v)
181
+ elif self.RETURNS_VALUE:
182
+ self.init_return(model, attribute_name, None, self.value)
183
+ for index, value in enumerate(multi_valued_attribute):
184
+ if evaluate_filter(value, condition):
185
+ self.operation(model, attribute_name, self.value, index)
186
+ else:
187
+ new_value = [
188
+ value
189
+ for value in multi_valued_attribute
190
+ if not evaluate_filter(value, condition)
191
+ ]
192
+ setattr(model, attribute_name, new_value)
193
+
194
+ def match_complex_attribute(self, attribute: str, model: BaseModel, sub_path: str):
195
+ complex_attribute = get_or_create(
196
+ model, get_by_alias(model, attribute), self.REQUIRES_VALUE
197
+ )
198
+ if isinstance(complex_attribute, list) and complex_attribute:
199
+ for value in complex_attribute:
200
+ self.match_attribute(sub_path, value)
201
+ else:
202
+ if not isinstance(complex_attribute, BaseModel):
203
+ raise SCIMException(Error.make_invalid_path_error())
204
+ self.match_attribute(sub_path, complex_attribute)
205
+
206
+ def match_attribute(self, attribute: str, model: BaseModel):
207
+ self.init_return(model, attribute, None, self.value)
208
+ self.operation(model, attribute, self.value)
209
+
210
+ def call_on_root(self, model: Resource):
211
+ if not self.OPERATE_ON_ROOT:
212
+ raise SCIMException(Error.make_no_target_error())
213
+ if not isinstance(self.value, dict):
214
+ raise SCIMException(Error.make_invalid_value_error())
215
+ for k, v in self.value.items():
216
+ ext, scim_name = handle_extension(model, k)
217
+ if ext == model:
218
+ self.operation(model, scim_name, v)
219
+ else:
220
+ type(self)(self.path, v)(ext)
221
+
222
+
223
+ class AddOperator(Operator):
224
+ """The implementation for the PATCH "add" operator."""
225
+
226
+ @classmethod
227
+ def operation(cls, model: BaseModel, attribute: str, value: any):
228
+ alias = get_by_alias(model, attribute)
229
+ if is_multi_valued(model, alias) and isinstance(value, list):
230
+ for v in value:
231
+ cls.operation(model, attribute, v)
232
+ return
233
+
234
+ existing_value = getattr(model, alias)
235
+ new_value = parse_new_value(model, alias, value)
236
+ if new_value == existing_value:
237
+ return
238
+
239
+ if model.get_field_annotation(alias, Mutability) == Mutability.read_only:
240
+ raise SCIMException(Error.make_mutability_error())
241
+
242
+ if is_multi_valued(model, alias):
243
+ if getattr(model, alias) is None:
244
+ setattr(model, alias, [])
245
+ if getattr(new_value, "primary", False):
246
+ for value in getattr(model, alias):
247
+ value.primary = False
248
+ getattr(model, alias).append(new_value)
249
+ else:
250
+ if (
251
+ model.get_field_annotation(alias, Required) == Required.true
252
+ and not new_value
253
+ ):
254
+ raise SCIMException(Error.make_invalid_value_error())
255
+ setattr(model, alias, new_value)
256
+
257
+
258
+ class RemoveOperator(Operator):
259
+ """The implementation for the PATCH "remove" operator."""
260
+
261
+ OPERATE_ON_ROOT = False
262
+ REQUIRES_VALUE = False
263
+
264
+ @classmethod
265
+ def operation(cls, model: BaseModel, attribute: str, value: any):
266
+ alias = get_by_alias(model, attribute)
267
+ existing_value = getattr(model, alias)
268
+ if not existing_value:
269
+ return
270
+
271
+ if model.get_field_annotation(alias, Mutability) in (
272
+ Mutability.read_only,
273
+ Mutability.immutable,
274
+ ):
275
+ raise SCIMException(Error.make_mutability_error())
276
+
277
+ if model.get_field_annotation(alias, Required) == Required.true:
278
+ raise SCIMException(Error.make_invalid_value_error())
279
+
280
+ setattr(model, alias, None)
281
+
282
+
283
+ class ReplaceOperator(Operator):
284
+ """The implementation for the PATCH "replace" operator."""
285
+
286
+ @classmethod
287
+ def operation(cls, model: BaseModel, attribute: str, value: any):
288
+ alias = get_by_alias(model, attribute)
289
+ if is_multi_valued(model, alias) and not isinstance(value, list):
290
+ raise SCIMException(Error.make_invalid_value_error())
291
+
292
+ existing_value = getattr(model, alias)
293
+ new_value = parse_new_value(model, alias, value)
294
+ if new_value == existing_value:
295
+ return
296
+
297
+ if model.get_field_annotation(alias, Mutability) == Mutability.read_only:
298
+ raise SCIMException(Error.make_mutability_error())
299
+
300
+ if (
301
+ model.get_field_annotation(alias, Required) == Required.true
302
+ and not new_value
303
+ ):
304
+ raise SCIMException(Error.make_invalid_value_error())
305
+ setattr(model, alias, new_value)
306
+
307
+
308
+ class ResolveResult:
309
+ """A descriptor for the result returned from the "ResolveOperator", used to
310
+ resolve attributes from a model."""
311
+
312
+ def __init__(self):
313
+ self.records = []
314
+ self.result_idx = 0
315
+ self.model = None
316
+ self.attribute = None
317
+ self.sub_attribute = None
318
+
319
+ def add_result(self, model: BaseModel, attribute_name: str):
320
+ """Adds a result to the descriptor."""
321
+ self.records.append((model, attribute_name))
322
+
323
+ def add_result_index(self, model: BaseModel, attribute_name: str, index: int):
324
+ """Adds a result to the descriptor.
325
+
326
+ The resulting attribute is part of a multi-valued attribute
327
+ described by its index.
328
+ """
329
+ self.records.append((model, attribute_name, index))
330
+
331
+ def _evaluate_result(self, record: Tuple[str, str] | Tuple[str, str, int]):
332
+ if len(record) == 2:
333
+ return getattr(*record)
334
+ else:
335
+ return getattr(record[0], record[1])[record[2]]
336
+
337
+ def get_field_annotation(self, annotation_type: Type):
338
+ if not self.model:
339
+ return None
340
+ return self.model.get_field_annotation(self.attribute, annotation_type)
341
+
342
+ def get_values(self):
343
+ if not self.records:
344
+ return None
345
+ if len(self.records) == 1:
346
+ return self._evaluate_result(self.records[0])
347
+ return [self._evaluate_result(record) for record in self.records]
348
+
349
+
350
+ class ResolveOperator(Operator):
351
+ """An operator that resolves attribute values from a model."""
352
+
353
+ REQUIRES_VALUE = False
354
+ RETURNS_VALUE = True
355
+
356
+ def __init__(self, path: Optional[str]):
357
+ super().__init__(path, ResolveResult())
358
+
359
+ def do_return(self):
360
+ ret = self.value
361
+ self.value = ResolveResult()
362
+ return ret
363
+
364
+ @classmethod
365
+ def init_return(
366
+ cls,
367
+ model: BaseModel,
368
+ attribute: str,
369
+ sub_attribute: Optional[str],
370
+ value: ResolveResult,
371
+ ):
372
+ alias = get_by_alias(model, attribute)
373
+ value.model = model
374
+ value.attribute = alias
375
+ value.sub_attribute = sub_attribute
376
+ if (
377
+ model.get_field_annotation(alias, Mutability) == Mutability.write_only
378
+ or model.get_field_annotation(alias, Returned) == Returned.never
379
+ ):
380
+ raise SCIMException(Error.make_sensitive_error())
381
+
382
+ @classmethod
383
+ def operation(
384
+ cls, model: BaseModel, attribute: str, value: any, index: Optional[int] = None
385
+ ):
386
+ alias = get_by_alias(model, attribute)
387
+ if index is None:
388
+ value.add_result(model, alias)
389
+ else:
390
+ value.add_result_index(model, alias, index)
391
+
392
+
393
+ class ResolveSortOperator(ResolveOperator):
394
+ """
395
+ Helper-Operator that implements sorting, according to RFC 7644, Section 3.4.2.3
396
+ The ResolveResult returned by this operator contains at most 1 value, according to
397
+ the specification:
398
+ "[...] if it's a multi-valued attribute, resources are sorted by the value of the
399
+ primary attribute (see Section 2.4 of [RFC7643]), if any, or else the first value
400
+ in the list, if any. [...]"
401
+
402
+ Since a Query can result in resources of different types, sorting by an attribute
403
+ that is not defined for a certain resource type does not result in an error. No
404
+ value is returned and the resource is sorted as if the attribute on the resource
405
+ is not set.
406
+ """
407
+
408
+ def __init__(self, path: Optional[str]):
409
+ super().__init__(path)
410
+
411
+ def alias_forbidden(self, model: BaseModel, alias: Optional[str]) -> bool:
412
+ return (
413
+ not alias
414
+ or model.get_field_annotation(alias, Mutability) == Mutability.write_only
415
+ or model.get_field_annotation(alias, Returned) == Returned.never
416
+ )
417
+
418
+ def set_value_case_exact(self, value: any, case_exact: CaseExact):
419
+ if isinstance(value, str) and case_exact == CaseExact.false:
420
+ value = value.lower()
421
+ self.value = value
422
+
423
+ def evaluate_value_for_complex(self, model: BaseModel, alias: str):
424
+ sub_attribute_alias = get_by_alias(model, alias, True)
425
+ if self.alias_forbidden(model, sub_attribute_alias):
426
+ return
427
+ case_exact = model.get_field_annotation(sub_attribute_alias, CaseExact)
428
+ sub_attribute_value = getattr(model, sub_attribute_alias)
429
+ self.set_value_case_exact(sub_attribute_value, case_exact)
430
+
431
+ def __call__(self, model: BaseModel):
432
+ self.value = None
433
+ if self.path:
434
+ model, path = self.parse_path(model)
435
+ if not path:
436
+ return
437
+ sub_attribute = path["sub_attribute"] or "value"
438
+
439
+ attribute_alias = get_by_alias(model, path["attribute"], True)
440
+ if self.alias_forbidden(model, attribute_alias):
441
+ return
442
+
443
+ case_exact = model.get_field_annotation(attribute_alias, CaseExact)
444
+ attribute_value = getattr(model, attribute_alias)
445
+ if not attribute_value:
446
+ return
447
+
448
+ if isinstance(attribute_value, list):
449
+ if path["condition"]:
450
+ token_stream = SCIMLexer().tokenize(path["condition"])
451
+ condition = SCIMParser().parse(token_stream)
452
+ attribute_value = [
453
+ model
454
+ for model in attribute_value
455
+ if evaluate_filter(model, condition)
456
+ ]
457
+ candidate = self.select_candidate(attribute_value)
458
+ if isinstance(candidate, BaseModel):
459
+ self.evaluate_value_for_complex(candidate, sub_attribute)
460
+ else:
461
+ self.set_value_case_exact(candidate, case_exact)
462
+ elif isinstance(attribute_value, BaseModel):
463
+ if not path["condition"]:
464
+ self.evaluate_value_for_complex(attribute_value, sub_attribute)
465
+ else:
466
+ if not path["condition"] and not path["sub_attribute"]:
467
+ self.set_value_case_exact(attribute_value, case_exact)
468
+ return self.value
469
+
470
+ def select_candidate(self, values: List[any]) -> Tuple[Optional[any], int]:
471
+ """Selects a viable candidate from a list of possible values."""
472
+ for value in values:
473
+ primary = getattr(value, "primary", False)
474
+ if primary:
475
+ return value
476
+ if values:
477
+ return values[0]
478
+ return None