auth 3.2.0__tar.gz → 3.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 (95) hide show
  1. {auth-3.2.0 → auth-3.4.0}/PKG-INFO +2 -3
  2. {auth-3.2.0 → auth-3.4.0}/auth/__init__.py +11 -0
  3. {auth-3.2.0 → auth-3.4.0}/auth/config.py +14 -0
  4. {auth-3.2.0 → auth-3.4.0}/auth/database.py +66 -39
  5. {auth-3.2.0 → auth-3.4.0}/auth/routes/public.py +32 -2
  6. {auth-3.2.0 → auth-3.4.0}/auth/schema_reconcile.py +93 -1
  7. {auth-3.2.0 → auth-3.4.0}/auth/services/rbac.py +3 -3
  8. {auth-3.2.0 → auth-3.4.0}/auth/services/tenant.py +16 -4
  9. {auth-3.2.0 → auth-3.4.0}/auth.egg-info/PKG-INFO +2 -3
  10. {auth-3.2.0 → auth-3.4.0}/auth.egg-info/SOURCES.txt +5 -0
  11. {auth-3.2.0 → auth-3.4.0}/pyproject.toml +3 -4
  12. auth-3.4.0/tests/test_client_exceptions_do_not_render_payload.py +52 -0
  13. auth-3.4.0/tests/test_database_pool_settings.py +340 -0
  14. auth-3.4.0/tests/test_degraded_response_is_a_contract.py +82 -0
  15. auth-3.4.0/tests/test_provision_script_no_dsn_leak.py +53 -0
  16. auth-3.4.0/tests/test_settings_do_not_echo_secrets.py +59 -0
  17. {auth-3.2.0 → auth-3.4.0}/LICENSE +0 -0
  18. {auth-3.2.0 → auth-3.4.0}/README.md +0 -0
  19. {auth-3.2.0 → auth-3.4.0}/auth/api_keys.py +0 -0
  20. {auth-3.2.0 → auth-3.4.0}/auth/audit.py +0 -0
  21. {auth-3.2.0 → auth-3.4.0}/auth/circuit_breaker.py +0 -0
  22. {auth-3.2.0 → auth-3.4.0}/auth/client/__init__.py +0 -0
  23. {auth-3.2.0 → auth-3.4.0}/auth/client/_transport.py +0 -0
  24. {auth-3.2.0 → auth-3.4.0}/auth/client/base.py +0 -0
  25. {auth-3.2.0 → auth-3.4.0}/auth/client/keys.py +0 -0
  26. {auth-3.2.0 → auth-3.4.0}/auth/client/rbac.py +0 -0
  27. {auth-3.2.0 → auth-3.4.0}/auth/client/workflow.py +0 -0
  28. {auth-3.2.0 → auth-3.4.0}/auth/cmd/__init__.py +0 -0
  29. {auth-3.2.0 → auth-3.4.0}/auth/cmd/server.py +0 -0
  30. {auth-3.2.0 → auth-3.4.0}/auth/core/REST/__init__.py +0 -0
  31. {auth-3.2.0 → auth-3.4.0}/auth/core/REST/client.py +0 -0
  32. {auth-3.2.0 → auth-3.4.0}/auth/core/__init__.py +0 -0
  33. {auth-3.2.0 → auth-3.4.0}/auth/core/models/__init__.py +0 -0
  34. {auth-3.2.0 → auth-3.4.0}/auth/decorators.py +0 -0
  35. {auth-3.2.0 → auth-3.4.0}/auth/docs_page.py +0 -0
  36. {auth-3.2.0 → auth-3.4.0}/auth/encryption.py +0 -0
  37. {auth-3.2.0 → auth-3.4.0}/auth/keycheck.py +0 -0
  38. {auth-3.2.0 → auth-3.4.0}/auth/logging_config.py +0 -0
  39. {auth-3.2.0 → auth-3.4.0}/auth/main.py +0 -0
  40. {auth-3.2.0 → auth-3.4.0}/auth/models/sql.py +0 -0
  41. {auth-3.2.0 → auth-3.4.0}/auth/response_format.py +0 -0
  42. {auth-3.2.0 → auth-3.4.0}/auth/rls.py +0 -0
  43. {auth-3.2.0 → auth-3.4.0}/auth/routes/__init__.py +0 -0
  44. {auth-3.2.0 → auth-3.4.0}/auth/routes/_common.py +0 -0
  45. {auth-3.2.0 → auth-3.4.0}/auth/routes/apikeys.py +0 -0
  46. {auth-3.2.0 → auth-3.4.0}/auth/routes/membership.py +0 -0
  47. {auth-3.2.0 → auth-3.4.0}/auth/routes/permissions.py +0 -0
  48. {auth-3.2.0 → auth-3.4.0}/auth/routes/roles.py +0 -0
  49. {auth-3.2.0 → auth-3.4.0}/auth/routes/tenant.py +0 -0
  50. {auth-3.2.0 → auth-3.4.0}/auth/routes/workflow.py +0 -0
  51. {auth-3.2.0 → auth-3.4.0}/auth/sanitizer.py +0 -0
  52. {auth-3.2.0 → auth-3.4.0}/auth/server.py +0 -0
  53. {auth-3.2.0 → auth-3.4.0}/auth/services/base.py +0 -0
  54. {auth-3.2.0 → auth-3.4.0}/auth/services/keys.py +0 -0
  55. {auth-3.2.0 → auth-3.4.0}/auth/services/queries.py +0 -0
  56. {auth-3.2.0 → auth-3.4.0}/auth/services/recovery.py +0 -0
  57. {auth-3.2.0 → auth-3.4.0}/auth/services/rotation.py +0 -0
  58. {auth-3.2.0 → auth-3.4.0}/auth/services/service.py +0 -0
  59. {auth-3.2.0 → auth-3.4.0}/auth/static_docs/__init__.py +0 -0
  60. {auth-3.2.0 → auth-3.4.0}/auth/static_docs/api_reference.md +0 -0
  61. {auth-3.2.0 → auth-3.4.0}/auth/static_docs/claude_guide.md +0 -0
  62. {auth-3.2.0 → auth-3.4.0}/auth/static_docs/landing.md +0 -0
  63. {auth-3.2.0 → auth-3.4.0}/auth/static_docs/page.html +0 -0
  64. {auth-3.2.0 → auth-3.4.0}/auth/validation.py +0 -0
  65. {auth-3.2.0 → auth-3.4.0}/auth/workflow_checker.py +0 -0
  66. {auth-3.2.0 → auth-3.4.0}/auth.egg-info/dependency_links.txt +0 -0
  67. {auth-3.2.0 → auth-3.4.0}/auth.egg-info/entry_points.txt +0 -0
  68. {auth-3.2.0 → auth-3.4.0}/auth.egg-info/requires.txt +0 -0
  69. {auth-3.2.0 → auth-3.4.0}/auth.egg-info/top_level.txt +0 -0
  70. {auth-3.2.0 → auth-3.4.0}/setup.cfg +0 -0
  71. {auth-3.2.0 → auth-3.4.0}/tests/test_api_keys.py +0 -0
  72. {auth-3.2.0 → auth-3.4.0}/tests/test_api_keys_encryption.py +0 -0
  73. {auth-3.2.0 → auth-3.4.0}/tests/test_audit_integrity.py +0 -0
  74. {auth-3.2.0 → auth-3.4.0}/tests/test_client.py +0 -0
  75. {auth-3.2.0 → auth-3.4.0}/tests/test_client_rest.py +0 -0
  76. {auth-3.2.0 → auth-3.4.0}/tests/test_cmd_server.py +0 -0
  77. {auth-3.2.0 → auth-3.4.0}/tests/test_config.py +0 -0
  78. {auth-3.2.0 → auth-3.4.0}/tests/test_correctness_hardening.py +0 -0
  79. {auth-3.2.0 → auth-3.4.0}/tests/test_database_sslmode.py +0 -0
  80. {auth-3.2.0 → auth-3.4.0}/tests/test_docs_page.py +0 -0
  81. {auth-3.2.0 → auth-3.4.0}/tests/test_encryption.py +0 -0
  82. {auth-3.2.0 → auth-3.4.0}/tests/test_encryption_integration.py +0 -0
  83. {auth-3.2.0 → auth-3.4.0}/tests/test_flask.py +0 -0
  84. {auth-3.2.0 → auth-3.4.0}/tests/test_gunicorn_config_invariants.py +0 -0
  85. {auth-3.2.0 → auth-3.4.0}/tests/test_key_rotation.py +0 -0
  86. {auth-3.2.0 → auth-3.4.0}/tests/test_log_redaction.py +0 -0
  87. {auth-3.2.0 → auth-3.4.0}/tests/test_no_bundled_libpq.py +0 -0
  88. {auth-3.2.0 → auth-3.4.0}/tests/test_phase_a_hardening.py +0 -0
  89. {auth-3.2.0 → auth-3.4.0}/tests/test_reencryption.py +0 -0
  90. {auth-3.2.0 → auth-3.4.0}/tests/test_routes_errors.py +0 -0
  91. {auth-3.2.0 → auth-3.4.0}/tests/test_self_service_audit.py +0 -0
  92. {auth-3.2.0 → auth-3.4.0}/tests/test_server.py +0 -0
  93. {auth-3.2.0 → auth-3.4.0}/tests/test_service_lifecycle.py +0 -0
  94. {auth-3.2.0 → auth-3.4.0}/tests/test_strict_default.py +0 -0
  95. {auth-3.2.0 → auth-3.4.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.4.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
@@ -56,6 +56,7 @@ class Authorization:
56
56
 
57
57
  self.client = client
58
58
  # Use provided session or create a new one
59
+ self._owns_session = not db_session
59
60
  self.db = db_session if db_session else SessionLocal()
60
61
  # Use legacy mode (no client validation) for backward compatibility.
61
62
  # strict_users: None reads the tenant's stored setting (identical
@@ -64,6 +65,16 @@ class Authorization:
64
65
  self.db, client, validate_client=False, strict_users=strict_users
65
66
  )
