auth 3.2.0__tar.gz → 3.3.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.
- {auth-3.2.0 → auth-3.3.0}/PKG-INFO +2 -3
- {auth-3.2.0 → auth-3.3.0}/auth/database.py +2 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/public.py +32 -2
- {auth-3.2.0 → auth-3.3.0}/auth/schema_reconcile.py +93 -1
- {auth-3.2.0 → auth-3.3.0}/auth/services/rbac.py +3 -3
- {auth-3.2.0 → auth-3.3.0}/auth/services/tenant.py +16 -4
- {auth-3.2.0 → auth-3.3.0}/auth.egg-info/PKG-INFO +2 -3
- {auth-3.2.0 → auth-3.3.0}/auth.egg-info/SOURCES.txt +4 -0
- {auth-3.2.0 → auth-3.3.0}/pyproject.toml +3 -4
- auth-3.3.0/tests/test_client_exceptions_do_not_render_payload.py +52 -0
- auth-3.3.0/tests/test_degraded_response_is_a_contract.py +82 -0
- auth-3.3.0/tests/test_provision_script_no_dsn_leak.py +53 -0
- auth-3.3.0/tests/test_settings_do_not_echo_secrets.py +59 -0
- {auth-3.2.0 → auth-3.3.0}/LICENSE +0 -0
- {auth-3.2.0 → auth-3.3.0}/README.md +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/api_keys.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/audit.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/circuit_breaker.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/client/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/client/_transport.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/client/base.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/client/keys.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/client/rbac.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/client/workflow.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/cmd/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/cmd/server.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/config.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/core/REST/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/core/REST/client.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/core/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/core/models/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/decorators.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/docs_page.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/encryption.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/keycheck.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/logging_config.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/main.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/models/sql.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/response_format.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/rls.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/_common.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/apikeys.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/membership.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/permissions.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/roles.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/tenant.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/routes/workflow.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/sanitizer.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/server.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/services/base.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/services/keys.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/services/queries.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/services/recovery.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/services/rotation.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/services/service.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/static_docs/__init__.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/static_docs/api_reference.md +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/static_docs/claude_guide.md +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/static_docs/landing.md +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/static_docs/page.html +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/validation.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth/workflow_checker.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth.egg-info/requires.txt +0 -0
- {auth-3.2.0 → auth-3.3.0}/auth.egg-info/top_level.txt +0 -0
- {auth-3.2.0 → auth-3.3.0}/setup.cfg +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_api_keys.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_api_keys_encryption.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_audit_integrity.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_client.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_client_rest.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_cmd_server.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_config.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_correctness_hardening.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_database_sslmode.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_docs_page.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_encryption.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_encryption_integration.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_flask.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_gunicorn_config_invariants.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_key_rotation.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_log_redaction.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_no_bundled_libpq.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_reencryption.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_routes_errors.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_self_service_audit.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_server.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_service_lifecycle.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_strict_default.py +0 -0
- {auth-3.2.0 → auth-3.3.0}/tests/test_strict_users.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auth
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: Authorization for humans
|
|
5
5
|
Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,13 +16,12 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
24
23
|
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.10
|
|
26
25
|
Description-Content-Type: text/markdown
|
|
27
26
|
License-File: LICENSE
|
|
28
27
|
Requires-Dist: flask<4,>=2.0.0
|
|
@@ -18,6 +18,7 @@ from auth.config import DatabaseType, get_settings, warn_on_weak_secrets
|
|
|
18
18
|
from auth.schema_reconcile import (
|
|
19
19
|
GRANDFATHER_MARKER, # noqa: F401 - re-exported; tests and embedded callers import it from here
|
|
20
20
|
_apply_tenant_rls,
|
|
21
|
+
_ensure_audit_partition_runway,
|
|
21
22
|
_grandfather_strict_users,
|
|
22
23
|
_reconcile_text_columns,
|
|
23
24
|
)
|
|
@@ -350,6 +351,7 @@ def create_tables(raise_on_error: bool = False):
|
|
|
350
351
|
# is how a fresh database came up with the junction tables unprotected
|
|
351
352
|
# -- grandfathering raised, and the RLS step below it never ran.
|
|
352
353
|
_apply_tenant_rls(engine)
|
|
354
|
+
_ensure_audit_partition_runway(engine)
|
|
353
355
|
_reconcile_text_columns(engine)
|
|
354
356
|
_grandfather_strict_users(engine)
|
|
355
357
|
logger.info("Tables created successfully.")
|
|
@@ -19,6 +19,26 @@ _COMMIT_PROBE_LOCK = threading.Lock()
|
|
|
19
19
|
_last_commit_verdict = ["ready"]
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
|
|
23
|
+
def _audit_partitions():
|
|
24
|
+
"""What the boot-time runway check saw, for /readyz.
|
|
25
|
+
|
|
26
|
+
Reported as a FIELD, never as the verdict: rows stranded in the default
|
|
27
|
+
partition are a retention problem, and pulling a healthy node out of the
|
|
28
|
+
load balancer over one would turn a slow leak into an outage.
|
|
29
|
+
|
|
30
|
+
It exists at all because logging is not a channel everywhere -- vm-2
|
|
31
|
+
captures no application log line, so the CRITICAL this condition also emits
|
|
32
|
+
is unreachable there.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from auth.schema_reconcile import audit_partition_state
|
|
36
|
+
|
|
37
|
+
return dict(audit_partition_state)
|
|
38
|
+
except Exception:
|
|
39
|
+
return {"checked": False, "stranded": None, "newest": None}
|
|
40
|
+
|
|
41
|
+
|
|
22
42
|
def _pool_depth():
|
|
23
43
|
"""Connection-pool saturation. Needs no database round trip.
|
|
24
44
|
|
|
@@ -146,6 +166,7 @@ def register(app):
|
|
|
146
166
|
"status": _last_commit_verdict[0],
|
|
147
167
|
"reason": "commit probe already in flight",
|
|
148
168
|
"pool": pool_depth,
|
|
169
|
+
"audit_partitions": _audit_partitions(),
|
|
149
170
|
}
|
|
150
171
|
return jsonify(body), (200 if _last_commit_verdict[0] == "ready" else 503)
|
|
151
172
|
try:
|
|
@@ -160,7 +181,16 @@ def register(app):
|
|
|
160
181
|
except Exception:
|
|
161
182
|
logger.exception("readiness check failed: commit path unavailable")
|
|
162
183
|
_last_commit_verdict[0] = "unready"
|
|
163
|
-
return jsonify({
|
|
184
|
+
return jsonify({
|
|
185
|
+
"status": "unready",
|
|
186
|
+
"reason": "commit path",
|
|
187
|
+
"pool": pool_depth,
|
|
188
|
+
"audit_partitions": _audit_partitions(),
|
|
189
|
+
}), 503
|
|
164
190
|
finally:
|
|
165
191
|
_COMMIT_PROBE_LOCK.release()
|
|
166
|
-
return jsonify({
|
|
192
|
+
return jsonify({
|
|
193
|
+
"status": "ready",
|
|
194
|
+
"pool": pool_depth,
|
|
195
|
+
"audit_partitions": _audit_partitions(),
|
|
196
|
+
})
|
|
@@ -15,6 +15,7 @@ junction tables holding every tenant's authorization edges under no policy.
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
import logging
|
|
18
|
+
from typing import Any
|
|
18
19
|
|
|
19
20
|
from sqlalchemy.engine import Engine
|
|
20
21
|
|
|
@@ -22,6 +23,11 @@ from auth.config import DatabaseType, get_settings
|
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
26
|
+
#: What the last audit-partition runway check saw, so /readyz can report it.
|
|
27
|
+
#: Logging is not a channel on every deployment -- vm-2 captures no application
|
|
28
|
+
#: log line at all, which makes a CRITICAL there unreachable by anyone.
|
|
29
|
+
audit_partition_state: dict = {"checked": False, "stranded": None, "newest": None}
|
|
30
|
+
|
|
25
31
|
def _apply_tenant_rls(target_engine: Engine) -> None:
|
|
26
32
|
"""Secure any tenant-scoped table that ``create_all`` has just created.
|
|
27
33
|
|
|
@@ -50,7 +56,7 @@ def _apply_tenant_rls(target_engine: Engine) -> None:
|
|
|
50
56
|
schema = settings.database_schema or "public"
|
|
51
57
|
try:
|
|
52
58
|
with target_engine.begin() as conn:
|
|
53
|
-
result = conn.execute(
|
|
59
|
+
result: Any = conn.execute(
|
|
54
60
|
text(f'SELECT {schema}.apply_tenant_rls()') # noqa: S608
|
|
55
61
|
).scalar_one()
|
|
56
62
|
logger.info("apply_tenant_rls: %s", result)
|
|
@@ -62,6 +68,92 @@ def _apply_tenant_rls(target_engine: Engine) -> None:
|
|
|
62
68
|
)
|
|
63
69
|
|
|
64
70
|
|
|
71
|
+
def _ensure_audit_partition_runway(target_engine: Engine, months: int = 6) -> None:
|
|
72
|
+
"""Keep monthly audit_log partitions provisioned ahead of now.
|
|
73
|
+
|
|
74
|
+
Partitions are finite and nothing renewed them. When they run out, rows do
|
|
75
|
+
not fail -- they land in audit_log_default, which
|
|
76
|
+
drop_audit_log_partitions_before deliberately skips, so retention can never
|
|
77
|
+
reclaim them and the growth problem partitioning solved returns with no
|
|
78
|
+
visible symptom (issuedb #15; production runway ended 2027-08).
|
|
79
|
+
|
|
80
|
+
Called here rather than scheduled in-process: create_tables runs pre-fork
|
|
81
|
+
under preload_app, so this executes once per service start, not once per
|
|
82
|
+
worker, and holds no thread across fork. A cron on the database-adjacent
|
|
83
|
+
host is still the durable answer for a deployment that runs longer than its
|
|
84
|
+
runway without restarting; this removes the cliff for one that restarts.
|
|
85
|
+
|
|
86
|
+
Non-raising, like everything beside it. A deployment whose role cannot
|
|
87
|
+
create partitions still starts, and rows in the default partition are
|
|
88
|
+
reported loudly below rather than silently absorbed.
|
|
89
|
+
"""
|
|
90
|
+
from sqlalchemy import text
|
|
91
|
+
|
|
92
|
+
settings = get_settings()
|
|
93
|
+
if settings.database_type != DatabaseType.POSTGRESQL:
|
|
94
|
+
return
|
|
95
|
+
schema = settings.database_schema or "public"
|
|
96
|
+
try:
|
|
97
|
+
with target_engine.begin() as conn:
|
|
98
|
+
if conn.execute(
|
|
99
|
+
text("SELECT to_regproc(:fn) IS NULL"),
|
|
100
|
+
{"fn": f"{schema}.provision_audit_log_partition"},
|
|
101
|
+
).scalar():
|
|
102
|
+
logger.info(
|
|
103
|
+
"audit partition runway: provisioner absent, skipping"
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
created = []
|
|
107
|
+
for ahead in range(months + 1):
|
|
108
|
+
result: Any = conn.execute(
|
|
109
|
+
text(
|
|
110
|
+
f"SELECT {schema}.provision_audit_log_partition(" # noqa: S608
|
|
111
|
+
"(date_trunc('month', now()) + make_interval(months => :n))::date)"
|
|
112
|
+
),
|
|
113
|
+
{"n": ahead},
|
|
114
|
+
).scalar_one()
|
|
115
|
+
if "created" in str(result):
|
|
116
|
+
created.append(str(result))
|
|
117
|
+
stranded: int = conn.execute(
|
|
118
|
+
text(f"SELECT count(*) FROM {schema}.audit_log_default") # noqa: S608
|
|
119
|
+
).scalar_one()
|
|
120
|
+
newest = conn.execute(
|
|
121
|
+
text(
|
|
122
|
+
"SELECT c.relname FROM pg_class c "
|
|
123
|
+
"JOIN pg_inherits i ON i.inhrelid = c.oid "
|
|
124
|
+
"WHERE i.inhparent = to_regclass(:p) "
|
|
125
|
+
"AND c.relname <> 'audit_log_default' "
|
|
126
|
+
"ORDER BY c.relname DESC LIMIT 1"
|
|
127
|
+
),
|
|
128
|
+
{"p": f"{schema}.audit_log"},
|
|
129
|
+
).scalar()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"audit partition runway could not be ensured (%s); "
|
|
133
|
+
"if partitions run out, rows land in audit_log_default and "
|
|
134
|
+
"retention cannot reclaim them",
|
|
135
|
+
exc.__class__.__name__,
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
audit_partition_state.update(
|
|
140
|
+
{"checked": True, "stranded": int(stranded), "newest": newest}
|
|
141
|
+
)
|
|
142
|
+
logger.info(
|
|
143
|
+
"audit partition runway: %d month(s) ahead, %d created this start",
|
|
144
|
+
months,
|
|
145
|
+
len(created),
|
|
146
|
+
)
|
|
147
|
+
if stranded:
|
|
148
|
+
logger.critical(
|
|
149
|
+
"AUDIT PARTITION RUNWAY WAS EXHAUSTED: %d row(s) are in "
|
|
150
|
+
"audit_log_default. Retention skips that partition, so they can "
|
|
151
|
+
"never be reclaimed. They must be moved into a real partition by "
|
|
152
|
+
"hand once one covers their timestamps.",
|
|
153
|
+
stranded,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
65
157
|
def _reconcile_text_columns(target_engine: Engine) -> None:
|
|
66
158
|
"""Widen live ``character varying`` columns to TEXT where the current models
|
|
67
159
|
declare :class:`~sqlalchemy.Text` (issuedb #21).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Roles, memberships and permissions — the mutating RBAC core."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import Optional
|
|
4
|
+
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
from sqlalchemy import func, select
|
|
7
7
|
|
|
@@ -139,7 +139,7 @@ class RbacMixin(QueryMixin):
|
|
|
139
139
|
set_={"is_active": True, "modified": func.now()},
|
|
140
140
|
)
|
|
141
141
|
self.db.execute(stmt)
|
|
142
|
-
membership_id = self.db.execute(
|
|
142
|
+
membership_id: Any = self.db.execute(
|
|
143
143
|
select(m_table.c.id).where(
|
|
144
144
|
m_table.c.creator == self.client,
|
|
145
145
|
m_table.c.user == encrypted_user,
|
|
@@ -258,7 +258,7 @@ class RbacMixin(QueryMixin):
|
|
|
258
258
|
)
|
|
259
259
|
)
|
|
260
260
|
self.db.execute(stmt)
|
|
261
|
-
perm_id = self.db.execute(
|
|
261
|
+
perm_id: Any = self.db.execute(
|
|
262
262
|
select(p_table.c.id).where(
|
|
263
263
|
p_table.c.creator == self.client,
|
|
264
264
|
p_table.c.name == encrypted_name,
|
|
@@ -4,6 +4,8 @@ import json
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from typing import Any, Dict, List, Optional, Tuple, cast
|
|
6
6
|
|
|
7
|
+
from sqlalchemy import desc, or_
|
|
8
|
+
|
|
7
9
|
from auth.api_keys import generate_rotate_key
|
|
8
10
|
from auth.audit import AuditLog, client_fingerprint
|
|
9
11
|
from auth.models.sql import (
|
|
@@ -12,6 +14,8 @@ from auth.models.sql import (
|
|
|
12
14
|
)
|
|
13
15
|
from auth.services.base import ServiceBase, _utcnow
|
|
14
16
|
|
|
17
|
+
_api_key_table = AuthApiKey.__table__
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class TenantMixin(ServiceBase):
|
|
17
21
|
"""Per-tenant settings, strict-identity checks and audit reads."""
|
|
@@ -112,7 +116,7 @@ class TenantMixin(ServiceBase):
|
|
|
112
116
|
if action:
|
|
113
117
|
query = query.filter(AuditLog.action == action.upper())
|
|
114
118
|
total = query.count()
|
|
115
|
-
rows = query.order_by(AuditLog.id
|
|
119
|
+
rows: List[Any] = query.order_by(desc(AuditLog.id)).offset(offset).limit(limit).all()
|
|
116
120
|
entries = [
|
|
117
121
|
{
|
|
118
122
|
"id": row.id,
|
|
@@ -131,15 +135,23 @@ class TenantMixin(ServiceBase):
|
|
|
131
135
|
|
|
132
136
|
def user_is_key_backed(self, user: str) -> bool:
|
|
133
137
|
"""True when the user holds ≥1 active, unexpired API key here."""
|
|
134
|
-
row = (
|
|
138
|
+
row: Any = (
|
|
135
139
|
self.db.query(AuthApiKey.id)
|
|
136
140
|
.filter(
|
|
137
141
|
AuthApiKey.creator == self.client,
|
|
138
142
|
AuthApiKey._user == self._get_encrypted_user(user),
|
|
139
|
-
|
|
143
|
+
# Core column rather than the mapped attribute. SQLAlchemy 2.1's
|
|
144
|
+
# stubs resolve a legacy `x = Column(...)` attribute to Never, so
|
|
145
|
+
# the bare `AuthApiKey.is_active` poisons the whole chain and every
|
|
146
|
+
# statement after it reads as unreachable. __table__.c types as a
|
|
147
|
+
# KeyedColumnElement and emits byte-identical SQL, verified.
|
|
148
|
+
_api_key_table.c.is_active.is_(True),
|
|
140
149
|
)
|
|
141
150
|
.filter(
|
|
142
|
-
(
|
|
151
|
+
or_(
|
|
152
|
+
_api_key_table.c.expires_at.is_(None),
|
|
153
|
+
_api_key_table.c.expires_at > _utcnow(),
|
|
154
|
+
)
|
|
143
155
|
)
|
|
144
156
|
.first()
|
|
145
157
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auth
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: Authorization for humans
|
|
5
5
|
Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,13 +16,12 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
24
23
|
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.10
|
|
26
25
|
Description-Content-Type: text/markdown
|
|
27
26
|
License-File: LICENSE
|
|
28
27
|
Requires-Dist: flask<4,>=2.0.0
|
|
@@ -65,11 +65,13 @@ tests/test_api_keys.py
|
|
|
65
65
|
tests/test_api_keys_encryption.py
|
|
66
66
|
tests/test_audit_integrity.py
|
|
67
67
|
tests/test_client.py
|
|
68
|
+
tests/test_client_exceptions_do_not_render_payload.py
|
|
68
69
|
tests/test_client_rest.py
|
|
69
70
|
tests/test_cmd_server.py
|
|
70
71
|
tests/test_config.py
|
|
71
72
|
tests/test_correctness_hardening.py
|
|
72
73
|
tests/test_database_sslmode.py
|
|
74
|
+
tests/test_degraded_response_is_a_contract.py
|
|
73
75
|
tests/test_docs_page.py
|
|
74
76
|
tests/test_encryption.py
|
|
75
77
|
tests/test_encryption_integration.py
|
|
@@ -79,10 +81,12 @@ tests/test_key_rotation.py
|
|
|
79
81
|
tests/test_log_redaction.py
|
|
80
82
|
tests/test_no_bundled_libpq.py
|
|
81
83
|
tests/test_phase_a_hardening.py
|
|
84
|
+
tests/test_provision_script_no_dsn_leak.py
|
|
82
85
|
tests/test_reencryption.py
|
|
83
86
|
tests/test_routes_errors.py
|
|
84
87
|
tests/test_self_service_audit.py
|
|
85
88
|
tests/test_server.py
|
|
86
89
|
tests/test_service_lifecycle.py
|
|
90
|
+
tests/test_settings_do_not_echo_secrets.py
|
|
87
91
|
tests/test_strict_default.py
|
|
88
92
|
tests/test_strict_users.py
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "auth"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.3.0"
|
|
8
8
|
description = "Authorization for humans"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [
|
|
13
13
|
{name = "Farshid Ashouri", email = "farsheed.ashouri@gmail.com"}
|
|
@@ -25,7 +25,6 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python",
|
|
26
26
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
27
27
|
"Programming Language :: Python :: 3",
|
|
28
|
-
"Programming Language :: Python :: 3.9",
|
|
29
28
|
"Programming Language :: Python :: 3.10",
|
|
30
29
|
"Programming Language :: Python :: 3.11",
|
|
31
30
|
"Programming Language :: Python :: 3.12",
|
|
@@ -185,7 +184,7 @@ line_length = 88
|
|
|
185
184
|
|
|
186
185
|
[tool.mypy]
|
|
187
186
|
# Lowest version current mypy can target; the service deploys on 3.11 and CI
|
|
188
|
-
# also runs 3.12. (Package metadata
|
|
187
|
+
# also runs 3.12. (Package metadata advertises >=3.10.)
|
|
189
188
|
python_version = "3.10"
|
|
190
189
|
# `python -m build` leaves a copy of the package under build/lib/, which mypy
|
|
191
190
|
# would otherwise report as a duplicate module — so `make build` broke the next
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from auth.client._transport import AuthRefused, AuthTransportError
|
|
4
|
+
|
|
5
|
+
SENTINEL = "Zq7SentinelCredential9xK"
|
|
6
|
+
PAYLOAD = {"message": "denied", "details": {"echoed": SENTINEL}}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_refusal_renders_only_its_message():
|
|
10
|
+
"""auth ships this client; other platforms run it and log its exceptions.
|
|
11
|
+
|
|
12
|
+
A three-argument exception normally renders its whole args tuple through
|
|
13
|
+
Exception.__str__, which would put the upstream response body into every
|
|
14
|
+
``log.error(e)`` a consumer writes. AuthRefused passes only the message to
|
|
15
|
+
super().__init__ and keeps status and payload as attributes, so the safe
|
|
16
|
+
rendering is the default one and the body is opt-in.
|
|
17
|
+
"""
|
|
18
|
+
exc = AuthRefused("auth refused the request: HTTP 401", 401, PAYLOAD)
|
|
19
|
+
|
|
20
|
+
assert SENTINEL not in str(exc)
|
|
21
|
+
assert SENTINEL not in repr(exc)
|
|
22
|
+
assert SENTINEL not in str(exc.args)
|
|
23
|
+
assert str(exc) == "auth refused the request: HTTP 401"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_the_payload_is_still_reachable_deliberately():
|
|
27
|
+
"""The other direction: redaction must not cost the caller the detail.
|
|
28
|
+
|
|
29
|
+
A guard that only proved the body is absent would pass equally if payload
|
|
30
|
+
were dropped entirely, which would break the 409 hints this argument exists
|
|
31
|
+
to carry.
|
|
32
|
+
"""
|
|
33
|
+
exc = AuthRefused("auth refused the request: HTTP 409", 409, PAYLOAD)
|
|
34
|
+
assert exc.payload == PAYLOAD
|
|
35
|
+
assert exc.status == 409
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_the_detector_would_see_a_leak():
|
|
39
|
+
"""Known-positive control.
|
|
40
|
+
|
|
41
|
+
If Exception.__str__ ever stopped rendering extra args, the assertions
|
|
42
|
+
above would hold for a reason that has nothing to do with AuthRefused, and
|
|
43
|
+
the guard would be protecting nothing.
|
|
44
|
+
"""
|
|
45
|
+
leaky = AuthTransportError("upstream said", PAYLOAD)
|
|
46
|
+
assert SENTINEL in str(leaky)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_refusal_is_still_a_transport_error_subclass():
|
|
50
|
+
assert issubclass(AuthRefused, AuthTransportError)
|
|
51
|
+
with pytest.raises(AuthTransportError):
|
|
52
|
+
raise AuthRefused("m", 400, None)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import auth.keycheck as keycheck
|
|
6
|
+
from auth.main import create_app
|
|
7
|
+
|
|
8
|
+
MARKER = "Service degraded"
|
|
9
|
+
MARKER_OFFSET = 102
|
|
10
|
+
|
|
11
|
+
REAL_DETAILS = [
|
|
12
|
+
"the configured AUTH_ENCRYPTION_KEY cannot decrypt this deployment's canary "
|
|
13
|
+
"- every authorization lookup would miss and answer negatively",
|
|
14
|
+
"row level security is not in force on: auth_api_key, auth_group, auth_membership, "
|
|
15
|
+
"auth_permission, auth_tenant_settings, membership_groups, permission_groups "
|
|
16
|
+
"- tenant isolation would rest on query convention alone",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def client():
|
|
22
|
+
application = create_app()
|
|
23
|
+
application.config["TESTING"] = True
|
|
24
|
+
return application.test_client()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def client_key():
|
|
29
|
+
return str(uuid.uuid4())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def degraded():
|
|
34
|
+
saved = (keycheck.state(), keycheck.detail())
|
|
35
|
+
|
|
36
|
+
def _set(detail):
|
|
37
|
+
keycheck._state, keycheck._detail = keycheck.MISMATCH, detail
|
|
38
|
+
|
|
39
|
+
yield _set
|
|
40
|
+
keycheck._state, keycheck._detail = saved
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_healthy_api_is_not_degraded(client, client_key):
|
|
44
|
+
"""Known-positive control: without this, every assertion below could pass
|
|
45
|
+
because the route is broken rather than because degraded mode works."""
|
|
46
|
+
resp = client.get("/api/roles", headers={"Authorization": f"Bearer {client_key}"})
|
|
47
|
+
assert resp.status_code == 200
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize("detail", REAL_DETAILS)
|
|
51
|
+
def test_degraded_api_carries_the_marker_at_a_stable_offset(client, client_key, degraded, detail):
|
|
52
|
+
"""RODMENA L10n classify this response by scanning for MARKER in the first
|
|
53
|
+
200 bytes. That was a heuristic they inferred from a body I sent them; this
|
|
54
|
+
makes it a contract, so moving it fails here instead of silently changing
|
|
55
|
+
how a consumer logs an encryption-key mismatch.
|
|
56
|
+
|
|
57
|
+
The offset is asserted rather than mere presence: the marker precedes the
|
|
58
|
+
variable detail, which is the property that makes a fixed-size scan window
|
|
59
|
+
safe regardless of which failure produced it.
|
|
60
|
+
"""
|
|
61
|
+
degraded(detail)
|
|
62
|
+
resp = client.get("/api/roles", headers={"Authorization": f"Bearer {client_key}"})
|
|
63
|
+
body = resp.get_data(as_text=True)
|
|
64
|
+
|
|
65
|
+
assert resp.status_code == 503
|
|
66
|
+
assert body.index(MARKER) == MARKER_OFFSET
|
|
67
|
+
assert MARKER_OFFSET < 200
|
|
68
|
+
# Werkzeug HTML-escapes the description, so an apostrophe in the detail
|
|
69
|
+
# arrives as ' -- markupsafe's form, not the stdlib's '. A consumer
|
|
70
|
+
# matching raw detail text against a real response will miss; the marker,
|
|
71
|
+
# which contains no escapable character, will not.
|
|
72
|
+
from markupsafe import escape
|
|
73
|
+
|
|
74
|
+
assert str(escape(detail)) in body
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_degraded_readyz_and_health_also_refuse(client, degraded):
|
|
78
|
+
degraded(REAL_DETAILS[0])
|
|
79
|
+
assert client.get("/health").status_code == 503
|
|
80
|
+
assert client.get("/readyz").status_code == 503
|
|
81
|
+
assert client.head("/health").status_code == 503
|
|
82
|
+
assert client.head("/readyz").status_code == 503
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "provision_audit_partitions.py"
|
|
9
|
+
SENTINEL = "Zq7SentinelPw9xK"
|
|
10
|
+
|
|
11
|
+
MALFORMED = {
|
|
12
|
+
"typod_scheme": f"postgres!!!://u:{SENTINEL}@127.0.0.1:1/db",
|
|
13
|
+
"sqlalchemy_url": f"postgresql+psycopg://u:{SENTINEL}@127.0.0.1:1/db",
|
|
14
|
+
"unreachable": f"postgresql://u:{SENTINEL}@127.0.0.1:1/db",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run(dsn):
|
|
19
|
+
env = {k: v for k, v in os.environ.items() if k not in ("AUDIT_DB_URL", "MG_DATABASE_URL", "AUTH_DATABASE_URL", "DATABASE_URL")}
|
|
20
|
+
env["AUTH_DATABASE_URL"] = dsn
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
[sys.executable, str(SCRIPT), "--months", "1"],
|
|
23
|
+
capture_output=True, text=True, env=env, timeout=60,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_the_trigger_still_exists():
|
|
28
|
+
"""Known-positive control.
|
|
29
|
+
|
|
30
|
+
psycopg must still quote a malformed conninfo string back in full, or the
|
|
31
|
+
assertions below pass because the danger vanished rather than because the
|
|
32
|
+
script defends against it -- a check that cannot go red.
|
|
33
|
+
"""
|
|
34
|
+
psycopg = pytest.importorskip("psycopg")
|
|
35
|
+
with pytest.raises(psycopg.Error) as caught:
|
|
36
|
+
psycopg.connect(MALFORMED["typod_scheme"], connect_timeout=1)
|
|
37
|
+
assert SENTINEL in str(caught.value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("name", sorted(MALFORMED))
|
|
41
|
+
def test_script_never_prints_the_dsn(name):
|
|
42
|
+
pytest.importorskip("psycopg")
|
|
43
|
+
proc = _run(MALFORMED[name])
|
|
44
|
+
assert SENTINEL not in proc.stdout
|
|
45
|
+
assert SENTINEL not in proc.stderr
|
|
46
|
+
assert proc.returncode == 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_failure_is_still_diagnosable():
|
|
50
|
+
pytest.importorskip("psycopg")
|
|
51
|
+
proc = _run(MALFORMED["typod_scheme"])
|
|
52
|
+
assert "AUTH_DATABASE_URL" in proc.stderr
|
|
53
|
+
assert "Error" in proc.stderr
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from auth.config import Settings
|
|
7
|
+
|
|
8
|
+
SENTINEL = "Zq7SentinelSecret9xK"
|
|
9
|
+
|
|
10
|
+
SECRET_FIELDS = ("database_url", "postgresql_url", "jwt_secret_key", "audit_pepper", "encryption_key")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_secret_fields_are_plain_str():
|
|
14
|
+
"""The reason a secret cannot reach a ValidationError, asserted directly.
|
|
15
|
+
|
|
16
|
+
pydantic appends ``input_value=<raw value>`` to a validation error. Settings
|
|
17
|
+
are populated from the environment, where every value is a string, so a
|
|
18
|
+
plain ``str`` field cannot fail and cannot be echoed. Give one of these a
|
|
19
|
+
constrained type or a validator and that stops being true silently -- so the
|
|
20
|
+
property itself is the test, not the symptom.
|
|
21
|
+
"""
|
|
22
|
+
for name in SECRET_FIELDS:
|
|
23
|
+
field = Settings.model_fields[name]
|
|
24
|
+
assert field.annotation is str, (
|
|
25
|
+
f"{name} is no longer a plain str. A constrained secret field can fail "
|
|
26
|
+
f"validation, and pydantic will echo its value. Set "
|
|
27
|
+
f"hide_input_in_errors=True on Settings.model_config before changing this."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_a_failure_elsewhere_does_not_echo_secrets(monkeypatch):
|
|
32
|
+
for key in [k for k in os.environ if k.startswith("AUTH_")]:
|
|
33
|
+
monkeypatch.delenv(key, raising=False)
|
|
34
|
+
monkeypatch.setenv("AUTH_DEBUG_MODE", "true")
|
|
35
|
+
monkeypatch.setenv("AUTH_DATABASE_URL", f"postgresql://u:{SENTINEL}@h:5432/db")
|
|
36
|
+
monkeypatch.setenv("AUTH_ENCRYPTION_KEY", SENTINEL)
|
|
37
|
+
monkeypatch.setenv("AUTH_AUDIT_PEPPER", SENTINEL)
|
|
38
|
+
monkeypatch.setenv("AUTH_DATABASE_TYPE", "not-a-valid-enum-value")
|
|
39
|
+
|
|
40
|
+
with pytest.raises(Exception) as caught:
|
|
41
|
+
Settings() # type: ignore[call-arg]
|
|
42
|
+
|
|
43
|
+
exc = caught.value
|
|
44
|
+
rendered = str(exc) + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
45
|
+
|
|
46
|
+
assert "not-a-valid-enum-value" in str(exc)
|
|
47
|
+
assert SENTINEL not in rendered
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_the_echo_mechanism_is_real():
|
|
51
|
+
"""Known-positive control.
|
|
52
|
+
|
|
53
|
+
If pydantic ever stops echoing input values, the assertions above would pass
|
|
54
|
+
because the danger vanished rather than because Settings avoids it -- and
|
|
55
|
+
the guard in the first test would be protecting nothing.
|
|
56
|
+
"""
|
|
57
|
+
with pytest.raises(Exception) as caught:
|
|
58
|
+
Settings(encryption_key=[SENTINEL]) # type: ignore[call-arg,arg-type]
|
|
59
|
+
assert SENTINEL in str(caught.value)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|