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.
Files changed (68) hide show
  1. auth-2.3.1/PKG-INFO +143 -0
  2. auth-2.3.1/README.md +87 -0
  3. {auth-2.0.0 → auth-2.3.1}/auth/audit.py +99 -33
  4. {auth-2.0.0 → auth-2.3.1}/auth/client.py +23 -0
  5. {auth-2.0.0 → auth-2.3.1}/auth/config.py +70 -2
  6. auth-2.3.1/auth/decorators.py +123 -0
  7. auth-2.3.1/auth/docs_page.py +526 -0
  8. {auth-2.0.0 → auth-2.3.1}/auth/main.py +9 -3
  9. {auth-2.0.0 → auth-2.3.1}/auth/response_format.py +0 -37
  10. {auth-2.0.0 → auth-2.3.1}/auth/routes.py +110 -30
  11. {auth-2.0.0 → auth-2.3.1}/auth/services/service.py +204 -22
  12. auth-2.3.1/auth.egg-info/PKG-INFO +143 -0
  13. {auth-2.0.0 → auth-2.3.1}/auth.egg-info/SOURCES.txt +8 -10
  14. auth-2.3.1/auth.egg-info/requires.txt +30 -0
  15. {auth-2.0.0 → auth-2.3.1}/pyproject.toml +26 -16
  16. auth-2.3.1/tests/test_audit_integrity.py +139 -0
  17. {auth-2.0.0 → auth-2.3.1}/tests/test_client.py +30 -0
  18. auth-2.3.1/tests/test_config.py +79 -0
  19. auth-2.3.1/tests/test_correctness_hardening.py +133 -0
  20. {auth-2.0.0 → auth-2.3.1}/tests/test_docs_page.py +48 -1
  21. auth-2.3.1/tests/test_encryption_integration.py +102 -0
  22. auth-2.3.1/tests/test_key_rotation.py +212 -0
  23. auth-2.3.1/tests/test_log_redaction.py +55 -0
  24. {auth-2.0.0 → auth-2.3.1}/tests/test_routes_errors.py +20 -3
  25. {auth-2.0.0 → auth-2.3.1}/tests/test_service_lifecycle.py +63 -7
  26. auth-2.0.0/PKG-INFO +0 -257
  27. auth-2.0.0/README.rst +0 -201
  28. auth-2.0.0/auth/dal/authorization_sqlite.py +0 -180
  29. auth-2.0.0/auth/decorators.py +0 -64
  30. auth-2.0.0/auth/docs_page.py +0 -287
  31. auth-2.0.0/auth/jwt_auth.py +0 -135
  32. auth-2.0.0/auth/models/sqlite.py +0 -395
  33. auth-2.0.0/auth/services/rest_service.py +0 -141
  34. auth-2.0.0/auth.egg-info/PKG-INFO +0 -257
  35. auth-2.0.0/auth.egg-info/requires.txt +0 -30
  36. auth-2.0.0/tests/test_auth_sqlite.py +0 -266
  37. auth-2.0.0/tests/test_authorization_sqlite.py +0 -749
  38. auth-2.0.0/tests/test_db_sqlite.py +0 -509
  39. auth-2.0.0/tests/test_service_rest.py +0 -221
  40. {auth-2.0.0 → auth-2.3.1}/LICENSE +0 -0
  41. {auth-2.0.0 → auth-2.3.1}/auth/__init__.py +0 -0
  42. {auth-2.0.0 → auth-2.3.1}/auth/circuit_breaker.py +0 -0
  43. {auth-2.0.0 → auth-2.3.1}/auth/cmd/__init__.py +0 -0
  44. {auth-2.0.0 → auth-2.3.1}/auth/cmd/server.py +0 -0
  45. {auth-2.0.0 → auth-2.3.1}/auth/core/REST/__init__.py +0 -0
  46. {auth-2.0.0 → auth-2.3.1}/auth/core/REST/client.py +0 -0
  47. {auth-2.0.0 → auth-2.3.1}/auth/core/__init__.py +0 -0
  48. {auth-2.0.0 → auth-2.3.1}/auth/core/models/__init__.py +0 -0
  49. {auth-2.0.0 → auth-2.3.1}/auth/database.py +0 -0
  50. {auth-2.0.0 → auth-2.3.1}/auth/encryption.py +0 -0
  51. {auth-2.0.0 → auth-2.3.1}/auth/logging_config.py +0 -0
  52. {auth-2.0.0 → auth-2.3.1}/auth/models/sql.py +0 -0
  53. {auth-2.0.0 → auth-2.3.1}/auth/sanitizer.py +0 -0
  54. {auth-2.0.0 → auth-2.3.1}/auth/server.py +0 -0
  55. {auth-2.0.0 → auth-2.3.1}/auth/validation.py +0 -0
  56. {auth-2.0.0 → auth-2.3.1}/auth/workflow_checker.py +0 -0
  57. {auth-2.0.0 → auth-2.3.1}/auth.egg-info/dependency_links.txt +0 -0
  58. {auth-2.0.0 → auth-2.3.1}/auth.egg-info/entry_points.txt +0 -0
  59. {auth-2.0.0 → auth-2.3.1}/auth.egg-info/top_level.txt +0 -0
  60. {auth-2.0.0 → auth-2.3.1}/setup.cfg +0 -0
  61. {auth-2.0.0 → auth-2.3.1}/tests/test_client_rest.py +0 -0
  62. {auth-2.0.0 → auth-2.3.1}/tests/test_cmd_server.py +0 -0
  63. {auth-2.0.0 → auth-2.3.1}/tests/test_encryption.py +0 -0
  64. {auth-2.0.0 → auth-2.3.1}/tests/test_flask.py +0 -0
  65. {auth-2.0.0 → auth-2.3.1}/tests/test_migrations.py +0 -0
  66. {auth-2.0.0 → auth-2.3.1}/tests/test_phase_a_hardening.py +0 -0
  67. {auth-2.0.0 → auth-2.3.1}/tests/test_reencryption.py +0 -0
  68. {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
+ [![CI](https://github.com/ourway/auth/actions/workflows/ci.yml/badge.svg)](https://github.com/ourway/auth/actions/workflows/ci.yml)
60
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)]()
61
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+ [![CI](https://github.com/ourway/auth/actions/workflows/ci.yml/badge.svg)](https://github.com/ourway/auth/actions/workflows/ci.yml)
4
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)]()
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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 log_audit_event(
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
- Log an audit event to the database and to structured logs
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
- # Create audit log entry
130
- audit_entry = AuditLog(
131
- client_id=_fit(client_id, "client_id"),
132
- user=_fit(user, "user"),
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
- # Also log to structured logger
145
- log_msg: Dict[str, Any] = {
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 == "default_secret_key_for_development":
120
+ if v in _KNOWN_WEAK_SECRETS:
104
121
  import logging
105
122
  logger = logging.getLogger(__name__)
106
- logger.warning("Using default JWT secret key. This should be changed for production!")
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