66
67
 
68
+ def close(self) -> None:
69
+ if self._owns_session:
70
+ self.db.close()
71
+
72
+ def __enter__(self) -> "Authorization":
73
+ return self
74
+
75
+ def __exit__(self, exc_type, exc, tb) -> None:
76
+ self.close()
77
+
67
78
  @property
68
79
  def roles(self):
69
80
  return self.service.get_roles()
@@ -5,6 +5,7 @@ Centralized configuration management for the authorization system
5
5
  import os
6
6
  from enum import Enum
7
7
  from functools import lru_cache
8
+ from typing import Optional
8
9
 
9
10
  from pydantic import Field, model_validator
10
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -89,6 +90,19 @@ class Settings(BaseSettings):
89
90
  # Schema settings (for PostgreSQL multi-tenancy)
90
91
  database_schema: str = "" # Optional schema name (e.g., "auth_rbac" for Highway)
91
92
 
93
+ database_pool_size: Optional[int] = Field(default=None, ge=1)
94
+ database_max_overflow: Optional[int] = Field(default=None, ge=0)
95
+ database_pool_timeout: Optional[float] = Field(default=None, gt=0)
96
+ database_pool_recycle: Optional[int] = Field(default=None, ge=-1)
97
+
98
+ @model_validator(mode="after")
99
+ def _reject_zero_pool_recycle(self):
100
+ if self.database_pool_recycle == 0:
101
+ raise ValueError(
102
+ "database_pool_recycle must be -1 (never recycle) or >= 1 second"
103
+ )
104
+ return self
105
+
92
106
  @model_validator(mode="after")
