python-authz 0.6.0__tar.gz → 0.8.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-authz
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph
5
5
  Author: Anupam Gupta
6
6
  Author-email: Anupam Gupta <anupam@trois.in>
@@ -14,15 +14,16 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Topic :: Security
16
16
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
- Requires-Dist: duckdb>=1.5.5
17
+ Classifier: Typing :: Typed
18
18
  Requires-Dist: aiosqlite>=0.21.0
19
- Requires-Dist: asyncpg>=0.31.0
20
- Requires-Dist: fastapi[all]>=0.141.1 ; extra == 'fastapi'
19
+ Requires-Dist: fastapi>=0.141.1 ; extra == 'fastapi'
20
+ Requires-Dist: asyncpg>=0.31.0 ; extra == 'postgres'
21
21
  Requires-Python: >=3.11
22
- Project-URL: Homepage, https://github.com/mapuna/python-authz
23
- Project-URL: Repository, https://github.com/mapuna/python-authz
24
- Project-URL: Issues, https://github.com/mapuna/python-authz/issues
22
+ Project-URL: Homepage, https://github.com/tensor-forge-ai/python-authz
23
+ Project-URL: Repository, https://github.com/tensor-forge-ai/python-authz
24
+ Project-URL: Issues, https://github.com/tensor-forge-ai/python-authz/issues
25
25
  Provides-Extra: fastapi
26
+ Provides-Extra: postgres
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
  # authz
@@ -53,15 +54,18 @@ kept in agreement with each other.
53
54
 
54
55
  ## Status
55
56
 
56
- Release, version 0.6.0:
57
+ Release, version 0.8.0, a security release. [`docs/security.md`](docs/security.md) analyses the library end to end and records what changed; [`CHANGELOG.md`](CHANGELOG.md) lists the upgrade notes. The library provides:
57
58
 
58
- - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
59
- - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
60
- - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
61
- - **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
62
- - **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
63
- - **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
64
- - Async SQLite and PostgreSQL storage backends.
59
+ - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with conditions evaluated against stored policy and request context. Stored context takes precedence.
60
+ - **Three-Valued Conditions**: a condition may return `None` when its context is missing or invalid; rewrite rules combine `True`, `False`, and `None` with Kleene logic, and a check allows only on `True`, so missing context never grants access, even under `Exclusion`.
61
+ - **Bounded Evaluation**: per-check memoization, `max_depth`, and an evaluation budget (`max_evaluations`) for `check()` and `expand()`.
62
+ - **Singleton and Cardinality Constraints**: `singleton_relations` and `max_subjects` on `Namespace`, enforced under storage locks so concurrent writers cannot exceed them.
63
+ - **Declarative Role Hierarchies**: `build_role_hierarchy(roles_high_to_low, extra_relations=...)` with transitive downward inheritance and `override_<role>` relations.
64
+ - **Audit and Explanation**: `Client.explain()` returns an `EvaluationDecision` with the visited relations, matched tuples, condition results, and a reason.
65
+ - **Batch Evaluation and Introspection**: `check_batch()`, `list_permissions()`, and `list_objects()`.
66
+ - **Built-in Conditions**: `max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`, all fail-closed.
67
+ - **FastAPI Integration**: `requires()` and `explain_access()`, with resource placeholders resolved from path parameters unless `resource_sources` enables query parameters or headers.
68
+ - **Storage**: async SQLite and PostgreSQL backends with configurable table names and an option to connect without DDL privileges.
65
69
 
66
70
  ## Roadmap
67
71
 
@@ -71,13 +75,13 @@ Release, version 0.6.0:
71
75
  - JSON-Logic and CEL `ConditionEvaluator` implementations, alongside the
72
76
  existing Python-callable one
73
77
  - Java, Kotlin, Rust, and C++ SDKs
74
- - making `asyncpg`/`duckdb` and `pyjwt`/`httpx2` optional too, the way
75
- `fastapi` already is
76
78
 
77
79
  ## Install
78
80
 
79
81
  ```bash
80
- uv add python-authz
82
+ uv add python-authz # core, with SQLite storage
83
+ uv add "python-authz[postgres]" # adds PostgresStorage (asyncpg)
84
+ uv add "python-authz[fastapi]" # adds authz.integrations.fastapi
81
85
  ```
82
86
 
83
87
  ## Quickstart
