auth 2.0.0__tar.gz → 2.3.1__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.
- auth-2.3.1/PKG-INFO +143 -0
- auth-2.3.1/README.md +87 -0
- {auth-2.0.0 → auth-2.3.1}/auth/audit.py +99 -33
- {auth-2.0.0 → auth-2.3.1}/auth/client.py +23 -0
- {auth-2.0.0 → auth-2.3.1}/auth/config.py +70 -2
- auth-2.3.1/auth/decorators.py +123 -0
- auth-2.3.1/auth/docs_page.py +526 -0
- {auth-2.0.0 → auth-2.3.1}/auth/main.py +9 -3
- {auth-2.0.0 → auth-2.3.1}/auth/response_format.py +0 -37
- {auth-2.0.0 → auth-2.3.1}/auth/routes.py +110 -30
- {auth-2.0.0 → auth-2.3.1}/auth/services/service.py +204 -22
- auth-2.3.1/auth.egg-info/PKG-INFO +143 -0
- {auth-2.0.0 → auth-2.3.1}/auth.egg-info/SOURCES.txt +8 -10
- auth-2.3.1/auth.egg-info/requires.txt +30 -0
- {auth-2.0.0 → auth-2.3.1}/pyproject.toml +26 -16
- auth-2.3.1/tests/test_audit_integrity.py +139 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_client.py +30 -0
- auth-2.3.1/tests/test_config.py +79 -0
- auth-2.3.1/tests/test_correctness_hardening.py +133 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_docs_page.py +48 -1
- auth-2.3.1/tests/test_encryption_integration.py +102 -0
- auth-2.3.1/tests/test_key_rotation.py +212 -0
- auth-2.3.1/tests/test_log_redaction.py +55 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_routes_errors.py +20 -3
- {auth-2.0.0 → auth-2.3.1}/tests/test_service_lifecycle.py +63 -7
- auth-2.0.0/PKG-INFO +0 -257
- auth-2.0.0/README.rst +0 -201
- auth-2.0.0/auth/dal/authorization_sqlite.py +0 -180
- auth-2.0.0/auth/decorators.py +0 -64
- auth-2.0.0/auth/docs_page.py +0 -287
- auth-2.0.0/auth/jwt_auth.py +0 -135
- auth-2.0.0/auth/models/sqlite.py +0 -395
- auth-2.0.0/auth/services/rest_service.py +0 -141
- auth-2.0.0/auth.egg-info/PKG-INFO +0 -257
- auth-2.0.0/auth.egg-info/requires.txt +0 -30
- auth-2.0.0/tests/test_auth_sqlite.py +0 -266
- auth-2.0.0/tests/test_authorization_sqlite.py +0 -749
- auth-2.0.0/tests/test_db_sqlite.py +0 -509
- auth-2.0.0/tests/test_service_rest.py +0 -221
- {auth-2.0.0 → auth-2.3.1}/LICENSE +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/__init__.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/circuit_breaker.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/cmd/__init__.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/cmd/server.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/core/REST/__init__.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/core/REST/client.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/core/__init__.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/core/models/__init__.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/database.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/encryption.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/logging_config.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/models/sql.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/sanitizer.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/server.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/validation.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth/workflow_checker.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth.egg-info/dependency_links.txt +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth.egg-info/entry_points.txt +0 -0
- {auth-2.0.0 → auth-2.3.1}/auth.egg-info/top_level.txt +0 -0
- {auth-2.0.0 → auth-2.3.1}/setup.cfg +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_client_rest.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_cmd_server.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_encryption.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_flask.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_migrations.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_phase_a_hardening.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_reencryption.py +0 -0
- {auth-2.0.0 → auth-2.3.1}/tests/test_server.py +0 -0
auth-2.3.1/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth
|
|
3
|
+
Version: 2.3.1
|
|
4
|
+
Summary: Authorization for humans
|
|
5
|
+
Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: authorization,role,auth,groups,membership,ensure,ldap
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Natural Language :: English
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: flask<4,>=2.0.0
|
|
29
|
+
Requires-Dist: flask-cors<7,>=3.0.0
|
|
30
|
+
Requires-Dist: sqlalchemy<3,>=2.0.0
|
|
31
|
+
Requires-Dist: waitress<4,>=2.0.0
|
|
32
|
+
Requires-Dist: cryptography<48,>=3.0.0
|
|
33
|
+
Requires-Dist: APScheduler<4,>=3.0.0
|
|
34
|
+
Requires-Dist: psycopg[binary]<4,>=3.0.0
|
|
35
|
+
Requires-Dist: pydantic<3,>=2.0.0
|
|
36
|
+
Requires-Dist: pydantic-settings<3,>=2.0.0
|
|
37
|
+
Requires-Dist: requests<3,>=2.25.0
|
|
38
|
+
Requires-Dist: bleach<7,>=5.0.0
|
|
39
|
+
Requires-Dist: python-json-logger<4,>=3.1.0
|
|
40
|
+
Provides-Extra: ratelimit
|
|
41
|
+
Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
|
|
42
|
+
Provides-Extra: migrations
|
|
43
|
+
Requires-Dist: alembic>=1.13.0; extra == "migrations"
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
46
|
+
Requires-Dist: pytest-cov>=2.0; extra == "dev"
|
|
47
|
+
Requires-Dist: alembic>=1.13.0; extra == "dev"
|
|
48
|
+
Requires-Dist: ruff>=0.0.260; extra == "dev"
|
|
49
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
50
|
+
Requires-Dist: types-requests; extra == "dev"
|
|
51
|
+
Requires-Dist: types-waitress; extra == "dev"
|
|
52
|
+
Requires-Dist: black>=22.0; extra == "dev"
|
|
53
|
+
Requires-Dist: isort>=5.0; extra == "dev"
|
|
54
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
55
|
+
Dynamic: license-file
|
|
56
|
+
|
|
57
|
+
# auth — RBAC authorization service
|
|
58
|
+
|
|
59
|
+
[](https://github.com/ourway/auth/actions/workflows/ci.yml)
|
|
60
|
+
[]()
|
|
61
|
+
[](LICENSE)
|
|
62
|
+
|
|
63
|
+
Role-based access control over HTTP. `auth` answers one question — **may user X
|
|
64
|
+
do Y** — so your services don't reinvent roles and permissions.
|
|
65
|
+
|
|
66
|
+
It is **authorization, not authentication**: it does not log anyone in, store
|
|
67
|
+
passwords, or issue tokens. It trusts that the caller already knows *who* the
|
|
68
|
+
user is, and decides *what they may do*. Model:
|
|
69
|
+
`user → (member of) → role → (holds) → permission`.
|
|
70
|
+
|
|
71
|
+
## Quickstart
|
|
72
|
+
|
|
73
|
+
Your **client key is any UUID4** — it is also your private, isolated namespace.
|
|
74
|
+
Generate one, keep it secret, and reuse it for every call. A role must exist
|
|
75
|
+
before you add members or permissions to it.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
KEY=$(python3 -c "import uuid; print(uuid.uuid4())")
|
|
79
|
+
BASE=https://auth.rodmena.app
|
|
80
|
+
|
|
81
|
+
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/role/engineers
|
|
82
|
+
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/permission/engineers/deploy
|
|
83
|
+
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/membership/alice/engineers
|
|
84
|
+
curl -H "Authorization: Bearer $KEY" $BASE/api/has_permission/alice/deploy
|
|
85
|
+
# -> {"success": true, "data": {"has_permission": true}, ...}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
With the Python client (`pip install auth`):
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from auth import Client
|
|
92
|
+
|
|
93
|
+
with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
|
|
94
|
+
c.create_role("engineers")
|
|
95
|
+
c.add_permission("engineers", "deploy")
|
|
96
|
+
c.add_membership("alice", "engineers")
|
|
97
|
+
c.user_has_permission("alice", "deploy") # -> {... "has_permission": true}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Things to know before you write code
|
|
101
|
+
|
|
102
|
+
- **Writes can return HTTP 200 with `{"result": false}`** (e.g. adding to a
|
|
103
|
+
missing role). Check the `result`/`data` field, not just the status code.
|
|
104
|
+
- **Two response shapes** — bare `{"result": ...}` and wrapped
|
|
105
|
+
`{"success", "data", ...}`. The API reference says which per endpoint.
|
|
106
|
+
- **Errors below 2xx are HTML**, not JSON. Branch on the status code first.
|
|
107
|
+
- **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
|
|
108
|
+
key out of source control, logs, and URLs — it is the only thing protecting
|
|
109
|
+
your data. Rotate it with `POST /api/keys/rotate` if it leaks.
|
|
110
|
+
|
|
111
|
+
## Documentation
|
|
112
|
+
|
|
113
|
+
| Doc | What's in it |
|
|
114
|
+
|---|---|
|
|
115
|
+
| **Live API reference** — [`/docs`](https://auth.rodmena.app/docs) · [`/llms.txt`](https://auth.rodmena.app/llms.txt) | Every endpoint and exact response shape, served by the app (agent-friendly). |
|
|
116
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design, components, request lifecycle, data model, permission-check and key-rotation flows, diagrams. |
|
|
117
|
+
| [SECURITY.md](SECURITY.md) | Security model (tenant isolation, encryption, audit, rotation), threat notes, reporting. |
|
|
118
|
+
| [MIGRATIONS.md](MIGRATIONS.md) | Schema creation vs migrations, upgrade/rollback runbook. |
|
|
119
|
+
| [CONTRIBUTING.md](CONTRIBUTING.md) | Local setup, tests (sqlite + postgres), lint/type-check, CI. |
|
|
120
|
+
| [docs/](docs/) | Full Sphinx docs (concepts, configuration, encryption, deployment, REST & Python usage). |
|
|
121
|
+
|
|
122
|
+
## When to use — and not
|
|
123
|
+
|
|
124
|
+
**Use it** for RBAC: named roles, permissions, group membership, and boolean
|
|
125
|
+
"can user X do Y" gates for a service, CLI, or workflow engine.
|
|
126
|
+
|
|
127
|
+
**Not** for authentication (login/passwords/sessions/OAuth/JWT), fine-grained /
|
|
128
|
+
attribute-based rules (owner-of-*this*-record, time-of-day, row-level tenancy —
|
|
129
|
+
reach for an ABAC/policy engine), or air-gapped hot loops where a network hop per
|
|
130
|
+
check is too costly (cache, or use the library in-process).
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python3.11 -m venv .venv && . .venv/bin/activate
|
|
136
|
+
pip install -e ".[dev,ratelimit,migrations]"
|
|
137
|
+
make check # ruff + mypy
|
|
138
|
+
make test # sqlite suite
|
|
139
|
+
make test-postgres # postgres integration (Docker), encryption on
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. Licensed under the
|
|
143
|
+
[MIT License](LICENSE).
|
auth-2.3.1/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# auth — RBAC authorization service
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ourway/auth/actions/workflows/ci.yml)
|
|
4
|
+
[]()
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Role-based access control over HTTP. `auth` answers one question — **may user X
|
|
8
|
+
do Y** — so your services don't reinvent roles and permissions.
|
|
9
|
+
|
|
10
|
+
It is **authorization, not authentication**: it does not log anyone in, store
|
|
11
|
+
passwords, or issue tokens. It trusts that the caller already knows *who* the
|
|
12
|
+
user is, and decides *what they may do*. Model:
|
|
13
|
+
`user → (member of) → role → (holds) → permission`.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
Your **client key is any UUID4** — it is also your private, isolated namespace.
|
|
18
|
+
Generate one, keep it secret, and reuse it for every call. A role must exist
|
|
19
|
+
before you add members or permissions to it.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
KEY=$(python3 -c "import uuid; print(uuid.uuid4())")
|
|
23
|
+
BASE=https://auth.rodmena.app
|
|
24
|
+
|
|
25
|
+
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/role/engineers
|
|
26
|
+
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/permission/engineers/deploy
|
|
27
|
+
curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/membership/alice/engineers
|
|
28
|
+
curl -H "Authorization: Bearer $KEY" $BASE/api/has_permission/alice/deploy
|
|
29
|
+
# -> {"success": true, "data": {"has_permission": true}, ...}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With the Python client (`pip install auth`):
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from auth import Client
|
|
36
|
+
|
|
37
|
+
with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
|
|
38
|
+
c.create_role("engineers")
|
|
39
|
+
c.add_permission("engineers", "deploy")
|
|
40
|
+
c.add_membership("alice", "engineers")
|
|
41
|
+
c.user_has_permission("alice", "deploy") # -> {... "has_permission": true}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Things to know before you write code
|
|
45
|
+
|
|
46
|
+
- **Writes can return HTTP 200 with `{"result": false}`** (e.g. adding to a
|
|
47
|
+
missing role). Check the `result`/`data` field, not just the status code.
|
|
48
|
+
- **Two response shapes** — bare `{"result": ...}` and wrapped
|
|
49
|
+
`{"success", "data", ...}`. The API reference says which per endpoint.
|
|
50
|
+
- **Errors below 2xx are HTML**, not JSON. Branch on the status code first.
|
|
51
|
+
- **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
|
|
52
|
+
key out of source control, logs, and URLs — it is the only thing protecting
|
|
53
|
+
your data. Rotate it with `POST /api/keys/rotate` if it leaks.
|
|
54
|
+
|
|
55
|
+
## Documentation
|
|
56
|
+
|
|
57
|
+
| Doc | What's in it |
|
|
58
|
+
|---|---|
|
|
59
|
+
| **Live API reference** — [`/docs`](https://auth.rodmena.app/docs) · [`/llms.txt`](https://auth.rodmena.app/llms.txt) | Every endpoint and exact response shape, served by the app (agent-friendly). |
|
|
60
|
+
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design, components, request lifecycle, data model, permission-check and key-rotation flows, diagrams. |
|
|
61
|
+
| [SECURITY.md](SECURITY.md) | Security model (tenant isolation, encryption, audit, rotation), threat notes, reporting. |
|
|
62
|
+
| [MIGRATIONS.md](MIGRATIONS.md) | Schema creation vs migrations, upgrade/rollback runbook. |
|
|
63
|
+
| [CONTRIBUTING.md](CONTRIBUTING.md) | Local setup, tests (sqlite + postgres), lint/type-check, CI. |
|
|
64
|
+
| [docs/](docs/) | Full Sphinx docs (concepts, configuration, encryption, deployment, REST & Python usage). |
|
|
65
|
+
|
|
66
|
+
## When to use — and not
|
|
67
|
+
|
|
68
|
+
**Use it** for RBAC: named roles, permissions, group membership, and boolean
|
|
69
|
+
"can user X do Y" gates for a service, CLI, or workflow engine.
|
|
70
|
+
|
|
71
|
+
**Not** for authentication (login/passwords/sessions/OAuth/JWT), fine-grained /
|
|
72
|
+
attribute-based rules (owner-of-*this*-record, time-of-day, row-level tenancy —
|
|
73
|
+
reach for an ABAC/policy engine), or air-gapped hot loops where a network hop per
|
|
74
|
+
check is too costly (cache, or use the library in-process).
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3.11 -m venv .venv && . .venv/bin/activate
|
|
80
|
+
pip install -e ".[dev,ratelimit,migrations]"
|
|
81
|
+
make check # ruff + mypy
|
|
82
|
+
make test # sqlite suite
|
|
83
|
+
make test-postgres # postgres integration (Docker), encryption on
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. Licensed under the
|
|
87
|
+
[MIT License](LICENSE).
|
|
@@ -37,6 +37,7 @@ class AuditAction(Enum):
|
|
|
37
37
|
LIST_PERMISSIONS = "LIST_PERMISSIONS"
|
|
38
38
|
LIST_MEMBERSHIPS = "LIST_MEMBERSHIPS"
|
|
39
39
|
USER_PERMISSIONS = "USER_PERMISSIONS"
|
|
40
|
+
ROTATE_KEY = "ROTATE_KEY"
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
class AuditLog(Base):
|
|
@@ -111,7 +112,65 @@ def client_fingerprint(token: Optional[str]) -> str:
|
|
|
111
112
|
return "fpr_" + digest[:32]
|
|
112
113
|
|
|
113
114
|
|
|
114
|
-
def
|
|
115
|
+
def _build_audit_entry(
|
|
116
|
+
client_id: str,
|
|
117
|
+
user: Optional[str],
|
|
118
|
+
action: AuditAction,
|
|
119
|
+
resource: Optional[str],
|
|
120
|
+
details: Optional[Dict[str, Any]],
|
|
121
|
+
ip_address: Optional[str],
|
|
122
|
+
user_agent: Optional[str],
|
|
123
|
+
success: bool,
|
|
124
|
+
) -> "AuditLog":
|
|
125
|
+
# The managed user is a human identifier (often an email) — store a
|
|
126
|
+
# non-reversible fingerprint, never plaintext, matching how the client key is
|
|
127
|
+
# handled. Auditors correlate by fingerprint and can confirm a known user by
|
|
128
|
+
# computing its fingerprint. Role/permission/workflow names (the `resource`)
|
|
129
|
+
# are application identifiers, not PII, and stay readable — except the caller
|
|
130
|
+
# is responsible for fingerprinting any user embedded in `resource`.
|
|
131
|
+
user_fp = client_fingerprint(user) if user else None
|
|
132
|
+
return AuditLog(
|
|
133
|
+
client_id=_fit(client_id, "client_id"),
|
|
134
|
+
user=_fit(user_fp, "user"),
|
|
135
|
+
action=_fit(action.value, "action"),
|
|
136
|
+
resource=_fit(resource, "resource"),
|
|
137
|
+
details=json.dumps(details) if details else None,
|
|
138
|
+
ip_address=_fit(ip_address, "ip_address"),
|
|
139
|
+
user_agent=_fit(user_agent, "user_agent"),
|
|
140
|
+
success=1 if success else 0,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _emit_structured_log(
|
|
145
|
+
client_id: str,
|
|
146
|
+
user: Optional[str],
|
|
147
|
+
action: AuditAction,
|
|
148
|
+
resource: Optional[str],
|
|
149
|
+
details: Optional[Dict[str, Any]],
|
|
150
|
+
ip_address: Optional[str],
|
|
151
|
+
success: bool,
|
|
152
|
+
) -> None:
|
|
153
|
+
# The DB row is the system of record. The log STREAM (journald / SIEM /
|
|
154
|
+
# shipping) is more widely exposed, so it carries no PII: no raw user and no
|
|
155
|
+
# resource string (which may embed a user). Only the non-reversible client
|
|
156
|
+
# fingerprint, the action, and the outcome.
|
|
157
|
+
log_msg: Dict[str, Any] = {
|
|
158
|
+
"type": "audit",
|
|
159
|
+
"client_id": client_id,
|
|
160
|
+
"action": action.value,
|
|
161
|
+
"success": success,
|
|
162
|
+
"timestamp": _utcnow().isoformat(),
|
|
163
|
+
}
|
|
164
|
+
if details:
|
|
165
|
+
log_msg["details"] = details
|
|
166
|
+
if ip_address:
|
|
167
|
+
log_msg["ip"] = ip_address
|
|
168
|
+
audit_logger.info(json.dumps(log_msg))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def record_audit(
|
|
172
|
+
session,
|
|
173
|
+
*,
|
|
115
174
|
client_id: str,
|
|
116
175
|
user: Optional[str],
|
|
117
176
|
action: AuditAction,
|
|
@@ -121,45 +180,52 @@ def log_audit_event(
|
|
|
121
180
|
user_agent: Optional[str] = None,
|
|
122
181
|
success: bool = True,
|
|
123
182
|
) -> None:
|
|
183
|
+
"""Add an audit row to an EXISTING session so it commits atomically with the
|
|
184
|
+
caller's transaction (the mutation and its audit land together, or not at
|
|
185
|
+
all). The caller is responsible for committing.
|
|
186
|
+
|
|
187
|
+
This does NOT swallow errors: a failure to stage the audit row must fail the
|
|
188
|
+
surrounding request (fail-closed), never leave a committed mutation
|
|
189
|
+
unaudited.
|
|
124
190
|
"""
|
|
125
|
-
|
|
191
|
+
session.add(
|
|
192
|
+
_build_audit_entry(
|
|
193
|
+
client_id, user, action, resource, details, ip_address, user_agent, success
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
_emit_structured_log(client_id, user, action, resource, details, ip_address, success)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def log_audit_event(
|
|
200
|
+
client_id: str,
|
|
201
|
+
user: Optional[str],
|
|
202
|
+
action: AuditAction,
|
|
203
|
+
resource: Optional[str] = None,
|
|
204
|
+
details: Optional[Dict[str, Any]] = None,
|
|
205
|
+
ip_address: Optional[str] = None,
|
|
206
|
+
user_agent: Optional[str] = None,
|
|
207
|
+
success: bool = True,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Write an audit event on its OWN committed session.
|
|
210
|
+
|
|
211
|
+
For contexts with no request transaction to join: in-process/library
|
|
212
|
+
callers, and the *failure* path of the request decorator (where the request
|
|
213
|
+
transaction is being rolled back and must not carry the audit row). This one
|
|
214
|
+
is best-effort — a failure is logged, not raised, so it cannot mask the
|
|
215
|
+
original error it is recording.
|
|
126
216
|
"""
|
|
127
217
|
session = SessionLocal()
|
|
128
218
|
try:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
action=_fit(action.value, "action"),
|
|
134
|
-
resource=_fit(resource, "resource"),
|
|
135
|
-
details=json.dumps(details) if details else None,
|
|
136
|
-
ip_address=_fit(ip_address, "ip_address"),
|
|
137
|
-
user_agent=_fit(user_agent, "user_agent"),
|
|
138
|
-
success=1 if success else 0,
|
|
219
|
+
session.add(
|
|
220
|
+
_build_audit_entry(
|
|
221
|
+
client_id, user, action, resource, details, ip_address, user_agent, success
|
|
222
|
+
)
|
|
139
223
|
)
|
|
140
|
-
|
|
141
|
-
session.add(audit_entry)
|
|
142
224
|
session.commit()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"type": "audit",
|
|
147
|
-
"client_id": client_id,
|
|
148
|
-
"user": user,
|
|
149
|
-
"action": action.value,
|
|
150
|
-
"resource": resource,
|
|
151
|
-
"success": success,
|
|
152
|
-
"timestamp": _utcnow().isoformat(),
|
|
153
|
-
}
|
|
154
|
-
if details:
|
|
155
|
-
log_msg["details"] = details
|
|
156
|
-
if ip_address:
|
|
157
|
-
log_msg["ip"] = ip_address
|
|
158
|
-
|
|
159
|
-
audit_logger.info(json.dumps(log_msg))
|
|
225
|
+
_emit_structured_log(
|
|
226
|
+
client_id, user, action, resource, details, ip_address, success
|
|
227
|
+
)
|
|
160
228
|
except Exception:
|
|
161
|
-
# If audit logging fails, we don't want to break the main operation
|
|
162
|
-
# But log the failure for monitoring
|
|
163
229
|
audit_logger.error(
|
|
164
230
|
f"Failed to log audit event: client_id={client_id}, action={action.value}"
|
|
165
231
|
)
|
|
@@ -131,6 +131,7 @@ class EnhancedAuthClient:
|
|
|
131
131
|
"role": "/api/role/{role}",
|
|
132
132
|
"workflow_users": "/api/workflow/users/{workflow_name}",
|
|
133
133
|
"workflow_permission": "/api/workflow/user/{user}/can_run/{workflow_name}",
|
|
134
|
+
"rotate_key": "/api/keys/rotate",
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
|
@@ -337,6 +338,28 @@ class EnhancedAuthClient:
|
|
|
337
338
|
except Exception as e:
|
|
338
339
|
return {"error": str(e), "success": False, "data": {"role": role}}
|
|
339
340
|
|
|
341
|
+
def rotate_key(self) -> Dict[str, Any]:
|
|
342
|
+
"""Rotate this client's API key (atomic cutover).
|
|
343
|
+
|
|
344
|
+
The server mints a fresh key, moves the whole namespace onto it, and
|
|
345
|
+
returns it in ``data.new_key``. On success this client is updated in
|
|
346
|
+
place — ``self.api_key`` and the session ``Authorization`` header switch
|
|
347
|
+
to the new key — so subsequent calls on this instance keep working. The
|
|
348
|
+
returned key is the ONLY copy: persist ``data.new_key`` (e.g. to your
|
|
349
|
+
secret store) or you lose access to the namespace.
|
|
350
|
+
"""
|
|
351
|
+
endpoint = self.endpoints["rotate_key"]
|
|
352
|
+
try:
|
|
353
|
+
response = self._make_request("POST", endpoint)
|
|
354
|
+
except Exception as e:
|
|
355
|
+
return {"error": str(e), "success": False, "data": {}}
|
|
356
|
+
|
|
357
|
+
new_key = (response or {}).get("data", {}).get("new_key")
|
|
358
|
+
if new_key:
|
|
359
|
+
self.api_key = new_key
|
|
360
|
+
self.session.headers["Authorization"] = f"Bearer {new_key}"
|
|
361
|
+
return response
|
|
362
|
+
|
|
340
363
|
# Workflow-related methods
|
|
341
364
|
def get_users_for_workflow(self, workflow_name: str) -> Dict[str, Any]:
|
|
342
365
|
"""Get all users who can run a specific workflow"""
|
|
@@ -18,6 +18,23 @@ class DatabaseType(Enum):
|
|
|
18
18
|
POSTGRESQL = "postgresql"
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
# Values that must never protect a production secret. The audit pepper keys the
|
|
22
|
+
# HMAC that fingerprints client keys in the audit trail; if it is one of these
|
|
23
|
+
# (or empty), the fingerprints are computable by anyone and the audit log's
|
|
24
|
+
# offline-guess resistance is gone. Boot fails closed rather than run weak.
|
|
25
|
+
_KNOWN_WEAK_SECRETS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"",
|
|
28
|
+
"default_secret_key_for_development",
|
|
29
|
+
"your_secure_jwt_secret_key_here",
|
|
30
|
+
"changeme",
|
|
31
|
+
"change-me",
|
|
32
|
+
"secret",
|
|
33
|
+
"password",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
21
38
|
class Settings(BaseSettings):
|
|
22
39
|
"""Configuration class for the authorization system"""
|
|
23
40
|
|
|
@@ -100,12 +117,63 @@ class Settings(BaseSettings):
|
|
|
100
117
|
@field_validator("jwt_secret_key")
|
|
101
118
|
@classmethod
|
|
102
119
|
def validate_secret_key(cls, v: str) -> str:
|
|
103
|
-
if v
|
|
120
|
+
if v in _KNOWN_WEAK_SECRETS:
|
|
104
121
|
import logging
|
|
105
122
|
logger = logging.getLogger(__name__)
|
|
106
|
-
logger.warning(
|
|
123
|
+
logger.warning(
|
|
124
|
+
"AUTH_JWT_SECRET_KEY is a weak/placeholder value. "
|
|
125
|
+
"Set a strong secret for production."
|
|
126
|
+
)
|
|
107
127
|
return v
|
|
108
128
|
|
|
129
|
+
@model_validator(mode="after")
|
|
130
|
+
def warn_on_weak_audit_pepper(self) -> "Settings":
|
|
131
|
+
"""Warn (never raise) when the effective audit pepper is weak.
|
|
132
|
+
|
|
133
|
+
Constructing Settings must not fail: ``auth`` is also a client library,
|
|
134
|
+
and ``pip install auth; from auth import Client`` to talk to a remote
|
|
135
|
+
service needs no pepper at all. The hard, fail-closed check belongs to
|
|
136
|
+
the *server* boot path — see :func:`verify_audit_pepper`, called by
|
|
137
|
+
``auth.main.create_app``.
|
|
138
|
+
"""
|
|
139
|
+
if self.enable_audit_logging and not self.debug_mode and audit_pepper_is_weak(
|
|
140
|
+
self
|
|
141
|
+
):
|
|
142
|
+
import logging
|
|
143
|
+
|
|
144
|
+
logging.getLogger(__name__).warning(
|
|
145
|
+
"AUTH_AUDIT_PEPPER is unset, a placeholder, or too short; audit "
|
|
146
|
+
"key fingerprints are not offline-guess resistant. Set a strong "
|
|
147
|
+
"value before serving traffic."
|
|
148
|
+
)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def audit_pepper_is_weak(settings: "Settings") -> bool:
|
|
153
|
+
"""Whether the effective audit pepper is unset/placeholder/too short.
|
|
154
|
+
|
|
155
|
+
The pepper is ``audit_pepper`` if set, else ``jwt_secret_key`` — the same
|
|
156
|
+
fallback ``audit.client_fingerprint`` uses.
|
|
157
|
+
"""
|
|
158
|
+
pepper = (settings.audit_pepper or settings.jwt_secret_key or "").strip()
|
|
159
|
+
return pepper in _KNOWN_WEAK_SECRETS or len(pepper) < 16
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def verify_audit_pepper(settings: "Settings") -> None:
|
|
163
|
+
"""Fail closed on a weak audit pepper — called when the SERVER starts.
|
|
164
|
+
|
|
165
|
+
A placeholder pepper makes the audit trail's key fingerprints computable, so
|
|
166
|
+
a server that writes audit rows must not run with one. Importing the package
|
|
167
|
+
as a library is unaffected (see :meth:`Settings.warn_on_weak_audit_pepper`).
|
|
168
|
+
"""
|
|
169
|
+
if settings.enable_audit_logging and not settings.debug_mode:
|
|
170
|
+
if audit_pepper_is_weak(settings):
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"Refusing to start: the audit pepper is unset, a placeholder, "
|
|
173
|
+
"or too short. Set AUTH_AUDIT_PEPPER to a strong random value "
|
|
174
|
+
"(>= 16 chars), or set AUTH_DEBUG_MODE=true for local use."
|
|
175
|
+
)
|
|
176
|
+
|
|
109
177
|
|
|
110
178
|
@lru_cache()
|
|
111
179
|
def get_settings() -> Settings:
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from flask import Response, g, request
|
|
5
|
+
|
|
6
|
+
from auth.audit import (
|
|
7
|
+
AuditAction,
|
|
8
|
+
client_fingerprint,
|
|
9
|
+
log_audit_event,
|
|
10
|
+
record_audit,
|
|
11
|
+
)
|
|
12
|
+
from auth.config import get_settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _status_code(response) -> int:
|
|
16
|
+
"""Best-effort HTTP status of a Flask view return value."""
|
|
17
|
+
if isinstance(response, Response):
|
|
18
|
+
return response.status_code
|
|
19
|
+
if isinstance(response, tuple) and len(response) >= 2 and isinstance(
|
|
20
|
+
response[1], int
|
|
21
|
+
):
|
|
22
|
+
return response[1]
|
|
23
|
+
return 200
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _response_body(response) -> Any:
|
|
27
|
+
"""Best-effort decoded JSON body of a Flask view return value."""
|
|
28
|
+
obj = response
|
|
29
|
+
if isinstance(response, tuple) and response:
|
|
30
|
+
obj = response[0]
|
|
31
|
+
if isinstance(obj, Response):
|
|
32
|
+
return obj.get_json(silent=True)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _derive_success(response) -> bool:
|
|
37
|
+
"""Whether the operation actually took effect — not merely whether HTTP was
|
|
38
|
+
2xx.
|
|
39
|
+
|
|
40
|
+
Write endpoints return HTTP 200 with ``{"result": false}`` (bare) or
|
|
41
|
+
``{"data": {"result": false}}`` (wrapped) when the write did nothing (e.g. a
|
|
42
|
+
missing role). Recording those as ``success`` would let the audit trail claim
|
|
43
|
+
a grant happened when it did not, so pull the real boolean out. Read
|
|
44
|
+
endpoints (e.g. a permission check answering ``has_permission: false``) have
|
|
45
|
+
no ``result`` field and are treated as successful — the query succeeded.
|
|
46
|
+
"""
|
|
47
|
+
status_code = _status_code(response)
|
|
48
|
+
if not (200 <= status_code < 400):
|
|
49
|
+
return False
|
|
50
|
+
body = _response_body(response)
|
|
51
|
+
if isinstance(body, dict):
|
|
52
|
+
result = body.get("result")
|
|
53
|
+
if isinstance(result, bool):
|
|
54
|
+
return result
|
|
55
|
+
data = body.get("data")
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
inner = data.get("result")
|
|
58
|
+
if isinstance(inner, bool):
|
|
59
|
+
return inner
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _request_db(args) -> Optional[Any]:
|
|
64
|
+
"""The request session injected by ``with_db_session`` as the first arg."""
|
|
65
|
+
return args[0] if args else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def audit_log(action: AuditAction, resource_extractor=None):
|
|
69
|
+
def decorator(func):
|
|
70
|
+
@wraps(func)
|
|
71
|
+
def wrapper(*args, **kwargs):
|
|
72
|
+
if not get_settings().enable_audit_logging:
|
|
73
|
+
return func(*args, **kwargs)
|
|
74
|
+
|
|
75
|
+
# ``before_request`` has already authenticated every /api/* request
|
|
76
|
+
# and stored the validated client key on ``g``. We record only a
|
|
77
|
+
# non-reversible fingerprint of it — never the raw key, which is the
|
|
78
|
+
# caller's credential.
|
|
79
|
+
client_ref = client_fingerprint(getattr(g, "client_key", None))
|
|
80
|
+
user = kwargs.get("user")
|
|
81
|
+
resource = resource_extractor(kwargs) if resource_extractor else None
|
|
82
|
+
db = _request_db(args)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
response = func(*args, **kwargs)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
# The request transaction is being rolled back, so the audit row
|
|
88
|
+
# must NOT ride on it — write it on its own session so the failed
|
|
89
|
+
# attempt is still recorded.
|
|
90
|
+
log_audit_event(
|
|
91
|
+
client_id=client_ref,
|
|
92
|
+
user=user,
|
|
93
|
+
action=action,
|
|
94
|
+
resource=resource,
|
|
95
|
+
details={"error": str(e)},
|
|
96
|
+
ip_address=request.remote_addr,
|
|
97
|
+
user_agent=request.headers.get("User-Agent", ""),
|
|
98
|
+
success=False,
|
|
99
|
+
)
|
|
100
|
+
raise
|
|
101
|
+
|
|
102
|
+
status_code = _status_code(response)
|
|
103
|
+
success = _derive_success(response)
|
|
104
|
+
# Stage the audit row on the SAME session as the mutation; the
|
|
105
|
+
# ``with_db_session`` wrapper commits both together. If this raises,
|
|
106
|
+
# the whole request fails closed rather than committing an unaudited
|
|
107
|
+
# mutation.
|
|
108
|
+
record_audit(
|
|
109
|
+
db,
|
|
110
|
+
client_id=client_ref,
|
|
111
|
+
user=user,
|
|
112
|
+
action=action,
|
|
113
|
+
resource=resource,
|
|
114
|
+
details={"status_code": status_code},
|
|
115
|
+
ip_address=request.remote_addr,
|
|
116
|
+
user_agent=request.headers.get("User-Agent", ""),
|
|
117
|
+
success=success,
|
|
118
|
+
)
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
return wrapper
|
|
122
|
+
|
|
123
|
+
return decorator
|