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.
- {python_authz-0.6.0 → python_authz-0.8.0}/PKG-INFO +60 -19
- {python_authz-0.6.0 → python_authz-0.8.0}/README.md +53 -13
- {python_authz-0.6.0 → python_authz-0.8.0}/pyproject.toml +11 -13
- {python_authz-0.6.0 → python_authz-0.8.0}/pyproject.toml.orig +11 -9
- {python_authz-0.6.0 → python_authz-0.8.0}/src/authz/__init__.py +22 -4
- python_authz-0.8.0/src/authz/conditions.py +159 -0
- {python_authz-0.6.0 → python_authz-0.8.0}/src/authz/engine.py +330 -208
- {python_authz-0.6.0 → python_authz-0.8.0}/src/authz/errors.py +31 -0
- python_authz-0.8.0/src/authz/integrations/fastapi.py +157 -0
- {python_authz-0.6.0 → python_authz-0.8.0}/src/authz/schema.py +63 -11
- python_authz-0.8.0/src/authz/storage/__init__.py +0 -0
- {python_authz-0.6.0 → python_authz-0.8.0}/src/authz/storage/base.py +5 -0
- {python_authz-0.6.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.6.0 → python_authz-0.8.0}/src/authz/tuples.py +43 -1
- python_authz-0.6.0/src/authz/conditions.py +0 -113
- python_authz-0.6.0/src/authz/integrations/fastapi.py +0 -86
- python_authz-0.6.0/src/authz/storage/sqlite.py +0 -154
- {python_authz-0.6.0 → python_authz-0.8.0}/LICENSE +0 -0
- {python_authz-0.6.0 → python_authz-0.8.0}/src/authz/integrations/__init__.py +0 -0
- /python_authz-0.6.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,15 +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
|
-
- **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
61
|
-
- **
|
|
62
|
-
- **Declarative
|
|
63
|
-
- **
|
|
64
|
-
-
|
|
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 --
|
|
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.
|
|
30
|
-
|
|
31
|
-
- **Unified ReBAC + RBAC + ABAC**: Zanzibar-style relationship graphs with
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **Declarative
|
|
36
|
-
- **
|
|
37
|
-
-
|
|
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 --
|
|
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.
|
|
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,
|
|
@@ -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
|
|
68
|
-
|
|
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
|
+
}
|