@@ -128,6 +132,37 @@ It covers schema vs. data cycles, which rewrite rule (`Union`,
128
132
  situation, and when a depth-limit error means your data is legitimately
129
133
  deep versus something bypassed the guards.
130
134
 
135
+ ## Role Hierarchies & Cardinality Constraints
136
+
137
+ Version 0.7.0 added first-class support for ordered role tiers with upward override relations and singleton limits:
138
+
139
+ ```python
140
+ from authz import Namespace, build_role_hierarchy, RelationTuple, CardinalityViolationError
141
+
142
+ # Build 5-tier role relations with downward inheritance and override_<role> relations
143
+ roles = build_role_hierarchy([
144
+ "org_admin",
145
+ "org_manager",
146
+ "tpa_coordinator",
147
+ "org_user",
148
+ "diagnostic_billing_staff",
149
+ ])
150
+
151
+ # Enforce singleton constraint: an organization can have at most ONE org_admin
152
+ org_ns = Namespace("org", roles, singleton_relations={"org_admin"})
153
+ client.register_schema(org_ns)
154
+
155
+ # Alice is assigned org_admin -> inherits org_manager, tpa_coordinator, etc.
156
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:alice")])
157
+ assert await client.check("user:alice", "org_user", "org:acme") is True
158
+
159
+ # Bob cannot also be assigned org_admin on the same org
160
+ try:
161
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:bob")])
162
+ except CardinalityViolationError as e:
163
+ print(f"Singleton violation: {e}")
164
+ ```
165
+
131
166
  ## FastAPI Integration Example
132
167
 
133
168
  A route needs one thing from an authorization library: block the request
@@ -160,9 +195,15 @@ caller receives a 403 automatically. See
160
195
  example, including how `get_client`/`get_subject` compose with your app's
161
196
  own database and auth setup.
162
197
 
198
+ ## Security
199
+
200
+ Read [`docs/security.md`](docs/security.md) for the threat model, the findings fixed in 0.8.0, and the responsibilities that stay with the application. Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
201
+
163
202
  ## Development
164
203
 
165
204
  ```bash
166
- uv sync --extra fastapi
205
+ uv sync --all-extras
167
206
  uv run pytest
168
207
  ```
208
+
209
+ The PostgreSQL tests use the server at `AUTHZ_TEST_POSTGRES_DSN` (a superuser DSN; the tests create and drop their own database), or start one with testcontainers when Docker is available, and are skipped otherwise.
@@ -26,15 +26,18 @@ kept in agreement with each other.
26
26
 
27
27
  ## Status
28
28
 
29
- Release, version 0.6.0:
30
-
31
- - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with dynamic runtime ABAC context.
32
- - **Audit Trail & Policy Explanation**: `Client.explain()` returning `EvaluationDecision` with traversed paths, matched tuples, condition evaluations, and human-readable reasons.
33
- - **Batch Evaluation & Caching**: `Client.check_batch()` evaluating sequences of `CheckItem`s with per-batch ephemeral read caching.
34
- - **Introspection**: `Client.list_permissions()` and `Client.list_objects()` for resource and subject discovery.
35
- - **Declarative Built-in ABAC Predicates**: `builtin_conditions()` and `PythonCallableEvaluator.with_builtins()` (`max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`).
36
- - **Enhanced FastAPI Integration**: `requires()` with `get_context`, path/query/header resource parameter resolution, and `explain_access()` storing decisions on `request.state.authz_decision`.
37
- - Async SQLite and PostgreSQL storage backends.
29
+ Release, version 0.8.0, a security release. [`docs/security.md`](docs/security.md) analyses the library end to end and records what changed; [`CHANGELOG.md`](CHANGELOG.md) lists the upgrade notes. The library provides:
30
+
31
+ - **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with conditions evaluated against stored policy and request context. Stored context takes precedence.
32
+ - **Three-Valued Conditions**: a condition may return `None` when its context is missing or invalid; rewrite rules combine `True`, `False`, and `None` with Kleene logic, and a check allows only on `True`, so missing context never grants access, even under `Exclusion`.
33
+ - **Bounded Evaluation**: per-check memoization, `max_depth`, and an evaluation budget (`max_evaluations`) for `check()` and `expand()`.
34
+ - **Singleton and Cardinality Constraints**: `singleton_relations` and `max_subjects` on `Namespace`, enforced under storage locks so concurrent writers cannot exceed them.
35
+ - **Declarative Role Hierarchies**: `build_role_hierarchy(roles_high_to_low, extra_relations=...)` with transitive downward inheritance and `override_<role>` relations.
36
+ - **Audit and Explanation**: `Client.explain()` returns an `EvaluationDecision` with the visited relations, matched tuples, condition results, and a reason.
37
+ - **Batch Evaluation and Introspection**: `check_batch()`, `list_permissions()`, and `list_objects()`.
38
+ - **Built-in Conditions**: `max_amount`, `min_amount`, `equals_attr`, `in_set`, `jurisdiction_match`, `not_same_subject`, all fail-closed.
39
+ - **FastAPI Integration**: `requires()` and `explain_access()`, with resource placeholders resolved from path parameters unless `resource_sources` enables query parameters or headers.
40
+ - **Storage**: async SQLite and PostgreSQL backends with configurable table names and an option to connect without DDL privileges.
38
41
 
39
42
  ## Roadmap
40
43
 
@@ -44,13 +47,13 @@ Release, version 0.6.0:
44
47
  - JSON-Logic and CEL `ConditionEvaluator` implementations, alongside the
45
48
  existing Python-callable one
46
49
  - Java, Kotlin, Rust, and C++ SDKs
47
- - making `asyncpg`/`duckdb` and `pyjwt`/`httpx2` optional too, the way
48
- `fastapi` already is
49
50
 
50
51
  ## Install
51
52
 
52
53
  ```bash
53
- uv add python-authz
54
+ uv add python-authz # core, with SQLite storage
55
+ uv add "python-authz[postgres]" # adds PostgresStorage (asyncpg)
56
+ uv add "python-authz[fastapi]" # adds authz.integrations.fastapi
54
57
  ```
55
58
 
56
59
  ## Quickstart
@@ -101,6 +104,37 @@ It covers schema vs. data cycles, which rewrite rule (`Union`,
101
104
  situation, and when a depth-limit error means your data is legitimately
102
105
  deep versus something bypassed the guards.
103
106
 
107
+ ## Role Hierarchies & Cardinality Constraints
108
+
109
+ Version 0.7.0 added first-class support for ordered role tiers with upward override relations and singleton limits:
110
+
111
+ ```python
112
+ from authz import Namespace, build_role_hierarchy, RelationTuple, CardinalityViolationError
113
+
114
+ # Build 5-tier role relations with downward inheritance and override_<role> relations
115
+ roles = build_role_hierarchy([
116
+ "org_admin",
117
+ "org_manager",
118
+ "tpa_coordinator",
119
+ "org_user",
120
+ "diagnostic_billing_staff",
121
+ ])
122
+
123
+ # Enforce singleton constraint: an organization can have at most ONE org_admin
124
+ org_ns = Namespace("org", roles, singleton_relations={"org_admin"})
125
+ client.register_schema(org_ns)
126
+
127
+ # Alice is assigned org_admin -> inherits org_manager, tpa_coordinator, etc.
128
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:alice")])
129
+ assert await client.check("user:alice", "org_user", "org:acme") is True
130
+
131
+ # Bob cannot also be assigned org_admin on the same org
132
+ try:
133
+ await client.write([RelationTuple("org", "acme", "org_admin", "user:bob")])
134
+ except CardinalityViolationError as e:
135
+ print(f"Singleton violation: {e}")
136
+ ```
137
+
104
138
  ## FastAPI Integration Example
105
139
 
106
140
  A route needs one thing from an authorization library: block the request
@@ -133,9 +167,15 @@ caller receives a 403 automatically. See
133
167
  example, including how `get_client`/`get_subject` compose with your app's
134
168
  own database and auth setup.
135
169
 
170
+ ## Security
171
+
172
+ Read [`docs/security.md`](docs/security.md) for the threat model, the findings fixed in 0.8.0, and the responsibilities that stay with the application. Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
173
+
136
174
  ## Development
137
175
 
138
176
  ```bash
139
- uv sync --extra fastapi
177
+ uv sync --all-extras
140
178
  uv run pytest
141
179
  ```
180
+
181
+ The PostgreSQL tests use the server at `AUTHZ_TEST_POSTGRES_DSN` (a superuser DSN; the tests create and drop their own database), or start one with testcontainers when Docker is available, and are skipped otherwise.
@@ -1,16 +1,12 @@
1
1
  [project]
2
2
  name = "python-authz"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  requires-python = ">=3.11"
9
- dependencies = [
10
- "duckdb>=1.5.5",
11
- "aiosqlite>=0.21.0",
12
- "asyncpg>=0.31.0",
13
- ]
9
+ dependencies = ["aiosqlite>=0.21.0"]
14
10
  classifiers = [
15
11
  "Development Status :: 4 - Beta",
16
12
  "Intended Audience :: Developers",
@@ -20,6 +16,7 @@ classifiers = [
20
16
  "Programming Language :: Python :: 3.13",
21
17
  "Topic :: Security",
22
18
  "Topic :: Software Development :: Libraries :: Python Modules",
19
+ "Typing :: Typed",
23
20
  ]
24
21
 
25
22
  [[project.authors]]
@@ -27,22 +24,23 @@ name = "Anupam Gupta"
27
24
  email = "anupam@trois.in"
28
25
 
29
26
  [project.urls]
30
- Homepage = "https://github.com/mapuna/python-authz"
31
- Repository = "https://github.com/mapuna/python-authz"
32
- Issues = "https://github.com/mapuna/python-authz/issues"
27
+ Homepage = "https://github.com/tensor-forge-ai/python-authz"
28
+ Repository = "https://github.com/tensor-forge-ai/python-authz"
29
+ Issues = "https://github.com/tensor-forge-ai/python-authz/issues"
33
30
 
34
31
  [project.optional-dependencies]
35
- fastapi = ["fastapi[all]>=0.141.1"]
36
-
37
- [project.scripts]
38
- authz = "authz:main"
32
+ fastapi = ["fastapi>=0.141.1"]
33
+ postgres = ["asyncpg>=0.31.0"]
39
34
 
40
35
  [dependency-groups]
41
36
  dev = [
37
+ "asyncpg>=0.31.0",
38
+ "fastapi>=0.141.1",
42
39
  "httpx2>=2.12.0",
43
40
  "pyjwt>=2.13.0",
44
41
  "pytest>=8.3.0",
45
42
  "pytest-asyncio>=0.24.0",
43
+ "python-multipart>=0.0.20",
46
44
  "testcontainers[postgres]>=4.15.0",
47
45
  ]
48
46
 
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "python-authz"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "A framework-agnostic ReBAC/RBAC/ABAC authorization engine built on one Zanzibar-style relationship graph"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  authors = [{ name = "Anupam Gupta", email = "anupam@trois.in" }]
9
9
  requires-python = ">=3.11"
10
- dependencies = ["duckdb>=1.5.5", "aiosqlite>=0.21.0", "asyncpg>=0.31.0"]
10
+ dependencies = ["aiosqlite>=0.21.0"]
11
11
  classifiers = [
12
12
  "Development Status :: 4 - Beta",
13
13
  "Intended Audience :: Developers",
@@ -17,28 +17,30 @@ classifiers = [
17
17
  "Programming Language :: Python :: 3.13",
18
18
  "Topic :: Security",
19
19
  "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Typing :: Typed",
20
21
  ]
21
22
 
22
23
  [project.urls]
23
- Homepage = "https://github.com/mapuna/python-authz"
24
- Repository = "https://github.com/mapuna/python-authz"
25
- Issues = "https://github.com/mapuna/python-authz/issues"
24
+ Homepage = "https://github.com/tensor-forge-ai/python-authz"
25
+ Repository = "https://github.com/tensor-forge-ai/python-authz"
26
+ Issues = "https://github.com/tensor-forge-ai/python-authz/issues"
26
27
 
27
28
  [project.optional-dependencies]
28
- fastapi = ["fastapi[all]>=0.141.1"]
29
+ fastapi = ["fastapi>=0.141.1"]
30
+ postgres = ["asyncpg>=0.31.0"]
29
31
 
30
32
  [dependency-groups]
31
33
  dev = [
34
+ "asyncpg>=0.31.0",
35
+ "fastapi>=0.141.1",
32
36
  "httpx2>=2.12.0",
33
37
  "pyjwt>=2.13.0",
34
38
  "pytest>=8.3.0",
35
39
  "pytest-asyncio>=0.24.0",
40
+ "python-multipart>=0.0.20",
36
41
  "testcontainers[postgres]>=4.15.0",
37
42
  ]
38
43
 
39
- [project.scripts]
40
- authz = "authz:main"
41
-
42
44
  [tool.uv.build-backend]
43
45
  module-name = "authz"
44
46
 
@@ -1,3 +1,5 @@
1
+ from typing import TYPE_CHECKING, Any
2
+
1
3
  from authz.conditions import (
2
4
  ConditionEvaluator,
3
5
  PythonCallableEvaluator,
@@ -6,9 +8,11 @@ from authz.conditions import (
6
8
  from authz.engine import CheckItem, Client, EvaluationDecision, UsersetTree
7
9
  from authz.errors import (
8
10
  AuthError,
11
+ CardinalityViolationError,
9
12
  ConditionNotFound,
10
13
  CycleDetected,
11
14
  DepthLimitExceeded,
15
+ EvaluationBudgetExceeded,
12
16
  NamespaceNotFound,
13
17
  RelationNotFound,
14
18
  SchemaCycleDetected,
@@ -23,14 +27,18 @@ from authz.schema import (
23
27
  RewriteRule,
24
28
  TupleToUserset,
25
29
  Union,
30
+ build_role_hierarchy,
26
31
  )
27
32
  from authz.storage.base import Storage, Token
28
- from authz.storage.postgres import PostgresStorage
29
33
  from authz.storage.sqlite import SqliteStorage
30
34
  from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
31
35
 
36
+ if TYPE_CHECKING:
37
+ from authz.storage.postgres import PostgresStorage
38
+
32
39
  __all__ = [
33
40
  "AuthError",
41
+ "CardinalityViolationError",
34
42
  "CheckItem",
35
43
  "Client",
36
44
  "Condition",
@@ -39,6 +47,7 @@ __all__ = [
39
47
  "CycleDetected",
40
48
  "DepthLimitExceeded",
41
49
  "Direct",
50
+ "EvaluationBudgetExceeded",
42
51
  "EvaluationDecision",
43
52
  "Exclusion",
44
53
  "From",
@@ -46,7 +55,6 @@ __all__ = [
46
55
  "Namespace",
47
56
  "NamespaceNotFound",
48
57
  "ObjectRef",
49
- "PostgresStorage",
50
58
  "PythonCallableEvaluator",
51
59
  "RelationNotFound",
52
60
  "RelationTuple",
@@ -60,9 +68,19 @@ __all__ = [
60
68
  "Union",
61
69
  "UsersetTree",
62
70
  "WouldCreateCycle",
71
+ "build_role_hierarchy",
63
72
  "builtin_conditions",
64
73
  ]
65
74
 
66
75
 
67
- def main() -> None:
68
- print("Hello from authz!")
76
+ def __getattr__(name: str) -> Any:
77
+ if name == "PostgresStorage":
78
+ try:
79
+ from authz.storage.postgres import PostgresStorage
80
+ except ImportError as exc:
81
+ raise ImportError(
82
+ "PostgresStorage requires asyncpg; install it with "
83
+ "'pip install python-authz[postgres]'"
84
+ ) from exc
85
+ return PostgresStorage
86
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,159 @@
1
+ import inspect
2
+ import math
3
+ from typing import Any, Awaitable, Callable, Mapping, Protocol, Union
4
+
5
+ from authz.errors import ConditionNotFound
6
+
7
+ ConditionResult = Union[bool, None]
8
+ ConditionFn = Callable[..., Union[ConditionResult, Awaitable[ConditionResult]]]
9
+
10
+ _RESERVED_KEYS = frozenset({"subject", "resource"})
11
+
12
+
13
+ class ConditionEvaluator(Protocol):
14
+ def is_registered(self, name: str) -> bool: ...
15
+
16
+ async def evaluate(
17
+ self, name: str, subject: str, resource: str, context: Mapping[str, Any]
18
+ ) -> bool | None: ... # None: required context missing or invalid
19
+
20
+
21
+ class PythonCallableEvaluator:
22
+ def __init__(self) -> None:
23
+ self._conditions: dict[str, ConditionFn] = {}
24
+
25
+ @classmethod
26
+ def with_builtins(
27
+ cls, extra: Mapping[str, ConditionFn] | None = None
28
+ ) -> "PythonCallableEvaluator":
29
+ evaluator = cls()
30
+ for name, fn in builtin_conditions().items():
31
+ evaluator.register(name, fn)
32
+ if extra:
33
+ for name, fn in extra.items():
34
+ evaluator.register(name, fn)
35
+ return evaluator
36
+
37
+ def register(self, name: str, fn: ConditionFn) -> None:
38
+ self._conditions[name] = fn
39
+
40
+ def is_registered(self, name: str) -> bool:
41
+ return name in self._conditions
42
+
43
+ async def evaluate(
44
+ self, name: str, subject: str, resource: str, context: Mapping[str, Any]
45
+ ) -> bool | None:
46
+ try:
47
+ fn = self._conditions[name]
48
+ except KeyError:
49
+ raise ConditionNotFound(name) from None
50
+ for key in context:
51
+ if not isinstance(key, str):
52
+ raise ValueError(
53
+ f"condition {name!r}: context keys must be strings, got {key!r}"
54
+ )
55
+ if key in _RESERVED_KEYS:
56
+ raise ValueError(
57
+ f"condition {name!r}: context key {key!r} is reserved"
58
+ )
59
+ result = fn(subject=subject, resource=resource, **context)
60
+ if inspect.isawaitable(result):
61
+ result = await result
62
+ if result is None or isinstance(result, bool):
63
+ return result
64
+ raise TypeError(
65
+ f"condition {name!r} must return True, False or None, "
66
+ f"got {type(result).__name__}"
67
+ )
68
+
69
+
70
+ def _number(value: Any) -> float | None:
71
+ if value is None or isinstance(value, bool):
72
+ return None
73
+ try:
74
+ number = float(value)
75
+ except (ValueError, TypeError, OverflowError):
76
+ return None
77
+ return number if math.isfinite(number) else None
78
+
79
+
80
+ def _collection(value: Any) -> Any:
81
+ return value if isinstance(value, (list, tuple, set, frozenset)) else None
82
+
83
+
84
+ def _attribute(ctx: Mapping[str, Any]) -> Any:
85
+ name = ctx.get("attribute")
86
+ if not isinstance(name, str) or not name:
87
+ return None
88
+ return ctx.get(name)
89
+
90
+
91
+ def _member(value: Any, allowed: Any) -> bool | None:
92
+ try:
93
+ return value in allowed
94
+ except TypeError:
95
+ return None
96
+
97
+
98
+ def _max_amount(*, subject: str, resource: str, **ctx: Any) -> bool | None:
99
+ amount, limit = _number(ctx.get("amount")), _number(ctx.get("max_amount"))
100
+ if amount is None or limit is None:
101
+ return None
102
+ return amount <= limit
103
+
104
+
105
+ def _min_amount(*, subject: str, resource: str, **ctx: Any) -> bool | None:
106
+ amount, limit = _number(ctx.get("amount")), _number(ctx.get("min_amount"))
107
+ if amount is None or limit is None:
108
+ return None
109
+ return amount >= limit
110
+
111
+
112
+ def _equals_attr(*, subject: str, resource: str, **ctx: Any) -> bool | None:
113
+ actual, expected = _attribute(ctx), ctx.get("expected")
114
+ if actual is None or expected is None:
115
+ return None
116
+ return actual == expected
117
+
118
+
119
+ def _in_set(*, subject: str, resource: str, **ctx: Any) -> bool | None:
120
+ actual, allowed = _attribute(ctx), _collection(ctx.get("allowed_values"))
121
+ if actual is None or allowed is None:
122
+ return None
123
+ return _member(actual, allowed)
124
+
125
+
126
+ def _jurisdiction_match(*, subject: str, resource: str, **ctx: Any) -> bool | None:
127
+ jurisdiction = ctx.get("jurisdiction")
128
+ if jurisdiction is None:
129
+ return None
130
+ allowed, required = ctx.get("allowed_jurisdictions"), ctx.get("required_jurisdiction")
131
+ if allowed is None and required is None:
132
+ return None
133
+ results = []
134
+ if allowed is not None:
135
+ allowed = _collection(allowed)
136
+ results.append(None if allowed is None else _member(jurisdiction, allowed))
137
+ if required is not None:
138
+ results.append(jurisdiction == required)
139
+ if False in results:
140
+ return False
141
+ return None if None in results else True
142
+
143
+
144
+ def _not_same_subject(*, subject: str, resource: str, **ctx: Any) -> bool | None:
145
+ excluded = ctx.get("excluded_subject")
146
+ if not isinstance(excluded, str) or not excluded:
147
+ return None
148
+ return subject != excluded
149
+
150
+
151
+ def builtin_conditions() -> dict[str, ConditionFn]:
152
+ return {
153
+ "max_amount": _max_amount,
154
+ "min_amount": _min_amount,
155
+ "equals_attr": _equals_attr,
156
+ "in_set": _in_set,
157
+ "jurisdiction_match": _jurisdiction_match,
158
+ "not_same_subject": _not_same_subject,
159
+ }