auth 3.1.1__tar.gz → 3.2.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 (91) hide show
  1. {auth-3.1.1 → auth-3.2.0}/PKG-INFO +4 -2
  2. {auth-3.1.1 → auth-3.2.0}/auth/__init__.py +2 -0
  3. {auth-3.1.1 → auth-3.2.0}/auth/api_keys.py +22 -0
  4. {auth-3.1.1 → auth-3.2.0}/auth/audit.py +21 -0
  5. {auth-3.1.1 → auth-3.2.0}/auth/client/__init__.py +2 -0
  6. {auth-3.1.1 → auth-3.2.0}/auth/client/_transport.py +25 -2
  7. {auth-3.1.1 → auth-3.2.0}/auth/client/base.py +19 -0
  8. {auth-3.1.1 → auth-3.2.0}/auth/database.py +29 -146
  9. auth-3.2.0/auth/keycheck.py +233 -0
  10. {auth-3.1.1 → auth-3.2.0}/auth/main.py +21 -0
  11. {auth-3.1.1 → auth-3.2.0}/auth/models/sql.py +8 -0
  12. auth-3.2.0/auth/rls.py +122 -0
  13. auth-3.2.0/auth/routes/public.py +166 -0
  14. {auth-3.1.1 → auth-3.2.0}/auth/routes/tenant.py +114 -1
  15. auth-3.2.0/auth/schema_reconcile.py +241 -0
  16. {auth-3.1.1 → auth-3.2.0}/auth/services/base.py +7 -12
  17. auth-3.2.0/auth/services/recovery.py +127 -0
  18. {auth-3.1.1 → auth-3.2.0}/auth/services/rotation.py +9 -0
  19. {auth-3.1.1 → auth-3.2.0}/auth/services/tenant.py +32 -0
  20. {auth-3.1.1 → auth-3.2.0}/auth/static_docs/api_reference.md +22 -0
  21. {auth-3.1.1 → auth-3.2.0}/auth/validation.py +24 -4
  22. {auth-3.1.1 → auth-3.2.0}/auth/workflow_checker.py +12 -1
  23. {auth-3.1.1 → auth-3.2.0}/auth.egg-info/PKG-INFO +4 -2
  24. {auth-3.1.1 → auth-3.2.0}/auth.egg-info/SOURCES.txt +6 -0
  25. {auth-3.1.1 → auth-3.2.0}/auth.egg-info/requires.txt +4 -1
  26. {auth-3.1.1 → auth-3.2.0}/pyproject.toml +8 -2
  27. auth-3.2.0/tests/test_gunicorn_config_invariants.py +92 -0
  28. auth-3.2.0/tests/test_no_bundled_libpq.py +69 -0
  29. auth-3.1.1/auth/routes/public.py +0 -74
  30. {auth-3.1.1 → auth-3.2.0}/LICENSE +0 -0
  31. {auth-3.1.1 → auth-3.2.0}/README.md +0 -0
  32. {auth-3.1.1 → auth-3.2.0}/auth/circuit_breaker.py +0 -0
  33. {auth-3.1.1 → auth-3.2.0}/auth/client/keys.py +0 -0
  34. {auth-3.1.1 → auth-3.2.0}/auth/client/rbac.py +0 -0
  35. {auth-3.1.1 → auth-3.2.0}/auth/client/workflow.py +0 -0
  36. {auth-3.1.1 → auth-3.2.0}/auth/cmd/__init__.py +0 -0
  37. {auth-3.1.1 → auth-3.2.0}/auth/cmd/server.py +0 -0
  38. {auth-3.1.1 → auth-3.2.0}/auth/config.py +0 -0
  39. {auth-3.1.1 → auth-3.2.0}/auth/core/REST/__init__.py +0 -0
  40. {auth-3.1.1 → auth-3.2.0}/auth/core/REST/client.py +0 -0
  41. {auth-3.1.1 → auth-3.2.0}/auth/core/__init__.py +0 -0
  42. {auth-3.1.1 → auth-3.2.0}/auth/core/models/__init__.py +0 -0
  43. {auth-3.1.1 → auth-3.2.0}/auth/decorators.py +0 -0
  44. {auth-3.1.1 → auth-3.2.0}/auth/docs_page.py +0 -0
  45. {auth-3.1.1 → auth-3.2.0}/auth/encryption.py +0 -0
  46. {auth-3.1.1 → auth-3.2.0}/auth/logging_config.py +0 -0
  47. {auth-3.1.1 → auth-3.2.0}/auth/response_format.py +0 -0
  48. {auth-3.1.1 → auth-3.2.0}/auth/routes/__init__.py +0 -0
  49. {auth-3.1.1 → auth-3.2.0}/auth/routes/_common.py +0 -0
  50. {auth-3.1.1 → auth-3.2.0}/auth/routes/apikeys.py +0 -0
  51. {auth-3.1.1 → auth-3.2.0}/auth/routes/membership.py +0 -0
  52. {auth-3.1.1 → auth-3.2.0}/auth/routes/permissions.py +0 -0
  53. {auth-3.1.1 → auth-3.2.0}/auth/routes/roles.py +0 -0
  54. {auth-3.1.1 → auth-3.2.0}/auth/routes/workflow.py +0 -0
  55. {auth-3.1.1 → auth-3.2.0}/auth/sanitizer.py +0 -0
  56. {auth-3.1.1 → auth-3.2.0}/auth/server.py +0 -0
  57. {auth-3.1.1 → auth-3.2.0}/auth/services/keys.py +0 -0
  58. {auth-3.1.1 → auth-3.2.0}/auth/services/queries.py +0 -0
  59. {auth-3.1.1 → auth-3.2.0}/auth/services/rbac.py +0 -0
  60. {auth-3.1.1 → auth-3.2.0}/auth/services/service.py +0 -0
  61. {auth-3.1.1 → auth-3.2.0}/auth/static_docs/__init__.py +0 -0
  62. {auth-3.1.1 → auth-3.2.0}/auth/static_docs/claude_guide.md +0 -0
  63. {auth-3.1.1 → auth-3.2.0}/auth/static_docs/landing.md +0 -0
  64. {auth-3.1.1 → auth-3.2.0}/auth/static_docs/page.html +0 -0
  65. {auth-3.1.1 → auth-3.2.0}/auth.egg-info/dependency_links.txt +0 -0
  66. {auth-3.1.1 → auth-3.2.0}/auth.egg-info/entry_points.txt +0 -0
  67. {auth-3.1.1 → auth-3.2.0}/auth.egg-info/top_level.txt +0 -0
  68. {auth-3.1.1 → auth-3.2.0}/setup.cfg +0 -0
  69. {auth-3.1.1 → auth-3.2.0}/tests/test_api_keys.py +0 -0
  70. {auth-3.1.1 → auth-3.2.0}/tests/test_api_keys_encryption.py +0 -0
  71. {auth-3.1.1 → auth-3.2.0}/tests/test_audit_integrity.py +0 -0
  72. {auth-3.1.1 → auth-3.2.0}/tests/test_client.py +0 -0
  73. {auth-3.1.1 → auth-3.2.0}/tests/test_client_rest.py +0 -0
  74. {auth-3.1.1 → auth-3.2.0}/tests/test_cmd_server.py +0 -0
  75. {auth-3.1.1 → auth-3.2.0}/tests/test_config.py +0 -0
  76. {auth-3.1.1 → auth-3.2.0}/tests/test_correctness_hardening.py +0 -0
  77. {auth-3.1.1 → auth-3.2.0}/tests/test_database_sslmode.py +0 -0
  78. {auth-3.1.1 → auth-3.2.0}/tests/test_docs_page.py +0 -0
  79. {auth-3.1.1 → auth-3.2.0}/tests/test_encryption.py +0 -0
  80. {auth-3.1.1 → auth-3.2.0}/tests/test_encryption_integration.py +0 -0
  81. {auth-3.1.1 → auth-3.2.0}/tests/test_flask.py +0 -0
  82. {auth-3.1.1 → auth-3.2.0}/tests/test_key_rotation.py +0 -0
  83. {auth-3.1.1 → auth-3.2.0}/tests/test_log_redaction.py +0 -0
  84. {auth-3.1.1 → auth-3.2.0}/tests/test_phase_a_hardening.py +0 -0
  85. {auth-3.1.1 → auth-3.2.0}/tests/test_reencryption.py +0 -0
  86. {auth-3.1.1 → auth-3.2.0}/tests/test_routes_errors.py +0 -0
  87. {auth-3.1.1 → auth-3.2.0}/tests/test_self_service_audit.py +0 -0
  88. {auth-3.1.1 → auth-3.2.0}/tests/test_server.py +0 -0
  89. {auth-3.1.1 → auth-3.2.0}/tests/test_service_lifecycle.py +0 -0
  90. {auth-3.1.1 → auth-3.2.0}/tests/test_strict_default.py +0 -0
  91. {auth-3.1.1 → auth-3.2.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.2.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -31,12 +31,14 @@ Requires-Dist: sqlalchemy<3,>=2.0.0
31
31
  Requires-Dist: waitress<4,>=2.0.0
32
32
  Requires-Dist: cryptography>=3.0.0
33
33
  Requires-Dist: APScheduler<4,>=3.0.0
34
- Requires-Dist: psycopg[binary]<4,>=3.0.0
34
+ Requires-Dist: psycopg<4,>=3.0.0
35
35
  Requires-Dist: pydantic<3,>=2.0.0
36
36
  Requires-Dist: pydantic-settings<3,>=2.0.0
37
37
  Requires-Dist: requests<3,>=2.25.0
38
38
  Requires-Dist: bleach<7,>=5.0.0
39
39
  Requires-Dist: python-json-logger<4,>=3.1.0
40
+ Provides-Extra: c
41
+ Requires-Dist: psycopg[c]<4,>=3.0.0; extra == "c"
40
42
  Provides-Extra: ratelimit
41
43
  Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
42
44
  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,12 @@ 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
+ _grandfather_strict_users,
22
+ _reconcile_text_columns,
23
+ )
18
24
 
