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/__init__.py +118 -0
- access_gate/abac.py +1081 -0
- access_gate/adapters/__init__.py +5 -0
- access_gate/adapters/django.py +177 -0
- access_gate/engine.py +224 -0
- access_gate/exceptions.py +13 -0
- access_gate/integrity.py +262 -0
- access_gate/mcp/__init__.py +1 -0
- access_gate/mcp/agent-contract.json +419 -0
- access_gate/mcp/server.py +378 -0
- access_gate/policies.py +242 -0
- access_gate/protocols.py +51 -0
- access_gate/py.typed +0 -0
- access_gate/schema/abac-policy-v1.json +113 -0
- access_gate/strategies.py +28 -0
- access_gate/types.py +315 -0
- accessgate-0.2.0a1.dist-info/METADATA +287 -0
- accessgate-0.2.0a1.dist-info/RECORD +22 -0
- accessgate-0.2.0a1.dist-info/WHEEL +5 -0
- accessgate-0.2.0a1.dist-info/entry_points.txt +2 -0
- accessgate-0.2.0a1.dist-info/licenses/LICENSE +21 -0
- accessgate-0.2.0a1.dist-info/top_level.txt +1 -0
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"))
|