policy-pattern 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.
- policy_pattern/__init__.py +0 -0
- policy_pattern/adapters/__init__.py +3 -0
- policy_pattern/adapters/memory.py +135 -0
- policy_pattern/errors/__init__.py +19 -0
- policy_pattern/errors/base_error.py +28 -0
- policy_pattern/errors/evaluation_error.py +16 -0
- policy_pattern/errors/identifier_error.py +15 -0
- policy_pattern/errors/model_validation_error.py +15 -0
- policy_pattern/errors/mutation_error.py +15 -0
- policy_pattern/errors/relation_error.py +15 -0
- policy_pattern/errors/unknown_name_error.py +16 -0
- policy_pattern/errors/unknown_type_error.py +16 -0
- policy_pattern/evaluator.py +285 -0
- policy_pattern/models/__init__.py +34 -0
- policy_pattern/models/_schema_validation.py +130 -0
- policy_pattern/models/compiled_model.py +43 -0
- policy_pattern/models/direct_subject.py +47 -0
- policy_pattern/models/identifier.py +55 -0
- policy_pattern/models/model.py +46 -0
- policy_pattern/models/mutation.py +52 -0
- policy_pattern/models/object_reference.py +42 -0
- policy_pattern/models/operators.py +116 -0
- policy_pattern/models/reference.py +36 -0
- policy_pattern/models/relation.py +44 -0
- policy_pattern/models/subject.py +22 -0
- policy_pattern/models/subject_set.py +56 -0
- policy_pattern/models/tuple.py +52 -0
- policy_pattern/models/tuple_to_userset.py +51 -0
- policy_pattern/models/type.py +71 -0
- policy_pattern/ports/__init__.py +7 -0
- policy_pattern/ports/tuple_store.py +118 -0
- policy_pattern/py.typed +0 -0
- policy_pattern-0.1.0.dist-info/METADATA +148 -0
- policy_pattern-0.1.0.dist-info/RECORD +36 -0
- policy_pattern-0.1.0.dist-info/WHEEL +4 -0
- policy_pattern-0.1.0.dist-info/licenses/LICENSE.md +201 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared schema validation for `Model.compile()` and `CompiledModel` construction.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from policy_pattern.errors import ModelValidationError
|
|
8
|
+
|
|
9
|
+
from .operators import Exclusion, Intersection, PermissionExpression, Union
|
|
10
|
+
from .reference import Reference
|
|
11
|
+
from .tuple_to_userset import TupleToUserset
|
|
12
|
+
from .type import Type
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_types(types: Mapping[str, Type]) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Validates every Type's key against its own name, and every Reference/tupleset it declares.
|
|
18
|
+
|
|
19
|
+
Lives in its own module, not inside `Model` or `CompiledModel`, so both
|
|
20
|
+
can call the exact same check — `Model.compile()` on the normal path,
|
|
21
|
+
`CompiledModel.__post_init__` against a direct construction that
|
|
22
|
+
bypassed it. One validator, not two copies that can drift apart.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ModelValidationError: If a `Type`'s key doesn't match its own `name`,
|
|
26
|
+
a `Reference`/`tupleset` name doesn't exist on its own type, or a
|
|
27
|
+
`tupleset` resolves to a permission instead of a relation.
|
|
28
|
+
|
|
29
|
+
ModelValidationError: If a permission expression and relation share the same name on the same type,
|
|
30
|
+
which is illegal.
|
|
31
|
+
"""
|
|
32
|
+
for type_name, type_declaration in types.items():
|
|
33
|
+
if type_name != type_declaration.name:
|
|
34
|
+
raise ModelValidationError(
|
|
35
|
+
message=f"model key {type_name!r} does not match Type.name {type_declaration.name!r}",
|
|
36
|
+
related=f"type={type_name!r}",
|
|
37
|
+
)
|
|
38
|
+
if overlap := sorted(type_declaration.relations.keys() & type_declaration.permissions.keys()):
|
|
39
|
+
raise ModelValidationError(
|
|
40
|
+
message=f"{type_name} declares {overlap!r} as both a relation and a permission",
|
|
41
|
+
related=f"type={type_name!r}",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
for permission_name, expression in type_declaration.permissions.items():
|
|
45
|
+
_validate_expression(expression, types, type_declaration, type_name, permission_name)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_expression(
|
|
49
|
+
expression: PermissionExpression,
|
|
50
|
+
types: Mapping[str, Type],
|
|
51
|
+
type_declaration: Type,
|
|
52
|
+
type_name: str,
|
|
53
|
+
permission_name: str,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Recursively validates one permission expression against its own type's declarations.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(expression, Reference):
|
|
59
|
+
# Leaf: name must resolve within this type's own namespace.
|
|
60
|
+
known = type_declaration.relations.keys() | type_declaration.permissions.keys()
|
|
61
|
+
if expression.name not in known:
|
|
62
|
+
raise ModelValidationError(
|
|
63
|
+
message=f"{type_name}.{permission_name} references undeclared name {expression.name!r}",
|
|
64
|
+
related=f"type={type_name!r}",
|
|
65
|
+
)
|
|
66
|
+
elif isinstance(expression, TupleToUserset):
|
|
67
|
+
# Leaf: tupleset must be a stored edge — nothing to traverse if it were computed.
|
|
68
|
+
if expression.tupleset not in type_declaration.relations:
|
|
69
|
+
raise ModelValidationError(
|
|
70
|
+
message=(
|
|
71
|
+
f"{type_name}.{permission_name} tupleset {expression.tupleset!r} "
|
|
72
|
+
"must be a relation, not a permission or an undeclared name"
|
|
73
|
+
),
|
|
74
|
+
related=f"type={type_name!r}",
|
|
75
|
+
)
|
|
76
|
+
_validate_tuple_to_userset_targets(expression, types, type_declaration, type_name, permission_name)
|
|
77
|
+
elif isinstance(expression, (Union, Intersection)):
|
|
78
|
+
# Rewrite combinator: set-algebra, commutative — validate both operands, order doesn't matter.
|
|
79
|
+
_validate_expression(expression.left, types, type_declaration, type_name, permission_name)
|
|
80
|
+
_validate_expression(expression.right, types, type_declaration, type_name, permission_name)
|
|
81
|
+
elif isinstance(expression, Exclusion):
|
|
82
|
+
# Rewrite combinator, not commutative — include/exclude both required; skipping one is the
|
|
83
|
+
# classic false-allow bug (an accidental swap would silently invert the exclusion).
|
|
84
|
+
_validate_expression(expression.include, types, type_declaration, type_name, permission_name)
|
|
85
|
+
_validate_expression(expression.exclude, types, type_declaration, type_name, permission_name)
|
|
86
|
+
else:
|
|
87
|
+
raise ModelValidationError(
|
|
88
|
+
message=f"{type_name}.{permission_name} contains unknown expression type {type(expression).__name__!r}",
|
|
89
|
+
related=f"type={type_name!r}",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate_tuple_to_userset_targets(
|
|
94
|
+
expression: TupleToUserset,
|
|
95
|
+
types: Mapping[str, Type],
|
|
96
|
+
type_declaration: Type,
|
|
97
|
+
type_name: str,
|
|
98
|
+
permission_name: str,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Validates `expression.computed` against every plain-type traversal target the tupleset relation allows.
|
|
102
|
+
|
|
103
|
+
Every plain-type entry in the tupleset relation's allowed_subject_types
|
|
104
|
+
is a traversal target `_check_tuple_to_userset` can actually land on at
|
|
105
|
+
runtime — unlike a subject-only leaf type, it is always used as the
|
|
106
|
+
*object* of the next check(), so `computed` must resolve there too. A
|
|
107
|
+
userset-shaped entry ("type#relation") is never followed by traversal
|
|
108
|
+
(only DirectSubject entries are), so it carries nothing to validate.
|
|
109
|
+
"""
|
|
110
|
+
tupleset_relation = type_declaration.relations[expression.tupleset]
|
|
111
|
+
for target_type_name in tupleset_relation.allowed_subject_types:
|
|
112
|
+
if "#" in target_type_name:
|
|
113
|
+
continue
|
|
114
|
+
target_type = types.get(target_type_name)
|
|
115
|
+
if target_type is None:
|
|
116
|
+
raise ModelValidationError(
|
|
117
|
+
message=(
|
|
118
|
+
f"{type_name}.{permission_name} tupleset {expression.tupleset!r} "
|
|
119
|
+
f"allows undeclared type {target_type_name!r}"
|
|
120
|
+
),
|
|
121
|
+
related=f"type={type_name!r}",
|
|
122
|
+
)
|
|
123
|
+
if expression.computed not in target_type.relations and expression.computed not in target_type.permissions:
|
|
124
|
+
raise ModelValidationError(
|
|
125
|
+
message=(
|
|
126
|
+
f"{type_name}.{permission_name} computed {expression.computed!r} is not declared as a "
|
|
127
|
+
f"relation or permission on {target_type_name!r}"
|
|
128
|
+
),
|
|
129
|
+
related=f"type={type_name!r}",
|
|
130
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing an immutable, validated authorization schema.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType # Immutable dict wrapper
|
|
8
|
+
|
|
9
|
+
from ._schema_validation import validate_types
|
|
10
|
+
from .type import Type
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CompiledModel:
|
|
15
|
+
"""
|
|
16
|
+
Immutable, validated result of `Model.compile()` — the only thing `Evaluator` accepts.
|
|
17
|
+
|
|
18
|
+
The intended path is always `Model.compile()`, but Python has no real
|
|
19
|
+
private constructor — calling `CompiledModel(types=...)` directly is
|
|
20
|
+
possible. `__post_init__` re-runs `validate_types()`, the same check
|
|
21
|
+
`Model.compile()` relies on, so a direct call can never hand the
|
|
22
|
+
`Evaluator` an object holding an undeclared `Reference`, a `tupleset`
|
|
23
|
+
resolving to a permission, or a mismatched type name — it either
|
|
24
|
+
produces the same result `compile()` would have, or raises the same
|
|
25
|
+
`ModelValidationError`.
|
|
26
|
+
|
|
27
|
+
`types` is wrapped in `MappingProxyType` on construction — a mutable
|
|
28
|
+
`dict` field would let that guarantee be violated after the fact
|
|
29
|
+
(`compiled.types["x"] = Type(...)` sneaking an unvalidated `Type` past
|
|
30
|
+
validation entirely).
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
compiled = model.compile()
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
types: Mapping[str, Type]
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Validates `types`, then makes it a read-only view over a copy of the input mapping.
|
|
41
|
+
"""
|
|
42
|
+
validate_types(self.types)
|
|
43
|
+
object.__setattr__(self, "types", MappingProxyType(dict(self.types)))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing a reference to a concrete subject in the authorization graph.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .object_reference import ObjectReference
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class DirectSubject:
|
|
12
|
+
"""
|
|
13
|
+
Value Object representing a concrete subject in the authorization graph.
|
|
14
|
+
|
|
15
|
+
A direct subject is theoretically the same kind of entity as an
|
|
16
|
+
`ObjectReference` — Zanzibar's own data model defines a "user" (a
|
|
17
|
+
direct subject) as exactly the same `<namespace>:<id>` shape as an
|
|
18
|
+
object. The only difference is the *role* it plays inside a `Tuple`
|
|
19
|
+
(subject side, not object side), never what kind of thing it is —
|
|
20
|
+
the same entity can be the object of one tuple
|
|
21
|
+
(`user:john#manager@user:jane`, where `user:john` is the object) and
|
|
22
|
+
the direct subject of another (`document:42#owner@user:john`).
|
|
23
|
+
|
|
24
|
+
Because of that, `DirectSubject` wraps an `ObjectReference` instead of
|
|
25
|
+
redeclaring `type`/`id` and their validation: any invalid `type`/`id`
|
|
26
|
+
already fails inside the `ObjectReference(...)` call, before
|
|
27
|
+
`DirectSubject` is ever constructed — there is nothing left for this
|
|
28
|
+
class to validate on its own.
|
|
29
|
+
|
|
30
|
+
This is one half of `Subject` (`Subject = DirectSubject | SubjectSet`)
|
|
31
|
+
— the other half, `SubjectSet`, is a genuinely
|
|
32
|
+
different concept: not a single entity, but a reference to *a relation
|
|
33
|
+
on an object* (`group:eng#member`), i.e. a set of subjects rather than
|
|
34
|
+
one.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
DirectSubject(object=ObjectReference(type="user", id="john"))
|
|
38
|
+
Equals:
|
|
39
|
+
ObjectReference(type="user", id="john") -> Identifier(
|
|
40
|
+
value="user", field="type"
|
|
41
|
+
) and Identifier(value="john", field="id") -> user:john
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
InvalidIdentifierError: Propagated from `ObjectReference` if `type` or `id` is invalid.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
object: ObjectReference
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing an entity identifier.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from ..errors import InvalidIdentifierError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Identifier:
|
|
13
|
+
"""
|
|
14
|
+
Value Object representing an entity identifier.
|
|
15
|
+
|
|
16
|
+
An identifier contains the value that identifies an entity and
|
|
17
|
+
the field/type associated with that identifier.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
Identifier(value="42", field="document")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
value: str
|
|
24
|
+
field: str
|
|
25
|
+
|
|
26
|
+
_MIN_VALUE_LENGTH: ClassVar[int] = 1
|
|
27
|
+
_MAX_VALUE_LENGTH: ClassVar[int] = 255
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Validates the identifier after initialization.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
InvalidIdentifierError: If the identifier value or field is invalid.
|
|
35
|
+
"""
|
|
36
|
+
if not self._MIN_VALUE_LENGTH <= len(self.value) <= self._MAX_VALUE_LENGTH:
|
|
37
|
+
raise InvalidIdentifierError(
|
|
38
|
+
message=f"identifier value length must be between {self._MIN_VALUE_LENGTH!r} and {self._MAX_VALUE_LENGTH!r}", # noqa: E501
|
|
39
|
+
related=f"field={self.field!r}",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not self.value.isprintable():
|
|
43
|
+
raise InvalidIdentifierError(
|
|
44
|
+
message=f"identifier value {self.value!r} must contain printable characters only",
|
|
45
|
+
related=f"field={self.field!r}",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if self.value != self.value.strip():
|
|
49
|
+
raise InvalidIdentifierError(
|
|
50
|
+
message=f"identifier value {self.value!r} cannot contain leading or trailing whitespace",
|
|
51
|
+
related=f"field={self.field!r}",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not self.field:
|
|
55
|
+
raise InvalidIdentifierError(message="identifier field cannot be empty")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing the whole authorization schema.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .compiled_model import CompiledModel
|
|
8
|
+
from .type import Type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class Model:
|
|
13
|
+
"""
|
|
14
|
+
The whole authorization schema — a mutable, authoring-time collection of `Type` declarations.
|
|
15
|
+
|
|
16
|
+
Unlike every other class in this package, `Model` is deliberately **not**
|
|
17
|
+
frozen. `architecture.md` already says why: "mutable, authoring-time" —
|
|
18
|
+
it exists to be built up incrementally (`model.types["document"] =
|
|
19
|
+
Type(...)`) before `.compile()` produces the immutable `CompiledModel`
|
|
20
|
+
that `Evaluator` actually accepts. There is no `check()` here, only
|
|
21
|
+
`compile()` — evaluating a bare `Model` is an illegal state the type
|
|
22
|
+
system doesn't even offer a method for.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
model = Model()
|
|
26
|
+
model.types["document"] = Type(name="document", relations={...}, permissions={...})
|
|
27
|
+
compiled = model.compile()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
types: dict[str, Type] = field(default_factory=dict[str, Type])
|
|
31
|
+
|
|
32
|
+
def compile(self) -> CompiledModel:
|
|
33
|
+
"""
|
|
34
|
+
Returns an immutable, validated CompiledModel.
|
|
35
|
+
|
|
36
|
+
Validation itself lives in `CompiledModel.__post_init__`, not here —
|
|
37
|
+
the only way both this normal path and a direct `CompiledModel(...)`
|
|
38
|
+
call get the same guarantee. See `CompiledModel`'s docstring.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ModelValidationError: If a `Type`'s key in `types` doesn't match
|
|
42
|
+
its own `name`, a `Reference`/`tupleset` name doesn't exist
|
|
43
|
+
on its own type, or a `tupleset` resolves to a permission
|
|
44
|
+
instead of a relation.
|
|
45
|
+
"""
|
|
46
|
+
return CompiledModel(types=dict(self.types))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing an atomic write to the authorization store.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from policy_pattern.errors import InvalidMutationError
|
|
8
|
+
|
|
9
|
+
from .tuple import Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Mutation:
|
|
14
|
+
"""
|
|
15
|
+
Value Object representing one atomic write: a set of additions and a
|
|
16
|
+
set of deletions applied together, or not at all.
|
|
17
|
+
|
|
18
|
+
S' = (S - deletions) | additions
|
|
19
|
+
|
|
20
|
+
`additions`/`deletions` must be disjoint — a tuple present in both has
|
|
21
|
+
no defined meaning (`semantics.md`). An empty mutation
|
|
22
|
+
(`additions=frozenset(), deletions=frozenset()`) is valid: a no-op,
|
|
23
|
+
not a special case to reject.
|
|
24
|
+
|
|
25
|
+
Unlike every other Value Object so far, `Mutation` has no
|
|
26
|
+
identifier-shaped field of its own to delegate to `Identifier` — its
|
|
27
|
+
only rule is structural (the two sets don't overlap), not a bounds/
|
|
28
|
+
charset check.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
Mutation(additions=frozenset({tuple_a}), deletions=frozenset({tuple_b}))
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
InvalidMutationError: If `additions` and `deletions` are not disjoint.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
additions (frozenset[Tuple]): The set of tuples to add.
|
|
38
|
+
deletions (frozenset[Tuple]): The set of tuples to delete.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
additions: frozenset[Tuple]
|
|
42
|
+
deletions: frozenset[Tuple]
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Validates that additions and deletions are disjoint.
|
|
47
|
+
|
|
48
|
+
An empty mutation (no additions, no deletions) is valid — a no-op,
|
|
49
|
+
not an error.
|
|
50
|
+
"""
|
|
51
|
+
if self.additions & self.deletions:
|
|
52
|
+
raise InvalidMutationError(message="additions and deletions must be disjoint")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing a reference to a concrete object in the authorization graph.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from .identifier import Identifier
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ObjectReference:
|
|
13
|
+
"""
|
|
14
|
+
Value Object representing a concrete object.
|
|
15
|
+
|
|
16
|
+
An object reference identifies a specific entity by its type and id —
|
|
17
|
+
e.g. "document:42" is the type "document" with id "42". Both `type`
|
|
18
|
+
and `id` are validated as identifiers (see `Identifier`): non-empty,
|
|
19
|
+
bounded length, printable characters only.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
ObjectReference(type="document", id="42")
|
|
23
|
+
|
|
24
|
+
Both components are validated as `Identifier` values with fields
|
|
25
|
+
`"type"` and `"id"`, respectively.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
type: str
|
|
29
|
+
id: str
|
|
30
|
+
|
|
31
|
+
_FIELD_TYPE_VALUE: ClassVar[str] = "type"
|
|
32
|
+
_FIELD_ID_VALUE: ClassVar[str] = "id"
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Validates the object reference after initialization by delegating to Identifier.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
InvalidIdentifierError: If `type` or `id` is invalid.
|
|
40
|
+
"""
|
|
41
|
+
Identifier(value=self.type, field=self._FIELD_TYPE_VALUE)
|
|
42
|
+
Identifier(value=self.id, field=self._FIELD_ID_VALUE)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class and node types for computed permission expressions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PermissionExpression:
|
|
11
|
+
r"""
|
|
12
|
+
Base class for a computed permission expression — never storable.
|
|
13
|
+
|
|
14
|
+
A `Relation` is a stored fact (`document:42#owner@user:john` can exist
|
|
15
|
+
as a `Tuple`). A `Permission` is the opposite: a *rule* for combining
|
|
16
|
+
relations/other permissions, computed at evaluation time, never
|
|
17
|
+
written to the store. `PermissionExpression` is the AST of that rule —
|
|
18
|
+
the same set-theory operations Zanzibar's `userset rewrite` describes,
|
|
19
|
+
expressed as data instead of prose:
|
|
20
|
+
|
|
21
|
+
- viewer(o) = owner(o) ∪ editor(o) Union
|
|
22
|
+
- can_edit(o) = editor(o) ∩ active_member(o) Intersection
|
|
23
|
+
- viewer(o) = member(o) \ blocked(o) Exclusion
|
|
24
|
+
|
|
25
|
+
Composed via real Python operators instead of nested constructor
|
|
26
|
+
calls — an operator-overloaded composite, not named factory methods:
|
|
27
|
+
|
|
28
|
+
viewer = Reference(name="owner") | (
|
|
29
|
+
Reference(name="member") - Reference(name="blocked")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
`__or__`/`__and__`/`__sub__` return new `PermissionExpression` nodes;
|
|
33
|
+
they never mutate `self` — every node here is frozen, an expression
|
|
34
|
+
tree is built bottom-up once and never changes shape afterward.
|
|
35
|
+
""" # noqa: D301, RUF002
|
|
36
|
+
|
|
37
|
+
__slots__ = ()
|
|
38
|
+
|
|
39
|
+
def __or__(self, other: PermissionExpression) -> Union:
|
|
40
|
+
"""
|
|
41
|
+
Combine with `other` as a Union — true if either side is true.
|
|
42
|
+
"""
|
|
43
|
+
return Union(left=self, right=other)
|
|
44
|
+
|
|
45
|
+
def __and__(self, other: PermissionExpression) -> Intersection:
|
|
46
|
+
"""
|
|
47
|
+
Combine with `other` as an Intersection — true only if both sides are true.
|
|
48
|
+
"""
|
|
49
|
+
return Intersection(left=self, right=other)
|
|
50
|
+
|
|
51
|
+
def __sub__(self, other: PermissionExpression) -> Exclusion:
|
|
52
|
+
"""
|
|
53
|
+
Combine with `other` as an Exclusion — true if `self` is true and `other` is false.
|
|
54
|
+
"""
|
|
55
|
+
return Exclusion(include=self, exclude=other)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Union(PermissionExpression):
|
|
60
|
+
"""
|
|
61
|
+
Union of two permission expressions — true if either side is true.
|
|
62
|
+
|
|
63
|
+
`viewer = owner | editor`: a subject only needs to satisfy one branch,
|
|
64
|
+
not both. The evaluator is free to short-circuit here (stop as soon as
|
|
65
|
+
one side is `True`) — unlike `Exclusion`, a `Union` has no case where
|
|
66
|
+
an error on the untaken branch could hide a false allow.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
Union(left=Reference(name="owner"), right=Reference(name="editor"))
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
left: PermissionExpression
|
|
73
|
+
right: PermissionExpression
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class Intersection(PermissionExpression):
|
|
78
|
+
"""
|
|
79
|
+
Intersection of two permission expressions — true only if both sides are true.
|
|
80
|
+
|
|
81
|
+
`can_edit = editor & active_member`: a subject must satisfy both
|
|
82
|
+
branches. Unlike `Union`, the evaluator can short-circuit on the first
|
|
83
|
+
`False` branch — a `False` on either side is enough to decide the
|
|
84
|
+
whole node is `False`, regardless of what the other side would have
|
|
85
|
+
evaluated to.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
Intersection(left=Reference(name="editor"), right=Reference(name="active_member"))
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
left: PermissionExpression
|
|
92
|
+
right: PermissionExpression
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Exclusion(PermissionExpression):
|
|
97
|
+
"""
|
|
98
|
+
Set difference: true if `include` is true and `exclude` is false.
|
|
99
|
+
|
|
100
|
+
`viewer = member - blocked`: the one node the evaluator must *never*
|
|
101
|
+
short-circuit. If evaluating `exclude` fails (a storage error, a
|
|
102
|
+
cycle, a budget overrun) while `include` is already known to be
|
|
103
|
+
`False`, that failure still has to propagate — treating the failed
|
|
104
|
+
`exclude` as "assume False" would compute `False - False = True`, a
|
|
105
|
+
false allow manufactured entirely out of an evaluation failure on the
|
|
106
|
+
branch that was never actually checked. This is `docs/semantics.md`'s
|
|
107
|
+
"storage errors are not empty results" rule, restated as an evaluation
|
|
108
|
+
rule instead of a storage one: both sides are always evaluated,
|
|
109
|
+
unconditionally.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
Exclusion(include=Reference(name="member"), exclude=Reference(name="blocked"))
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
include: PermissionExpression
|
|
116
|
+
exclude: PermissionExpression
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value object representing a reference to a relation or permission on the same type.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .identifier import Identifier
|
|
8
|
+
from .operators import PermissionExpression
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Reference(PermissionExpression):
|
|
13
|
+
"""
|
|
14
|
+
Names a relation or permission on the same type — the leaf case.
|
|
15
|
+
|
|
16
|
+
`Reference` is deliberately the *only* leaf node type, unifying what
|
|
17
|
+
an earlier draft of this design split into two (`This`, for stored
|
|
18
|
+
relations, and `ComputedUserset`, for other permissions). At authoring
|
|
19
|
+
time there is no way to tell which one `name` refers to without
|
|
20
|
+
looking at the rest of the type's declarations — so the distinction is
|
|
21
|
+
resolved once, by the evaluator, at evaluation time: if `name` is a
|
|
22
|
+
declared `Relation`, look it up in the `TupleStore`; if it is another
|
|
23
|
+
`Permission`, recurse into its own expression tree. Neither case is
|
|
24
|
+
knowable — or needs to be knowable — from `Reference` alone.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
Reference(name="owner")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Validates `name` by delegating to `Identifier`.
|
|
35
|
+
"""
|
|
36
|
+
Identifier(value=self.name, field="name")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value Object representing a storable relation on an object type.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from policy_pattern.errors import InvalidRelationError
|
|
8
|
+
|
|
9
|
+
from .identifier import Identifier
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Relation:
|
|
14
|
+
"""
|
|
15
|
+
Value Object representing a storable relation.
|
|
16
|
+
|
|
17
|
+
A relation is a storable edge — a fact that can exist directly in the
|
|
18
|
+
TupleStore, e.g. `document:42#owner@user:alice`. It carries no rewrite
|
|
19
|
+
algebra (that's `PermissionExpression`'s job); it only restricts which
|
|
20
|
+
subject types are allowed to hold it. Each entry is a type name
|
|
21
|
+
("user") or a userset restriction ("organization#member") — the same
|
|
22
|
+
textual shape as a SubjectSet.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
Relation(allowed_subject_types=("user", "organization#member"))
|
|
26
|
+
|
|
27
|
+
Each allowed subject type is validated as an `Identifier` with
|
|
28
|
+
`field="allowed_subject_types"`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
allowed_subject_types: tuple[str, ...]
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Validates the relation after initialization.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
InvalidRelationError: If `allowed_subject_types` is empty or contains an invalid entry.
|
|
39
|
+
"""
|
|
40
|
+
if not self.allowed_subject_types:
|
|
41
|
+
raise InvalidRelationError(message="relation must declare at least one allowed subject type")
|
|
42
|
+
|
|
43
|
+
for subject_type in self.allowed_subject_types:
|
|
44
|
+
Identifier(value=subject_type, field="allowed_subject_types")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type alias for a subject in the authorization graph.
|
|
3
|
+
|
|
4
|
+
A subject is either a `DirectSubject` (a reference to one concrete entity,
|
|
5
|
+
e.g. `user:john`) or a `SubjectSet` (a reference to a relation on an
|
|
6
|
+
object — a computed set of subjects, e.g. `group:eng#member`). Deliberately
|
|
7
|
+
a type alias, not a wrapper class: `DirectSubject`/`SubjectSet` are already
|
|
8
|
+
distinguishable by `isinstance`, and a wrapper with two `Optional` fields
|
|
9
|
+
would allow two illegal states a real union can't represent — neither set,
|
|
10
|
+
or both set at once. Anywhere a `Subject` is expected, construct one of the
|
|
11
|
+
two variants directly (`DirectSubject(...)` / `SubjectSet(...)`) and narrow
|
|
12
|
+
with `isinstance`/`match` where it matters, e.g. inside the evaluator.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
subject: Subject = DirectSubject(object=ObjectReference(type="user", id="john"))
|
|
16
|
+
subject: Subject = SubjectSet(object=ObjectReference(type="group", id="eng"), relation="member")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .direct_subject import DirectSubject
|
|
20
|
+
from .subject_set import SubjectSet
|
|
21
|
+
|
|
22
|
+
type Subject = DirectSubject | SubjectSet
|