auth 3.1.1__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 (95) hide show
  1. {auth-3.1.1 → auth-3.3.0}/PKG-INFO +5 -4
  2. {auth-3.1.1 → auth-3.3.0}/auth/__init__.py +2 -0
  3. {auth-3.1.1 → auth-3.3.0}/auth/api_keys.py +22 -0
  4. {auth-3.1.1 → auth-3.3.0}/auth/audit.py +21 -0
  5. {auth-3.1.1 → auth-3.3.0}/auth/client/__init__.py +2 -0
  6. {auth-3.1.1 → auth-3.3.0}/auth/client/_transport.py +25 -2
  7. {auth-3.1.1 → auth-3.3.0}/auth/client/base.py +19 -0
  8. {auth-3.1.1 → auth-3.3.0}/auth/database.py +31 -146
  9. auth-3.3.0/auth/keycheck.py +233 -0
  10. {auth-3.1.1 → auth-3.3.0}/auth/main.py +21 -0
  11. {auth-3.1.1 → auth-3.3.0}/auth/models/sql.py +8 -0
  12. auth-3.3.0/auth/rls.py +122 -0
  13. auth-3.3.0/auth/routes/public.py +196 -0
  14. {auth-3.1.1 → auth-3.3.0}/auth/routes/tenant.py +114 -1
  15. auth-3.3.0/auth/schema_reconcile.py +333 -0
  16. {auth-3.1.1 → auth-3.3.0}/auth/services/base.py +7 -12
  17. {auth-3.1.1 → auth-3.3.0}/auth/services/rbac.py +3 -3
  18. auth-3.3.0/auth/services/recovery.py +127 -0
  19. {auth-3.1.1 → auth-3.3.0}/auth/services/rotation.py +9 -0
  20. {auth-3.1.1 → auth-3.3.0}/auth/services/tenant.py +48 -4
  21. {auth-3.1.1 → auth-3.3.0}/auth/static_docs/api_reference.md +22 -0
  22. {auth-3.1.1 → auth-3.3.0}/auth/validation.py +24 -4
  23. {auth-3.1.1 → auth-3.3.0}/auth/workflow_checker.py +12 -1
  24. {auth-3.1.1 → auth-3.3.0}/auth.egg-info/PKG-INFO +5 -4
  25. {auth-3.1.1 → auth-3.3.0}/auth.egg-info/SOURCES.txt +10 -0
  26. {auth-3.1.1 → auth-3.3.0}/auth.egg-info/requires.txt +4 -1
  27. {auth-3.1.1 → auth-3.3.0}/pyproject.toml +10 -5
  28. auth-3.3.0/tests/test_client_exceptions_do_not_render_payload.py +52 -0
  29. auth-3.3.0/tests/test_degraded_response_is_a_contract.py +82 -0
  30. auth-3.3.0/tests/test_gunicorn_config_invariants.py +92 -0
  31. auth-3.3.0/tests/test_no_bundled_libpq.py +69 -0
  32. auth-3.3.0/tests/test_provision_script_no_dsn_leak.py +53 -0
  33. auth-3.3.0/tests/test_settings_do_not_echo_secrets.py +59 -0
  34. auth-3.1.1/auth/routes/public.py +0 -74
  35. {auth-3.1.1 → auth-3.3.0}/LICENSE +0 -0
  36. {auth-3.1.1 → auth-3.3.0}/README.md +0 -0
  37. {auth-3.1.1 → auth-3.3.0}/auth/circuit_breaker.py +0 -0
  38. {auth-3.1.1 → auth-3.3.0}/auth/client/keys.py +0 -0
  39. {auth-3.1.1 → auth-3.3.0}/auth/client/rbac.py +0 -0
  40. {auth-3.1.1 → auth-3.3.0}/auth/client/workflow.py +0 -0
  41. {auth-3.1.1 → auth-3.3.0}/auth/cmd/__init__.py +0 -0
  42. {auth-3.1.1 → auth-3.3.0}/auth/cmd/server.py +0 -0
  43. {auth-3.1.1 → auth-3.3.0}/auth/config.py +0 -0
  44. {auth-3.1.1 → auth-3.3.0}/auth/core/REST/__init__.py +0 -0
  45. {auth-3.1.1 → auth-3.3.0}/auth/core/REST/client.py +0 -0
  46. {auth-3.1.1 → auth-3.3.0}/auth/core/__init__.py +0 -0
  47. {auth-3.1.1 → auth-3.3.0}/auth/core/models/__init__.py +0 -0
  48. {auth-3.1.1 → auth-3.3.0}/auth/decorators.py +0 -0
  49. {auth-3.1.1 → auth-3.3.0}/auth/docs_page.py +0 -0
  50. {auth-3.1.1 → auth-3.3.0}/auth/encryption.py +0 -0
  51. {auth-3.1.1 → auth-3.3.0}/auth/logging_config.py +0 -0
  52. {auth-3.1.1 → auth-3.3.0}/auth/response_format.py +0 -0
  53. {auth-3.1.1 → auth-3.3.0}/auth/routes/__init__.py +0 -0
  54. {auth-3.1.1 → auth-3.3.0}/auth/routes/_common.py +0 -0
  55. {auth-3.1.1 → auth-3.3.0}/auth/routes/apikeys.py +0 -0
  56. {auth-3.1.1 → auth-3.3.0}/auth/routes/membership.py +0 -0
  57. {auth-3.1.1 → auth-3.3.0}/auth/routes/permissions.py +0 -0
  58. {auth-3.1.1 → auth-3.3.0}/auth/routes/roles.py +0 -0
  59. {auth-3.1.1 → auth-3.3.0}/auth/routes/workflow.py +0 -0
  60. {auth-3.1.1 → auth-3.3.0}/auth/sanitizer.py +0 -0
  61. {auth-3.1.1 → auth-3.3.0}/auth/server.py +0 -0
  62. {auth-3.1.1 → auth-3.3.0}/auth/services/keys.py +0 -0
  63. {auth-3.1.1 → auth-3.3.0}/auth/services/queries.py +0 -0
  64. {auth-3.1.1 → auth-3.3.0}/auth/services/service.py +0 -0
  65. {auth-3.1.1 → auth-3.3.0}/auth/static_docs/__init__.py +0 -0
  66. {auth-3.1.1 → auth-3.3.0}/auth/static_docs/claude_guide.md +0 -0
  67. {auth-3.1.1 → auth-3.3.0}/auth/static_docs/landing.md +0 -0
  68. {auth-3.1.1 → auth-3.3.0}/auth/static_docs/page.html +0 -0
  69. {auth-3.1.1 → auth-3.3.0}/auth.egg-info/dependency_links.txt +0 -0
  70. {auth-3.1.1 → auth-3.3.0}/auth.egg-info/entry_points.txt +0 -0
  71. {auth-3.1.1 → auth-3.3.0}/auth.egg-info/top_level.txt +0 -0
  72. {auth-3.1.1 → auth-3.3.0}/setup.cfg +0 -0
  73. {auth-3.1.1 → auth-3.3.0}/tests/test_api_keys.py +0 -0
  74. {auth-3.1.1 → auth-3.3.0}/tests/test_api_keys_encryption.py +0 -0
  75. {auth-3.1.1 → auth-3.3.0}/tests/test_audit_integrity.py +0 -0
  76. {auth-3.1.1 → auth-3.3.0}/tests/test_client.py +0 -0
  77. {auth-3.1.1 → auth-3.3.0}/tests/test_client_rest.py +0 -0
  78. {auth-3.1.1 → auth-3.3.0}/tests/test_cmd_server.py +0 -0
  79. {auth-3.1.1 → auth-3.3.0}/tests/test_config.py +0 -0
  80. {auth-3.1.1 → auth-3.3.0}/tests/test_correctness_hardening.py +0 -0
  81. {auth-3.1.1 → auth-3.3.0}/tests/test_database_sslmode.py +0 -0
  82. {auth-3.1.1 → auth-3.3.0}/tests/test_docs_page.py +0 -0
  83. {auth-3.1.1 → auth-3.3.0}/tests/test_encryption.py +0 -0
  84. {auth-3.1.1 → auth-3.3.0}/tests/test_encryption_integration.py +0 -0
  85. {auth-3.1.1 → auth-3.3.0}/tests/test_flask.py +0 -0
  86. {auth-3.1.1 → auth-3.3.0}/tests/test_key_rotation.py +0 -0
  87. {auth-3.1.1 → auth-3.3.0}/tests/test_log_redaction.py +0 -0
  88. {auth-3.1.1 → auth-3.3.0}/tests/test_phase_a_hardening.py +0 -0
  89. {auth-3.1.1 → auth-3.3.0}/tests/test_reencryption.py +0 -0
  90. {auth-3.1.1 → auth-3.3.0}/tests/test_routes_errors.py +0 -0
  91. {auth-3.1.1 → auth-3.3.0}/tests/test_self_service_audit.py +0 -0
  92. {auth-3.1.1 → auth-3.3.0}/tests/test_server.py +0 -0
  93. {auth-3.1.1 → auth-3.3.0}/tests/test_service_lifecycle.py +0 -0
  94. {auth-3.1.1 → auth-3.3.0}/tests/test_strict_default.py +0 -0
  95. {auth-3.1.1 → 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.1.1
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
@@ -31,12 +30,14 @@ Requires-Dist: sqlalchemy<3,>=2.0.0
31
30
  Requires-Dist: waitress<4,>=2.0.0
32
31
  Requires-Dist: cryptography>=3.0.0
33
32
  Requires-Dist: APScheduler<4,>=3.0.0
34
- Requires-Dist: psycopg[binary]<4,>=3.0.0
33
+ Requires-Dist: psycopg<4,>=3.0.0
35
34
  Requires-Dist: pydantic<3,>=2.0.0
36
35
  Requires-Dist: pydantic-settings<3,>=2.0.0
37
36
  Requires-Dist: requests<3,>=2.25.0
38
37
  Requires-Dist: bleach<7,>=5.0.0
39
38
  Requires-Dist: python-json-logger<4,>=3.1.0
39
+ Provides-Extra: c
40
+ Requires-Dist: psycopg[c]<4,>=3.0.0; extra == "c"
40
41
  Provides-Extra: ratelimit
41
42
  Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
42
43
  Provides-Extra: migrations
@@ -4,6 +4,7 @@ from importlib.metadata import PackageNotFoundError, version
4
4
  from typing import Optional
5
5
 
6
6
  from auth.client import ( # Import the new client
7
+ AuthRefused,
7
8
  AuthTransportError,
8
9
  Client,
9
10
  EnhancedAuthClient,
@@ -140,6 +141,7 @@ class Authorization:
140
141
  # Export the new client for users who want enhanced features
141
142
  __all__ = [
142
143
  "Authorization",
144
+ "AuthRefused",
143
145
  "AuthTransportError",
144
146
  "Client",
145
147
  "EnhancedAuthClient",
@@ -15,7 +15,11 @@ import uuid
15
15
  from typing import Tuple
16
16
 
17
17
  API_KEY_PREFIX = "rak_"
18
+ #: Recovery ("rotate") keys. A different prefix so a credential is never
19
+ #: mistaken for the other kind, and so a leaked one is identifiable on sight.
20
+ ROTATE_KEY_PREFIX = "rrk_"
18
21
  API_KEY_PATTERN = re.compile(r"^rak_[0-9A-Za-z]{43}$")
22
+ ROTATE_KEY_PATTERN = re.compile(r"^rrk_[0-9A-Za-z]{43}$")
19
23
 
20
24
  # "rak_" + first 8 payload chars — safe to store and display in listings.
21
25
  KEY_PREFIX_LEN = 12
@@ -48,3 +52,21 @@ def generate_api_key() -> Tuple[str, str, str, str]:
48
52
  def hash_api_key(secret: str) -> str:
49
53
  """SHA-256 hex digest of the full secret string (equality-queryable)."""
50
54
  return hashlib.sha256(secret.encode()).hexdigest()
55
+
56
+
57
+ def generate_rotate_key() -> Tuple[str, str]:
58
+ """Mint a recovery key: returns ``(secret, secret_hash)``.
59
+
60
+ Same construction and entropy as a user API key -- 256 bits from
61
+ ``secrets.token_bytes(32)`` -- because it guards more, not less: whoever
62
+ holds it can take over the namespace without the client key. Only the hash
63
+ is stored, and the secret is disclosed exactly once.
64
+ """
65
+ payload = _base62(secrets.token_bytes(32)).rjust(_PAYLOAD_LEN, _ALPHABET[0])
66
+ secret = ROTATE_KEY_PREFIX + payload
67
+ return secret, hash_api_key(secret)
68
+
69
+
70
+ def validate_rotate_key_format(secret: str) -> bool:
71
+ """Shape check before any database work."""
72
+ return bool(ROTATE_KEY_PATTERN.match(secret))
@@ -46,6 +46,8 @@ class AuditAction(Enum):
46
46
  GET_SETTINGS = "GET_SETTINGS"
47
47
  UPDATE_SETTINGS = "UPDATE_SETTINGS"
48
48
  GET_AUDIT = "GET_AUDIT"
49
+ GET_ROTATE_KEY = "GET_ROTATE_KEY"
50
+ RECOVER_KEY = "RECOVER_KEY"
49
51
 
50
52
 
51
53
  class AuditLog(Base):
@@ -176,6 +178,23 @@ def _emit_structured_log(
176
178
  audit_logger.info(json.dumps(log_msg))
177
179
 
178
180
 
181
+ def _bind_audit(session, client_id: str) -> None:
182
+ """Bind the audit tenant so RLS admits the row (no-op off PostgreSQL).
183
+
184
+ audit_log's policy compares ``client_id`` against a session variable, and
185
+ the value is bound VERBATIM: every caller already passes a fingerprint (see
186
+ ``client_ref`` in auth/decorators.py), and ``_build_audit_entry`` stores it
187
+ unchanged. Fingerprinting it again here would bind a fingerprint OF a
188
+ fingerprint, which matches no row and rejects every audit write.
189
+ """
190
+ try:
191
+ from auth.rls import AUDIT_GUC, bind_session
192
+
193
+ bind_session(session, **{AUDIT_GUC: client_id})
194
+ except Exception: # pragma: no cover - binding must never break an audit write
195
+ pass
196
+
197
+
179
198
  def record_audit(
180
199
  session,
181
200
  *,
@@ -196,6 +215,7 @@ def record_audit(
196
215
  surrounding request (fail-closed), never leave a committed mutation
197
216
  unaudited.
198
217
  """
218
+ _bind_audit(session, client_id)
199
219
  session.add(
200
220
  _build_audit_entry(
201
221
  client_id, user, action, resource, details, ip_address, user_agent, success
@@ -224,6 +244,7 @@ def log_audit_event(
224
244
  """
225
245
  session = SessionLocal()
226
246
  try:
247
+ _bind_audit(session, client_id)
227
248
  session.add(
228
249
  _build_audit_entry(
229
250
  client_id, user, action, resource, details, ip_address, user_agent, success
@@ -13,6 +13,7 @@ their ``__module__``.
13
13
  from auth.circuit_breaker import circuit_breaker
14
14
  from auth.client._transport import (
15
15
  _RETRY_METHODS,
16
+ AuthRefused,
16
17
  AuthTransportError,
17
18
  RetryableHTTPAdapter,
18
19
  _build_retry,
@@ -44,6 +45,7 @@ class Client(EnhancedAuthClient):
44
45
 
45
46
  __all__ = [
46
47
  "ApiKeyMixin",
48
+ "AuthRefused",
47
49
  "AuthTransportError",
48
50
  "Client",
49
51
  "ClientBase",
@@ -11,14 +11,37 @@ _RETRY_METHODS = ["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
11
11
  class AuthTransportError(Exception):
12
12
  """The client could not get an answer from the auth service.
13
13
 
14
- Raised by every client method on transport failure (connection failure,
15
- exhausted retries, open circuit breaker, non-2xx status) since 3.0.0.
14
+ Raised by every client method when no answer could be obtained:
15
+ connection failure, exhausted retries, open circuit breaker, or a 5xx.
16
+ A 4xx raises :class:`AuthRefused`, a subclass — the service DID answer,
17
+ it refused the request.
16
18
  Distinguishes "we could not ask" from a genuine negative answer such as
17
19
  ``has_permission: false`` — map it to your unavailable/503 path, never
18
20
  to a denial.
19
21
  """
20
22
 
21
23
 
24
+ class AuthRefused(AuthTransportError):
25
+ """The auth service answered and refused the request (a 4xx).
26
+
27
+ Subclasses :class:`AuthTransportError` so existing handlers keep working,
28
+ but lets a caller tell "auth would not accept this" from "auth could not be
29
+ reached". Reported by a consumer whose platform-admin check logged "auth
30
+ unreachable at login" while auth was up, answering, and simply rejecting a
31
+ subject containing ``|``: that sends whoever reads the log to the health
32
+ endpoint and the network when the cause is the shape of a string.
33
+
34
+ ``status`` is the HTTP status; ``payload`` is the decoded body when there
35
+ was one, so actionable errors (such as the 409 ``user_not_key_backed`` hint)
36
+ survive instead of being flattened into a transport message.
37
+ """
38
+
39
+ def __init__(self, message: str, status: int, payload=None):
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.payload = payload
43
+
44
+
22
45
  def _build_retry(total, backoff_factor, status_forcelist) -> Retry:
23
46
  """Build a Retry that works on both urllib3 1.x and 2.x.
24
47
 
@@ -11,6 +11,7 @@ from requests.exceptions import ConnectionError
11
11
 
12
12
  from auth.circuit_breaker import circuit_breaker
13
13
  from auth.client._transport import (
14
+ AuthRefused,
14
15
  AuthTransportError,
15
16
  RetryableHTTPAdapter,
16
17
  _build_retry,
@@ -168,6 +169,20 @@ class ClientBase:
168
169
  return text_response
169
170
 
170
171
  except requests.exceptions.RequestException as e:
172
+ # A 4xx means the service ANSWERED and refused. Collapsing that
173
+ # into a transport failure sends the reader to the health
174
+ # endpoint and the network when the cause is the request itself.
175
+ resp = getattr(e, "response", None)
176
+ if resp is not None and 400 <= resp.status_code < 500:
177
+ try:
178
+ payload = resp.json()
179
+ except Exception:
180
+ payload = None
181
+ raise AuthRefused(
182
+ f"auth refused the request: HTTP {resp.status_code}",
183
+ resp.status_code,
184
+ payload,
185
+ ) from e
171
186
  # Convert to our expected exception type
172
187
  raise ConnectionError(f"Request failed: {str(e)}") from e
173
188
 
@@ -176,6 +191,8 @@ class ClientBase:
176
191
  try:
177
192
  cb_result: Dict[str, Any] = circuit_breaker("api_call")(request_func)()
178
193
  return cb_result
194
+ except AuthRefused:
195
+ raise # a refusal is an answer; it is not a breaker event
179
196
  except Exception as e:
180
197
  raise ConnectionError(
181
198
  f"Circuit breaker prevented request: {str(e)}"
@@ -195,6 +212,8 @@ class ClientBase:
195
212
  compatibility and intentionally unused: inputs like key material must
196
213
  never ride on an exception.
197
214
  """
215
+ if isinstance(exc, AuthRefused):
216
+ raise exc
198
217
  raise AuthTransportError(str(exc)) from exc
199
218
 
200
219
  def ping(self) -> Dict[str, Any]:
@@ -15,6 +15,13 @@ from sqlalchemy.orm import Session, sessionmaker
15
15
  from sqlalchemy.pool import Pool
16
16
 
17
17
  from auth.config import DatabaseType, get_settings, warn_on_weak_secrets
18
+ from auth.schema_reconcile import (
19
+ GRANDFATHER_MARKER, # noqa: F401 - re-exported; tests and embedded callers import it from here
20
+ _apply_tenant_rls,
21
+ _ensure_audit_partition_runway,
22
+ _grandfather_strict_users,
23
+ _reconcile_text_columns,
24
+ )
18
25
 
19
26
 
20
27
  def _forced_sslmode(database_url: str) -> Optional[str]:
@@ -92,8 +99,12 @@ class DatabaseEngine(metaclass=SingletonMeta):
92
99
  settings = get_settings()
93
100
 
94
101
  if settings.database_type == DatabaseType.POSTGRESQL:
95
- # Conservative sizing for production with multiple workers
96
- return (5, 5) # 10 max connections per worker
102
+ # Sized for ONE request per worker, because the workers are sync.
103
+ # A request can hold two connections at once: its own session, and
104
+ # the separate committed session `audit.py` opens on the decorator's
105
+ # failure path. Two plus two spare, so 8 workers reach at most 32
106
+ # connections against max_connections=200.
107
+ return (2, 2)
97
108
  else:
98
109
  # SQLite - smaller pool since it's file-based
99
110
  return (5, 10)
@@ -106,7 +117,17 @@ class DatabaseEngine(metaclass=SingletonMeta):
106
117
  "connect_timeout": 30,
107
118
  "application_name": "auth_server",
108
119
  # Set statement timeout to prevent long-running queries
109
- "options": "-c statement_timeout=30000", # 30 seconds
120
+ # 10s: the slowest legitimate operation is a key rotation, measured
121
+ # at ~0.12 ms/row (~1s for the largest namespace on this deployment),
122
+ # so this leaves 10x headroom.
123
+ #
124
+ # NOTE: statement_timeout does NOT bound COMMIT. A commit stall is
125
+ # therefore unaffected by this value, which is why worker
126
+ # concurrency -- not this timeout -- is the mitigation for a stalled
127
+ # database. tcp_user_timeout would bound it at the socket, but
128
+ # dropping a connection mid-COMMIT leaves the outcome ambiguous, and
129
+ # an ambiguous permission grant is a worse failure than a slow one.
130
+ "options": "-c statement_timeout=10000", # 10 seconds
110
131
  }
111
132
 
112
133
  forced = _forced_sslmode(database_url)
@@ -119,7 +140,7 @@ class DatabaseEngine(metaclass=SingletonMeta):
119
140
  poolclass=pool.QueuePool, # Explicitly use QueuePool
120
141
  pool_size=pool_size,
121
142
  max_overflow=max_overflow,
122
- pool_timeout=30, # Wait up to 30s for a connection
143
+ pool_timeout=10, # Fail fast rather than queueing behind a stalled DB
123
144
  pool_recycle=3600, # Recycle connections after 1 hour
124
145
  pool_pre_ping=True, # Verify connections before using them
125
146
  # Performance settings
@@ -325,6 +346,12 @@ def create_tables(raise_on_error: bool = False):
325
346
  text(f'CREATE SCHEMA IF NOT EXISTS "{settings.database_schema}"')
326
347
  )
327
348
  Base.metadata.create_all(bind=engine, checkfirst=True)
349
+ # Row level security first, and each step separately: these are
350
+ # independent reconciliations, and letting one exception abort the rest
351
+ # is how a fresh database came up with the junction tables unprotected
352
+ # -- grandfathering raised, and the RLS step below it never ran.
353
+ _apply_tenant_rls(engine)
354
+ _ensure_audit_partition_runway(engine)
328
355
  _reconcile_text_columns(engine)
329
356
  _grandfather_strict_users(engine)
330
357
  logger.info("Tables created successfully.")
@@ -334,148 +361,6 @@ def create_tables(raise_on_error: bool = False):
334
361
  raise
335
362
 
336
363
 
337
- def _reconcile_text_columns(target_engine: Engine) -> None:
338
- """Widen live ``character varying`` columns to TEXT where the current models
339
- declare :class:`~sqlalchemy.Text` (issuedb #21).
340
-
341
- ``create_all(checkfirst=True)`` creates missing tables but never ALTERs an
342
- existing one, so an embedded database created by a pre-2.x version keeps the
343
- narrow ``varchar`` widths those versions declared. Encryption made several of
344
- those columns hold ciphertext far longer than the plaintext they used to, so
345
- the mismatch surfaces as ``StringDataRightTruncation`` on write — highway hit
346
- exactly this on ``auth_membership.user`` (varchar(64)) when a longer email
347
- was encrypted, inside ``add_membership`` (agent-mail thr-d99bb6c79b894ff69f16).
348
-
349
- Only columns the models declare as Text are touched. A bounded ``String`` is
350
- a deliberate width — ``audit_log.user`` is a 64-char fingerprint, not a user
351
- identifier — and is left alone. PostgreSQL only: SQLite does not enforce
352
- varchar length, so there is nothing to reconcile there.
353
-
354
- Non-raising, like everything else in ``create_tables``: a runtime role
355
- without DDL rights must still be able to start the app.
356
- """
357
- from sqlalchemy import Text, inspect, text
358
-
359
- import auth.audit # noqa: F401 (registers AuditLog in Base.metadata)
360
- from auth.models.sql import Base
361
-
362
- if target_engine.dialect.name != "postgresql":
363
- return
364
-
365
- settings = get_settings()
366
- schema = settings.database_schema or None
367
- inspector = inspect(target_engine)
368
- try:
369
- existing_tables = set(inspector.get_table_names(schema=schema))
370
- except Exception:
371
- logger.exception("text-column reconciliation could not list tables")
372
- return
373
-
374
- widened: list[str] = []
375
- for table in Base.metadata.sorted_tables:
376
- if table.name not in existing_tables:
377
- continue
378
- wanted = {c.name for c in table.columns if isinstance(c.type, Text)}
379
- if not wanted:
380
- continue
381
- try:
382
- live = inspector.get_columns(table.name, schema=schema)
383
- except Exception:
384
- logger.exception(
385
- "text-column reconciliation could not inspect %s", table.name
386
- )
387
- continue
388
- for col in live:
389
- if col["name"] not in wanted:
390
- continue
391
- # VARCHAR carries a length; TEXT does not. Anything already
392
- # unbounded needs no ALTER, which keeps this pass a no-op on a
393
- # database that already matches the models.
394
- if getattr(col["type"], "length", None) is None:
395
- continue
396
- qualified = f'"{schema}".' if schema else ""
397
- stmt = (
398
- f'ALTER TABLE {qualified}"{table.name}" '
399
- f'ALTER COLUMN "{col["name"]}" TYPE TEXT'
400
- )
401
- try:
402
- with target_engine.begin() as conn:
403
- conn.execute(text(stmt))
404
- widened.append(f"{table.name}.{col['name']}")
405
- except Exception:
406
- logger.exception(
407
- "could not widen %s.%s to TEXT; a pre-2.x column width "
408
- "remains and long encrypted values may fail to write",
409
- table.name,
410
- col["name"],
411
- )
412
-
413
- if widened:
414
- logger.warning(
415
- "widened %d pre-2.x varchar column(s) to TEXT to match the current "
416
- "models: %s",
417
- len(widened),
418
- ", ".join(widened),
419
- )
420
-
421
-
422
- # Marker creator recording that the one-shot 3.0.0 grandfathering pass ran on
423
- # this database. Reserved — never use it as a real tenant identifier.
424
- GRANDFATHER_MARKER = "__meta:grandfathered-3.0__"
425
-
426
-
427
- def _grandfather_strict_users(target_engine: Engine) -> None:
428
- """One-shot 3.0.0 flip protection (SPEC 0012): write explicit
429
- ``strict_users = false`` rows for every creator that exists on this
430
- database, then record a marker so the pass never runs again.
431
-
432
- 3.0.0 makes no-settings-row tenants strict by default; this pass is what
433
- guarantees that flip reaches ONLY tenants created after it ran — every
434
- pre-existing tenant keeps its behavior as an explicit, auditable opt-out
435
- it can change later. Runs inside create_tables so embedded consumers get
436
- the same protection our deployment gets from the migretti migration
437
- (both are marker-guarded, so they compose idempotently).
438
- """
439
- from typing import cast
440
-
441
- from sqlalchemy import Table, literal, select, union
442
-
443
- from auth.models.sql import (
444
- AuthApiKey,
445
- AuthGroup,
446
- AuthMembership,
447
- AuthPermission,
448
- AuthTenantSettings,
449
- )
450
-
451
- settings_t = cast(Table, AuthTenantSettings.__table__)
452
- with target_engine.begin() as conn:
453
- marker_exists = conn.execute(
454
- select(settings_t.c.id).where(settings_t.c.creator == GRANDFATHER_MARKER)
455
- ).first()
456
- if marker_exists:
457
- return
458
- creators = union(
459
- *(
460
- select(t.__table__.c.creator)
461
- for t in (AuthGroup, AuthMembership, AuthPermission, AuthApiKey)
462
- )
463
- ).subquery()
464
- already = select(settings_t.c.creator)
465
- conn.execute(
466
- settings_t.insert().from_select(
467
- ["creator", "strict_users"],
468
- select(creators.c.creator, literal(False)).where(
469
- creators.c.creator.notin_(already)
470
- ),
471
- )
472
- )
473
- conn.execute(
474
- settings_t.insert().values(creator=GRANDFATHER_MARKER, strict_users=False)
475
- )
476
- logger.info("strict_users grandfathering pass completed (one-shot).")
477
-
478
-
479
364
  def log_pool_stats():
480
365
  """Log current connection pool statistics"""
481
366
  stats = get_pool_status()