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
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory TupleStore adapter — demo/test adapter, no throughput requirement.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from heapq import nsmallest
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from typing import override
|
|
10
|
+
|
|
11
|
+
from policy_pattern.models import Mutation, ObjectReference, Subject, Tuple
|
|
12
|
+
from policy_pattern.ports import ReadResult, TupleStore, View
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryTupleStore(TupleStore):
|
|
16
|
+
"""
|
|
17
|
+
In-memory `TupleStore` — a plain `set[Tuple]` behind a lock, no index.
|
|
18
|
+
|
|
19
|
+
`read_relation()` does a linear scan over a snapshot; no
|
|
20
|
+
`dict[(object, relation), set[Tuple]]` index is maintained. An index
|
|
21
|
+
would need to stay in lockstep with `write_tuples()` on every call —
|
|
22
|
+
real complexity bought for a performance property this adapter is
|
|
23
|
+
explicitly not required to have (`docs/semantics.md` § Threading calls
|
|
24
|
+
it a "demo/test adapter with no throughput requirement"). Real
|
|
25
|
+
throughput is `PostgresTupleStore`'s job, not this one's.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
store = MemoryTupleStore()
|
|
29
|
+
store.write_tuples(Mutation(additions=frozenset({t}), deletions=frozenset()))
|
|
30
|
+
with store.snapshot() as view:
|
|
31
|
+
view.read_tuple(subject, "owner", object)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Starts empty.
|
|
37
|
+
"""
|
|
38
|
+
self._tuples: set[Tuple] = set()
|
|
39
|
+
self._lock = Lock()
|
|
40
|
+
|
|
41
|
+
@override
|
|
42
|
+
def write_tuples(self, mutation: Mutation) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Atomically applies additions/deletions under the lock.
|
|
45
|
+
|
|
46
|
+
`Mutation.__post_init__` already guarantees `additions`/`deletions`
|
|
47
|
+
are disjoint — this method does not re-validate that.
|
|
48
|
+
"""
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._tuples = (self._tuples - mutation.deletions) | mutation.additions
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
@contextmanager
|
|
54
|
+
def snapshot(self) -> Generator[View, None, None]:
|
|
55
|
+
"""
|
|
56
|
+
Copies the current tuple set under the lock, then yields a view over the copy.
|
|
57
|
+
|
|
58
|
+
The copy — not a re-held lock — is what makes the view stable: once
|
|
59
|
+
the copy exists, nothing that happens to the live set afterward can
|
|
60
|
+
be observed through the already-open view, because the view never
|
|
61
|
+
looks at the live set again.
|
|
62
|
+
"""
|
|
63
|
+
with self._lock:
|
|
64
|
+
frozen = frozenset(self._tuples)
|
|
65
|
+
yield _MemoryView(frozen)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _MemoryView(View):
|
|
69
|
+
"""
|
|
70
|
+
Read-only view over one immutable snapshot of a `MemoryTupleStore`.
|
|
71
|
+
|
|
72
|
+
Private: nothing outside `MemoryTupleStore.snapshot()` ever constructs
|
|
73
|
+
one directly.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, tuples: frozenset[Tuple]) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Wraps an already-copied, immutable tuple set.
|
|
79
|
+
"""
|
|
80
|
+
self._tuples = tuples
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def tuples(self) -> frozenset[Tuple]:
|
|
84
|
+
"""
|
|
85
|
+
Returns the snapshot's tuples.
|
|
86
|
+
|
|
87
|
+
Private: nothing outside `MemoryTupleStore.snapshot()` ever needs
|
|
88
|
+
to see this.
|
|
89
|
+
"""
|
|
90
|
+
return self._tuples
|
|
91
|
+
|
|
92
|
+
@override
|
|
93
|
+
def read_tuple(self, subject: Subject, relation: str, object: ObjectReference) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Checks exact membership by constructing the equivalent Tuple.
|
|
96
|
+
|
|
97
|
+
Reuses `Tuple.__post_init__`'s own validation (delegating `relation`
|
|
98
|
+
to `Identifier`) instead of duplicating it here.
|
|
99
|
+
"""
|
|
100
|
+
return Tuple(subject=subject, relation=relation, object=object) in self.tuples
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
def read_relation(self, object: ObjectReference, relation: str, limit: int) -> ReadResult:
|
|
104
|
+
"""
|
|
105
|
+
Linearly scans the snapshot for matches, bounded by `limit + 1`.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
object: The `ObjectReference` to match.
|
|
109
|
+
relation: The relation name to match.
|
|
110
|
+
limit: The maximum number of matches to return. If more than
|
|
111
|
+
`limit` matches exist, the result is truncated to exactly
|
|
112
|
+
`limit` and `complete=False`.
|
|
113
|
+
|
|
114
|
+
Matches are ranked by `repr()` before truncating — `frozenset`
|
|
115
|
+
iteration order is not stable (hash randomization), and an
|
|
116
|
+
unordered scan would make which tuples survive truncation vary
|
|
117
|
+
between runs with identical data, breaking
|
|
118
|
+
docs/architecture.md's determinism guarantee. `heapq.nsmallest`
|
|
119
|
+
keeps only `limit + 1` candidates in memory at a time, rather than
|
|
120
|
+
`sorted()`'s full match set — with a huge match set and a small
|
|
121
|
+
limit, that is the difference between O(limit) and O(match count)
|
|
122
|
+
memory. `limit=0` needs no special-casing: it is already
|
|
123
|
+
well-defined (immediately incomplete if any match exists,
|
|
124
|
+
complete-and-empty otherwise).
|
|
125
|
+
"""
|
|
126
|
+
candidates: list[Tuple] = nsmallest(
|
|
127
|
+
limit + 1,
|
|
128
|
+
(t for t in self.tuples if t.object == object and t.relation == relation),
|
|
129
|
+
key=repr,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if len(candidates) > limit:
|
|
133
|
+
return ReadResult(tuples=frozenset(candidates[:limit]), complete=False)
|
|
134
|
+
|
|
135
|
+
return ReadResult(tuples=frozenset(candidates), complete=True)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .base_error import BaseError
|
|
2
|
+
from .evaluation_error import EvaluationError
|
|
3
|
+
from .identifier_error import InvalidIdentifierError
|
|
4
|
+
from .model_validation_error import ModelValidationError
|
|
5
|
+
from .mutation_error import InvalidMutationError
|
|
6
|
+
from .relation_error import InvalidRelationError
|
|
7
|
+
from .unknown_name_error import UnknownNameError
|
|
8
|
+
from .unknown_type_error import UnknownTypeError
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseError",
|
|
12
|
+
"EvaluationError",
|
|
13
|
+
"InvalidIdentifierError",
|
|
14
|
+
"InvalidMutationError",
|
|
15
|
+
"InvalidRelationError",
|
|
16
|
+
"ModelValidationError",
|
|
17
|
+
"UnknownNameError",
|
|
18
|
+
"UnknownTypeError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for all policy pattern errors.
|
|
3
|
+
To create a standardized error response, inherit from this class and set the `code` attribute to a unique error code.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseError(Exception):
|
|
10
|
+
"""
|
|
11
|
+
Custom exception class for policy pattern errors.
|
|
12
|
+
It provides a standardized error response format with a unique error code, message,
|
|
13
|
+
and optional related information.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
code: ClassVar[str]
|
|
17
|
+
|
|
18
|
+
def __init__(self, *, message: str, related: str | None = None) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Initialize the BaseError following the standard error response format in the policy pattern docs errors.md.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
message (str): A description of the error.
|
|
24
|
+
related (str | None): Additional information about the error, such as the field or type that caused it.
|
|
25
|
+
"""
|
|
26
|
+
self.message = message
|
|
27
|
+
self.related = related
|
|
28
|
+
super().__init__(f"[{self.code}] {message}")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to Evaluator runtime failures (budget exceeded, cycle detected).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EvaluationError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when check() cannot complete: a cycle was detected, or a configured
|
|
11
|
+
EvaluationBudget limit (max_depth, max_nodes, max_store_reads) was exceeded.
|
|
12
|
+
Attributes:
|
|
13
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
code = "PP201"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to Identifier class.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidIdentifierError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when an invalid identifier is encountered.
|
|
11
|
+
Attributes:
|
|
12
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
code = "PP001"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to Model validation at compile() time.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ModelValidationError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when a Model fails compile()-time validation.
|
|
11
|
+
Attributes:
|
|
12
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
code = "PP101"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to Mutation class.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidMutationError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when an invalid mutation is encountered.
|
|
11
|
+
Attributes:
|
|
12
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
code = "PP003"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to Relation class.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidRelationError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when an invalid relation is encountered.
|
|
11
|
+
Attributes:
|
|
12
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
code = "PP002"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to referencing a relation/permission name not declared on a type.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnknownNameError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when check() references a name that is declared as neither a relation
|
|
11
|
+
nor a permission on the object's (already-known) type.
|
|
12
|
+
Attributes:
|
|
13
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
code = "PP203"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set of errors related to referencing a type the CompiledModel does not declare.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base_error import BaseError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnknownTypeError(BaseError):
|
|
9
|
+
"""
|
|
10
|
+
Error raised when check() (or internal userset-expansion recursion) references an
|
|
11
|
+
ObjectReference whose type is not declared in the CompiledModel.
|
|
12
|
+
Attributes:
|
|
13
|
+
code (ClassVar[str]): A unique error code for the specific error type.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
code = "PP202"
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Evaluator: resolves check() over a CompiledModel and a TupleStore.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .errors import EvaluationError, UnknownNameError, UnknownTypeError
|
|
8
|
+
from .models import (
|
|
9
|
+
CompiledModel,
|
|
10
|
+
DirectSubject,
|
|
11
|
+
Exclusion,
|
|
12
|
+
Intersection,
|
|
13
|
+
ObjectReference,
|
|
14
|
+
PermissionExpression,
|
|
15
|
+
Reference,
|
|
16
|
+
Subject,
|
|
17
|
+
SubjectSet,
|
|
18
|
+
TupleToUserset,
|
|
19
|
+
Union,
|
|
20
|
+
)
|
|
21
|
+
from .ports import TupleStore, View
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class EvaluationBudget:
|
|
26
|
+
"""
|
|
27
|
+
Per-`check()` resource limits.
|
|
28
|
+
|
|
29
|
+
No `__post_init__` validation — a degenerate value (e.g. `max_depth=0`)
|
|
30
|
+
is well-defined without a guard: it simply fails on the first node
|
|
31
|
+
`Evaluator` visits. Same reasoning already applied to `read_relation`'s
|
|
32
|
+
`limit=0` — no invariant to enforce, trust the caller.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
EvaluationBudget(max_depth=10, max_nodes=500, max_store_reads=100)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Maximum recursion depth for a single `check()` call. Each recursive call
|
|
39
|
+
# to `_check_name()` adds one to the depth; exceeding this limit raises an
|
|
40
|
+
# `EvaluationError`.
|
|
41
|
+
max_depth: int = 25
|
|
42
|
+
|
|
43
|
+
# Maximum number of nodes visited during a single `check()` call. Each
|
|
44
|
+
# call to `_check_name()` increments this count; exceeding this limit raises an `EvaluationError`.
|
|
45
|
+
max_nodes: int = 10_000
|
|
46
|
+
|
|
47
|
+
# Maximum number of store reads during a single `check()` call. Each call
|
|
48
|
+
# to `_charge_read()` increments this count; exceeding this limit raises an `EvaluationError`.
|
|
49
|
+
max_store_reads: int = 1_000
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class EvaluationContext:
|
|
54
|
+
"""
|
|
55
|
+
Live bookkeeping for exactly one `check()` call.
|
|
56
|
+
|
|
57
|
+
Deliberately mutable, unlike almost everything else in this package —
|
|
58
|
+
this is not a domain value, it is the in-flight state of one
|
|
59
|
+
evaluation, never seen or reused outside that call's lifetime.
|
|
60
|
+
|
|
61
|
+
`len(visited)` doubles as the current recursion depth: `visited` only
|
|
62
|
+
ever holds ancestors currently in progress on the live call path
|
|
63
|
+
(added before recursing, discarded after returning) — anything
|
|
64
|
+
permanently resolved lives in `memo` instead, so the two never overlap
|
|
65
|
+
in meaning.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
view: View
|
|
69
|
+
budget: EvaluationBudget
|
|
70
|
+
visited: set[tuple[ObjectReference, str]] = field(default_factory=lambda: set[tuple[ObjectReference, str]]())
|
|
71
|
+
memo: dict[tuple[ObjectReference, str], bool] = field(
|
|
72
|
+
default_factory=lambda: dict[tuple[ObjectReference, str], bool]()
|
|
73
|
+
)
|
|
74
|
+
node_count: int = 0
|
|
75
|
+
store_read_count: int = 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Evaluator:
|
|
79
|
+
"""
|
|
80
|
+
Resolves check(subject, name, object) against a CompiledModel and a TupleStore.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
evaluator = Evaluator(model=compiled_model, store=memory_store)
|
|
84
|
+
evaluator.check(subject, "viewer", document)
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, model: CompiledModel, store: TupleStore) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Binds this evaluator to one CompiledModel and one TupleStore.
|
|
90
|
+
"""
|
|
91
|
+
self._model = model
|
|
92
|
+
self._store = store
|
|
93
|
+
|
|
94
|
+
def check(
|
|
95
|
+
self,
|
|
96
|
+
subject: Subject,
|
|
97
|
+
name: str,
|
|
98
|
+
object: ObjectReference,
|
|
99
|
+
budget: EvaluationBudget | None = None,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
Returns whether subject holds relation/permission name on object.
|
|
103
|
+
|
|
104
|
+
Opens exactly one store.snapshot() for the lifetime of this call —
|
|
105
|
+
docs/semantics.md's snapshot-consistency requirement — every read
|
|
106
|
+
this call triggers, however deep the recursion, goes through the
|
|
107
|
+
same View.
|
|
108
|
+
|
|
109
|
+
`budget` defaults to `None`/`EvaluationBudget()` rather than a
|
|
110
|
+
bare `EvaluationBudget()` default argument — Python evaluates a
|
|
111
|
+
default argument once, at function-definition time, so every
|
|
112
|
+
omitted-budget call would share one instance. Harmless here since
|
|
113
|
+
`EvaluationBudget` is frozen, but `ruff`'s `B008` flags the pattern
|
|
114
|
+
regardless of mutability, and the codebase doesn't carve out an
|
|
115
|
+
exception for "this particular immutable case."
|
|
116
|
+
"""
|
|
117
|
+
if budget is None:
|
|
118
|
+
budget = EvaluationBudget()
|
|
119
|
+
with self._store.snapshot() as view:
|
|
120
|
+
context = EvaluationContext(view=view, budget=budget)
|
|
121
|
+
return self._check_name(subject, name, object, context)
|
|
122
|
+
|
|
123
|
+
def _check_name(self, subject: Subject, name: str, object: ObjectReference, context: EvaluationContext) -> bool:
|
|
124
|
+
"""
|
|
125
|
+
Dispatches (object, name) to a relation or permission check, with cycle/memo/budget bookkeeping.
|
|
126
|
+
"""
|
|
127
|
+
key = (object, name)
|
|
128
|
+
if key in context.memo:
|
|
129
|
+
return context.memo[key]
|
|
130
|
+
if key in context.visited:
|
|
131
|
+
raise EvaluationError(
|
|
132
|
+
message=f"cycle detected: {name!r} on {object!r} is already being evaluated for {subject!r}",
|
|
133
|
+
related=f"object={object!r}",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if len(context.visited) >= context.budget.max_depth:
|
|
137
|
+
raise EvaluationError(
|
|
138
|
+
message=f"max_depth={context.budget.max_depth!r} exceeded evaluating {name!r} on {object!r}",
|
|
139
|
+
related=f"object={object!r}",
|
|
140
|
+
)
|
|
141
|
+
context.node_count += 1
|
|
142
|
+
if context.node_count > context.budget.max_nodes:
|
|
143
|
+
raise EvaluationError(
|
|
144
|
+
message=f"max_nodes={context.budget.max_nodes!r} exceeded evaluating {name!r} on {object!r}",
|
|
145
|
+
related=f"object={object!r}",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
context.visited.add(key)
|
|
149
|
+
try:
|
|
150
|
+
try:
|
|
151
|
+
type_declaration = self._model.types[object.type]
|
|
152
|
+
except KeyError:
|
|
153
|
+
raise UnknownTypeError(
|
|
154
|
+
message=f"type {object.type!r} is not declared in this CompiledModel",
|
|
155
|
+
related=f"object={object!r}",
|
|
156
|
+
) from None
|
|
157
|
+
if name in type_declaration.relations:
|
|
158
|
+
result = self._check_relation(subject, name, object, context)
|
|
159
|
+
elif name in type_declaration.permissions:
|
|
160
|
+
result = self._check_permission(subject, type_declaration.permissions[name], object, context)
|
|
161
|
+
else:
|
|
162
|
+
raise UnknownNameError(
|
|
163
|
+
message=f"{name!r} is not declared as a relation or permission on {object.type!r}",
|
|
164
|
+
related=f"object={object!r}",
|
|
165
|
+
)
|
|
166
|
+
finally:
|
|
167
|
+
context.visited.discard(key)
|
|
168
|
+
|
|
169
|
+
context.memo[key] = result
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def _check_relation(self, subject: Subject, name: str, object: ObjectReference, context: EvaluationContext) -> bool:
|
|
173
|
+
"""
|
|
174
|
+
Exact tuple lookup, then bounded userset expansion for any SubjectSet found.
|
|
175
|
+
|
|
176
|
+
Every candidate subject's shape is checked against this Relation's
|
|
177
|
+
own `allowed_subject_types` before it counts — `TupleStore` is
|
|
178
|
+
schema-agnostic by design (docs/architecture.md), so it never
|
|
179
|
+
rejects a malformed write; this is the one place that knows both
|
|
180
|
+
the schema and the stored fact, so it is the actual trust boundary.
|
|
181
|
+
A tuple whose subject shape isn't declared is inert: never a match,
|
|
182
|
+
never followed.
|
|
183
|
+
"""
|
|
184
|
+
relation = self._model.types[object.type].relations[name]
|
|
185
|
+
|
|
186
|
+
def shape_allowed(candidate: Subject) -> bool:
|
|
187
|
+
if isinstance(candidate, DirectSubject):
|
|
188
|
+
return candidate.object.type in relation.allowed_subject_types
|
|
189
|
+
return f"{candidate.object.type}#{candidate.relation}" in relation.allowed_subject_types
|
|
190
|
+
|
|
191
|
+
self._charge_read(context, object, name)
|
|
192
|
+
if shape_allowed(subject) and context.view.read_tuple(subject, name, object):
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
self._charge_read(context, object, name)
|
|
196
|
+
result = context.view.read_relation(object, name, limit=context.budget.max_nodes - context.node_count)
|
|
197
|
+
if not result.complete:
|
|
198
|
+
raise EvaluationError(
|
|
199
|
+
message=f"enumeration of {name!r} on {object!r} truncated by remaining max_nodes budget",
|
|
200
|
+
related=f"object={object!r}",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return any(
|
|
204
|
+
isinstance(t.subject, SubjectSet)
|
|
205
|
+
and shape_allowed(t.subject)
|
|
206
|
+
and self._check_name(subject, t.subject.relation, t.subject.object, context)
|
|
207
|
+
for t in sorted(result.tuples, key=repr)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _check_permission(
|
|
211
|
+
self, subject: Subject, expression: PermissionExpression, object: ObjectReference, context: EvaluationContext
|
|
212
|
+
) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Recursively evaluates a PermissionExpression tree.
|
|
215
|
+
|
|
216
|
+
Union/Intersection may use Python's or/and directly: if the left
|
|
217
|
+
operand already decides the boolean result, skipping the right one
|
|
218
|
+
cannot produce an incorrect decision (docs/semantics.md forbids
|
|
219
|
+
short-circuiting only for Exclusion, where skipping `exclude` could
|
|
220
|
+
hide a real subtraction). Python's or/and evaluate left-to-right by
|
|
221
|
+
language specification, satisfying the determinism requirement for
|
|
222
|
+
free.
|
|
223
|
+
"""
|
|
224
|
+
if isinstance(expression, Reference):
|
|
225
|
+
return self._check_name(subject, expression.name, object, context)
|
|
226
|
+
if isinstance(expression, TupleToUserset):
|
|
227
|
+
return self._check_tuple_to_userset(subject, expression, object, context)
|
|
228
|
+
if isinstance(expression, (Union, Intersection)):
|
|
229
|
+
left = self._check_permission(subject, expression.left, object, context)
|
|
230
|
+
if isinstance(expression, Union):
|
|
231
|
+
return left or self._check_permission(subject, expression.right, object, context)
|
|
232
|
+
return left and self._check_permission(subject, expression.right, object, context)
|
|
233
|
+
if isinstance(expression, Exclusion):
|
|
234
|
+
include = self._check_permission(subject, expression.include, object, context)
|
|
235
|
+
exclude = self._check_permission(subject, expression.exclude, object, context)
|
|
236
|
+
return include and not exclude
|
|
237
|
+
raise EvaluationError(
|
|
238
|
+
message=f"unknown expression type {type(expression).__name__!r}",
|
|
239
|
+
related=f"object={object!r}",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _check_tuple_to_userset(
|
|
243
|
+
self, subject: Subject, expression: TupleToUserset, object: ObjectReference, context: EvaluationContext
|
|
244
|
+
) -> bool:
|
|
245
|
+
"""
|
|
246
|
+
Follows expression.tupleset to each related object, then checks expression.computed there.
|
|
247
|
+
|
|
248
|
+
Only DirectSubject entries are followed — a SubjectSet stored under
|
|
249
|
+
the tupleset relation has no single "related object" to jump to, so
|
|
250
|
+
it does not participate in traversal. A DirectSubject whose type
|
|
251
|
+
isn't declared in the tupleset Relation's own `allowed_subject_types`
|
|
252
|
+
is malformed data and is skipped the same way `_check_relation`
|
|
253
|
+
skips a malformed direct/userset match.
|
|
254
|
+
"""
|
|
255
|
+
relation = self._model.types[object.type].relations[expression.tupleset]
|
|
256
|
+
|
|
257
|
+
self._charge_read(context, object, expression.tupleset)
|
|
258
|
+
result = context.view.read_relation(
|
|
259
|
+
object, expression.tupleset, limit=context.budget.max_nodes - context.node_count
|
|
260
|
+
)
|
|
261
|
+
if not result.complete:
|
|
262
|
+
raise EvaluationError(
|
|
263
|
+
message=f"enumeration of {expression.tupleset!r} on {object!r} truncated by remaining max_nodes budget",
|
|
264
|
+
related=f"object={object!r}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return any(
|
|
268
|
+
isinstance(t.subject, DirectSubject)
|
|
269
|
+
and t.subject.object.type in relation.allowed_subject_types
|
|
270
|
+
and self._check_name(subject, expression.computed, t.subject.object, context)
|
|
271
|
+
for t in sorted(result.tuples, key=repr)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _charge_read(self, context: EvaluationContext, object: ObjectReference, name: str) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Counts one store read, raising EvaluationError if max_store_reads is now exceeded.
|
|
277
|
+
"""
|
|
278
|
+
context.store_read_count += 1
|
|
279
|
+
if context.store_read_count > context.budget.max_store_reads:
|
|
280
|
+
raise EvaluationError(
|
|
281
|
+
message=(
|
|
282
|
+
f"max_store_reads={context.budget.max_store_reads!r} exceeded evaluating {name!r} on {object!r}"
|
|
283
|
+
),
|
|
284
|
+
related=f"object={object!r}",
|
|
285
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .compiled_model import CompiledModel
|
|
2
|
+
from .direct_subject import DirectSubject
|
|
3
|
+
from .identifier import Identifier
|
|
4
|
+
from .model import Model
|
|
5
|
+
from .mutation import Mutation
|
|
6
|
+
from .object_reference import ObjectReference
|
|
7
|
+
from .operators import Exclusion, Intersection, PermissionExpression, Union
|
|
8
|
+
from .reference import Reference
|
|
9
|
+
from .relation import Relation
|
|
10
|
+
from .subject import Subject
|
|
11
|
+
from .subject_set import SubjectSet
|
|
12
|
+
from .tuple import Tuple
|
|
13
|
+
from .tuple_to_userset import TupleToUserset
|
|
14
|
+
from .type import Type
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CompiledModel",
|
|
18
|
+
"DirectSubject",
|
|
19
|
+
"Exclusion",
|
|
20
|
+
"Identifier",
|
|
21
|
+
"Intersection",
|
|
22
|
+
"Model",
|
|
23
|
+
"Mutation",
|
|
24
|
+
"ObjectReference",
|
|
25
|
+
"PermissionExpression",
|
|
26
|
+
"Reference",
|
|
27
|
+
"Relation",
|
|
28
|
+
"Subject",
|
|
29
|
+
"SubjectSet",
|
|
30
|
+
"Tuple",
|
|
31
|
+
"TupleToUserset",
|
|
32
|
+
"Type",
|
|
33
|
+
"Union",
|
|
34
|
+
]
|