19
25
 
20
26
  def _forced_sslmode(database_url: str) -> Optional[str]:
@@ -92,8 +98,12 @@ class DatabaseEngine(metaclass=SingletonMeta):
92
98
  settings = get_settings()
93
99
 
94
100
  if settings.database_type == DatabaseType.POSTGRESQL:
95
- # Conservative sizing for production with multiple workers
96
- return (5, 5) # 10 max connections per worker
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)
97
107
  else:
98
108
  # SQLite - smaller pool since it's file-based
99
109
  return (5, 10)
@@ -106,7 +116,17 @@ class DatabaseEngine(metaclass=SingletonMeta):
106
116
  "connect_timeout": 30,
107
117
  "application_name": "auth_server",
108
118
  # Set statement timeout to prevent long-running queries
109
- "options": "-c statement_timeout=30000", # 30 seconds
119
+ # 10s: the slowest legitimate operation is a key rotation, measured
120
+ # at ~0.12 ms/row (~1s for the largest namespace on this deployment),
121
+ # so this leaves 10x headroom.
122
+ #
123
+ # NOTE: statement_timeout does NOT bound COMMIT. A commit stall is
124
+ # therefore unaffected by this value, which is why worker
125
+ # concurrency -- not this timeout -- is the mitigation for a stalled
126
+ # database. tcp_user_timeout would bound it at the socket, but
127
+ # dropping a connection mid-COMMIT leaves the outcome ambiguous, and
128
+ # an ambiguous permission grant is a worse failure than a slow one.
129
+ "options": "-c statement_timeout=10000", # 10 seconds
110
130
  }
