auth 2.0.0__tar.gz → 2.4.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.
Files changed (74) hide show
  1. auth-2.4.0/PKG-INFO +157 -0
  2. auth-2.4.0/README.md +100 -0
  3. {auth-2.0.0 → auth-2.4.0}/auth/__init__.py +6 -1
  4. auth-2.4.0/auth/api_keys.py +50 -0
  5. {auth-2.0.0 → auth-2.4.0}/auth/audit.py +103 -33
  6. auth-2.4.0/auth/client.py +595 -0
  7. {auth-2.0.0 → auth-2.4.0}/auth/config.py +70 -2
  8. auth-2.4.0/auth/decorators.py +123 -0
  9. auth-2.4.0/auth/docs_page.py +565 -0
  10. {auth-2.0.0 → auth-2.4.0}/auth/main.py +9 -3
  11. {auth-2.0.0 → auth-2.4.0}/auth/models/sql.py +74 -0
  12. {auth-2.0.0 → auth-2.4.0}/auth/response_format.py +0 -37
  13. {auth-2.0.0 → auth-2.4.0}/auth/routes.py +251 -32
  14. auth-2.4.0/auth/services/service.py +836 -0
  15. {auth-2.0.0 → auth-2.4.0}/auth/validation.py +17 -0
  16. auth-2.4.0/auth.egg-info/PKG-INFO +157 -0
  17. {auth-2.0.0 → auth-2.4.0}/auth.egg-info/SOURCES.txt +11 -10
  18. auth-2.4.0/auth.egg-info/requires.txt +31 -0
  19. {auth-2.0.0 → auth-2.4.0}/pyproject.toml +30 -18
  20. auth-2.4.0/tests/test_api_keys.py +353 -0
  21. auth-2.4.0/tests/test_api_keys_encryption.py +91 -0
  22. auth-2.4.0/tests/test_audit_integrity.py +139 -0
  23. auth-2.4.0/tests/test_client.py +317 -0
  24. auth-2.4.0/tests/test_config.py +79 -0
  25. auth-2.4.0/tests/test_correctness_hardening.py +133 -0
  26. {auth-2.0.0 → auth-2.4.0}/tests/test_docs_page.py +59 -1
  27. auth-2.4.0/tests/test_encryption_integration.py +102 -0
  28. auth-2.4.0/tests/test_key_rotation.py +277 -0
  29. auth-2.4.0/tests/test_log_redaction.py +55 -0
  30. {auth-2.0.0 → auth-2.4.0}/tests/test_migrations.py +9 -2
  31. {auth-2.0.0 → auth-2.4.0}/tests/test_routes_errors.py +20 -3
  32. {auth-2.0.0 → auth-2.4.0}/tests/test_service_lifecycle.py +63 -7
  33. auth-2.0.0/PKG-INFO +0 -257
  34. auth-2.0.0/README.rst +0 -201
  35. auth-2.0.0/auth/client.py +0 -385
  36. auth-2.0.0/auth/dal/authorization_sqlite.py +0 -180
  37. auth-2.0.0/auth/decorators.py +0 -64
  38. auth-2.0.0/auth/docs_page.py +0 -287
  39. auth-2.0.0/auth/jwt_auth.py +0 -135
  40. auth-2.0.0/auth/models/sqlite.py +0 -395
  41. auth-2.0.0/auth/services/rest_service.py +0 -141
  42. auth-2.0.0/auth/services/service.py +0 -485
  43. auth-2.0.0/auth.egg-info/PKG-INFO +0 -257
  44. auth-2.0.0/auth.egg-info/requires.txt +0 -30
  45. auth-2.0.0/tests/test_auth_sqlite.py +0 -266
  46. auth-2.0.0/tests/test_authorization_sqlite.py +0 -749
  47. auth-2.0.0/tests/test_client.py +0 -127
  48. auth-2.0.0/tests/test_db_sqlite.py +0 -509
  49. auth-2.0.0/tests/test_service_rest.py +0 -221
  50. {auth-2.0.0 → auth-2.4.0}/LICENSE +0 -0
  51. {auth-2.0.0 → auth-2.4.0}/auth/circuit_breaker.py +0 -0
  52. {auth-2.0.0 → auth-2.4.0}/auth/cmd/__init__.py +0 -0
  53. {auth-2.0.0 → auth-2.4.0}/auth/cmd/server.py +0 -0
  54. {auth-2.0.0 → auth-2.4.0}/auth/core/REST/__init__.py +0 -0
  55. {auth-2.0.0 → auth-2.4.0}/auth/core/REST/client.py +0 -0
  56. {auth-2.0.0 → auth-2.4.0}/auth/core/__init__.py +0 -0
  57. {auth-2.0.0 → auth-2.4.0}/auth/core/models/__init__.py +0 -0
  58. {auth-2.0.0 → auth-2.4.0}/auth/database.py +0 -0
  59. {auth-2.0.0 → auth-2.4.0}/auth/encryption.py +0 -0
  60. {auth-2.0.0 → auth-2.4.0}/auth/logging_config.py +0 -0
  61. {auth-2.0.0 → auth-2.4.0}/auth/sanitizer.py +0 -0
  62. {auth-2.0.0 → auth-2.4.0}/auth/server.py +0 -0
  63. {auth-2.0.0 → auth-2.4.0}/auth/workflow_checker.py +0 -0
  64. {auth-2.0.0 → auth-2.4.0}/auth.egg-info/dependency_links.txt +0 -0
  65. {auth-2.0.0 → auth-2.4.0}/auth.egg-info/entry_points.txt +0 -0
  66. {auth-2.0.0 → auth-2.4.0}/auth.egg-info/top_level.txt +0 -0
  67. {auth-2.0.0 → auth-2.4.0}/setup.cfg +0 -0
  68. {auth-2.0.0 → auth-2.4.0}/tests/test_client_rest.py +0 -0
  69. {auth-2.0.0 → auth-2.4.0}/tests/test_cmd_server.py +0 -0
  70. {auth-2.0.0 → auth-2.4.0}/tests/test_encryption.py +0 -0
  71. {auth-2.0.0 → auth-2.4.0}/tests/test_flask.py +0 -0
  72. {auth-2.0.0 → auth-2.4.0}/tests/test_phase_a_hardening.py +0 -0
  73. {auth-2.0.0 → auth-2.4.0}/tests/test_reencryption.py +0 -0
  74. {auth-2.0.0 → auth-2.4.0}/tests/test_server.py +0 -0