93
107
  def assemble_db_connection(self) -> "Settings":
94
108
  # If database_url is not explicitly set, construct it from other fields
@@ -7,17 +7,18 @@ import os
7
7
  import sqlite3
8
8
  import threading
9
9
  from contextlib import contextmanager
10
- from typing import Generator, Optional
10
+ from typing import Generator, NamedTuple, Optional
11
11
 
12
12
  from sqlalchemy import create_engine, event, pool
13
13
  from sqlalchemy.engine import Engine, make_url
14
14
  from sqlalchemy.orm import Session, sessionmaker
15
15
  from sqlalchemy.pool import Pool
16
16
 
17
- from auth.config import DatabaseType, get_settings, warn_on_weak_secrets
17
+ from auth.config import DatabaseType, Settings, 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
  )
@@ -46,6 +47,47 @@ def _forced_sslmode(database_url: str) -> Optional[str]:
46
47
  logger = logging.getLogger(__name__)
47
48
 
48
49
 
50
+ class PoolSettings(NamedTuple):
51
+ pool_size: int
52
+ max_overflow: int
53
+ pool_timeout: float
54
+ pool_recycle: int
55
+
56
+
57
+ _POSTGRESQL_POOL_DEFAULTS = PoolSettings(2, 2, 10.0, 3600)
58
+ _SQLITE_POOL_DEFAULTS = PoolSettings(5, 10, 30.0, 300)
59
+
60
+
61
+ def resolve_pool_settings(settings: Settings) -> PoolSettings:
62
+ defaults = (
63
+ _POSTGRESQL_POOL_DEFAULTS
64
+ if settings.database_type == DatabaseType.POSTGRESQL
65
+ else _SQLITE_POOL_DEFAULTS
66
+ )
67
+ return PoolSettings(
68
+ pool_size=(
69
+ defaults.pool_size
70
+ if settings.database_pool_size is None
71
+ else settings.database_pool_size
72
+ ),
73
+ max_overflow=(
74
+ defaults.max_overflow
75
+ if settings.database_max_overflow is None
76
+ else settings.database_max_overflow
77
+ ),
78
+ pool_timeout=(
79
+ defaults.pool_timeout
80
+ if settings.database_pool_timeout is None
81
+ else settings.database_pool_timeout
82
+ ),
83
+ pool_recycle=(
84
+ defaults.pool_recycle
85
+ if settings.database_pool_recycle is None
86
+ else settings.database_pool_recycle
87
+ ),
88
+ )
89
+
90
+
49
91
  class SingletonMeta(type):