111
131
 
112
132
  forced = _forced_sslmode(database_url)
@@ -119,7 +139,7 @@ class DatabaseEngine(metaclass=SingletonMeta):
119
139
  poolclass=pool.QueuePool, # Explicitly use QueuePool
120
140
  pool_size=pool_size,
121
141
  max_overflow=max_overflow,
122
- pool_timeout=30, # Wait up to 30s for a connection
142
+ pool_timeout=10, # Fail fast rather than queueing behind a stalled DB
123
143
  pool_recycle=3600, # Recycle connections after 1 hour
124
144
  pool_pre_ping=True, # Verify connections before using them
125
145
  # Performance settings
@@ -325,6 +345,11 @@ def create_tables(raise_on_error: bool = False):
325
345
  text(f'CREATE SCHEMA IF NOT EXISTS "{settings.database_schema}"')
326
346
  )
327
347
  Base.metadata.create_all(bind=engine, checkfirst=True)
348
+ # Row level security first, and each step separately: these are
349
+ # independent reconciliations, and letting one exception abort the rest
350
+ # is how a fresh database came up with the junction tables unprotected
351
+ # -- grandfathering raised, and the RLS step below it never ran.
352
+ _apply_tenant_rls(engine)
328
353
  _reconcile_text_columns(engine)
