AccessGate 0.2.0a1__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.
access_gate/abac.py ADDED
@@ -0,0 +1,1081 @@
1
+ """Safe, declarative attribute-based access control primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable, Collection, Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from importlib.resources import files
11
+ from inspect import isawaitable
12
+ from math import isfinite, isnan
13
+ from types import MappingProxyType
14
+ from typing import Protocol, cast, runtime_checkable
15
+
16
+ from .policies import MissEffect
17
+ from .types import AuthorizationRequest, Decision, Effect, _freeze_value
18
+
19
+ POLICY_SCHEMA_VERSION = "access_gate.policy.v1"
20
+ _OPERATOR_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
21
+ MAX_POLICY_DOCUMENT_CHARS = 1_000_000
22
+ MAX_POLICY_CONDITION_DEPTH = 64
23
+ MAX_POLICY_CONDITION_NODES = 4_096
24
+ MAX_POLICY_LITERAL_NODES = 100_000
25
+ MAX_COLLECTION_ITEMS = 10_000
26
+ MAX_FALLBACK_COMPARISONS = 100_000
27
+
28
+
29
+ class PolicySchemaError(ValueError):
30
+ """Raised when a serialized policy is malformed or unsupported."""
31
+
32
+
33
+ def _thaw_policy_value(value: object) -> object:
34
+ if isinstance(value, Mapping):
35
+ return {key: _thaw_policy_value(child) for key, child in value.items()}
36
+ if isinstance(value, tuple | list):
37
+ return [_thaw_policy_value(child) for child in value]
38
+ if isinstance(value, frozenset | set):
39
+ return value
40
+ return value
41
+
42
+
43
+ class AttributeSource(str, Enum):
44
+ """A namespace from which an ABAC operand is resolved."""
45
+
46
+ SUBJECT = "subject"
47
+ RESOURCE = "resource"
48
+ ACTION = "action"
49
+ CONTEXT = "context"
50
+
51
+
52
+ class ConditionState(str, Enum):
53
+ """Tri-state condition outcome used to preserve fail-closed semantics."""
54
+
55
+ MATCH = "match"
56
+ NO_MATCH = "no_match"
57
+ INDETERMINATE = "indeterminate"
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class ConditionResult:
62
+ """A condition outcome that never contains resolved attribute values."""
63
+
64
+ state: ConditionState
65
+ reason: str
66
+ metadata: Mapping[str, object] = field(default_factory=dict)
67
+
68
+ @property
69
+ def matched(self) -> bool:
70
+ return self.state is ConditionState.MATCH
71
+
72
+
73
+ class _Missing:
74
+ __slots__ = ()
75
+
76
+
77
+ MISSING = _Missing()
78
+ _UNSET_OPERAND = object()
79
+ ResolvedValue = object | _Missing
80
+ OperatorFunction = Callable[[object, object], bool]
81
+
82
+
83
+ def _is_number(value: object) -> bool:
84
+ return isinstance(value, int | float) and not isinstance(value, bool)
85
+
86
+
87
+ def _equal(left: object, right: object) -> bool:
88
+ if _is_number(left) and _is_number(right):
89
+ return bool(left == right)
90
+ if type(left) is not type(right):
91
+ return False
92
+ return bool(left == right)
93
+
94
+
95
+ def _collection(value: object) -> Collection[object] | Mapping[object, object] | None:
96
+ if isinstance(value, str | bytes | bytearray):
97
+ return None
98
+ if isinstance(value, Mapping):
99
+ return cast(Mapping[object, object], value)
100
+ if isinstance(value, Collection):
101
+ return cast(Collection[object], value)
102
+ return None
103
+
104
+
105
+ def _required_collection(
106
+ value: object,
107
+ *,
108
+ operand: str,
109
+ ) -> Collection[object] | Mapping[object, object]:
110
+ values = _collection(value)
111
+ if values is None:
112
+ raise TypeError(f"{operand} operand must be a non-string collection.")
113
+ if len(values) > MAX_COLLECTION_ITEMS:
114
+ raise ValueError(f"{operand} operand exceeds the collection size limit.")
115
+ return values
116
+
117
+
118
+ _UNHASHABLE = object()
119
+
120
+
121
+ def _exact_key(value: object) -> object:
122
+ if isinstance(value, bool):
123
+ return ("bool", value)
124
+ if isinstance(value, int | float):
125
+ if isinstance(value, float) and isnan(value):
126
+ return _UNHASHABLE
127
+ return ("number", value)
128
+ if isinstance(value, str):
129
+ return ("str", value)
130
+ if isinstance(value, bytes):
131
+ return ("bytes", value)
132
+ if value is None:
133
+ return ("none", None)
134
+ return _UNHASHABLE
135
+
136
+
137
+ def _exact_key_set(values: Collection[object] | Mapping[object, object]) -> set[object] | None:
138
+ keys: set[object] = set()
139
+ for value in values:
140
+ key = _exact_key(value)
141
+ if key is _UNHASHABLE:
142
+ return None
143
+ keys.add(key)
144
+ return keys
145
+
146
+
147
+ def _contains(container: object, member: object) -> bool:
148
+ values = _required_collection(container, operand="collection")
149
+ return any(_equal(candidate, member) for candidate in values)
150
+
151
+
152
+ def _intersects(left: object, right: object) -> bool:
153
+ left_values = _required_collection(left, operand="left")
154
+ right_values = _required_collection(right, operand="right")
155
+ left_keys = _exact_key_set(left_values)
156
+ right_keys = _exact_key_set(right_values)
157
+ if left_keys is not None and right_keys is not None:
158
+ return not left_keys.isdisjoint(right_keys)
159
+ if len(left_values) * len(right_values) > MAX_FALLBACK_COMPARISONS:
160
+ raise ValueError("Collection comparison exceeds the safe comparison limit.")
161
+ return any(_equal(a, b) for a in left_values for b in right_values)
162
+
163
+
164
+ def _subset_of(left: object, right: object) -> bool:
165
+ left_values = _required_collection(left, operand="left")
166
+ right_values = _required_collection(right, operand="right")
167
+ left_keys = _exact_key_set(left_values)
168
+ right_keys = _exact_key_set(right_values)
169
+ if left_keys is not None and right_keys is not None:
170
+ return left_keys <= right_keys
171
+ if len(left_values) * len(right_values) > MAX_FALLBACK_COMPARISONS:
172
+ raise ValueError("Collection comparison exceeds the safe comparison limit.")
173
+ return all(any(_equal(item, candidate) for candidate in right_values) for item in left_values)
174
+
175
+
176
+ def _ordered(left: object, right: object, operation: str) -> bool:
177
+ if not (_is_number(left) and _is_number(right)):
178
+ return False
179
+ left_number = cast(float, left)
180
+ right_number = cast(float, right)
181
+ if operation == "gt":
182
+ return left_number > right_number
183
+ if operation == "gte":
184
+ return left_number >= right_number
185
+ if operation == "lt":
186
+ return left_number < right_number
187
+ return left_number <= right_number
188
+
189
+
190
+ def _builtins() -> dict[str, OperatorFunction]:
191
+ return {
192
+ "eq": _equal,
193
+ "neq": lambda left, right: not _equal(left, right),
194
+ "in": lambda left, right: _contains(right, left),
195
+ "not_in": lambda left, right: not _contains(right, left),
196
+ "contains": _contains,
197
+ "not_contains": lambda left, right: not _contains(left, right),
198
+ "intersects": _intersects,
199
+ "subset_of": _subset_of,
200
+ "superset_of": lambda left, right: _subset_of(right, left),
201
+ "gt": lambda left, right: _ordered(left, right, "gt"),
202
+ "gte": lambda left, right: _ordered(left, right, "gte"),
203
+ "lt": lambda left, right: _ordered(left, right, "lt"),
204
+ "lte": lambda left, right: _ordered(left, right, "lte"),
205
+ }
206
+
207
+
208
+ class OperatorRegistry:
209
+ """A named collection of comparison callables.
210
+
211
+ Registries are copied into each policy, so application customization cannot
212
+ mutate a process-wide global operator table.
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ operators: Mapping[str, OperatorFunction] | None = None,
218
+ *,
219
+ include_builtins: bool = True,
220
+ frozen: bool = False,
221
+ ) -> None:
222
+ self._frozen = False
223
+ self._operators = _builtins() if include_builtins else {}
224
+ if operators is not None:
225
+ for name, evaluator in operators.items():
226
+ self.register(name, evaluator, replace=True)
227
+ self._frozen = frozen
228
+
229
+ def register(
230
+ self,
231
+ name: str,
232
+ evaluator: OperatorFunction,
233
+ *,
234
+ replace: bool = False,
235
+ ) -> None:
236
+ if self._frozen:
237
+ raise TypeError("A frozen operator registry cannot be modified.")
238
+ if not _OPERATOR_NAME.fullmatch(name):
239
+ raise ValueError("Operator names must use lowercase letters, digits, and underscores.")
240
+ if name in {"exists", "not_exists"}:
241
+ raise ValueError(f"{name!r} is reserved by the condition evaluator.")
242
+ if name in self._operators and not replace:
243
+ raise ValueError(f"Operator {name!r} is already registered.")
244
+ if not callable(evaluator):
245
+ raise TypeError("Operator evaluators must be callable.")
246
+ self._operators[name] = evaluator
247
+
248
+ def contains(self, name: str) -> bool:
249
+ return name in {"exists", "not_exists"} or name in self._operators
250
+
251
+ @property
252
+ def names(self) -> tuple[str, ...]:
253
+ return tuple(sorted((*self._operators, "exists", "not_exists")))
254
+
255
+ @property
256
+ def frozen(self) -> bool:
257
+ return self._frozen
258
+
259
+ def evaluate(self, name: str, left: object, right: object) -> bool:
260
+ try:
261
+ evaluator = self._operators[name]
262
+ except KeyError as error:
263
+ raise KeyError(f"Unknown ABAC operator: {name}") from error
264
+ result = evaluator(left, right)
265
+ if isawaitable(result):
266
+ close = getattr(result, "close", None)
267
+ if callable(close):
268
+ close()
269
+ raise TypeError("ABAC operators must be synchronous.")
270
+ if not isinstance(result, bool):
271
+ raise TypeError("ABAC operators must return a boolean.")
272
+ return result
273
+
274
+ def copy(self, *, frozen: bool = False) -> OperatorRegistry:
275
+ return OperatorRegistry(self._operators, include_builtins=False, frozen=frozen)
276
+
277
+
278
+ @runtime_checkable
279
+ class Operand(Protocol):
280
+ def resolve(self, request: AuthorizationRequest) -> ResolvedValue: ...
281
+
282
+ def to_dict(self) -> dict[str, object]: ...
283
+
284
+ @property
285
+ def label(self) -> str: ...
286
+
287
+
288
+ @dataclass(frozen=True, slots=True)
289
+ class Literal:
290
+ """A literal condition operand."""
291
+
292
+ value: object
293
+
294
+ def __post_init__(self) -> None:
295
+ object.__setattr__(
296
+ self,
297
+ "value",
298
+ _freeze_value(self.value, field_name="literal.value"),
299
+ )
300
+
301
+ def resolve(self, request: AuthorizationRequest) -> ResolvedValue:
302
+ del request
303
+ return self.value
304
+
305
+ def to_dict(self) -> dict[str, object]:
306
+ return {"value": _thaw_policy_value(self.value)}
307
+
308
+ @property
309
+ def label(self) -> str:
310
+ return "literal"
311
+
312
+
313
+ @dataclass(frozen=True, slots=True)
314
+ class AttributeRef:
315
+ """A mapping-only attribute reference; object attribute access is never used."""
316
+
317
+ source: AttributeSource
318
+ path: str
319
+
320
+ def __post_init__(self) -> None:
321
+ if not self.path or any(not part for part in self.path.split(".")):
322
+ raise ValueError("Attribute paths must contain non-empty dot-separated segments.")
323
+
324
+ @property
325
+ def label(self) -> str:
326
+ return f"{self.source.value}.{self.path}"
327
+
328
+ def to_dict(self) -> dict[str, object]:
329
+ return {"source": self.source.value, "path": self.path}
330
+
331
+ def resolve(self, request: AuthorizationRequest) -> ResolvedValue:
332
+ root: object
333
+ if self.source is AttributeSource.SUBJECT:
334
+ root = {
335
+ "id": request.subject.identifier,
336
+ "authenticated": request.subject.authenticated,
337
+ "roles": request.subject.roles,
338
+ "attributes": request.subject.attributes,
339
+ }
340
+ elif self.source is AttributeSource.RESOURCE:
341
+ if request.resource is None:
342
+ return MISSING
343
+ root = {
344
+ "id": request.resource.identifier,
345
+ "kind": request.resource.kind,
346
+ "attributes": request.resource.attributes,
347
+ }
348
+ elif self.source is AttributeSource.ACTION:
349
+ root = {"name": request.action, "attributes": request.action_attributes}
350
+ else:
351
+ root = request.context
352
+
353
+ current: object = root
354
+ for segment in self.path.split("."):
355
+ if not isinstance(current, Mapping) or segment not in current:
356
+ return MISSING
357
+ current = cast(Mapping[str, object], current)[segment]
358
+ return current
359
+
360
+ def compare(self, operator: str, other: Operand | object = _UNSET_OPERAND) -> Comparison:
361
+ right = None if other is _UNSET_OPERAND else _operand(other)
362
+ return Comparison(self, operator, right)
363
+
364
+ def equals(self, other: Operand | object) -> Comparison:
365
+ return self.compare("eq", other)
366
+
367
+ def not_equals(self, other: Operand | object) -> Comparison:
368
+ return self.compare("neq", other)
369
+
370
+ def is_in(self, other: Operand | object) -> Comparison:
371
+ return self.compare("in", other)
372
+
373
+ def is_not_in(self, other: Operand | object) -> Comparison:
374
+ return self.compare("not_in", other)
375
+
376
+ def contains(self, other: Operand | object) -> Comparison:
377
+ return self.compare("contains", other)
378
+
379
+ def does_not_contain(self, other: Operand | object) -> Comparison:
380
+ return self.compare("not_contains", other)
381
+
382
+ def intersects(self, other: Operand | object) -> Comparison:
383
+ return self.compare("intersects", other)
384
+
385
+ def subset_of(self, other: Operand | object) -> Comparison:
386
+ return self.compare("subset_of", other)
387
+
388
+ def superset_of(self, other: Operand | object) -> Comparison:
389
+ return self.compare("superset_of", other)
390
+
391
+ def greater_than(self, other: Operand | object) -> Comparison:
392
+ return self.compare("gt", other)
393
+
394
+ def greater_than_or_equal(self, other: Operand | object) -> Comparison:
395
+ return self.compare("gte", other)
396
+
397
+ def less_than(self, other: Operand | object) -> Comparison:
398
+ return self.compare("lt", other)
399
+
400
+ def less_than_or_equal(self, other: Operand | object) -> Comparison:
401
+ return self.compare("lte", other)
402
+
403
+ def exists(self) -> Comparison:
404
+ return self.compare("exists")
405
+
406
+ def does_not_exist(self) -> Comparison:
407
+ return self.compare("not_exists")
408
+
409
+
410
+ def SubjectAttribute(path: str) -> AttributeRef:
411
+ return AttributeRef(AttributeSource.SUBJECT, f"attributes.{path}")
412
+
413
+
414
+ def ResourceAttribute(path: str) -> AttributeRef:
415
+ return AttributeRef(AttributeSource.RESOURCE, f"attributes.{path}")
416
+
417
+
418
+ def ActionAttribute(path: str) -> AttributeRef:
419
+ return AttributeRef(AttributeSource.ACTION, f"attributes.{path}")
420
+
421
+
422
+ def ContextAttribute(path: str) -> AttributeRef:
423
+ return AttributeRef(AttributeSource.CONTEXT, path)
424
+
425
+
426
+ class Condition(Protocol):
427
+ def evaluate(
428
+ self,
429
+ request: AuthorizationRequest,
430
+ operators: OperatorRegistry,
431
+ ) -> ConditionResult: ...
432
+
433
+ def to_dict(self) -> dict[str, object]: ...
434
+
435
+
436
+ def _operand(value: Operand | object) -> Operand:
437
+ if isinstance(value, Operand):
438
+ return value
439
+ return Literal(value)
440
+
441
+
442
+ def _result(
443
+ state: ConditionState,
444
+ reason: str,
445
+ **metadata: object,
446
+ ) -> ConditionResult:
447
+ return ConditionResult(state, reason, MappingProxyType(metadata))
448
+
449
+
450
+ @dataclass(frozen=True, slots=True)
451
+ class Comparison:
452
+ """Compare two operands using a named, registry-controlled operator."""
453
+
454
+ left: Operand
455
+ operator: str
456
+ right: Operand | None = None
457
+
458
+ def __post_init__(self) -> None:
459
+ if not _OPERATOR_NAME.fullmatch(self.operator):
460
+ raise ValueError("Operator names must use lowercase letters, digits, and underscores.")
461
+ if self.operator not in {"exists", "not_exists"} and self.right is None:
462
+ raise ValueError(f"Operator {self.operator!r} requires a right operand.")
463
+ if self.operator in {"exists", "not_exists"} and self.right is not None:
464
+ raise ValueError(f"Operator {self.operator!r} does not accept a right operand.")
465
+
466
+ def evaluate(
467
+ self,
468
+ request: AuthorizationRequest,
469
+ operators: OperatorRegistry,
470
+ ) -> ConditionResult:
471
+ left_value = self.left.resolve(request)
472
+ metadata = {"operator": self.operator, "left": self.left.label}
473
+ if self.right is not None:
474
+ metadata["right"] = self.right.label
475
+
476
+ if self.operator == "exists":
477
+ state = ConditionState.NO_MATCH if left_value is MISSING else ConditionState.MATCH
478
+ return _result(state, "Attribute existence was evaluated.", **metadata)
479
+ if self.operator == "not_exists":
480
+ state = ConditionState.MATCH if left_value is MISSING else ConditionState.NO_MATCH
481
+ return _result(state, "Attribute absence was evaluated.", **metadata)
482
+ if left_value is MISSING:
483
+ return _result(
484
+ ConditionState.INDETERMINATE,
485
+ "A required left attribute is missing.",
486
+ issue="missing_left_operand",
487
+ **metadata,
488
+ )
489
+
490
+ if self.right is None: # Defensive against corrupted/deserialized objects.
491
+ return _result(
492
+ ConditionState.INDETERMINATE,
493
+ "The condition is missing a required operand.",
494
+ issue="missing_configured_operand",
495
+ **metadata,
496
+ )
497
+ right_value = self.right.resolve(request)
498
+ if right_value is MISSING:
499
+ return _result(
500
+ ConditionState.INDETERMINATE,
501
+ "A required right attribute is missing.",
502
+ issue="missing_right_operand",
503
+ **metadata,
504
+ )
505
+ if not operators.contains(self.operator):
506
+ return _result(
507
+ ConditionState.INDETERMINATE,
508
+ "The condition references an unknown operator.",
509
+ issue="unknown_operator",
510
+ **metadata,
511
+ )
512
+ try:
513
+ matched = operators.evaluate(self.operator, left_value, right_value)
514
+ except Exception as error:
515
+ return _result(
516
+ ConditionState.INDETERMINATE,
517
+ "The condition operator failed.",
518
+ issue="operator_error",
519
+ error_type=type(error).__name__,
520
+ **metadata,
521
+ )
522
+ state = ConditionState.MATCH if matched else ConditionState.NO_MATCH
523
+ return _result(state, "Attribute comparison was evaluated.", **metadata)
524
+
525
+ def to_dict(self) -> dict[str, object]:
526
+ document: dict[str, object] = {
527
+ "type": "comparison",
528
+ "operator": self.operator,
529
+ "left": self.left.to_dict(),
530
+ }
531
+ if self.right is not None:
532
+ document["right"] = self.right.to_dict()
533
+ return document
534
+
535
+
536
+ @dataclass(frozen=True, slots=True, init=False)
537
+ class AllOf:
538
+ conditions: tuple[Condition, ...]
539
+
540
+ def __init__(self, *conditions: Condition) -> None:
541
+ if not conditions:
542
+ raise ValueError("AllOf requires at least one condition.")
543
+ object.__setattr__(self, "conditions", tuple(conditions))
544
+
545
+ def evaluate(
546
+ self,
547
+ request: AuthorizationRequest,
548
+ operators: OperatorRegistry,
549
+ ) -> ConditionResult:
550
+ states: list[str] = []
551
+ indeterminate = False
552
+ for condition in self.conditions:
553
+ result = condition.evaluate(request, operators)
554
+ states.append(result.state.value)
555
+ if result.state is ConditionState.NO_MATCH:
556
+ return _result(
557
+ ConditionState.NO_MATCH,
558
+ "At least one required condition did not match.",
559
+ children=tuple(states),
560
+ )
561
+ if result.state is ConditionState.INDETERMINATE:
562
+ indeterminate = True
563
+ if indeterminate:
564
+ return _result(
565
+ ConditionState.INDETERMINATE,
566
+ "A required condition was indeterminate.",
567
+ children=tuple(states),
568
+ )
569
+ return _result(
570
+ ConditionState.MATCH,
571
+ "All required conditions matched.",
572
+ children=tuple(states),
573
+ )
574
+
575
+ def to_dict(self) -> dict[str, object]:
576
+ return {"type": "all_of", "conditions": [item.to_dict() for item in self.conditions]}
577
+
578
+
579
+ @dataclass(frozen=True, slots=True, init=False)
580
+ class AnyOf:
581
+ conditions: tuple[Condition, ...]
582
+
583
+ def __init__(self, *conditions: Condition) -> None:
584
+ if not conditions:
585
+ raise ValueError("AnyOf requires at least one condition.")
586
+ object.__setattr__(self, "conditions", tuple(conditions))
587
+
588
+ def evaluate(
589
+ self,
590
+ request: AuthorizationRequest,
591
+ operators: OperatorRegistry,
592
+ ) -> ConditionResult:
593
+ states: list[str] = []
594
+ indeterminate = False
595
+ for condition in self.conditions:
596
+ result = condition.evaluate(request, operators)
597
+ states.append(result.state.value)
598
+ if result.state is ConditionState.MATCH:
599
+ return _result(
600
+ ConditionState.MATCH,
601
+ "At least one condition matched.",
602
+ children=tuple(states),
603
+ )
604
+ if result.state is ConditionState.INDETERMINATE:
605
+ indeterminate = True
606
+ if indeterminate:
607
+ return _result(
608
+ ConditionState.INDETERMINATE,
609
+ "An alternative condition was indeterminate.",
610
+ children=tuple(states),
611
+ )
612
+ return _result(
613
+ ConditionState.NO_MATCH,
614
+ "No alternative condition matched.",
615
+ children=tuple(states),
616
+ )
617
+
618
+ def to_dict(self) -> dict[str, object]:
619
+ return {"type": "any_of", "conditions": [item.to_dict() for item in self.conditions]}
620
+
621
+
622
+ @dataclass(frozen=True, slots=True)
623
+ class Not:
624
+ condition: Condition
625
+
626
+ def evaluate(
627
+ self,
628
+ request: AuthorizationRequest,
629
+ operators: OperatorRegistry,
630
+ ) -> ConditionResult:
631
+ child = self.condition.evaluate(request, operators)
632
+ if child.state is ConditionState.INDETERMINATE:
633
+ return _result(
634
+ ConditionState.INDETERMINATE,
635
+ "A negated condition was indeterminate.",
636
+ child=child.state.value,
637
+ )
638
+ state = (
639
+ ConditionState.NO_MATCH
640
+ if child.state is ConditionState.MATCH
641
+ else ConditionState.MATCH
642
+ )
643
+ return _result(state, "A condition result was negated.", child=child.state.value)
644
+
645
+ def to_dict(self) -> dict[str, object]:
646
+ return {"type": "not", "condition": self.condition.to_dict()}
647
+
648
+
649
+ @dataclass(frozen=True, slots=True, init=False)
650
+ class AttributePolicy:
651
+ """A declarative ABAC policy with explicit no-match and error behaviour."""
652
+
653
+ condition: Condition
654
+ actions: frozenset[str] | None
655
+ match_effect: Effect
656
+ on_no_match: MissEffect
657
+ on_indeterminate: MissEffect
658
+ name: str
659
+ operators: OperatorRegistry
660
+ include_policy_document_in_metadata: bool
661
+ _condition_document: Mapping[str, object]
662
+
663
+ def __init__(
664
+ self,
665
+ condition: Condition,
666
+ *,
667
+ actions: Collection[str] | None = None,
668
+ match_effect: Effect = Effect.ALLOW,
669
+ on_no_match: MissEffect | None = None,
670
+ on_indeterminate: MissEffect = MissEffect.DENY,
671
+ name: str = "attribute",
672
+ operators: OperatorRegistry | None = None,
673
+ include_policy_document_in_metadata: bool = False,
674
+ ) -> None:
675
+ if on_no_match is None:
676
+ on_no_match = MissEffect.DENY if match_effect is Effect.ALLOW else MissEffect.ABSTAIN
677
+ if not isinstance(match_effect, Effect):
678
+ raise TypeError("match_effect must be an Effect value.")
679
+ if not isinstance(on_no_match, MissEffect):
680
+ raise TypeError("on_no_match must be a MissEffect value.")
681
+ if not isinstance(on_indeterminate, MissEffect):
682
+ raise TypeError("on_indeterminate must be a MissEffect value.")
683
+ if not isinstance(name, str) or not name:
684
+ raise TypeError("policy name must be a non-empty string.")
685
+ if not isinstance(include_policy_document_in_metadata, bool):
686
+ raise TypeError("include_policy_document_in_metadata must be a boolean.")
687
+ if isinstance(actions, str):
688
+ raise TypeError("actions must be a collection of names, not a string.")
689
+ normalized_actions = None if actions is None else frozenset(actions)
690
+ if normalized_actions is not None:
691
+ if not normalized_actions:
692
+ raise ValueError("actions must contain at least one action when provided.")
693
+ if len(normalized_actions) > MAX_POLICY_CONDITION_NODES:
694
+ raise ValueError("actions exceeds the maximum item count.")
695
+ if not all(isinstance(action, str) and action for action in normalized_actions):
696
+ raise TypeError("actions must contain only non-empty strings.")
697
+ try:
698
+ condition_document = condition.to_dict()
699
+ except RecursionError as error:
700
+ raise PolicySchemaError("condition exceeds the maximum nesting depth.") from error
701
+ if not isinstance(condition_document, Mapping):
702
+ raise TypeError("condition.to_dict() must return a mapping.")
703
+ _validate_condition_complexity(condition_document)
704
+ frozen_document = _freeze_value(
705
+ condition_document,
706
+ field_name="condition document",
707
+ )
708
+ if not isinstance(frozen_document, Mapping):
709
+ raise TypeError("condition.to_dict() must return a mapping.")
710
+ object.__setattr__(self, "condition", condition)
711
+ object.__setattr__(self, "actions", normalized_actions)
712
+ object.__setattr__(self, "match_effect", match_effect)
713
+ object.__setattr__(self, "on_no_match", on_no_match)
714
+ object.__setattr__(self, "on_indeterminate", on_indeterminate)
715
+ object.__setattr__(self, "name", name)
716
+ object.__setattr__(self, "operators", (operators or OperatorRegistry()).copy(frozen=True))
717
+ object.__setattr__(
718
+ self,
719
+ "include_policy_document_in_metadata",
720
+ include_policy_document_in_metadata,
721
+ )
722
+ object.__setattr__(self, "_condition_document", frozen_document)
723
+
724
+ def evaluate(self, request: AuthorizationRequest) -> Decision | None:
725
+ if self.actions is not None and request.action not in self.actions:
726
+ return None
727
+ result = self.condition.evaluate(request, self.operators)
728
+ if isawaitable(result):
729
+ close = getattr(result, "close", None)
730
+ if callable(close):
731
+ close()
732
+ raise TypeError("ABAC conditions must be synchronous.")
733
+ if not isinstance(result, ConditionResult):
734
+ raise TypeError("ABAC conditions must return a ConditionResult.")
735
+ metadata: dict[str, object] = {
736
+ "condition_state": result.state.value,
737
+ "evaluation": dict(result.metadata),
738
+ }
739
+ if self.include_policy_document_in_metadata:
740
+ metadata["condition"] = self._condition_document
741
+ if result.state is ConditionState.INDETERMINATE:
742
+ if self.on_indeterminate is MissEffect.ABSTAIN:
743
+ return None
744
+ return Decision.deny(
745
+ "The attribute condition could not be safely evaluated.",
746
+ policy=self.name,
747
+ metadata=metadata,
748
+ )
749
+ if result.state is ConditionState.NO_MATCH:
750
+ if self.on_no_match is MissEffect.ABSTAIN:
751
+ return None
752
+ return Decision.deny(
753
+ "The attribute condition did not match.",
754
+ policy=self.name,
755
+ metadata=metadata,
756
+ )
757
+ if self.match_effect is Effect.DENY:
758
+ return Decision.deny(
759
+ "The attribute condition matched a deny rule.",
760
+ policy=self.name,
761
+ metadata=metadata,
762
+ )
763
+ return Decision.allow(
764
+ "The attribute condition matched.",
765
+ policy=self.name,
766
+ metadata=metadata,
767
+ )
768
+
769
+ def to_dict(self) -> dict[str, object]:
770
+ condition = _thaw_policy_value(self._condition_document)
771
+ if not isinstance(condition, dict): # pragma: no cover - constructor invariant
772
+ raise TypeError("The cached condition document is invalid.")
773
+ return {
774
+ "schema_version": POLICY_SCHEMA_VERSION,
775
+ "type": "attribute_policy",
776
+ "name": self.name,
777
+ "actions": None if self.actions is None else sorted(self.actions),
778
+ "match_effect": self.match_effect.value,
779
+ "on_no_match": self.on_no_match.value,
780
+ "on_indeterminate": self.on_indeterminate.value,
781
+ "condition": condition,
782
+ }
783
+
784
+ @classmethod
785
+ def from_dict(
786
+ cls,
787
+ document: Mapping[str, object],
788
+ *,
789
+ operators: OperatorRegistry | None = None,
790
+ ) -> AttributePolicy:
791
+ return attribute_policy_from_dict(document, operators=operators)
792
+
793
+
794
+ def _mapping(value: object, field_name: str) -> Mapping[str, object]:
795
+ if not isinstance(value, Mapping) or not all(isinstance(key, str) for key in value):
796
+ raise PolicySchemaError(f"{field_name} must be an object with string keys.")
797
+ return cast(Mapping[str, object], value)
798
+
799
+
800
+ def _string(value: object, field_name: str) -> str:
801
+ if not isinstance(value, str) or not value:
802
+ raise PolicySchemaError(f"{field_name} must be a non-empty string.")
803
+ return value
804
+
805
+
806
+ def _check_keys(
807
+ document: Mapping[str, object],
808
+ *,
809
+ allowed: Collection[str],
810
+ field_name: str,
811
+ ) -> None:
812
+ extras = sorted(set(document) - set(allowed))
813
+ if extras:
814
+ raise PolicySchemaError(f"{field_name} contains unknown fields: {', '.join(extras)}")
815
+
816
+
817
+ def _validate_condition_complexity(condition: Mapping[str, object]) -> None:
818
+ stack: list[tuple[object, int]] = [(condition, 1)]
819
+ nodes = 0
820
+ while stack:
821
+ current, depth = stack.pop()
822
+ nodes += 1
823
+ if nodes > MAX_POLICY_CONDITION_NODES:
824
+ raise PolicySchemaError("condition exceeds the maximum node count.")
825
+ if depth > MAX_POLICY_CONDITION_DEPTH:
826
+ raise PolicySchemaError("condition exceeds the maximum nesting depth.")
827
+ if not isinstance(current, Mapping):
828
+ continue
829
+ condition_type = current.get("type")
830
+ if condition_type == "not":
831
+ stack.append((current.get("condition"), depth + 1))
832
+ elif condition_type in {"all_of", "any_of"}:
833
+ children = current.get("conditions")
834
+ if isinstance(children, Sequence) and not isinstance(children, str | bytes):
835
+ stack.extend((child, depth + 1) for child in children)
836
+
837
+
838
+ def _validate_serialized_literal(value: object) -> None:
839
+ stack: list[tuple[object, int]] = [(value, 0)]
840
+ seen: set[int] = set()
841
+ nodes = 0
842
+ while stack:
843
+ current, depth = stack.pop()
844
+ nodes += 1
845
+ if nodes > MAX_POLICY_LITERAL_NODES:
846
+ raise PolicySchemaError("literal value exceeds the maximum node count.")
847
+ if depth > MAX_POLICY_CONDITION_DEPTH:
848
+ raise PolicySchemaError("literal value exceeds the maximum nesting depth.")
849
+ if current is None or isinstance(current, str | bool | int):
850
+ continue
851
+ if isinstance(current, float):
852
+ if not isfinite(current):
853
+ raise PolicySchemaError("literal numbers must be finite.")
854
+ continue
855
+ if isinstance(current, Mapping):
856
+ identity = id(current)
857
+ if identity in seen:
858
+ raise PolicySchemaError(
859
+ "literal values must not contain shared or cyclic containers."
860
+ )
861
+ seen.add(identity)
862
+ if not all(isinstance(key, str) for key in current):
863
+ raise PolicySchemaError("literal object keys must be strings.")
864
+ stack.extend((child, depth + 1) for child in current.values())
865
+ continue
866
+ if isinstance(current, list):
867
+ identity = id(current)
868
+ if identity in seen:
869
+ raise PolicySchemaError(
870
+ "literal values must not contain shared or cyclic containers."
871
+ )
872
+ seen.add(identity)
873
+ stack.extend((child, depth + 1) for child in current)
874
+ continue
875
+ raise PolicySchemaError("literal values must contain only JSON-compatible data.")
876
+
877
+
878
+ def operand_from_dict(document: Mapping[str, object]) -> Operand:
879
+ has_source = "source" in document
880
+ has_value = "value" in document
881
+ if has_source == has_value:
882
+ raise PolicySchemaError("An operand must contain exactly one of source or value.")
883
+ if has_value:
884
+ _check_keys(document, allowed={"value"}, field_name="operand")
885
+ value = document["value"]
886
+ _validate_serialized_literal(value)
887
+ try:
888
+ return Literal(value)
889
+ except (TypeError, ValueError) as error:
890
+ raise PolicySchemaError("literal value could not be safely normalized.") from error
891
+ _check_keys(document, allowed={"source", "path"}, field_name="operand")
892
+ try:
893
+ source = AttributeSource(_string(document.get("source"), "operand.source"))
894
+ except ValueError as error:
895
+ raise PolicySchemaError("operand.source is not supported.") from error
896
+ path = _string(document.get("path"), "operand.path")
897
+ try:
898
+ return AttributeRef(source, path)
899
+ except ValueError as error:
900
+ raise PolicySchemaError(str(error)) from error
901
+
902
+
903
+ def condition_from_dict(document: Mapping[str, object]) -> Condition:
904
+ _validate_condition_complexity(document)
905
+ return _condition_from_dict(document)
906
+
907
+
908
+ def _condition_from_dict(document: Mapping[str, object]) -> Condition:
909
+ condition_type = _string(document.get("type"), "condition.type")
910
+ if condition_type == "comparison":
911
+ _check_keys(
912
+ document,
913
+ allowed={"type", "operator", "left", "right"},
914
+ field_name="comparison condition",
915
+ )
916
+ left = operand_from_dict(_mapping(document.get("left"), "condition.left"))
917
+ operator = _string(document.get("operator"), "condition.operator")
918
+ right_value = document.get("right")
919
+ right = (
920
+ None
921
+ if right_value is None
922
+ else operand_from_dict(_mapping(right_value, "condition.right"))
923
+ )
924
+ try:
925
+ return Comparison(left, operator, right)
926
+ except ValueError as error:
927
+ raise PolicySchemaError(str(error)) from error
928
+ if condition_type in {"all_of", "any_of"}:
929
+ _check_keys(
930
+ document,
931
+ allowed={"type", "conditions"},
932
+ field_name=f"{condition_type} condition",
933
+ )
934
+ raw_conditions = document.get("conditions")
935
+ if not isinstance(raw_conditions, Sequence) or isinstance(raw_conditions, str | bytes):
936
+ raise PolicySchemaError("condition.conditions must be an array.")
937
+ children = tuple(
938
+ _condition_from_dict(_mapping(item, "condition.conditions[]"))
939
+ for item in raw_conditions
940
+ )
941
+ try:
942
+ return AllOf(*children) if condition_type == "all_of" else AnyOf(*children)
943
+ except ValueError as error:
944
+ raise PolicySchemaError(str(error)) from error
945
+ if condition_type == "not":
946
+ _check_keys(
947
+ document,
948
+ allowed={"type", "condition"},
949
+ field_name="not condition",
950
+ )
951
+ return Not(_condition_from_dict(_mapping(document.get("condition"), "condition.condition")))
952
+ raise PolicySchemaError(f"Unsupported condition type: {condition_type}")
953
+
954
+
955
+ def _condition_operators(condition: Condition) -> tuple[str, ...]:
956
+ if isinstance(condition, Comparison):
957
+ return (condition.operator,)
958
+ if isinstance(condition, AllOf | AnyOf):
959
+ return tuple(name for child in condition.conditions for name in _condition_operators(child))
960
+ if isinstance(condition, Not):
961
+ return _condition_operators(condition.condition)
962
+ return ()
963
+
964
+
965
+ def attribute_policy_from_dict(
966
+ document: Mapping[str, object],
967
+ *,
968
+ operators: OperatorRegistry | None = None,
969
+ ) -> AttributePolicy:
970
+ _check_keys(
971
+ document,
972
+ allowed={
973
+ "schema_version",
974
+ "type",
975
+ "name",
976
+ "actions",
977
+ "match_effect",
978
+ "on_no_match",
979
+ "on_indeterminate",
980
+ "condition",
981
+ },
982
+ field_name="policy",
983
+ )
984
+ if document.get("schema_version") != POLICY_SCHEMA_VERSION:
985
+ raise PolicySchemaError(f"schema_version must be {POLICY_SCHEMA_VERSION!r}.")
986
+ if document.get("type") != "attribute_policy":
987
+ raise PolicySchemaError("type must be 'attribute_policy'.")
988
+ registry = operators or OperatorRegistry()
989
+ condition_document = _mapping(document.get("condition"), "condition")
990
+ condition = condition_from_dict(condition_document)
991
+ unknown = sorted(
992
+ name for name in _condition_operators(condition) if not registry.contains(name)
993
+ )
994
+ if unknown:
995
+ raise PolicySchemaError(f"Unknown operators: {', '.join(unknown)}")
996
+
997
+ raw_actions = document.get("actions")
998
+ actions: Collection[str] | None
999
+ if raw_actions is None:
1000
+ actions = None
1001
+ elif isinstance(raw_actions, Sequence) and not isinstance(raw_actions, str | bytes):
1002
+ if not raw_actions:
1003
+ raise PolicySchemaError("actions must contain at least one action when provided.")
1004
+ if len(raw_actions) > MAX_POLICY_CONDITION_NODES:
1005
+ raise PolicySchemaError("actions exceeds the maximum item count.")
1006
+ if not all(isinstance(action, str) and action for action in raw_actions):
1007
+ raise PolicySchemaError("actions must contain only non-empty strings.")
1008
+ actions = cast(Sequence[str], raw_actions)
1009
+ if len(set(actions)) != len(actions):
1010
+ raise PolicySchemaError("actions must not contain duplicates.")
1011
+ else:
1012
+ raise PolicySchemaError("actions must be null or an array of strings.")
1013
+ try:
1014
+ match_effect = Effect(_string(document.get("match_effect"), "match_effect"))
1015
+ on_no_match = MissEffect(_string(document.get("on_no_match"), "on_no_match"))
1016
+ on_indeterminate = MissEffect(
1017
+ _string(document.get("on_indeterminate"), "on_indeterminate")
1018
+ )
1019
+ except ValueError as error:
1020
+ raise PolicySchemaError("A policy effect value is unsupported.") from error
1021
+ try:
1022
+ return AttributePolicy(
1023
+ condition,
1024
+ actions=actions,
1025
+ match_effect=match_effect,
1026
+ on_no_match=on_no_match,
1027
+ on_indeterminate=on_indeterminate,
1028
+ name=_string(document.get("name"), "name"),
1029
+ operators=registry,
1030
+ )
1031
+ except PolicySchemaError:
1032
+ raise
1033
+ except (TypeError, ValueError) as error:
1034
+ raise PolicySchemaError("The policy could not be safely normalized.") from error
1035
+
1036
+
1037
+ def dumps_policy(policy: AttributePolicy, *, indent: int | None = 2) -> str:
1038
+ try:
1039
+ return json.dumps(policy.to_dict(), ensure_ascii=False, indent=indent, sort_keys=True)
1040
+ except (TypeError, ValueError) as error:
1041
+ raise PolicySchemaError(
1042
+ "The policy contains a value that is not JSON serializable."
1043
+ ) from error
1044
+
1045
+
1046
+ def _reject_json_constant(value: str) -> None:
1047
+ raise ValueError(f"Unsupported JSON constant: {value}")
1048
+
1049
+
1050
+ def _reject_duplicate_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
1051
+ value: dict[str, object] = {}
1052
+ for key, item in pairs:
1053
+ if key in value:
1054
+ raise ValueError(f"Duplicate JSON object key: {key}")
1055
+ value[key] = item
1056
+ return value
1057
+
1058
+
1059
+ def loads_policy(document: str, *, operators: OperatorRegistry | None = None) -> AttributePolicy:
1060
+ if not isinstance(document, str):
1061
+ raise TypeError("policy document must be a string.")
1062
+ if len(document) > MAX_POLICY_DOCUMENT_CHARS:
1063
+ raise PolicySchemaError("The policy document exceeds the size limit.")
1064
+ try:
1065
+ value = json.loads(
1066
+ document,
1067
+ parse_constant=_reject_json_constant,
1068
+ object_pairs_hook=_reject_duplicate_json_object,
1069
+ )
1070
+ except (json.JSONDecodeError, RecursionError, ValueError) as error:
1071
+ raise PolicySchemaError("The policy document is not valid JSON.") from error
1072
+ return attribute_policy_from_dict(_mapping(value, "policy"), operators=operators)
1073
+
1074
+
1075
+ def load_policy_schema() -> dict[str, object]:
1076
+ """Load a fresh copy of the packaged ABAC policy JSON Schema."""
1077
+
1078
+ schema_text = files("access_gate").joinpath("schema/abac-policy-v1.json").read_text(
1079
+ encoding="utf-8"
1080
+ )
1081
+ return dict(_mapping(json.loads(schema_text), "policy schema"))