rbacx 0.1.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.
- rbacx-0.1.0/LICENSE +22 -0
- rbacx-0.1.0/MANIFEST.in +14 -0
- rbacx-0.1.0/PKG-INFO +179 -0
- rbacx-0.1.0/README.md +93 -0
- rbacx-0.1.0/pyproject.toml +62 -0
- rbacx-0.1.0/setup.cfg +4 -0
- rbacx-0.1.0/src/rbacx/__init__.py +20 -0
- rbacx-0.1.0/src/rbacx/__pycache__/__init__.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/adapters/asgi.py +25 -0
- rbacx-0.1.0/src/rbacx/adapters/asgi_accesslog.py +36 -0
- rbacx-0.1.0/src/rbacx/adapters/asgi_logging.py +46 -0
- rbacx-0.1.0/src/rbacx/adapters/django/decorators.py +25 -0
- rbacx-0.1.0/src/rbacx/adapters/django/middleware.py +38 -0
- rbacx-0.1.0/src/rbacx/adapters/django/trace.py +25 -0
- rbacx-0.1.0/src/rbacx/adapters/drf.py +20 -0
- rbacx-0.1.0/src/rbacx/adapters/fastapi.py +55 -0
- rbacx-0.1.0/src/rbacx/adapters/fastapi_guard.py +19 -0
- rbacx-0.1.0/src/rbacx/adapters/flask.py +49 -0
- rbacx-0.1.0/src/rbacx/adapters/flask_guard.py +26 -0
- rbacx-0.1.0/src/rbacx/adapters/litestar.py +35 -0
- rbacx-0.1.0/src/rbacx/adapters/litestar_guard.py +36 -0
- rbacx-0.1.0/src/rbacx/adapters/starlette.py +53 -0
- rbacx-0.1.0/src/rbacx/cli.py +56 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/compiler.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/decision.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/engine.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/interpreter.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/model.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/obligations.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/policy.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/policyset.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/ports.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/__pycache__/roles.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/core/compiler.py +143 -0
- rbacx-0.1.0/src/rbacx/core/decision.py +16 -0
- rbacx-0.1.0/src/rbacx/core/engine.py +212 -0
- rbacx-0.1.0/src/rbacx/core/model.py +26 -0
- rbacx-0.1.0/src/rbacx/core/obligations.py +18 -0
- rbacx-0.1.0/src/rbacx/core/policy.py +367 -0
- rbacx-0.1.0/src/rbacx/core/policyset.py +119 -0
- rbacx-0.1.0/src/rbacx/core/ports.py +27 -0
- rbacx-0.1.0/src/rbacx/core/roles.py +28 -0
- rbacx-0.1.0/src/rbacx/dsl/__pycache__/lint.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/dsl/lint.py +201 -0
- rbacx-0.1.0/src/rbacx/dsl/policy.schema.json +388 -0
- rbacx-0.1.0/src/rbacx/dsl/validate.py +16 -0
- rbacx-0.1.0/src/rbacx/logging/context.py +29 -0
- rbacx-0.1.0/src/rbacx/logging/decision_logger.py +30 -0
- rbacx-0.1.0/src/rbacx/metrics/__pycache__/otel.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/metrics/__pycache__/prometheus.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/metrics/otel.py +46 -0
- rbacx-0.1.0/src/rbacx/metrics/prometheus.py +37 -0
- rbacx-0.1.0/src/rbacx/obligations/enforcer.py +49 -0
- rbacx-0.1.0/src/rbacx/policy/__pycache__/loader.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/policy/loader.py +53 -0
- rbacx-0.1.0/src/rbacx/py.typed +0 -0
- rbacx-0.1.0/src/rbacx/storage/__init__.py +122 -0
- rbacx-0.1.0/src/rbacx/storage/__pycache__/__init__.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/storage/s3.py +91 -0
- rbacx-0.1.0/src/rbacx/store/__pycache__/file_store.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/store/__pycache__/manager.cpython-312.pyc +0 -0
- rbacx-0.1.0/src/rbacx/store/file_store.py +23 -0
- rbacx-0.1.0/src/rbacx/store/http_store.py +33 -0
- rbacx-0.1.0/src/rbacx/store/manager.py +64 -0
- rbacx-0.1.0/src/rbacx/store/s3_store.py +30 -0
- rbacx-0.1.0/src/rbacx/telemetry/decision_log.py +18 -0
- rbacx-0.1.0/src/rbacx/telemetry/metrics_prom.py +22 -0
- rbacx-0.1.0/src/rbacx.egg-info/PKG-INFO +179 -0
- rbacx-0.1.0/src/rbacx.egg-info/SOURCES.txt +71 -0
- rbacx-0.1.0/src/rbacx.egg-info/dependency_links.txt +1 -0
- rbacx-0.1.0/src/rbacx.egg-info/entry_points.txt +2 -0
- rbacx-0.1.0/src/rbacx.egg-info/requires.txt +58 -0
- rbacx-0.1.0/src/rbacx.egg-info/top_level.txt +1 -0
rbacx-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
MIT License
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2025 RBACX (Cheater121)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
rbacx-0.1.0/MANIFEST.in
ADDED
rbacx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rbacx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: RBAC/ABAC policy engine for Python with policy sets, condition DSL, and hot reload
|
|
5
|
+
Author-email: Cheater121 <cheater1211@gmail.com>
|
|
6
|
+
License:
|
|
7
|
+
MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2025 RBACX (Cheater121)
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
Project-URL: Homepage, https://github.com/Cheater121/rbacx
|
|
30
|
+
Project-URL: Repository, https://github.com/Cheater121/rbacx
|
|
31
|
+
Project-URL: Issues, https://github.com/Cheater121/rbacx/issues
|
|
32
|
+
Project-URL: Changelog, https://github.com/Cheater121/rbacx/blob/main/CHANGELOG.md
|
|
33
|
+
Keywords: rbac,abac,policy,authorization
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Typing :: Typed
|
|
37
|
+
Requires-Python: >=3.8
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Provides-Extra: adapters-fastapi
|
|
41
|
+
Requires-Dist: fastapi>=0.110; extra == "adapters-fastapi"
|
|
42
|
+
Requires-Dist: starlette>=0.37; extra == "adapters-fastapi"
|
|
43
|
+
Provides-Extra: adapters-flask
|
|
44
|
+
Requires-Dist: flask>=2.3; extra == "adapters-flask"
|
|
45
|
+
Provides-Extra: adapters-drf
|
|
46
|
+
Requires-Dist: django>=4.2; extra == "adapters-drf"
|
|
47
|
+
Requires-Dist: djangorestframework>=3.14; extra == "adapters-drf"
|
|
48
|
+
Provides-Extra: metrics
|
|
49
|
+
Requires-Dist: prometheus-client>=0.14; extra == "metrics"
|
|
50
|
+
Requires-Dist: opentelemetry-api>=1.25; extra == "metrics"
|
|
51
|
+
Provides-Extra: dates
|
|
52
|
+
Requires-Dist: python-dateutil>=2.8; extra == "dates"
|
|
53
|
+
Provides-Extra: http
|
|
54
|
+
Requires-Dist: requests>=2.28; extra == "http"
|
|
55
|
+
Provides-Extra: s3
|
|
56
|
+
Requires-Dist: boto3>=1.26; extra == "s3"
|
|
57
|
+
Provides-Extra: otel
|
|
58
|
+
Requires-Dist: opentelemetry-api>=1.19; extra == "otel"
|
|
59
|
+
Requires-Dist: opentelemetry-sdk>=1.19; extra == "otel"
|
|
60
|
+
Provides-Extra: dev
|
|
61
|
+
Requires-Dist: pytest; extra == "dev"
|
|
62
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
63
|
+
Requires-Dist: pre-commit>=3.7; extra == "dev"
|
|
64
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
65
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
66
|
+
Requires-Dist: twine; extra == "dev"
|
|
67
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
68
|
+
Provides-Extra: tests
|
|
69
|
+
Requires-Dist: pytest>=8; extra == "tests"
|
|
70
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "tests"
|
|
71
|
+
Requires-Dist: coverage>=7; extra == "tests"
|
|
72
|
+
Provides-Extra: docs
|
|
73
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
74
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
75
|
+
Provides-Extra: examples
|
|
76
|
+
Requires-Dist: uvicorn>=0.30; extra == "examples"
|
|
77
|
+
Requires-Dist: fastapi>=0.110; extra == "examples"
|
|
78
|
+
Requires-Dist: starlette>=0.37; extra == "examples"
|
|
79
|
+
Requires-Dist: flask>=2.3; extra == "examples"
|
|
80
|
+
Requires-Dist: django>=4.2; extra == "examples"
|
|
81
|
+
Requires-Dist: djangorestframework>=3.14; extra == "examples"
|
|
82
|
+
Requires-Dist: litestar>=2.8; extra == "examples"
|
|
83
|
+
Provides-Extra: validate
|
|
84
|
+
Requires-Dist: jsonschema>=4.18; extra == "validate"
|
|
85
|
+
Dynamic: license-file
|
|
86
|
+
|
|
87
|
+
# RBACX
|
|
88
|
+
|
|
89
|
+
<!--
|
|
90
|
+
[](https://github.com/Cheater121/rbacx/actions/workflows/ci.yml)
|
|
91
|
+
[](https://cheater121.github.io/rbacx/)
|
|
92
|
+
-->
|
|
93
|
+
[](LICENSE)
|
|
94
|
+
<!-- After publishing to PyPI:
|
|
95
|
+
[](https://pypi.org/project/rbacx/)
|
|
96
|
+
[](https://pypi.org/project/rbacx/)
|
|
97
|
+
-->
|
|
98
|
+
|
|
99
|
+
Universal **RBAC/ABAC** policy engine for Python with a clean core, policy sets, a compact condition DSL (including time ops), and adapters for common web frameworks.
|
|
100
|
+
|
|
101
|
+
## Features
|
|
102
|
+
- Algorithms: `deny-overrides` (default), `permit-overrides`, `first-applicable`
|
|
103
|
+
- Conditions: `==`, `!=`, `<`, `<=`, `>`, `>=`, `contains`, `in`, `hasAll`, `hasAny`, `startsWith`, `endsWith`, `before`, `after`, `between`
|
|
104
|
+
- Explainability: `decision`, `reason`, `rule_id`/`last_rule_id`, `obligations`
|
|
105
|
+
- Policy sets: combine multiple policies with the same algorithms
|
|
106
|
+
- Hot reload: file/HTTP/S3 sources with ETag and a polling manager
|
|
107
|
+
- Types & lint: mypy-friendly core, Ruff-ready
|
|
108
|
+
|
|
109
|
+
## Installation
|
|
110
|
+
```bash
|
|
111
|
+
pip install rbacx
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Quickstart
|
|
115
|
+
```python
|
|
116
|
+
from rbacx.core.engine import Guard
|
|
117
|
+
from rbacx.core.model import Subject, Action, Resource, Context
|
|
118
|
+
|
|
119
|
+
policy = {
|
|
120
|
+
"algorithm": "deny-overrides",
|
|
121
|
+
"rules": [
|
|
122
|
+
{
|
|
123
|
+
"id": "doc_read",
|
|
124
|
+
"effect": "permit",
|
|
125
|
+
"actions": ["read"],
|
|
126
|
+
"resource": {"type": "doc", "attrs": {"visibility": ["public", "internal"]}},
|
|
127
|
+
"condition": {"hasAny": [ {"attr": "subject.roles"}, ["reader", "admin"] ]},
|
|
128
|
+
"obligations": [{"mfa": True}]
|
|
129
|
+
},
|
|
130
|
+
{"id": "doc_deny_archived", "effect": "deny", "actions": ["*"], "resource": {"type": "doc", "attrs": {"archived": True}}},
|
|
131
|
+
],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
g = Guard(policy)
|
|
135
|
+
|
|
136
|
+
d = g.evaluate_sync(
|
|
137
|
+
subject=Subject(id="u1", roles=["reader"]),
|
|
138
|
+
action=Action("read"),
|
|
139
|
+
resource=Resource(type="doc", id="42", attrs={"visibility": "public"}),
|
|
140
|
+
context=Context(attrs={"mfa": True}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert d.allowed is True
|
|
144
|
+
assert d.effect == "permit"
|
|
145
|
+
print(d.reason, d.rule_id) # "matched", "doc_read"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Decision schema
|
|
149
|
+
- `decision`: `"permit"` or `"deny"`
|
|
150
|
+
- `reason`: `"matched"`, `"explicit_deny"`, `"action_mismatch"`, `"resource_mismatch"`, `"condition_mismatch"`, `"condition_type_mismatch"`, `"no_match"`
|
|
151
|
+
- `rule_id` / `last_rule_id`
|
|
152
|
+
- `obligations`: list passed to the obligation checker
|
|
153
|
+
|
|
154
|
+
### Policy sets
|
|
155
|
+
```python
|
|
156
|
+
from rbacx.core.policyset import decide as decide_policyset
|
|
157
|
+
|
|
158
|
+
policyset = {"algorithm":"deny-overrides", "policies":[ policy, {"rules":[...]} ]}
|
|
159
|
+
result = decide_policyset(policyset, {"subject":..., "action":"read", "resource":...})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Hot reloading
|
|
163
|
+
```python
|
|
164
|
+
from rbacx.core.engine import Guard
|
|
165
|
+
from rbacx.storage import FilePolicySource # from rbacx.storage import HotReloader if you prefer
|
|
166
|
+
from rbacx.store.manager import PolicyManager
|
|
167
|
+
|
|
168
|
+
guard = Guard(policy={})
|
|
169
|
+
mgr = PolicyManager(guard, FilePolicySource("policy.json"))
|
|
170
|
+
mgr.poll_once() # initial load
|
|
171
|
+
mgr.start_polling(10) # background polling thread
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Packaging
|
|
175
|
+
- We ship `py.typed` so type checkers pick up annotations.
|
|
176
|
+
- Standard PyPA flow: `python -m build`, then `twine upload` to (Test)PyPI.
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
MIT
|
rbacx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# RBACX
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
[](https://github.com/Cheater121/rbacx/actions/workflows/ci.yml)
|
|
5
|
+
[](https://cheater121.github.io/rbacx/)
|
|
6
|
+
-->
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
<!-- After publishing to PyPI:
|
|
9
|
+
[](https://pypi.org/project/rbacx/)
|
|
10
|
+
[](https://pypi.org/project/rbacx/)
|
|
11
|
+
-->
|
|
12
|
+
|
|
13
|
+
Universal **RBAC/ABAC** policy engine for Python with a clean core, policy sets, a compact condition DSL (including time ops), and adapters for common web frameworks.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
- Algorithms: `deny-overrides` (default), `permit-overrides`, `first-applicable`
|
|
17
|
+
- Conditions: `==`, `!=`, `<`, `<=`, `>`, `>=`, `contains`, `in`, `hasAll`, `hasAny`, `startsWith`, `endsWith`, `before`, `after`, `between`
|
|
18
|
+
- Explainability: `decision`, `reason`, `rule_id`/`last_rule_id`, `obligations`
|
|
19
|
+
- Policy sets: combine multiple policies with the same algorithms
|
|
20
|
+
- Hot reload: file/HTTP/S3 sources with ETag and a polling manager
|
|
21
|
+
- Types & lint: mypy-friendly core, Ruff-ready
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
```bash
|
|
25
|
+
pip install rbacx
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
```python
|
|
30
|
+
from rbacx.core.engine import Guard
|
|
31
|
+
from rbacx.core.model import Subject, Action, Resource, Context
|
|
32
|
+
|
|
33
|
+
policy = {
|
|
34
|
+
"algorithm": "deny-overrides",
|
|
35
|
+
"rules": [
|
|
36
|
+
{
|
|
37
|
+
"id": "doc_read",
|
|
38
|
+
"effect": "permit",
|
|
39
|
+
"actions": ["read"],
|
|
40
|
+
"resource": {"type": "doc", "attrs": {"visibility": ["public", "internal"]}},
|
|
41
|
+
"condition": {"hasAny": [ {"attr": "subject.roles"}, ["reader", "admin"] ]},
|
|
42
|
+
"obligations": [{"mfa": True}]
|
|
43
|
+
},
|
|
44
|
+
{"id": "doc_deny_archived", "effect": "deny", "actions": ["*"], "resource": {"type": "doc", "attrs": {"archived": True}}},
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
g = Guard(policy)
|
|
49
|
+
|
|
50
|
+
d = g.evaluate_sync(
|
|
51
|
+
subject=Subject(id="u1", roles=["reader"]),
|
|
52
|
+
action=Action("read"),
|
|
53
|
+
resource=Resource(type="doc", id="42", attrs={"visibility": "public"}),
|
|
54
|
+
context=Context(attrs={"mfa": True}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert d.allowed is True
|
|
58
|
+
assert d.effect == "permit"
|
|
59
|
+
print(d.reason, d.rule_id) # "matched", "doc_read"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Decision schema
|
|
63
|
+
- `decision`: `"permit"` or `"deny"`
|
|
64
|
+
- `reason`: `"matched"`, `"explicit_deny"`, `"action_mismatch"`, `"resource_mismatch"`, `"condition_mismatch"`, `"condition_type_mismatch"`, `"no_match"`
|
|
65
|
+
- `rule_id` / `last_rule_id`
|
|
66
|
+
- `obligations`: list passed to the obligation checker
|
|
67
|
+
|
|
68
|
+
### Policy sets
|
|
69
|
+
```python
|
|
70
|
+
from rbacx.core.policyset import decide as decide_policyset
|
|
71
|
+
|
|
72
|
+
policyset = {"algorithm":"deny-overrides", "policies":[ policy, {"rules":[...]} ]}
|
|
73
|
+
result = decide_policyset(policyset, {"subject":..., "action":"read", "resource":...})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Hot reloading
|
|
77
|
+
```python
|
|
78
|
+
from rbacx.core.engine import Guard
|
|
79
|
+
from rbacx.storage import FilePolicySource # from rbacx.storage import HotReloader if you prefer
|
|
80
|
+
from rbacx.store.manager import PolicyManager
|
|
81
|
+
|
|
82
|
+
guard = Guard(policy={})
|
|
83
|
+
mgr = PolicyManager(guard, FilePolicySource("policy.json"))
|
|
84
|
+
mgr.poll_once() # initial load
|
|
85
|
+
mgr.start_polling(10) # background polling thread
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Packaging
|
|
89
|
+
- We ship `py.typed` so type checkers pick up annotations.
|
|
90
|
+
- Standard PyPA flow: `python -m build`, then `twine upload` to (Test)PyPI.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rbacx"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "RBAC/ABAC policy engine for Python with policy sets, condition DSL, and hot reload"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.8"
|
|
7
|
+
license = { file = "LICENSE" }
|
|
8
|
+
authors = [{ name = "Cheater121", email = "cheater1211@gmail.com" }]
|
|
9
|
+
keywords = ["rbac", "abac", "policy", "authorization"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Typing :: Typed",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/Cheater121/rbacx"
|
|
18
|
+
Repository = "https://github.com/Cheater121/rbacx"
|
|
19
|
+
Issues = "https://github.com/Cheater121/rbacx/issues"
|
|
20
|
+
Changelog = "https://github.com/Cheater121/rbacx/blob/main/CHANGELOG.md"
|
|
21
|
+
# Documentation = "https://cheater121.github.io/rbacx/"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
adapters-fastapi = ["fastapi>=0.110","starlette>=0.37"]
|
|
25
|
+
adapters-flask = ["flask>=2.3"]
|
|
26
|
+
adapters-drf = ["django>=4.2","djangorestframework>=3.14"]
|
|
27
|
+
metrics = ["prometheus-client>=0.14", "opentelemetry-api>=1.25"]
|
|
28
|
+
dates = ["python-dateutil>=2.8"]
|
|
29
|
+
http = ["requests>=2.28"]
|
|
30
|
+
s3 = ["boto3>=1.26"]
|
|
31
|
+
otel = ["opentelemetry-api>=1.19", "opentelemetry-sdk>=1.19"]
|
|
32
|
+
dev = ["pytest", "pytest-cov", "pre-commit>=3.7", "build>=1.2", "ruff>=0.6", "twine", "mypy>=1.8"]
|
|
33
|
+
tests = ["pytest>=8", "pytest-asyncio>=0.23", "coverage>=7"]
|
|
34
|
+
docs = ["mkdocs>=1.6", "mkdocs-material>=9.5"]
|
|
35
|
+
examples = ["uvicorn>=0.30", "fastapi>=0.110", "starlette>=0.37", "flask>=2.3", "django>=4.2", "djangorestframework>=3.14", "litestar>=2.8"]
|
|
36
|
+
validate = ["jsonschema>=4.18"]
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
rbacx = "rbacx.cli:main"
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
extend-select = ["I", "B"]
|
|
46
|
+
|
|
47
|
+
[tool.coverage.run]
|
|
48
|
+
source = ["rbacx"]
|
|
49
|
+
branch = true
|
|
50
|
+
|
|
51
|
+
[tool.coverage.report]
|
|
52
|
+
show_missing = true
|
|
53
|
+
|
|
54
|
+
[tool.setuptools]
|
|
55
|
+
package-dir = {"" = "src"}
|
|
56
|
+
include-package-data = true
|
|
57
|
+
|
|
58
|
+
[tool.setuptools.packages.find]
|
|
59
|
+
where = ["src"]
|
|
60
|
+
|
|
61
|
+
[tool.setuptools.package-data]
|
|
62
|
+
rbacx = ["py.typed", "dsl/policy.schema.json"]
|
rbacx-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version # py3.8+
|
|
6
|
+
except Exception: # pragma: no cover
|
|
7
|
+
version = None # type: ignore
|
|
8
|
+
PackageNotFoundError = Exception # type: ignore
|
|
9
|
+
|
|
10
|
+
__all__ = ["core", "adapters", "storage", "obligations", "__version__"]
|
|
11
|
+
|
|
12
|
+
def _detect_version() -> str:
|
|
13
|
+
try:
|
|
14
|
+
if version is None:
|
|
15
|
+
raise PackageNotFoundError # type: ignore
|
|
16
|
+
return version("rbacx")
|
|
17
|
+
except Exception:
|
|
18
|
+
return "0.1.0"
|
|
19
|
+
|
|
20
|
+
__version__ = _detect_version()
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..core.engine import Guard
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("rbacx.adapters.asgi")
|
|
10
|
+
|
|
11
|
+
class RbacxMiddleware:
|
|
12
|
+
def __init__(self, app: Any, *, guard: Guard, mode: str = "enforce", policy_reloader: Any | None = None) -> None:
|
|
13
|
+
self.app = app
|
|
14
|
+
self.guard = guard
|
|
15
|
+
self.mode = mode
|
|
16
|
+
self.reloader = policy_reloader
|
|
17
|
+
|
|
18
|
+
async def __call__(self, scope, receive, send):
|
|
19
|
+
if self.reloader:
|
|
20
|
+
try:
|
|
21
|
+
self.reloader.check_and_reload()
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.exception("RBACX: policy reload failed", exc_info=e)
|
|
24
|
+
scope["rbacx_guard"] = self.guard
|
|
25
|
+
await self.app(scope, receive, send)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..logging.context import get_current_trace_id
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("rbacx.adapters.asgi.access")
|
|
11
|
+
|
|
12
|
+
class AccessLogMiddleware:
|
|
13
|
+
def __init__(self, app: Any) -> None:
|
|
14
|
+
self.app = app
|
|
15
|
+
|
|
16
|
+
async def __call__(self, scope, receive, send):
|
|
17
|
+
if scope.get("type") != "http":
|
|
18
|
+
await self.app(scope, receive, send)
|
|
19
|
+
return
|
|
20
|
+
method = scope.get("method")
|
|
21
|
+
path = (scope.get("path") or "") + (("?" + scope.get("query_string", b"").decode("latin1")) if scope.get("query_string") else "")
|
|
22
|
+
start = time.time()
|
|
23
|
+
status = 0
|
|
24
|
+
|
|
25
|
+
async def send_wrapper(message):
|
|
26
|
+
nonlocal status
|
|
27
|
+
if message.get("type") == "http.response.start":
|
|
28
|
+
status = int(message.get("status", 0))
|
|
29
|
+
await send(message)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
await self.app(scope, receive, send_wrapper)
|
|
33
|
+
finally:
|
|
34
|
+
dur_ms = int((time.time() - start) * 1000)
|
|
35
|
+
rid = get_current_trace_id() or "-"
|
|
36
|
+
logger.info("access %s %s %s %sms trace_id=%s", method, path, status, dur_ms, rid)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Iterable, Tuple
|
|
6
|
+
|
|
7
|
+
from ..logging.context import clear_current_trace_id, gen_trace_id, set_current_trace_id
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("rbacx.adapters.asgi")
|
|
10
|
+
|
|
11
|
+
class TraceIdMiddleware:
|
|
12
|
+
def __init__(self, app: Any, header_name: bytes = b"x-request-id") -> None:
|
|
13
|
+
self.app = app
|
|
14
|
+
self.header_name = header_name
|
|
15
|
+
|
|
16
|
+
async def __call__(self, scope, receive, send):
|
|
17
|
+
if scope.get("type") != "http":
|
|
18
|
+
await self.app(scope, receive, send)
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
req_headers: Iterable[Tuple[bytes, bytes]] = scope.get("headers", []) or []
|
|
22
|
+
rid = None
|
|
23
|
+
for k, v in req_headers:
|
|
24
|
+
if k.lower() == self.header_name:
|
|
25
|
+
rid = v.decode("latin1")
|
|
26
|
+
break
|
|
27
|
+
if not rid:
|
|
28
|
+
rid = gen_trace_id()
|
|
29
|
+
|
|
30
|
+
token = set_current_trace_id(rid)
|
|
31
|
+
|
|
32
|
+
async def send_wrapper(message):
|
|
33
|
+
if message.get("type") == "http.response.start":
|
|
34
|
+
headers = message.setdefault("headers", [])
|
|
35
|
+
try:
|
|
36
|
+
# remove any existing header to avoid duplicates
|
|
37
|
+
headers[:] = [h for h in headers if h[0].lower() != self.header_name]
|
|
38
|
+
headers.append([self.header_name, rid.encode("latin1")])
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
await send(message)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
await self.app(scope, receive, send_wrapper)
|
|
45
|
+
finally:
|
|
46
|
+
clear_current_trace_id(token)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
from django.http import HttpRequest, HttpResponseForbidden # type: ignore[import-untyped]
|
|
6
|
+
|
|
7
|
+
from ...core.model import Action, Context, Resource, Subject
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require(action: str, resource_type: str, *, audit: bool = False):
|
|
11
|
+
def deco(view_func):
|
|
12
|
+
@wraps(view_func)
|
|
13
|
+
def _wrapped(request: HttpRequest, *args, **kwargs):
|
|
14
|
+
guard = getattr(request, "rbacx_guard", None)
|
|
15
|
+
if guard is None:
|
|
16
|
+
return view_func(request, *args, **kwargs)
|
|
17
|
+
sub = Subject(id=str(getattr(getattr(request, 'user', None), 'id', 'anonymous')))
|
|
18
|
+
res = Resource(type=resource_type)
|
|
19
|
+
ctx = Context(attrs={})
|
|
20
|
+
ok = guard.is_allowed_sync(sub, Action(action), res, ctx)
|
|
21
|
+
if not ok and not audit:
|
|
22
|
+
return HttpResponseForbidden("Forbidden")
|
|
23
|
+
return view_func(request, *args, **kwargs)
|
|
24
|
+
return _wrapped
|
|
25
|
+
return deco
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from django.conf import settings # type: ignore[import-untyped]
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("rbacx.adapters.django")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_dotted(path: str) -> Callable[[], Any]:
|
|
13
|
+
mod, _, attr = path.rpartition(".")
|
|
14
|
+
if not mod:
|
|
15
|
+
raise ImportError(f"Invalid dotted path: {path}")
|
|
16
|
+
m = import_module(mod)
|
|
17
|
+
obj = getattr(m, attr, None)
|
|
18
|
+
if obj is None:
|
|
19
|
+
raise ImportError(f"Attribute '{attr}' not found in module '{mod}'")
|
|
20
|
+
if not callable(obj):
|
|
21
|
+
raise TypeError(f"Object at '{path}' is not callable")
|
|
22
|
+
return obj
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RbacxDjangoMiddleware:
|
|
26
|
+
def __init__(self, get_response: Callable) -> None:
|
|
27
|
+
self.get_response = get_response
|
|
28
|
+
self._guard: Any | None = None
|
|
29
|
+
factory_path = getattr(settings, "RBACX_GUARD_FACTORY", None)
|
|
30
|
+
if factory_path:
|
|
31
|
+
factory = _load_dotted(factory_path)
|
|
32
|
+
self._guard = factory()
|
|
33
|
+
|
|
34
|
+
def __call__(self, request):
|
|
35
|
+
if self._guard is not None:
|
|
36
|
+
request.rbacx_guard = self._guard
|
|
37
|
+
response = self.get_response(request)
|
|
38
|
+
return response
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from ...logging.context import (
|
|
6
|
+
clear_current_trace_id,
|
|
7
|
+
gen_trace_id,
|
|
8
|
+
get_current_trace_id,
|
|
9
|
+
set_current_trace_id,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TraceIdMiddleware:
|
|
14
|
+
def __init__(self, get_response: Callable) -> None:
|
|
15
|
+
self.get_response = get_response
|
|
16
|
+
|
|
17
|
+
def __call__(self, request):
|
|
18
|
+
rid = request.META.get("HTTP_X_REQUEST_ID") or gen_trace_id()
|
|
19
|
+
token = set_current_trace_id(rid)
|
|
20
|
+
response = self.get_response(request)
|
|
21
|
+
try:
|
|
22
|
+
response["X-Request-ID"] = get_current_trace_id() or rid
|
|
23
|
+
finally:
|
|
24
|
+
clear_current_trace_id(token)
|
|
25
|
+
return response
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from rest_framework.permissions import BasePermission # type: ignore
|
|
7
|
+
|
|
8
|
+
from ..core.engine import Guard
|
|
9
|
+
from ..core.model import Action, Context, Resource, Subject
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def make_permission(guard: Guard, build_env: Callable[[Any], tuple[Subject, Action, Resource, Context]]):
|
|
13
|
+
class RBACXPermission(BasePermission):
|
|
14
|
+
message = "forbidden"
|
|
15
|
+
def has_permission(self, request, view):
|
|
16
|
+
subject, action, resource, context = build_env(request)
|
|
17
|
+
dec = guard.evaluate_sync(subject, action, resource, context)
|
|
18
|
+
self.message = f"forbidden: {dec.reason}"
|
|
19
|
+
return bool(dec.allowed)
|
|
20
|
+
return RBACXPermission
|