python-authz 0.7.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.
- {python_authz-0.7.0 → python_authz-0.8.0}/PKG-INFO +30 -22
- {python_authz-0.7.0 → python_authz-0.8.0}/README.md +23 -16
- {python_authz-0.7.0 → python_authz-0.8.0}/pyproject.toml +11 -13
- {python_authz-0.7.0 → python_authz-0.8.0}/pyproject.toml.orig +11 -9
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/__init__.py +18 -4
- python_authz-0.8.0/src/authz/conditions.py +159 -0
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/engine.py +303 -208
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/errors.py +6 -0
- python_authz-0.8.0/src/authz/integrations/fastapi.py +157 -0
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/schema.py +14 -11
- python_authz-0.8.0/src/authz/storage/__init__.py +0 -0
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/storage/base.py +5 -0
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/storage/postgres.py +81 -35
- python_authz-0.8.0/src/authz/storage/sqlite.py +198 -0
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/tuples.py +43 -1
- python_authz-0.7.0/src/authz/conditions.py +0 -113
- python_authz-0.7.0/src/authz/integrations/fastapi.py +0 -86
- python_authz-0.7.0/src/authz/storage/sqlite.py +0 -154
- {python_authz-0.7.0 → python_authz-0.8.0}/LICENSE +0 -0
- {python_authz-0.7.0 → python_authz-0.8.0}/src/authz/integrations/__init__.py +0 -0
- /python_authz-0.7.0/src/authz/storage/__init__.py → /python_authz-0.8.0/src/authz/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-authz
|
|
3
|
-
Version: 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
|
-
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Dist: aiosqlite>=0.21.0
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
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/
|
|
23
|
-
Project-URL: Repository, https://github.com/
|
|
24
|
-
Project-URL: Issues, https://github.com/
|
|
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,17 +54,18 @@ kept in agreement with each other.
|
|
|
53
54
|
|
|
54
55
|
## Status
|
|
55
56
|
|
|
56
|
-
Release, version 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
|
-
- **
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
61
|
-
- **
|
|
62
|
-
- **
|
|
63
|
-
- **
|
|
64
|
-
- **
|
|
65
|
-
- **
|
|
66
|
-
-
|
|
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.
|
|
67
69
|
|
|
68
70
|
## Roadmap
|
|
69
71
|
|
|
@@ -73,13 +75,13 @@ Release, version 0.7.0:
|
|
|
73
75
|
- JSON-Logic and CEL `ConditionEvaluator` implementations, alongside the
|
|
74
76
|
existing Python-callable one
|
|
75
77
|
- Java, Kotlin, Rust, and C++ SDKs
|
|
76
|
-
- making `asyncpg`/`duckdb` and `pyjwt`/`httpx2` optional too, the way
|
|
77
|
-
`fastapi` already is
|
|
78
78
|
|
|
79
79
|
## Install
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
-
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
|
|
83
85
|
```
|
|
84
86
|
|
|
85
87
|
## Quickstart
|
|
@@ -132,7 +134,7 @@ deep versus something bypassed the guards.
|
|
|
132
134
|
|
|
133
135
|
## Role Hierarchies & Cardinality Constraints
|
|
134
136
|
|
|
135
|
-
|
|
137
|
+
Version 0.7.0 added first-class support for ordered role tiers with upward override relations and singleton limits:
|
|
136
138
|
|
|
137
139
|
```python
|
|
138
140
|
from authz import Namespace, build_role_hierarchy, RelationTuple, CardinalityViolationError
|
|
@@ -193,9 +195,15 @@ caller receives a 403 automatically. See
|
|
|
193
195
|
example, including how `get_client`/`get_subject` compose with your app's
|
|
194
196
|
own database and auth setup.
|
|
195
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
|
+
|
|
196
202
|
## Development
|
|
197
203
|
|
|
198
204
|
```bash
|
|
199
|
-
uv sync --
|
|
205
|
+
uv sync --all-extras
|
|
200
206
|
uv run pytest
|
|
201
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,17 +26,18 @@ kept in agreement with each other.
|
|
|
26
26
|
|
|
27
27
|
## Status
|
|
28
28
|
|
|
29
|
-
Release, version 0.
|
|
30
|
-
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
-
|
|
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.
|
|
40
41
|
|
|
41
42
|
## Roadmap
|
|
42
43
|
|
|
@@ -46,13 +47,13 @@ Release, version 0.7.0:
|
|
|
46
47
|
- JSON-Logic and CEL `ConditionEvaluator` implementations, alongside the
|
|
47
48
|
existing Python-callable one
|
|
48
49
|
- Java, Kotlin, Rust, and C++ SDKs
|
|
49
|
-
- making `asyncpg`/`duckdb` and `pyjwt`/`httpx2` optional too, the way
|
|
50
|
-
`fastapi` already is
|
|
51
50
|
|
|
52
51
|
## Install
|
|
53
52
|
|
|
54
53
|
```bash
|
|
55
|
-
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
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
## Quickstart
|
|
@@ -105,7 +106,7 @@ deep versus something bypassed the guards.
|
|
|
105
106
|
|
|
106
107
|
## Role Hierarchies & Cardinality Constraints
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
Version 0.7.0 added first-class support for ordered role tiers with upward override relations and singleton limits:
|
|
109
110
|
|
|
110
111
|
```python
|
|
111
112
|
from authz import Namespace, build_role_hierarchy, RelationTuple, CardinalityViolationError
|
|
@@ -166,9 +167,15 @@ caller receives a 403 automatically. See
|
|
|
166
167
|
example, including how `get_client`/`get_subject` compose with your app's
|
|
167
168
|
own database and auth setup.
|
|
168
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
|
+
|
|
169
174
|
## Development
|
|
170
175
|
|
|
171
176
|
```bash
|
|
172
|
-
uv sync --
|
|
177
|
+
uv sync --all-extras
|
|
173
178
|
uv run pytest
|
|
174
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.
|
|
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/
|
|
31
|
-
Repository = "https://github.com/
|
|
32
|
-
Issues = "https://github.com/
|
|
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
|
|
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.
|
|
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 = ["
|
|
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/
|
|
24
|
-
Repository = "https://github.com/
|
|
25
|
-
Issues = "https://github.com/
|
|
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
|
|
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,
|
|
@@ -10,6 +12,7 @@ from authz.errors import (
|
|
|
10
12
|
ConditionNotFound,
|
|
11
13
|
CycleDetected,
|
|
12
14
|
DepthLimitExceeded,
|
|
15
|
+
EvaluationBudgetExceeded,
|
|
13
16
|
NamespaceNotFound,
|
|
14
17
|
RelationNotFound,
|
|
15
18
|
SchemaCycleDetected,
|
|
@@ -27,10 +30,12 @@ from authz.schema import (
|
|
|
27
30
|
build_role_hierarchy,
|
|
28
31
|
)
|
|
29
32
|
from authz.storage.base import Storage, Token
|
|
30
|
-
from authz.storage.postgres import PostgresStorage
|
|
31
33
|
from authz.storage.sqlite import SqliteStorage
|
|
32
34
|
from authz.tuples import Condition, ObjectRef, RelationTuple, SubjectRef
|
|
33
35
|
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from authz.storage.postgres import PostgresStorage
|
|
38
|
+
|
|
34
39
|
__all__ = [
|
|
35
40
|
"AuthError",
|
|
36
41
|
"CardinalityViolationError",
|
|
@@ -42,6 +47,7 @@ __all__ = [
|
|
|
42
47
|
"CycleDetected",
|
|
43
48
|
"DepthLimitExceeded",
|
|
44
49
|
"Direct",
|
|
50
|
+
"EvaluationBudgetExceeded",
|
|
45
51
|
"EvaluationDecision",
|
|
46
52
|
"Exclusion",
|
|
47
53
|
"From",
|
|
@@ -49,7 +55,6 @@ __all__ = [
|
|
|
49
55
|
"Namespace",
|
|
50
56
|
"NamespaceNotFound",
|
|
51
57
|
"ObjectRef",
|
|
52
|
-
"PostgresStorage",
|
|
53
58
|
"PythonCallableEvaluator",
|
|
54
59
|
"RelationNotFound",
|
|
55
60
|
"RelationTuple",
|
|
@@ -68,5 +73,14 @@ __all__ = [
|
|
|
68
73
|
]
|
|
69
74
|
|
|
70
75
|
|
|
71
|
-
def
|
|
72
|
-
|
|
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
|
+
}
|