329
354
  _grandfather_strict_users(engine)
330
355
  logger.info("Tables created successfully.")
@@ -334,148 +359,6 @@ def create_tables(raise_on_error: bool = False):
334
359
  raise
335
360
 
336
361
 
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
362
  def log_pool_stats():
480
363
  """Log current connection pool statistics"""
481
364
  stats = get_pool_status()
@@ -0,0 +1,233 @@
1
+ """Boot-time verification that the configured encryption key matches stored data.
2
+
3
+ Field encryption is deterministic and used for equality lookups, so
4
+ encrypt-to-store and encrypt-to-query share one key derivation. If the key
5
+ changes, both sides move together into a namespace where nothing matches: every
6
+ membership lookup misses, every authorization answer becomes "denied", and each
7
+ one is returned with HTTP 200. Nothing in the request path can notice, because
8
+ the query path never decrypts — it compares ciphertext.
9
+
10
+ This module samples rows that are actually encrypted and decrypts them. A wrong
11
+ key fails authentication in ``DeterministicEncryption._open`` (the IV is an HMAC
12
+ of the plaintext, re-derived and constant-time compared), so the failure is
13
+ loud here even though it is silent everywhere else.
14
+
15
+ The states are distinguished deliberately:
16
+
17
+ ``ok``
18
+ Encrypted rows exist and decrypt, or nothing is encrypted yet.
19
+ ``mismatch``
20
+ Encrypted rows exist and this key cannot read them. Serving would answer
21
+ every authorization question wrongly, so ``/api`` is refused instead.
22
+ ``unverified``
23
+ The check could not run (database unavailable at boot). Not an assertion
24
+ that the key is good — the service starts, because refusing to boot on a
25
+ transient database problem turns a blip into a restart loop.
26
+ """
27
+
28
+ import logging
29
+ from typing import Optional
30
+
31
+ import auth.encryption as _encryption
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ OK = "ok"
36
+ MISMATCH = "mismatch"
37
+ UNVERIFIED = "unverified"
38
+
39
+ _V2_PREFIX = "v2:"
40
+
41
+ #: Reserved tenant for the canary row. The client-key validator requires a real
42
+ #: UUID4, so the nil UUID is refused at the door and can never be a caller's
43
+ #: namespace -- which is what makes it safe to reserve.
44
+ SENTINEL_TENANT = "00000000-0000-0000-0000-000000000000"
45
+ _CANARY_PLAINTEXT = "auth-encryption-canary-v1"
46
+
47
+ #: Tables that must be protected once the RLS migration has run.
48
+ _RLS_TABLES = (
49
+ "auth_group",
50
+ "auth_membership",
51
+ "auth_permission",
52
+ "auth_api_key",
53
+ "auth_tenant_settings",
54
+ "membership_groups",
55
+ "permission_groups",
56
+ )
57
+
58
+ #: Checked the same way, but only when present. audit_log is created by
59
+ #: ``setup_audit_tables`` rather than by create_all, so a deployment with audit
60
+ #: logging switched off legitimately has no such table -- while one that DOES
61
+ #: have it is holding tenant data and must protect it like any other.
62
+ _RLS_OPTIONAL_TABLES = ("audit_log",)
63
+
64
+ _state = OK
65
+ _detail = "not yet checked"
66
+
67
+
68
+ def state() -> str:
69
+ """Current verdict: ``ok``, ``mismatch`` or ``unverified``."""
70
+ return _state
71
+
72
+
73
+ def detail() -> str:
74
+ """Human-readable reason behind :func:`state`."""
75
+ return _detail
76
+
77
+
78
+ def is_degraded() -> bool:
79
+ """True when this deployment cannot safely answer authorization questions."""
80
+ return _state == MISMATCH
81
+
82
+
83
+ def _set(new_state: str, why: str) -> str:
84
+ global _state, _detail
85
+ # A later OK must never clear an earlier failure. Boot runs several checks
86
+ # in sequence and the last one would otherwise decide the verdict for all
87
+ # of them -- which is how the RLS check silently stopped mattering the first
88
+ # time this ran: it reported a problem and the encryption check overwrote it.
89
+ if _state == MISMATCH and new_state != MISMATCH:
90
+ return _state
91
+ if _state == UNVERIFIED and new_state == OK:
92
+ return _state
93
+ _state, _detail = new_state, why
94
+ if new_state == MISMATCH:
95
+ logger.critical("STARTUP CHECK FAILED: %s", why)
96
+ elif new_state == UNVERIFIED:
97
+ logger.warning("startup check inconclusive: %s", why)
98
+ else:
99
+ logger.info("startup check: %s", why)
100
+ return new_state
101
+
102
+
103
+ def verify_encryption_key(db) -> str:
104
+ """Check the configured key against this deployment's canary.
105
+
106
+ The canary lives under :data:`SENTINEL_TENANT` so it can be read with Row
107
+ Level Security in force -- the previous implementation sampled rows across
108
+ tenants, which RLS would reduce to "nothing to verify against", passing
109
+ vacuously and silently disabling this check.
110
+
111
+ Never raises: a database problem yields ``unverified`` rather than
112
+ preventing startup, because refusing to boot on a transient outage turns a
113
+ blip into a restart loop.
114
+ """
115
+ from auth.models.sql import AuthTenantSettings
116
+ from auth.rls import bind_tenant
117
+
118
+ fe = _encryption.field_encryption
119
+ try:
120
+ bind_tenant(db, SENTINEL_TENANT)
121
+ row = (
122
+ db.query(AuthTenantSettings)
123
+ .filter(AuthTenantSettings.creator == SENTINEL_TENANT)
124
+ .first()
125
+ )
126
+ except Exception as exc:
127
+ return _set(UNVERIFIED, f"could not read the canary: {exc.__class__.__name__}")
128
+
129
+ stored = getattr(row, "canary", None) if row is not None else None
130
+
131
+ if not stored:
132
+ if not fe.enabled or fe.encryptor is None:
133
+ return _set(OK, "encryption disabled and no canary stored")
134
+ try:
135
+ value = fe.encryptor.encrypt(_CANARY_PLAINTEXT, SENTINEL_TENANT)
136
+ if row is None:
137
+ row = AuthTenantSettings(creator=SENTINEL_TENANT, strict_users=False)
138
+ db.add(row)
139
+ row.canary = value
140
+ db.commit()
141
+ return _set(OK, "canary written for this deployment")
142
+ except Exception as exc:
143
+ db.rollback()
144
+ return _set(UNVERIFIED, f"could not write the canary: {exc.__class__.__name__}")
145
+
146
+ if not fe.enabled:
147
+ return _set(
148
+ MISMATCH,
149
+ "this deployment has an encryption canary but field encryption is "
150
+ "disabled - every authorization lookup would miss and answer "
151
+ "negatively",
152
+ )
153
+ try:
154
+ recovered = fe.encryptor.decrypt(str(stored), SENTINEL_TENANT) # type: ignore[union-attr]
155
+ except Exception:
156
+ return _set(
157
+ MISMATCH,
158
+ "the configured AUTH_ENCRYPTION_KEY cannot decrypt this "
159
+ "deployment's canary - every authorization lookup would miss and "
160
+ "answer negatively",
161
+ )
162
+ if recovered != _CANARY_PLAINTEXT:
163
+ return _set(MISMATCH, "the canary decrypted to an unexpected value")
164
+ return _set(OK, "encryption key verified against the deployment canary")
165
+
166
+
167
+ def verify_row_level_security(db) -> str:
168
+ """Confirm RLS is actually in force, not merely enabled.
169
+
170
+ The application owns these tables and PostgreSQL does not apply RLS to a
171
+ table's owner, so ``ENABLE`` without ``FORCE`` protects nothing while every
172
+ casual check looks correct. That is the one way this feature fails silently,
173
+ so it is asserted at boot rather than assumed.
174
+
175
+ Skipped on SQLite, which has no RLS.
176
+ """
177
+ from sqlalchemy import text
178
+
179
+ try:
180
+ if db.get_bind().dialect.name != "postgresql":
181
+ return OK
182
+ from auth.config import get_settings
183
+
184
+ # The configured schema, NOT current_schema(): search_path rarely points
185
+ # at it, so current_schema() matched no tables and the check reported
186
+ # "nothing to check" -- passing while RLS was off.
187
+ schema = get_settings().database_schema or "public"
188
+ rows = db.execute(
189
+ text(
190
+ "SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, "
191
+ "(SELECT count(*) FROM pg_policy p WHERE p.polrelid = c.oid) "
192
+ "FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
193
+ "WHERE c.relname = ANY(:names) AND n.nspname = :schema"
194
+ ),
195
+ {"names": list(_RLS_TABLES + _RLS_OPTIONAL_TABLES), "schema": schema},
196
+ ).fetchall()
197
+ except Exception as exc:
198
+ return _set(UNVERIFIED, f"could not read RLS state: {exc.__class__.__name__}")
199
+
200
+ if not rows:
201
+ return _set(UNVERIFIED, "no tenant tables found to check RLS on")
202
+
203
+ # A table that is absent is not a table that is safe. The junction tables
204
+ # are created by the ORM rather than by a migration, so on a fresh database
205
+ # they appeared after the RLS migration had run -- and a check that only
206
+ # inspects the rows it happens to find reports every remaining table forced
207
+ # and calls that a pass.
208
+ missing = sorted(set(_RLS_TABLES) - {r[0] for r in rows})
209
+ if missing:
210
+ return _set(
211
+ MISMATCH,
212
+ "expected tenant tables are absent, so nothing protects them: "
213
+ + ", ".join(missing),
214
+ )
215
+
216
+ unprotected = [
217
+ r[0] for r in rows if not (r[1] and r[2] and r[3] > 0)
218
+ ]
219
+ if unprotected:
220
+ return _set(
221
+ MISMATCH,
222
+ "row level security is not in force on: "
223
+ + ", ".join(sorted(unprotected))
224
+ + " - tenant isolation would rest on query convention alone",
225
+ )
226
+ return _set(OK, f"row level security forced on {len(rows)} tables")
227
+
228
+
229
+ def reset_for_tests(new_state: Optional[str] = None) -> None:
230
+ """Test hook: force the recorded verdict, bypassing the sticky rule."""
231
+ global _state, _detail
232
+ _state = new_state or OK
233
+ _detail = "set by test"