auth-2.4.0/PKG-INFO ADDED
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: auth
3
+ Version: 2.4.0
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: migretti>=0.10.0; extra == "migrations"
44
+ Requires-Dist: alembic>=1.13.0; extra == "migrations"
45
+ Provides-Extra: dev
46
+ Requires-Dist: pytest>=6.0; extra == "dev"
47
+ Requires-Dist: pytest-cov>=2.0; extra == "dev"
48
+ Requires-Dist: alembic>=1.13.0; extra == "dev"
49
+ Requires-Dist: ruff>=0.0.260; extra == "dev"
50
+ Requires-Dist: mypy>=1.0; extra == "dev"
51
+ Requires-Dist: types-requests; extra == "dev"
52
+ Requires-Dist: types-waitress; extra == "dev"
53
+ Requires-Dist: black>=22.0; extra == "dev"
54
+ Requires-Dist: isort>=5.0; extra == "dev"
55
+ Requires-Dist: responses>=0.23; extra == "dev"
56
+ Dynamic: license-file
57
+
58
+ # auth — RBAC authorization service
59
+
60
+ [![CI](https://github.com/ourway/auth/actions/workflows/ci.yml/badge.svg)](https://github.com/ourway/auth/actions/workflows/ci.yml)
61
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue)]()
62
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
63
+
64
+ Role-based access control over HTTP. `auth` answers one question — **may user X
65
+ do Y** — so your services don't reinvent roles and permissions.
66
+
67
+ It is **authorization, not authentication**: it does not log anyone in, store
68
+ passwords, or issue tokens. It trusts that the caller already knows *who* the
69
+ user is, and decides *what they may do*. Model:
70
+ `user → (member of) → role → (holds) → permission`.
71
+
72
+ ## Quickstart
73
+
74
+ Your **client key is any UUID4** — it is also your private, isolated namespace.
75
+ Generate one, keep it secret, and reuse it for every call. A role must exist
76
+ before you add members or permissions to it.
77
+
78
+ ```bash
79
+ KEY=$(python3 -c "import uuid; print(uuid.uuid4())")
80
+ BASE=https://auth.rodmena.app
81
+
82
+ curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/role/engineers
83
+ curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/permission/engineers/deploy
84
+ curl -X POST -H "Authorization: Bearer $KEY" $BASE/api/membership/alice/engineers
85
+ curl -H "Authorization: Bearer $KEY" $BASE/api/has_permission/alice/deploy
86
+ # -> {"success": true, "data": {"has_permission": true}, ...}
87
+ ```
88
+
89
+ With the Python client (`pip install auth`):
90
+
91
+ ```python
92
+ from auth import Client
93
+
94
+ with Client(api_key=KEY, service_url="https://auth.rodmena.app") as c:
95
+ c.create_role("engineers")
96
+ c.add_permission("engineers", "deploy")
97
+ c.add_membership("alice", "engineers")
98
+ c.user_has_permission("alice", "deploy") # -> {... "has_permission": true}
99
+ ```
100
+
101
+ ## Things to know before you write code
102
+
103
+ - **Writes can return HTTP 200 with `{"result": false}`** (e.g. adding to a
104
+ missing role). Check the `result`/`data` field, not just the status code.
105
+ - **Two response shapes** — bare `{"result": ...}` and wrapped
106
+ `{"success", "data", ...}`. The API reference says which per endpoint.
107
+ - **Errors below 2xx are HTML**, not JSON. Branch on the status code first.
108
+ - **Python client: check `success` before reading `data`.** On transport
109
+ failure the client does not raise by default — it returns
110
+ `{"error", "success": False, "transport_error": True, "data": {...}}` where
111
+ `data` only echoes your inputs and does NOT contain the answer field
112
+ (`has_permission`, `count`, ...). Reading `data` blindly turns an outage into
113
+ a false "no". Pass `Client(..., raise_on_error=True)` to get an
114
+ `AuthTransportError` exception instead of the error dict.
115
+ - **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
116
+ key out of source control, logs, and URLs — it is the only thing protecting
117
+ your data. Rotate it with `POST /api/keys/rotate` if it leaks.
118
+ - **Per-user API keys** (2.4.0): `/api/apikeys/user/<user>` (create/list),
119
+ `/api/apikeys/user/<user>/<key_id>` (revoke), `/api/apikeys/validate`. auth
120
+ mints `rak_...` secrets for *your users*, shows each exactly once, stores only
121
+ a hash, and validates them inside your namespace — an identity UI fronts the
122
+ lifecycle, backends validate then use the RBAC checks. Client methods:
123
+ `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
124
+
125
+ ## Documentation
126
+
127
+ | Doc | What's in it |
128
+ |---|---|
129
+ | **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). |
130
+ | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design, components, request lifecycle, data model, permission-check and key-rotation flows, diagrams. |
131
+ | [SECURITY.md](SECURITY.md) | Security model (tenant isolation, encryption, audit, rotation), threat notes, reporting. |
132
+ | [MIGRATIONS.md](MIGRATIONS.md) | Schema creation vs migrations, upgrade/rollback runbook. |
133
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Local setup, tests (sqlite + postgres), lint/type-check, CI. |
134
+ | [docs/](docs/) | Full Sphinx docs (concepts, configuration, encryption, deployment, REST & Python usage). |
135
+
136
+ ## When to use — and not
137
+
138
+ **Use it** for RBAC: named roles, permissions, group membership, and boolean
139
+ "can user X do Y" gates for a service, CLI, or workflow engine.
140
+
141
+ **Not** for authentication (login/passwords/sessions/OAuth/JWT), fine-grained /
142
+ attribute-based rules (owner-of-*this*-record, time-of-day, row-level tenancy —
143
+ reach for an ABAC/policy engine), or air-gapped hot loops where a network hop per
144
+ check is too costly (cache, or use the library in-process).
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ python3.11 -m venv .venv && . .venv/bin/activate
150
+ pip install -e ".[dev,ratelimit,migrations]"
151
+ make check # ruff + mypy
152
+ make test # sqlite suite
153
+ make test-postgres # postgres integration (Docker), encryption on
154
+ ```
155
+
156
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. Licensed under the
157
+ [MIT License](LICENSE).
auth-2.4.0/README.md ADDED
@@ -0,0 +1,100 @@
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
+ - **Python client: check `success` before reading `data`.** On transport
52
+ failure the client does not raise by default — it returns
53
+ `{"error", "success": False, "transport_error": True, "data": {...}}` where
54
+ `data` only echoes your inputs and does NOT contain the answer field
55
+ (`has_permission`, `count`, ...). Reading `data` blindly turns an outage into
56
+ a false "no". Pass `Client(..., raise_on_error=True)` to get an
57
+ `AuthTransportError` exception instead of the error dict.
58
+ - **Reuse one key.** A new key is a new empty namespace, not an error. Keep the
59
+ key out of source control, logs, and URLs — it is the only thing protecting
60
+ your data. Rotate it with `POST /api/keys/rotate` if it leaks.
61
+ - **Per-user API keys** (2.4.0): `/api/apikeys/user/<user>` (create/list),
62
+ `/api/apikeys/user/<user>/<key_id>` (revoke), `/api/apikeys/validate`. auth
63
+ mints `rak_...` secrets for *your users*, shows each exactly once, stores only
64
+ a hash, and validates them inside your namespace — an identity UI fronts the
65
+ lifecycle, backends validate then use the RBAC checks. Client methods:
66
+ `create_api_key`, `list_api_keys`, `revoke_api_key`, `validate_api_key`.
67
+
68
+ ## Documentation
69
+
70
+ | Doc | What's in it |
71
+ |---|---|
72
+ | **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). |
73
+ | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design, components, request lifecycle, data model, permission-check and key-rotation flows, diagrams. |
74
+ | [SECURITY.md](SECURITY.md) | Security model (tenant isolation, encryption, audit, rotation), threat notes, reporting. |
75
+ | [MIGRATIONS.md](MIGRATIONS.md) | Schema creation vs migrations, upgrade/rollback runbook. |
76
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Local setup, tests (sqlite + postgres), lint/type-check, CI. |
77
+ | [docs/](docs/) | Full Sphinx docs (concepts, configuration, encryption, deployment, REST & Python usage). |
78
+
79
+ ## When to use — and not
80
+
81
+ **Use it** for RBAC: named roles, permissions, group membership, and boolean
82
+ "can user X do Y" gates for a service, CLI, or workflow engine.
83
+
84
+ **Not** for authentication (login/passwords/sessions/OAuth/JWT), fine-grained /
85
+ attribute-based rules (owner-of-*this*-record, time-of-day, row-level tenancy —
86
+ reach for an ABAC/policy engine), or air-gapped hot loops where a network hop per
87
+ check is too costly (cache, or use the library in-process).
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ python3.11 -m venv .venv && . .venv/bin/activate
93
+ pip install -e ".[dev,ratelimit,migrations]"
94
+ make check # ruff + mypy
95
+ make test # sqlite suite
96
+ make test-postgres # postgres integration (Docker), encryption on
97
+ ```
98
+
99
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow. Licensed under the
100
+ [MIT License](LICENSE).
@@ -3,7 +3,11 @@ __author__ = "Farshid Ashouri"
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
  from typing import Optional
5
5
 
6
- from auth.client import Client, EnhancedAuthClient # Import the new client
6
+ from auth.client import ( # Import the new client
7
+ AuthTransportError,
8
+ Client,
9
+ EnhancedAuthClient,
10
+ )
7
11
  from auth.database import SessionLocal
8
12
  from auth.services.service import AuthorizationService
9
13
 
@@ -94,6 +98,7 @@ class Authorization:
94
98
  # Export the new client for users who want enhanced features
95
99
  __all__ = [
96
100
  "Authorization",
101
+ "AuthTransportError",
97
102
  "Client",
98
103
  "EnhancedAuthClient",
99
104
  "SessionLocal",
@@ -0,0 +1,50 @@
1
+ """Per-user API key generation and hashing (SPEC 0004).
2
+
3
+ Secrets are ``rak_`` + 43 base62 characters carrying the full 256 bits of
4
+ ``secrets.token_bytes(32)``. Only the SHA-256 hex digest is stored; the raw
5
+ secret is returned once at creation and never persisted, logged, or audited.
6
+ The digest deliberately excludes the tenant and any server pepper: at this
7
+ entropy an offline attack on a leaked hash is not a threat, and neither a
8
+ client-key rotation nor a pepper change may invalidate issued keys.
9
+ """
10
+
11
+ import hashlib
12
+ import re
13
+ import secrets
14
+ import uuid
15
+ from typing import Tuple
16
+
17
+ API_KEY_PREFIX = "rak_"
18
+ API_KEY_PATTERN = re.compile(r"^rak_[0-9A-Za-z]{43}$")
19
+
20
+ # "rak_" + first 8 payload chars — safe to store and display in listings.
21
+ KEY_PREFIX_LEN = 12
22
+
23
+ _PAYLOAD_LEN = 43
24
+ _ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
25
+
26
+
27
+ def _base62(data: bytes) -> str:
28
+ number = int.from_bytes(data, "big")
29
+ chars = []
30
+ while number:
31
+ number, rem = divmod(number, 62)
32
+ chars.append(_ALPHABET[rem])
33
+ return "".join(reversed(chars)) or _ALPHABET[0]
34
+
35
+
36
+ def generate_api_key() -> Tuple[str, str, str, str]:
37
+ """Mint a fresh key: returns ``(secret, key_id, key_hash, key_prefix)``.
38
+
39
+ ``secret`` is shown to the caller exactly once; ``key_id`` is the public
40
+ UUID4 handle used in revoke paths; ``key_hash``/``key_prefix`` are what
41
+ gets stored.
42
+ """
43
+ payload = _base62(secrets.token_bytes(32)).rjust(_PAYLOAD_LEN, _ALPHABET[0])
44
+ secret = API_KEY_PREFIX + payload
45
+ return secret, str(uuid.uuid4()), hash_api_key(secret), secret[:KEY_PREFIX_LEN]
46
+
47
+
48
+ def hash_api_key(secret: str) -> str:
49
+ """SHA-256 hex digest of the full secret string (equality-queryable)."""
50
+ return hashlib.sha256(secret.encode()).hexdigest()
@@ -37,6 +37,11 @@ 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"
41
+ CREATE_API_KEY = "CREATE_API_KEY"
42
+ LIST_API_KEYS = "LIST_API_KEYS"
43
+ REVOKE_API_KEY = "REVOKE_API_KEY"
44
+ VALIDATE_API_KEY = "VALIDATE_API_KEY"
40
45
 
41
46
 
42
47
  class AuditLog(Base):
@@ -111,7 +116,65 @@ def client_fingerprint(token: Optional[str]) -> str:
111
116
  return "fpr_" + digest[:32]
112
117
 
113
118
 
114
- def log_audit_event(
119
+ def _build_audit_entry(
120
+ client_id: str,
121
+ user: Optional[str],
122
+ action: AuditAction,
123
+ resource: Optional[str],
124
+ details: Optional[Dict[str, Any]],
125
+ ip_address: Optional[str],
126
+ user_agent: Optional[str],
127
+ success: bool,
128
+ ) -> "AuditLog":
129
+ # The managed user is a human identifier (often an email) — store a
130
+ # non-reversible fingerprint, never plaintext, matching how the client key is
131
+ # handled. Auditors correlate by fingerprint and can confirm a known user by
132
+ # computing its fingerprint. Role/permission/workflow names (the `resource`)
133
+ # are application identifiers, not PII, and stay readable — except the caller
134
+ # is responsible for fingerprinting any user embedded in `resource`.
135
+ user_fp = client_fingerprint(user) if user else None
136
+ return AuditLog(
137
+ client_id=_fit(client_id, "client_id"),
138
+ user=_fit(user_fp, "user"),
139
+ action=_fit(action.value, "action"),
140
+ resource=_fit(resource, "resource"),
141
+ details=json.dumps(details) if details else None,
142
+ ip_address=_fit(ip_address, "ip_address"),
143
+ user_agent=_fit(user_agent, "user_agent"),
144
+ success=1 if success else 0,
145
+ )
146
+
147
+
148
+ def _emit_structured_log(
149
+ client_id: str,
150
+ user: Optional[str],
151
+ action: AuditAction,
152
+ resource: Optional[str],
153
+ details: Optional[Dict[str, Any]],
154
+ ip_address: Optional[str],
155
+ success: bool,
156
+ ) -> None:
157
+ # The DB row is the system of record. The log STREAM (journald / SIEM /
158
+ # shipping) is more widely exposed, so it carries no PII: no raw user and no
159
+ # resource string (which may embed a user). Only the non-reversible client
160
+ # fingerprint, the action, and the outcome.
161
+ log_msg: Dict[str, Any] = {
162
+ "type": "audit",
163
+ "client_id": client_id,
164
+ "action": action.value,
165
+ "success": success,
166
+ "timestamp": _utcnow().isoformat(),
167
+ }
168
+ if details:
169
+ log_msg["details"] = details
170
+ if ip_address:
171
+ log_msg["ip"] = ip_address
172
+ audit_logger.info(json.dumps(log_msg))
173
+
174
+
175
+ def record_audit(
176
+ session,
177
+ *,
115
178
  client_id: str,
116
179
  user: Optional[str],
117
180
  action: AuditAction,
@@ -121,45 +184,52 @@ def log_audit_event(
121
184
  user_agent: Optional[str] = None,
122
185
  success: bool = True,
123
186
  ) -> None:
187
+ """Add an audit row to an EXISTING session so it commits atomically with the
188
+ caller's transaction (the mutation and its audit land together, or not at
189
+ all). The caller is responsible for committing.
190
+
191
+ This does NOT swallow errors: a failure to stage the audit row must fail the
192
+ surrounding request (fail-closed), never leave a committed mutation
193
+ unaudited.
124
194
  """
125
- Log an audit event to the database and to structured logs
195
+ session.add(
196
+ _build_audit_entry(
197
+ client_id, user, action, resource, details, ip_address, user_agent, success
198
+ )
199
+ )
200
+ _emit_structured_log(client_id, user, action, resource, details, ip_address, success)
201
+
202
+
203
+ def log_audit_event(
204
+ client_id: str,
205
+ user: Optional[str],
206
+ action: AuditAction,
207
+ resource: Optional[str] = None,
208
+ details: Optional[Dict[str, Any]] = None,
209
+ ip_address: Optional[str] = None,
210
+ user_agent: Optional[str] = None,
211
+ success: bool = True,
212
+ ) -> None:
213
+ """Write an audit event on its OWN committed session.
214
+
215
+ For contexts with no request transaction to join: in-process/library
216
+ callers, and the *failure* path of the request decorator (where the request
217
+ transaction is being rolled back and must not carry the audit row). This one
218
+ is best-effort — a failure is logged, not raised, so it cannot mask the
219
+ original error it is recording.
126
220
  """
127
221
  session = SessionLocal()
128
222
  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,
223
+ session.add(
224
+ _build_audit_entry(
225
+ client_id, user, action, resource, details, ip_address, user_agent, success
226
+ )
139
227
  )
140
-
141
- session.add(audit_entry)
142
228
  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))
229
+ _emit_structured_log(
230
+ client_id, user, action, resource, details, ip_address, success
231
+ )
160
232
  except Exception:
161
- # If audit logging fails, we don't want to break the main operation
162
- # But log the failure for monitoring
163
233
  audit_logger.error(
164
234
  f"Failed to log audit event: client_id={client_id}, action={action.value}"
165
235
  )