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.
Files changed (94) hide show
  1. {auth-3.2.0 → auth-3.3.0}/PKG-INFO +2 -3
  2. {auth-3.2.0 → auth-3.3.0}/auth/database.py +2 -0
  3. {auth-3.2.0 → auth-3.3.0}/auth/routes/public.py +32 -2
  4. {auth-3.2.0 → auth-3.3.0}/auth/schema_reconcile.py +93 -1
  5. {auth-3.2.0 → auth-3.3.0}/auth/services/rbac.py +3 -3
  6. {auth-3.2.0 → auth-3.3.0}/auth/services/tenant.py +16 -4
  7. {auth-3.2.0 → auth-3.3.0}/auth.egg-info/PKG-INFO +2 -3
  8. {auth-3.2.0 → auth-3.3.0}/auth.egg-info/SOURCES.txt +4 -0
  9. {auth-3.2.0 → auth-3.3.0}/pyproject.toml +3 -4
  10. auth-3.3.0/tests/test_client_exceptions_do_not_render_payload.py +52 -0
  11. auth-3.3.0/tests/test_degraded_response_is_a_contract.py +82 -0
  12. auth-3.3.0/tests/test_provision_script_no_dsn_leak.py +53 -0
  13. auth-3.3.0/tests/test_settings_do_not_echo_secrets.py +59 -0
  14. {auth-3.2.0 → auth-3.3.0}/LICENSE +0 -0
  15. {auth-3.2.0 → auth-3.3.0}/README.md +0 -0
  16. {auth-3.2.0 → auth-3.3.0}/auth/__init__.py +0 -0
  17. {auth-3.2.0 → auth-3.3.0}/auth/api_keys.py +0 -0
  18. {auth-3.2.0 → auth-3.3.0}/auth/audit.py +0 -0
  19. {auth-3.2.0 → auth-3.3.0}/auth/circuit_breaker.py +0 -0
  20. {auth-3.2.0 → auth-3.3.0}/auth/client/__init__.py +0 -0
  21. {auth-3.2.0 → auth-3.3.0}/auth/client/_transport.py +0 -0
  22. {auth-3.2.0 → auth-3.3.0}/auth/client/base.py +0 -0
  23. {auth-3.2.0 → auth-3.3.0}/auth/client/keys.py +0 -0
  24. {auth-3.2.0 → auth-3.3.0}/auth/client/rbac.py +0 -0
  25. {auth-3.2.0 → auth-3.3.0}/auth/client/workflow.py +0 -0
  26. {auth-3.2.0 → auth-3.3.0}/auth/cmd/__init__.py +0 -0
  27. {auth-3.2.0 → auth-3.3.0}/auth/cmd/server.py +0 -0
  28. {auth-3.2.0 → auth-3.3.0}/auth/config.py +0 -0
  29. {auth-3.2.0 → auth-3.3.0}/auth/core/REST/__init__.py +0 -0
  30. {auth-3.2.0 → auth-3.3.0}/auth/core/REST/client.py +0 -0
  31. {auth-3.2.0 → auth-3.3.0}/auth/core/__init__.py +0 -0
  32. {auth-3.2.0 → auth-3.3.0}/auth/core/models/__init__.py +0 -0
  33. {auth-3.2.0 → auth-3.3.0}/auth/decorators.py +0 -0
  34. {auth-3.2.0 → auth-3.3.0}/auth/docs_page.py +0 -0
  35. {auth-3.2.0 → auth-3.3.0}/auth/encryption.py +0 -0
  36. {auth-3.2.0 → auth-3.3.0}/auth/keycheck.py +0 -0
  37. {auth-3.2.0 → auth-3.3.0}/auth/logging_config.py +0 -0
  38. {auth-3.2.0 → auth-3.3.0}/auth/main.py +0 -0
  39. {auth-3.2.0 → auth-3.3.0}/auth/models/sql.py +0 -0
  40. {auth-3.2.0 → auth-3.3.0}/auth/response_format.py +0 -0
  41. {auth-3.2.0 → auth-3.3.0}/auth/rls.py +0 -0
  42. {auth-3.2.0 → auth-3.3.0}/auth/routes/__init__.py +0 -0
  43. {auth-3.2.0 → auth-3.3.0}/auth/routes/_common.py +0 -0
  44. {auth-3.2.0 → auth-3.3.0}/auth/routes/apikeys.py +0 -0
  45. {auth-3.2.0 → auth-3.3.0}/auth/routes/membership.py +0 -0
  46. {auth-3.2.0 → auth-3.3.0}/auth/routes/permissions.py +0 -0
  47. {auth-3.2.0 → auth-3.3.0}/auth/routes/roles.py +0 -0
  48. {auth-3.2.0 → auth-3.3.0}/auth/routes/tenant.py +0 -0
  49. {auth-3.2.0 → auth-3.3.0}/auth/routes/workflow.py +0 -0
  50. {auth-3.2.0 → auth-3.3.0}/auth/sanitizer.py +0 -0
  51. {auth-3.2.0 → auth-3.3.0}/auth/server.py +0 -0
  52. {auth-3.2.0 → auth-3.3.0}/auth/services/base.py +0 -0
  53. {auth-3.2.0 → auth-3.3.0}/auth/services/keys.py +0 -0
  54. {auth-3.2.0 → auth-3.3.0}/auth/services/queries.py +0 -0
  55. {auth-3.2.0 → auth-3.3.0}/auth/services/recovery.py +0 -0
  56. {auth-3.2.0 → auth-3.3.0}/auth/services/rotation.py +0 -0
  57. {auth-3.2.0 → auth-3.3.0}/auth/services/service.py +0 -0
  58. {auth-3.2.0 → auth-3.3.0}/auth/static_docs/__init__.py +0 -0
  59. {auth-3.2.0 → auth-3.3.0}/auth/static_docs/api_reference.md +0 -0
  60. {auth-3.2.0 → auth-3.3.0}/auth/static_docs/claude_guide.md +0 -0
  61. {auth-3.2.0 → auth-3.3.0}/auth/static_docs/landing.md +0 -0
  62. {auth-3.2.0 → auth-3.3.0}/auth/static_docs/page.html +0 -0
  63. {auth-3.2.0 → auth-3.3.0}/auth/validation.py +0 -0
  64. {auth-3.2.0 → auth-3.3.0}/auth/workflow_checker.py +0 -0
  65. {auth-3.2.0 → auth-3.3.0}/auth.egg-info/dependency_links.txt +0 -0
  66. {auth-3.2.0 → auth-3.3.0}/auth.egg-info/entry_points.txt +0 -0
  67. {auth-3.2.0 → auth-3.3.0}/auth.egg-info/requires.txt +0 -0
  68. {auth-3.2.0 → auth-3.3.0}/auth.egg-info/top_level.txt +0 -0
  69. {auth-3.2.0 → auth-3.3.0}/setup.cfg +0 -0
  70. {auth-3.2.0 → auth-3.3.0}/tests/test_api_keys.py +0 -0
  71. {auth-3.2.0 → auth-3.3.0}/tests/test_api_keys_encryption.py +0 -0
  72. {auth-3.2.0 → auth-3.3.0}/tests/test_audit_integrity.py +0 -0
  73. {auth-3.2.0 → auth-3.3.0}/tests/test_client.py +0 -0
  74. {auth-3.2.0 → auth-3.3.0}/tests/test_client_rest.py +0 -0
  75. {auth-3.2.0 → auth-3.3.0}/tests/test_cmd_server.py +0 -0
  76. {auth-3.2.0 → auth-3.3.0}/tests/test_config.py +0 -0
  77. {auth-3.2.0 → auth-3.3.0}/tests/test_correctness_hardening.py +0 -0
  78. {auth-3.2.0 → auth-3.3.0}/tests/test_database_sslmode.py +0 -0
  79. {auth-3.2.0 → auth-3.3.0}/tests/test_docs_page.py +0 -0
  80. {auth-3.2.0 → auth-3.3.0}/tests/test_encryption.py +0 -0
  81. {auth-3.2.0 → auth-3.3.0}/tests/test_encryption_integration.py +0 -0
  82. {auth-3.2.0 → auth-3.3.0}/tests/test_flask.py +0 -0
  83. {auth-3.2.0 → auth-3.3.0}/tests/test_gunicorn_config_invariants.py +0 -0
  84. {auth-3.2.0 → auth-3.3.0}/tests/test_key_rotation.py +0 -0
  85. {auth-3.2.0 → auth-3.3.0}/tests/test_log_redaction.py +0 -0
  86. {auth-3.2.0 → auth-3.3.0}/tests/test_no_bundled_libpq.py +0 -0
  87. {auth-3.2.0 → auth-3.3.0}/tests/test_phase_a_hardening.py +0 -0
  88. {auth-3.2.0 → auth-3.3.0}/tests/test_reencryption.py +0 -0
  89. {auth-3.2.0 → auth-3.3.0}/tests/test_routes_errors.py +0 -0
  90. {auth-3.2.0 → auth-3.3.0}/tests/test_self_service_audit.py +0 -0
  91. {auth-3.2.0 → auth-3.3.0}/tests/test_server.py +0 -0
  92. {auth-3.2.0 → auth-3.3.0}/tests/test_service_lifecycle.py +0 -0
  93. {auth-3.2.0 → auth-3.3.0}/tests/test_strict_default.py +0 -0
  94. {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.2.0
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.9
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({"status": "unready", "reason": "commit path", "pool": pool_depth}), 503
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({"status": "ready", "pool": pool_depth})
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.desc()).offset(offset).limit(limit).all()
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
- AuthApiKey.is_active,
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
- (AuthApiKey.expires_at.is_(None)) | (AuthApiKey.expires_at > _utcnow())
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.2.0
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.9
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.2.0"
7
+ version = "3.3.0"
8
8
  description = "Authorization for humans"
9
9
  readme = "README.md"
10
- requires-python = ">=3.9"
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 still advertises >=3.9 for older installs.)
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 &#39; -- markupsafe's form, not the stdlib's &#x27;. 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