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.
Files changed (36) hide show
  1. policy_pattern/__init__.py +0 -0
  2. policy_pattern/adapters/__init__.py +3 -0
  3. policy_pattern/adapters/memory.py +135 -0
  4. policy_pattern/errors/__init__.py +19 -0
  5. policy_pattern/errors/base_error.py +28 -0
  6. policy_pattern/errors/evaluation_error.py +16 -0
  7. policy_pattern/errors/identifier_error.py +15 -0
  8. policy_pattern/errors/model_validation_error.py +15 -0
  9. policy_pattern/errors/mutation_error.py +15 -0
  10. policy_pattern/errors/relation_error.py +15 -0
  11. policy_pattern/errors/unknown_name_error.py +16 -0
  12. policy_pattern/errors/unknown_type_error.py +16 -0
  13. policy_pattern/evaluator.py +285 -0
  14. policy_pattern/models/__init__.py +34 -0
  15. policy_pattern/models/_schema_validation.py +130 -0
  16. policy_pattern/models/compiled_model.py +43 -0
  17. policy_pattern/models/direct_subject.py +47 -0
  18. policy_pattern/models/identifier.py +55 -0
  19. policy_pattern/models/model.py +46 -0
  20. policy_pattern/models/mutation.py +52 -0
  21. policy_pattern/models/object_reference.py +42 -0
  22. policy_pattern/models/operators.py +116 -0
  23. policy_pattern/models/reference.py +36 -0
  24. policy_pattern/models/relation.py +44 -0
  25. policy_pattern/models/subject.py +22 -0
  26. policy_pattern/models/subject_set.py +56 -0
  27. policy_pattern/models/tuple.py +52 -0
  28. policy_pattern/models/tuple_to_userset.py +51 -0
  29. policy_pattern/models/type.py +71 -0
  30. policy_pattern/ports/__init__.py +7 -0
  31. policy_pattern/ports/tuple_store.py +118 -0
  32. policy_pattern/py.typed +0 -0
  33. policy_pattern-0.1.0.dist-info/METADATA +148 -0
  34. policy_pattern-0.1.0.dist-info/RECORD +36 -0
  35. policy_pattern-0.1.0.dist-info/WHEEL +4 -0
  36. 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