50
92
  """
51
93
  Thread-safe Singleton metaclass for database engine
@@ -82,35 +124,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
82
124
  self._initialize_engine()
83
125
  self._setup_pool_monitoring()
84
126
 
85
- def _calculate_pool_size(self) -> tuple[int, int]:
86
- """
87
- Calculate optimal pool size for Gunicorn workers
88
-
89
- For 8 workers with PostgreSQL:
90
- - pool_size=5 per worker = 40 base connections
91
- - max_overflow=5 per worker = 80 total max connections
92
- - Leaves headroom for PostgreSQL default max_connections (100)
93
- - Also accounts for other services using the database
94
-
95
- Returns:
96
- tuple: (pool_size, max_overflow)
97
- """
98
- settings = get_settings()
127
+ def _pool_settings(self) -> PoolSettings:
128
+ return resolve_pool_settings(get_settings())
99
129
 
100
- if settings.database_type == DatabaseType.POSTGRESQL:
101
- # Sized for ONE request per worker, because the workers are sync.
102
- # A request can hold two connections at once: its own session, and
103
- # the separate committed session `audit.py` opens on the decorator's
104
- # failure path. Two plus two spare, so 8 workers reach at most 32
105
- # connections against max_connections=200.
106
- return (2, 2)
107
- else:
108
- # SQLite - smaller pool since it's file-based
109
- return (5, 10)
130
+ def _calculate_pool_size(self) -> tuple[int, int]:
131
+ pool_settings = self._pool_settings()
132
+ return (pool_settings.pool_size, pool_settings.max_overflow)
110
133
 
111
134
  def _create_postgresql_engine(self, database_url: str) -> Engine:
112
135
  """Create optimized PostgreSQL engine with enterprise pooling"""
113
- pool_size, max_overflow = self._calculate_pool_size()
136
+ pool_settings = self._pool_settings()
114
137
 
115
138
  connect_args = {
116
139
  "connect_timeout": 30,
@@ -137,10 +160,10 @@ class DatabaseEngine(metaclass=SingletonMeta):
137
160
  database_url,
138
161
  # Connection pool settings
139
162
  poolclass=pool.QueuePool, # Explicitly use QueuePool
140
- pool_size=pool_size,
141
- max_overflow=max_overflow,
142
- pool_timeout=10, # Fail fast rather than queueing behind a stalled DB
143
- pool_recycle=3600, # Recycle connections after 1 hour
163
+ pool_size=pool_settings.pool_size,
164
+ max_overflow=pool_settings.max_overflow,
165
+ pool_timeout=pool_settings.pool_timeout,
166
+ pool_recycle=pool_settings.pool_recycle,
144
167
  pool_pre_ping=True, # Verify connections before using them
145
168
  # Performance settings
146
169
  echo=False, # Disable SQL logging in production
@@ -160,16 +183,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
160
183
  connect_args={"check_same_thread": False},
161
184
  )
162
185
 
163
- pool_size, max_overflow = self._calculate_pool_size()
186
+ pool_settings = self._pool_settings()
164
187
 
165
188
  return create_engine(
166
189
  database_url,
167
190
  poolclass=pool.QueuePool,
168
- pool_size=pool_size,
169
- max_overflow=max_overflow,
191
+ pool_size=pool_settings.pool_size,
192
+ max_overflow=pool_settings.max_overflow,
170
193
  pool_pre_ping=True,
171
- pool_recycle=300, # 5 minutes for SQLite
172
- pool_timeout=30,
194
+ pool_recycle=pool_settings.pool_recycle,
195
+ pool_timeout=pool_settings.pool_timeout,
173
196
  connect_args={
174
197
  "check_same_thread": False,
175
198
  "timeout": 60,
@@ -199,10 +222,13 @@ class DatabaseEngine(metaclass=SingletonMeta):
199
222
  expire_on_commit=False, # Prevent unnecessary queries
200
223
  )
201
224
 
202
- pool_size, max_overflow = self._calculate_pool_size()
225
+ pool_settings = self._pool_settings()
203
226
  logger.info(
204
- f"Database engine initialized: pool_size={pool_size}, "
205
- f"max_overflow={max_overflow}, max_connections={pool_size + max_overflow}"
227
+ f"Database engine initialized: pool_size={pool_settings.pool_size}, "
228
+ f"max_overflow={pool_settings.max_overflow}, "
229
+ f"max_connections={pool_settings.pool_size + pool_settings.max_overflow}, "
230
+ f"pool_timeout={pool_settings.pool_timeout}, "
231
+ f"pool_recycle={pool_settings.pool_recycle}"
206
232
  )
207
233
 
208
234
  def _setup_pool_monitoring(self):
@@ -350,6 +376,7 @@ def create_tables(raise_on_error: bool = False):
350
376
  # is how a fresh database came up with the junction tables unprotected
351
377
  # -- grandfathering raised, and the RLS step below it never ran.
352
378
  _apply_tenant_rls(engine)
379
+ _ensure_audit_partition_runway(engine)
353
380
  _reconcile_text_columns(engine)
354
381
  _grandfather_strict_users(engine)
355
382
  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.4.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,14 @@ 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
73
+ tests/test_database_pool_settings.py
72
74
  tests/test_database_sslmode.py
75
+ tests/test_degraded_response_is_a_contract.py
73
76
  tests/test_docs_page.py
74
77
  tests/test_encryption.py
75
78
  tests/test_encryption_integration.py
@@ -79,10 +82,12 @@ tests/test_key_rotation.py
79
82
  tests/test_log_redaction.py
80
83
  tests/test_no_bundled_libpq.py
81
84
  tests/test_phase_a_hardening.py
85
+ tests/test_provision_script_no_dsn_leak.py
82
86
  tests/test_reencryption.py
83
87
  tests/test_routes_errors.py
84
88
  tests/test_self_service_audit.py
85
89
  tests/test_server.py
86
90
  tests/test_service_lifecycle.py
91
+ tests/test_settings_do_not_echo_secrets.py
87
92
  tests/test_strict_default.py
88
93
  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.4.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)