auth 3.3.0__tar.gz → 3.5.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 (97) hide show
  1. {auth-3.3.0 → auth-3.5.0}/PKG-INFO +1 -1
  2. {auth-3.3.0 → auth-3.5.0}/auth/__init__.py +14 -0
  3. {auth-3.3.0 → auth-3.5.0}/auth/config.py +14 -0
  4. {auth-3.3.0 → auth-3.5.0}/auth/database.py +66 -39
  5. auth-3.5.0/auth/schema_check.py +61 -0
  6. {auth-3.3.0 → auth-3.5.0}/auth.egg-info/PKG-INFO +1 -1
  7. {auth-3.3.0 → auth-3.5.0}/auth.egg-info/SOURCES.txt +3 -0
  8. {auth-3.3.0 → auth-3.5.0}/pyproject.toml +1 -1
  9. auth-3.5.0/tests/test_database_pool_settings.py +340 -0
  10. auth-3.5.0/tests/test_schema_check.py +134 -0
  11. {auth-3.3.0 → auth-3.5.0}/LICENSE +0 -0
  12. {auth-3.3.0 → auth-3.5.0}/README.md +0 -0
  13. {auth-3.3.0 → auth-3.5.0}/auth/api_keys.py +0 -0
  14. {auth-3.3.0 → auth-3.5.0}/auth/audit.py +0 -0
  15. {auth-3.3.0 → auth-3.5.0}/auth/circuit_breaker.py +0 -0
  16. {auth-3.3.0 → auth-3.5.0}/auth/client/__init__.py +0 -0
  17. {auth-3.3.0 → auth-3.5.0}/auth/client/_transport.py +0 -0
  18. {auth-3.3.0 → auth-3.5.0}/auth/client/base.py +0 -0
  19. {auth-3.3.0 → auth-3.5.0}/auth/client/keys.py +0 -0
  20. {auth-3.3.0 → auth-3.5.0}/auth/client/rbac.py +0 -0
  21. {auth-3.3.0 → auth-3.5.0}/auth/client/workflow.py +0 -0
  22. {auth-3.3.0 → auth-3.5.0}/auth/cmd/__init__.py +0 -0
  23. {auth-3.3.0 → auth-3.5.0}/auth/cmd/server.py +0 -0
  24. {auth-3.3.0 → auth-3.5.0}/auth/core/REST/__init__.py +0 -0
  25. {auth-3.3.0 → auth-3.5.0}/auth/core/REST/client.py +0 -0
  26. {auth-3.3.0 → auth-3.5.0}/auth/core/__init__.py +0 -0
  27. {auth-3.3.0 → auth-3.5.0}/auth/core/models/__init__.py +0 -0
  28. {auth-3.3.0 → auth-3.5.0}/auth/decorators.py +0 -0
  29. {auth-3.3.0 → auth-3.5.0}/auth/docs_page.py +0 -0
  30. {auth-3.3.0 → auth-3.5.0}/auth/encryption.py +0 -0
  31. {auth-3.3.0 → auth-3.5.0}/auth/keycheck.py +0 -0
  32. {auth-3.3.0 → auth-3.5.0}/auth/logging_config.py +0 -0
  33. {auth-3.3.0 → auth-3.5.0}/auth/main.py +0 -0
  34. {auth-3.3.0 → auth-3.5.0}/auth/models/sql.py +0 -0
  35. {auth-3.3.0 → auth-3.5.0}/auth/response_format.py +0 -0
  36. {auth-3.3.0 → auth-3.5.0}/auth/rls.py +0 -0
  37. {auth-3.3.0 → auth-3.5.0}/auth/routes/__init__.py +0 -0
  38. {auth-3.3.0 → auth-3.5.0}/auth/routes/_common.py +0 -0
  39. {auth-3.3.0 → auth-3.5.0}/auth/routes/apikeys.py +0 -0
  40. {auth-3.3.0 → auth-3.5.0}/auth/routes/membership.py +0 -0
  41. {auth-3.3.0 → auth-3.5.0}/auth/routes/permissions.py +0 -0
  42. {auth-3.3.0 → auth-3.5.0}/auth/routes/public.py +0 -0
  43. {auth-3.3.0 → auth-3.5.0}/auth/routes/roles.py +0 -0
  44. {auth-3.3.0 → auth-3.5.0}/auth/routes/tenant.py +0 -0
  45. {auth-3.3.0 → auth-3.5.0}/auth/routes/workflow.py +0 -0
  46. {auth-3.3.0 → auth-3.5.0}/auth/sanitizer.py +0 -0
  47. {auth-3.3.0 → auth-3.5.0}/auth/schema_reconcile.py +0 -0
  48. {auth-3.3.0 → auth-3.5.0}/auth/server.py +0 -0
  49. {auth-3.3.0 → auth-3.5.0}/auth/services/base.py +0 -0
  50. {auth-3.3.0 → auth-3.5.0}/auth/services/keys.py +0 -0
  51. {auth-3.3.0 → auth-3.5.0}/auth/services/queries.py +0 -0
  52. {auth-3.3.0 → auth-3.5.0}/auth/services/rbac.py +0 -0
  53. {auth-3.3.0 → auth-3.5.0}/auth/services/recovery.py +0 -0
  54. {auth-3.3.0 → auth-3.5.0}/auth/services/rotation.py +0 -0
  55. {auth-3.3.0 → auth-3.5.0}/auth/services/service.py +0 -0
  56. {auth-3.3.0 → auth-3.5.0}/auth/services/tenant.py +0 -0
  57. {auth-3.3.0 → auth-3.5.0}/auth/static_docs/__init__.py +0 -0
  58. {auth-3.3.0 → auth-3.5.0}/auth/static_docs/api_reference.md +0 -0
  59. {auth-3.3.0 → auth-3.5.0}/auth/static_docs/claude_guide.md +0 -0
  60. {auth-3.3.0 → auth-3.5.0}/auth/static_docs/landing.md +0 -0
  61. {auth-3.3.0 → auth-3.5.0}/auth/static_docs/page.html +0 -0
  62. {auth-3.3.0 → auth-3.5.0}/auth/validation.py +0 -0
  63. {auth-3.3.0 → auth-3.5.0}/auth/workflow_checker.py +0 -0
  64. {auth-3.3.0 → auth-3.5.0}/auth.egg-info/dependency_links.txt +0 -0
  65. {auth-3.3.0 → auth-3.5.0}/auth.egg-info/entry_points.txt +0 -0
  66. {auth-3.3.0 → auth-3.5.0}/auth.egg-info/requires.txt +0 -0
  67. {auth-3.3.0 → auth-3.5.0}/auth.egg-info/top_level.txt +0 -0
  68. {auth-3.3.0 → auth-3.5.0}/setup.cfg +0 -0
  69. {auth-3.3.0 → auth-3.5.0}/tests/test_api_keys.py +0 -0
  70. {auth-3.3.0 → auth-3.5.0}/tests/test_api_keys_encryption.py +0 -0
  71. {auth-3.3.0 → auth-3.5.0}/tests/test_audit_integrity.py +0 -0
  72. {auth-3.3.0 → auth-3.5.0}/tests/test_client.py +0 -0
  73. {auth-3.3.0 → auth-3.5.0}/tests/test_client_exceptions_do_not_render_payload.py +0 -0
  74. {auth-3.3.0 → auth-3.5.0}/tests/test_client_rest.py +0 -0
  75. {auth-3.3.0 → auth-3.5.0}/tests/test_cmd_server.py +0 -0
  76. {auth-3.3.0 → auth-3.5.0}/tests/test_config.py +0 -0
  77. {auth-3.3.0 → auth-3.5.0}/tests/test_correctness_hardening.py +0 -0
  78. {auth-3.3.0 → auth-3.5.0}/tests/test_database_sslmode.py +0 -0
  79. {auth-3.3.0 → auth-3.5.0}/tests/test_degraded_response_is_a_contract.py +0 -0
  80. {auth-3.3.0 → auth-3.5.0}/tests/test_docs_page.py +0 -0
  81. {auth-3.3.0 → auth-3.5.0}/tests/test_encryption.py +0 -0
  82. {auth-3.3.0 → auth-3.5.0}/tests/test_encryption_integration.py +0 -0
  83. {auth-3.3.0 → auth-3.5.0}/tests/test_flask.py +0 -0
  84. {auth-3.3.0 → auth-3.5.0}/tests/test_gunicorn_config_invariants.py +0 -0
  85. {auth-3.3.0 → auth-3.5.0}/tests/test_key_rotation.py +0 -0
  86. {auth-3.3.0 → auth-3.5.0}/tests/test_log_redaction.py +0 -0
  87. {auth-3.3.0 → auth-3.5.0}/tests/test_no_bundled_libpq.py +0 -0
  88. {auth-3.3.0 → auth-3.5.0}/tests/test_phase_a_hardening.py +0 -0
  89. {auth-3.3.0 → auth-3.5.0}/tests/test_provision_script_no_dsn_leak.py +0 -0
  90. {auth-3.3.0 → auth-3.5.0}/tests/test_reencryption.py +0 -0
  91. {auth-3.3.0 → auth-3.5.0}/tests/test_routes_errors.py +0 -0
  92. {auth-3.3.0 → auth-3.5.0}/tests/test_self_service_audit.py +0 -0
  93. {auth-3.3.0 → auth-3.5.0}/tests/test_server.py +0 -0
  94. {auth-3.3.0 → auth-3.5.0}/tests/test_service_lifecycle.py +0 -0
  95. {auth-3.3.0 → auth-3.5.0}/tests/test_settings_do_not_echo_secrets.py +0 -0
  96. {auth-3.3.0 → auth-3.5.0}/tests/test_strict_default.py +0 -0
  97. {auth-3.3.0 → auth-3.5.0}/tests/test_strict_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.3.0
3
+ Version: 3.5.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -10,6 +10,7 @@ from auth.client import ( # Import the new client
10
10
  EnhancedAuthClient,
11
11
  )
12
12
  from auth.database import SessionLocal
13
+ from auth.schema_check import SchemaMismatchError, check_schema
13
14
  from auth.services.service import AuthorizationService
14
15
 
15
16
  try:
@@ -56,6 +57,7 @@ class Authorization:
56
57
 
57
58
  self.client = client
58
59
  # Use provided session or create a new one
60
+ self._owns_session = not db_session
59
61
  self.db = db_session if db_session else SessionLocal()
60
62
  # Use legacy mode (no client validation) for backward compatibility.
61
63
  # strict_users: None reads the tenant's stored setting (identical
@@ -64,6 +66,16 @@ class Authorization:
64
66
  self.db, client, validate_client=False, strict_users=strict_users
65
67
  )
66
68
 
69
+ def close(self) -> None:
70
+ if self._owns_session:
71
+ self.db.close()
72
+
73
+ def __enter__(self) -> "Authorization":
74
+ return self
75
+
76
+ def __exit__(self, exc_type, exc, tb) -> None:
77
+ self.close()
78
+
67
79
  @property
68
80
  def roles(self):
69
81
  return self.service.get_roles()
@@ -146,5 +158,7 @@ __all__ = [
146
158
  "Client",
147
159
  "EnhancedAuthClient",
148
160
  "SessionLocal",
161
+ "SchemaMismatchError",
162
+ "check_schema",
149
163
  "__version__",
150
164
  ]
@@ -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,14 +7,15 @@ 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
+ from auth.schema_check import check_schema
18
19
  from auth.schema_reconcile import (
19
20
  GRANDFATHER_MARKER, # noqa: F401 - re-exported; tests and embedded callers import it from here
20
21
  _apply_tenant_rls,
@@ -47,6 +48,47 @@ def _forced_sslmode(database_url: str) -> Optional[str]:
47
48
  logger = logging.getLogger(__name__)
48
49
 
49
50
 
51
+ class PoolSettings(NamedTuple):
52
+ pool_size: int
53
+ max_overflow: int
54
+ pool_timeout: float
55
+ pool_recycle: int
56
+
57
+
58
+ _POSTGRESQL_POOL_DEFAULTS = PoolSettings(2, 2, 10.0, 3600)
59
+ _SQLITE_POOL_DEFAULTS = PoolSettings(5, 10, 30.0, 300)
60
+
61
+
62
+ def resolve_pool_settings(settings: Settings) -> PoolSettings:
63
+ defaults = (
64
+ _POSTGRESQL_POOL_DEFAULTS
65
+ if settings.database_type == DatabaseType.POSTGRESQL
66
+ else _SQLITE_POOL_DEFAULTS
67
+ )
68
+ return PoolSettings(
69
+ pool_size=(
70
+ defaults.pool_size
71
+ if settings.database_pool_size is None
72
+ else settings.database_pool_size
73
+ ),
74
+ max_overflow=(
75
+ defaults.max_overflow
76
+ if settings.database_max_overflow is None
77
+ else settings.database_max_overflow
78
+ ),
79
+ pool_timeout=(
80
+ defaults.pool_timeout
81
+ if settings.database_pool_timeout is None
82
+ else settings.database_pool_timeout
83
+ ),
84
+ pool_recycle=(
85
+ defaults.pool_recycle
86
+ if settings.database_pool_recycle is None
87
+ else settings.database_pool_recycle
88
+ ),
89
+ )
90
+
91
+
50
92
  class SingletonMeta(type):
51
93
  """
52
94
  Thread-safe Singleton metaclass for database engine
@@ -83,35 +125,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
83
125
  self._initialize_engine()
84
126
  self._setup_pool_monitoring()
85
127
 
86
- def _calculate_pool_size(self) -> tuple[int, int]:
87
- """
88
- Calculate optimal pool size for Gunicorn workers
89
-
90
- For 8 workers with PostgreSQL:
91
- - pool_size=5 per worker = 40 base connections
92
- - max_overflow=5 per worker = 80 total max connections
93
- - Leaves headroom for PostgreSQL default max_connections (100)
94
- - Also accounts for other services using the database
95
-
96
- Returns:
97
- tuple: (pool_size, max_overflow)
98
- """
99
- settings = get_settings()
128
+ def _pool_settings(self) -> PoolSettings:
129
+ return resolve_pool_settings(get_settings())
100
130
 
101
- if settings.database_type == DatabaseType.POSTGRESQL:
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)
108
- else:
109
- # SQLite - smaller pool since it's file-based
110
- return (5, 10)
131
+ def _calculate_pool_size(self) -> tuple[int, int]:
132
+ pool_settings = self._pool_settings()
133
+ return (pool_settings.pool_size, pool_settings.max_overflow)
111
134
 
112
135
  def _create_postgresql_engine(self, database_url: str) -> Engine:
113
136
  """Create optimized PostgreSQL engine with enterprise pooling"""
114
- pool_size, max_overflow = self._calculate_pool_size()
137
+ pool_settings = self._pool_settings()
115
138
 
116
139
  connect_args = {
117
140
  "connect_timeout": 30,
@@ -138,10 +161,10 @@ class DatabaseEngine(metaclass=SingletonMeta):
138
161
  database_url,
139
162
  # Connection pool settings
140
163
  poolclass=pool.QueuePool, # Explicitly use QueuePool
141
- pool_size=pool_size,
142
- max_overflow=max_overflow,
143
- pool_timeout=10, # Fail fast rather than queueing behind a stalled DB
144
- pool_recycle=3600, # Recycle connections after 1 hour
164
+ pool_size=pool_settings.pool_size,
165
+ max_overflow=pool_settings.max_overflow,
166
+ pool_timeout=pool_settings.pool_timeout,
167
+ pool_recycle=pool_settings.pool_recycle,
145
168
  pool_pre_ping=True, # Verify connections before using them
146
169
  # Performance settings
147
170
  echo=False, # Disable SQL logging in production
@@ -161,16 +184,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
161
184
  connect_args={"check_same_thread": False},
162
185
  )
163
186
 
164
- pool_size, max_overflow = self._calculate_pool_size()
187
+ pool_settings = self._pool_settings()
165
188
 
166
189
  return create_engine(
167
190
  database_url,
168
191
  poolclass=pool.QueuePool,
169
- pool_size=pool_size,
170
- max_overflow=max_overflow,
192
+ pool_size=pool_settings.pool_size,
193
+ max_overflow=pool_settings.max_overflow,
171
194
  pool_pre_ping=True,
172
- pool_recycle=300, # 5 minutes for SQLite
173
- pool_timeout=30,
195
+ pool_recycle=pool_settings.pool_recycle,
196
+ pool_timeout=pool_settings.pool_timeout,
174
197
  connect_args={
175
198
  "check_same_thread": False,
176
199
  "timeout": 60,
@@ -200,10 +223,13 @@ class DatabaseEngine(metaclass=SingletonMeta):
200
223
  expire_on_commit=False, # Prevent unnecessary queries
201
224
  )
202
225
 
203
- pool_size, max_overflow = self._calculate_pool_size()
226
+ pool_settings = self._pool_settings()
204
227
  logger.info(
205
- f"Database engine initialized: pool_size={pool_size}, "
206
- f"max_overflow={max_overflow}, max_connections={pool_size + max_overflow}"
228
+ f"Database engine initialized: pool_size={pool_settings.pool_size}, "
229
+ f"max_overflow={pool_settings.max_overflow}, "
230
+ f"max_connections={pool_settings.pool_size + pool_settings.max_overflow}, "
231
+ f"pool_timeout={pool_settings.pool_timeout}, "
232
+ f"pool_recycle={pool_settings.pool_recycle}"
207
233
  )
208
234
 
209
235
  def _setup_pool_monitoring(self):
@@ -354,6 +380,7 @@ def create_tables(raise_on_error: bool = False):
354
380
  _ensure_audit_partition_runway(engine)
355
381
  _reconcile_text_columns(engine)
356
382
  _grandfather_strict_users(engine)
383
+ check_schema(engine)
357
384
  logger.info("Tables created successfully.")
358
385
  except Exception:
359
386
  logger.exception("create_tables failed")
@@ -0,0 +1,61 @@
1
+ from typing import List, Optional, Sequence, Tuple, Union
2
+
3
+ from sqlalchemy import Table, inspect
4
+ from sqlalchemy.engine import Connection, Engine
5
+
6
+ Bind = Union[Engine, Connection]
7
+
8
+
9
+ class SchemaMismatchError(RuntimeError):
10
+ def __init__(
11
+ self, missing_tables: Sequence[str], missing_columns: Sequence[str]
12
+ ) -> None:
13
+ self.missing_tables = list(missing_tables)
14
+ self.missing_columns = list(missing_columns)
15
+ parts = []
16
+ if self.missing_tables:
17
+ parts.append("missing tables: " + ", ".join(self.missing_tables))
18
+ if self.missing_columns:
19
+ parts.append("missing columns: " + ", ".join(self.missing_columns))
20
+ super().__init__(
21
+ "the live database is behind the installed auth models ("
22
+ + "; ".join(parts)
23
+ + "). The auth changelog lists the DDL each release needs."
24
+ )
25
+
26
+
27
+ def _qualified(table: Table) -> str:
28
+ return f"{table.schema}.{table.name}" if table.schema else table.name
29
+
30
+
31
+ def find_schema_drift(bind: Bind) -> Tuple[List[str], List[str]]:
32
+ import auth.audit # noqa: F401
33
+ from auth.models.sql import Base
34
+
35
+ inspector = inspect(bind)
36
+ missing_tables: List[str] = []
37
+ missing_columns: List[str] = []
38
+ for table in sorted(Base.metadata.tables.values(), key=_qualified):
39
+ if not inspector.has_table(table.name, schema=table.schema):
40
+ missing_tables.append(_qualified(table))
41
+ continue
42
+ live = {
43
+ column["name"]
44
+ for column in inspector.get_columns(table.name, schema=table.schema)
45
+ }
46
+ missing_columns.extend(
47
+ f"{_qualified(table)}.{column.name}"
48
+ for column in table.columns
49
+ if column.name not in live
50
+ )
51
+ return missing_tables, missing_columns
52
+
53
+
54
+ def check_schema(bind: Optional[Bind] = None) -> None:
55
+ if bind is None:
56
+ from auth.database import engine
57
+
58
+ bind = engine
59
+ missing_tables, missing_columns = find_schema_drift(bind)
60
+ if missing_tables or missing_columns:
61
+ raise SchemaMismatchError(missing_tables, missing_columns)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.3.0
3
+ Version: 3.5.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -16,6 +16,7 @@ auth/main.py
16
16
  auth/response_format.py
17
17
  auth/rls.py
18
18
  auth/sanitizer.py
19
+ auth/schema_check.py
19
20
  auth/schema_reconcile.py
20
21
  auth/server.py
21
22
  auth/validation.py
@@ -70,6 +71,7 @@ tests/test_client_rest.py
70
71
  tests/test_cmd_server.py
71
72
  tests/test_config.py
72
73
  tests/test_correctness_hardening.py
74
+ tests/test_database_pool_settings.py
73
75
  tests/test_database_sslmode.py
74
76
  tests/test_degraded_response_is_a_contract.py
75
77
  tests/test_docs_page.py
@@ -84,6 +86,7 @@ tests/test_phase_a_hardening.py
84
86
  tests/test_provision_script_no_dsn_leak.py
85
87
  tests/test_reencryption.py
86
88
  tests/test_routes_errors.py
89
+ tests/test_schema_check.py
87
90
  tests/test_self_service_audit.py
88
91
  tests/test_server.py
89
92
  tests/test_service_lifecycle.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "auth"
7
- version = "3.3.0"
7
+ version = "3.5.0"
8
8
  description = "Authorization for humans"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,340 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import threading
6
+ import time
7
+
8
+ import pytest
9
+ from pydantic import ValidationError
10
+ from sqlalchemy import exc, text
11
+
12
+ import auth
13
+ from auth.config import Settings
14
+ from auth.database import DatabaseEngine, PoolSettings, resolve_pool_settings
15
+
16
+ PG_URL = "postgresql+psycopg://app:s3cr3t-pw@db.example:5432/x"
17
+ POOL_ENV = (
18
+ "AUTH_DATABASE_POOL_SIZE",
19
+ "AUTH_DATABASE_MAX_OVERFLOW",
20
+ "AUTH_DATABASE_POOL_TIMEOUT",
21
+ "AUTH_DATABASE_POOL_RECYCLE",
22
+ )
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def _clean_pool_env(monkeypatch):
27
+ for name in POOL_ENV:
28
+ monkeypatch.delenv(name, raising=False)
29
+
30
+
31
+ def _bare_engine_manager():
32
+ return object.__new__(DatabaseEngine)
33
+
34
+
35
+ def _use_settings(monkeypatch, **env):
36
+ for name, value in env.items():
37
+ monkeypatch.setenv(name, value)
38
+ monkeypatch.setattr(
39
+ "auth.database.get_settings", lambda: Settings() # type: ignore[call-arg]
40
+ )
41
+
42
+
43
+ def test_unset_postgresql_pool_keeps_3_3_0_values():
44
+ settings = Settings(database_url=PG_URL)
45
+ assert resolve_pool_settings(settings) == PoolSettings(2, 2, 10, 3600)
46
+
47
+
48
+ def test_unset_sqlite_pool_keeps_3_3_0_values(tmp_path):
49
+ settings = Settings(database_url=f"sqlite:///{tmp_path}/a.db")
50
+ assert resolve_pool_settings(settings) == PoolSettings(5, 10, 30, 300)
51
+
52
+
53
+ def test_env_overrides_every_pool_parameter(monkeypatch):
54
+ _use_settings(
55
+ monkeypatch,
56
+ AUTH_DATABASE_URL=PG_URL,
57
+ AUTH_DATABASE_POOL_SIZE="1",
58
+ AUTH_DATABASE_MAX_OVERFLOW="0",
59
+ AUTH_DATABASE_POOL_TIMEOUT="3.5",
60
+ AUTH_DATABASE_POOL_RECYCLE="120",
61
+ )
62
+ engine = _bare_engine_manager()._create_postgresql_engine(PG_URL)
63
+ try:
64
+ assert engine.pool.size() == 1
65
+ assert engine.pool._max_overflow == 0
66
+ assert engine.pool._timeout == 3.5
67
+ assert engine.pool._recycle == 120
68
+ finally:
69
+ engine.dispose()
70
+
71
+
72
+ def test_partial_override_keeps_remaining_defaults(monkeypatch):
73
+ _use_settings(
74
+ monkeypatch, AUTH_DATABASE_URL=PG_URL, AUTH_DATABASE_POOL_SIZE="1"
75
+ )
76
+ engine = _bare_engine_manager()._create_postgresql_engine(PG_URL)
77
+ try:
78
+ assert engine.pool.size() == 1
79
+ assert engine.pool._max_overflow == 2
80
+ assert engine.pool._timeout == 10
81
+ assert engine.pool._recycle == 3600
82
+ finally:
83
+ engine.dispose()
84
+
85
+
86
+ def test_recycle_minus_one_is_accepted_as_never():
87
+ settings = Settings(
88
+ database_url=PG_URL, database_pool_recycle=-1
89
+ )
90
+ assert resolve_pool_settings(settings).pool_recycle == -1
91
+
92
+
93
+ @pytest.mark.parametrize(
94
+ "name,value",
95
+ [
96
+ ("AUTH_DATABASE_POOL_SIZE", "0"),
97
+ ("AUTH_DATABASE_POOL_SIZE", "-1"),
98
+ ("AUTH_DATABASE_MAX_OVERFLOW", "-1"),
99
+ ("AUTH_DATABASE_POOL_TIMEOUT", "0"),
100
+ ("AUTH_DATABASE_POOL_TIMEOUT", "-5"),
101
+ ("AUTH_DATABASE_POOL_RECYCLE", "0"),
102
+ ("AUTH_DATABASE_POOL_RECYCLE", "-2"),
103
+ ("AUTH_DATABASE_POOL_SIZE", "two"),
104
+ ],
105
+ )
106
+ def test_invalid_pool_value_is_refused_without_echoing_dsn(monkeypatch, name, value):
107
+ monkeypatch.setenv("AUTH_DATABASE_URL", PG_URL)
108
+ monkeypatch.setenv(name, value)
109
+ with pytest.raises(ValidationError) as caught:
110
+ Settings() # type: ignore[call-arg]
111
+ message = str(caught.value)
112
+ field = name.removeprefix("AUTH_").lower()
113
+ assert field in message
114
+ assert "s3cr3t-pw" not in message
115
+
116
+
117
+ def test_valid_boundary_values_are_accepted(monkeypatch):
118
+ monkeypatch.setenv("AUTH_DATABASE_URL", PG_URL)
119
+ monkeypatch.setenv("AUTH_DATABASE_POOL_SIZE", "1")
120
+ monkeypatch.setenv("AUTH_DATABASE_MAX_OVERFLOW", "0")
121
+ monkeypatch.setenv("AUTH_DATABASE_POOL_TIMEOUT", "0.1")
122
+ monkeypatch.setenv("AUTH_DATABASE_POOL_RECYCLE", "1")
123
+ settings = Settings() # type: ignore[call-arg]
124
+ assert resolve_pool_settings(settings) == PoolSettings(1, 0, 0.1, 1)
125
+
126
+
127
+ def _import_auth_database(tmp_path, **pool_env):
128
+ env = {
129
+ key: value
130
+ for key, value in os.environ.items()
131
+ if not key.startswith("AUTH_") and key != "PGSSLMODE"
132
+ }
133
+ package_root = os.path.dirname(os.path.dirname(os.path.abspath(auth.__file__)))
134
+ env.update(
135
+ PYTHONPATH=package_root,
136
+ AUTH_DATABASE_URL=f"sqlite:///{tmp_path}/start.db",
137
+ AUTH_JWT_SECRET_KEY="pool-settings-test-secret-0001",
138
+ **pool_env,
139
+ )
140
+ return subprocess.run(
141
+ [
142
+ sys.executable,
143
+ "-c",
144
+ "import auth, auth.database; print(auth.__file__)",
145
+ ],
146
+ env=env,
147
+ cwd=tmp_path,
148
+ capture_output=True,
149
+ text=True,
150
+ timeout=60,
151
+ )
152
+
153
+
154
+ def test_process_refuses_to_start_on_invalid_pool_size(tmp_path):
155
+ good = _import_auth_database(tmp_path, AUTH_DATABASE_POOL_SIZE="1")
156
+ assert good.returncode == 0, good.stderr
157
+ assert good.stdout.strip() == os.path.abspath(auth.__file__)
158
+ bad = _import_auth_database(tmp_path, AUTH_DATABASE_POOL_SIZE="0")
159
+ assert bad.returncode != 0
160
+ assert "database_pool_size" in bad.stderr
161
+ assert "ValidationError" in bad.stderr
162
+
163
+
164
+ def test_effective_values_are_logged(monkeypatch, tmp_path, caplog):
165
+ url = f"sqlite:///{tmp_path}/log.db"
166
+ _use_settings(
167
+ monkeypatch,
168
+ AUTH_DATABASE_URL=url,
169
+ AUTH_DATABASE_POOL_SIZE="3",
170
+ AUTH_DATABASE_MAX_OVERFLOW="1",
171
+ AUTH_DATABASE_POOL_TIMEOUT="7",
172
+ AUTH_DATABASE_POOL_RECYCLE="90",
173
+ )
174
+ manager = _bare_engine_manager()
175
+ with caplog.at_level(logging.INFO, logger="auth.database"):
176
+ manager._initialize_engine()
177
+ try:
178
+ logged = " ".join(r.getMessage() for r in caplog.records)
179
+ assert "pool_size=3" in logged
180
+ assert "max_overflow=1" in logged
181
+ assert "max_connections=4" in logged
182
+ assert "pool_timeout=7.0" in logged
183
+ assert "pool_recycle=90" in logged
184
+ finally:
185
+ manager.engine.dispose()
186
+
187
+
188
+ def test_exhausted_pool_times_out_then_resumes_on_release(monkeypatch, tmp_path):
189
+ url = f"sqlite:///{tmp_path}/cap.db"
190
+ _use_settings(
191
+ monkeypatch,
192
+ AUTH_DATABASE_URL=url,
193
+ AUTH_DATABASE_POOL_SIZE="1",
194
+ AUTH_DATABASE_MAX_OVERFLOW="0",
195
+ AUTH_DATABASE_POOL_TIMEOUT="0.5",
196
+ )
197
+ engine = _bare_engine_manager()._create_sqlite_engine(url)
198
+ try:
199
+ held = engine.connect()
200
+ assert held.execute(text("SELECT 1")).scalar() == 1
201
+ started = time.monotonic()
202
+ with pytest.raises(exc.TimeoutError):
203
+ engine.connect()
204
+ waited = time.monotonic() - started
205
+ assert 0.4 <= waited <= 1.5, waited
206
+
207
+ result = {}
208
+
209
+ def waiter():
210
+ with engine.connect() as conn:
211
+ result["value"] = conn.execute(text("SELECT 2")).scalar()
212
+
213
+ thread = threading.Thread(target=waiter)
214
+ thread.start()
215
+ time.sleep(0.1)
216
+ held.close()
217
+ thread.join(timeout=5)
218
+ assert result == {"value": 2}
219
+ finally:
220
+ engine.dispose()
221
+
222
+
223
+ def test_tls_url_parameters_reach_psycopg_unchanged(monkeypatch):
224
+ monkeypatch.delenv("PGSSLMODE", raising=False)
225
+ tls_url = (
226
+ PG_URL
227
+ + "?sslmode=verify-full&sslrootcert=/etc/tls/root.crt"
228
+ + "&sslcert=/etc/tls/client.crt&sslkey=/etc/tls/client.key"
229
+ )
230
+ _use_settings(monkeypatch, AUTH_DATABASE_URL=tls_url)
231
+ captured = {}
232
+
233
+ def fake_connect(*args, **kwargs):
234
+ captured.update(kwargs)
235
+ raise RuntimeError("stop before network")
236
+
237
+ engine = _bare_engine_manager()._create_postgresql_engine(tls_url)
238
+ monkeypatch.setattr(engine.dialect.loaded_dbapi, "connect", fake_connect)
239
+ try:
240
+ with pytest.raises(RuntimeError, match="stop before network"):
241
+ engine.connect()
242
+ finally:
243
+ engine.dispose()
244
+ assert captured["sslmode"] == "verify-full"
245
+ assert captured["sslrootcert"] == "/etc/tls/root.crt"
246
+ assert captured["sslcert"] == "/etc/tls/client.crt"
247
+ assert captured["sslkey"] == "/etc/tls/client.key"
248
+
249
+
250
+ def test_capture_sees_forced_sslmode_when_url_has_none(monkeypatch):
251
+ monkeypatch.delenv("PGSSLMODE", raising=False)
252
+ _use_settings(monkeypatch, AUTH_DATABASE_URL=PG_URL)
253
+ captured = {}
254
+
255
+ def fake_connect(*args, **kwargs):
256
+ captured.update(kwargs)
257
+ raise RuntimeError("stop before network")
258
+
259
+ engine = _bare_engine_manager()._create_postgresql_engine(PG_URL)
260
+ monkeypatch.setattr(engine.dialect.loaded_dbapi, "connect", fake_connect)
261
+ try:
262
+ with pytest.raises(RuntimeError, match="stop before network"):
263
+ engine.connect()
264
+ finally:
265
+ engine.dispose()
266
+ assert captured["sslmode"] == "require"
267
+ assert "sslrootcert" not in captured
268
+
269
+
270
+ def _embedded_env(tmp_path):
271
+ package_root = os.path.dirname(os.path.dirname(os.path.abspath(auth.__file__)))
272
+ env = {
273
+ key: value
274
+ for key, value in os.environ.items()
275
+ if not key.startswith("AUTH_") and key != "PGSSLMODE"
276
+ }
277
+ env.update(
278
+ PYTHONPATH=package_root,
279
+ AUTH_DATABASE_URL=f"sqlite:///{tmp_path}/embedded.db",
280
+ AUTH_JWT_SECRET_KEY="pool-settings-test-secret-0001",
281
+ AUTH_STRICT_USERS_DEFAULT="false",
282
+ AUTH_DATABASE_POOL_SIZE="1",
283
+ AUTH_DATABASE_MAX_OVERFLOW="0",
284
+ AUTH_DATABASE_POOL_TIMEOUT="0.5",
285
+ )
286
+ return env
287
+
288
+
289
+ EMBEDDED_SCRIPT = """
290
+ import logging, sys
291
+ logging.disable(logging.CRITICAL)
292
+ import auth
293
+ from sqlalchemy import exc
294
+ from auth.database import create_tables, engine, SessionLocal
295
+ create_tables(raise_on_error=True)
296
+ tenant = "11111111-1111-4111-8111-111111111111"
297
+ seed = auth.Authorization(tenant)
298
+ seed.add_role("viewer"); seed.add_permission("viewer", "read")
299
+ seed.add_membership("alice", "viewer")
300
+ seed.close()
301
+
302
+ held = auth.Authorization(tenant)
303
+ assert held.user_has_permission("alice", "read") is True
304
+ assert engine.pool.checkedout() == 1
305
+ try:
306
+ auth.Authorization(tenant).user_has_permission("alice", "read")
307
+ print("UNBLOCKED")
308
+ sys.exit(3)
309
+ except exc.TimeoutError:
310
+ pass
311
+ held.close()
312
+ assert engine.pool.checkedout() == 0
313
+
314
+ with auth.Authorization(tenant) as scoped:
315
+ assert scoped.user_has_permission("alice", "read") is True
316
+ assert engine.pool.checkedout() == 1
317
+ assert engine.pool.checkedout() == 0
318
+
319
+ caller_session = SessionLocal()
320
+ borrowed = auth.Authorization(tenant, db_session=caller_session)
321
+ assert borrowed.user_has_permission("alice", "read") is True
322
+ borrowed.close()
323
+ assert engine.pool.checkedout() == 1
324
+ caller_session.close()
325
+ assert engine.pool.checkedout() == 0
326
+ print("OK", auth.__file__)
327
+ """
328
+
329
+
330
+ def test_embedded_read_holds_one_connection_until_close(tmp_path):
331
+ result = subprocess.run(
332
+ [sys.executable, "-c", EMBEDDED_SCRIPT],
333
+ env=_embedded_env(tmp_path),
334
+ cwd=tmp_path,
335
+ capture_output=True,
336
+ text=True,
337
+ timeout=60,
338
+ )
339
+ assert result.returncode == 0, result.stdout + result.stderr
340
+ assert result.stdout.strip() == f"OK {os.path.abspath(auth.__file__)}"
@@ -0,0 +1,134 @@
1
+ import logging
2
+
3
+ import pytest
4
+ from sqlalchemy import create_engine, event, text
5
+
6
+ import auth
7
+ import auth.audit # noqa: F401
8
+ import auth.database
9
+ from auth.models.sql import Base
10
+ from auth.schema_check import SchemaMismatchError, check_schema, find_schema_drift
11
+
12
+ V310_TENANT_SETTINGS = (
13
+ "CREATE TABLE auth_tenant_settings ("
14
+ "id INTEGER PRIMARY KEY, creator VARCHAR(64) NOT NULL, "
15
+ "strict_users BOOLEAN NOT NULL, date_created DATETIME, modified DATETIME)"
16
+ )
17
+ V340_ADDED = [
18
+ "auth_tenant_settings.rotate_key_hash",
19
+ "auth_tenant_settings.rotate_key_issued_at",
20
+ "auth_tenant_settings.canary",
21
+ ]
22
+
23
+ pytestmark = pytest.mark.skipif(
24
+ any(t.schema for t in Base.metadata.tables.values()),
25
+ reason="SQLite drift tests build unqualified tables",
26
+ )
27
+
28
+
29
+ @pytest.fixture
30
+ def current_engine(tmp_path):
31
+ engine = create_engine(f"sqlite:///{tmp_path / 'current.sqlite3'}")
32
+ Base.metadata.create_all(engine)
33
+ yield engine
34
+ engine.dispose()
35
+
36
+
37
+ @pytest.fixture
38
+ def v310_engine(current_engine):
39
+ with current_engine.begin() as conn:
40
+ conn.execute(text("DROP TABLE auth_tenant_settings"))
41
+ conn.execute(text(V310_TENANT_SETTINGS))
42
+ return current_engine
43
+
44
+
45
+ def _statements(engine):
46
+ seen = []
47
+ event.listen(
48
+ engine,
49
+ "before_cursor_execute",
50
+ lambda conn, cursor, statement, *a: seen.append(statement),
51
+ )
52
+ return seen
53
+
54
+
55
+ def test_current_schema_passes(current_engine):
56
+ assert find_schema_drift(current_engine) == ([], [])
57
+ check_schema(current_engine)
58
+
59
+
60
+ def test_v310_tenant_settings_names_the_three_missing_columns(v310_engine):
61
+ assert find_schema_drift(v310_engine) == ([], V340_ADDED)
62
+ with pytest.raises(SchemaMismatchError) as caught:
63
+ check_schema(v310_engine)
64
+ assert caught.value.missing_columns == V340_ADDED
65
+ assert caught.value.missing_tables == []
66
+ for name in V340_ADDED:
67
+ assert name in str(caught.value)
68
+
69
+
70
+ def test_missing_table_is_named(current_engine):
71
+ with current_engine.begin() as conn:
72
+ conn.execute(text("DROP TABLE auth_tenant_settings"))
73
+ with pytest.raises(SchemaMismatchError) as caught:
74
+ check_schema(current_engine)
75
+ assert caught.value.missing_tables == ["auth_tenant_settings"]
76
+ assert caught.value.missing_columns == []
77
+ assert "missing tables: auth_tenant_settings" in str(caught.value)
78
+
79
+
80
+ def test_extra_live_columns_are_allowed(current_engine):
81
+ with current_engine.begin() as conn:
82
+ conn.execute(text("ALTER TABLE auth_tenant_settings ADD COLUMN creator_fp TEXT"))
83
+ check_schema(current_engine)
84
+
85
+
86
+ def test_check_issues_no_ddl_or_writes(v310_engine):
87
+ seen = _statements(v310_engine)
88
+ with v310_engine.begin() as conn:
89
+ conn.execute(text("ALTER TABLE auth_tenant_settings ADD COLUMN probe TEXT"))
90
+ assert any(s.startswith("ALTER TABLE") for s in seen)
91
+ seen.clear()
92
+ with pytest.raises(SchemaMismatchError):
93
+ check_schema(v310_engine)
94
+ assert seen
95
+ writes = ("CREATE", "ALTER", "DROP", "INSERT", "UPDATE", "DELETE")
96
+ assert [s for s in seen if s.lstrip().upper().startswith(writes)] == []
97
+
98
+
99
+ def test_message_does_not_carry_the_database_location(v310_engine):
100
+ with pytest.raises(SchemaMismatchError) as caught:
101
+ check_schema(v310_engine)
102
+ assert str(v310_engine.url.database) not in str(caught.value)
103
+ assert "sqlite" not in str(caught.value)
104
+
105
+
106
+ def test_create_tables_raises_on_drift_when_asked(v310_engine, monkeypatch):
107
+ monkeypatch.setattr(auth.database, "engine", v310_engine)
108
+ with pytest.raises(SchemaMismatchError) as caught:
109
+ auth.database.create_tables(raise_on_error=True)
110
+ assert caught.value.missing_columns == V340_ADDED
111
+
112
+
113
+ def test_create_tables_logs_drift_by_default(v310_engine, monkeypatch, caplog):
114
+ monkeypatch.setattr(auth.database, "engine", v310_engine)
115
+ with caplog.at_level(logging.ERROR, logger=auth.database.logger.name):
116
+ auth.database.create_tables()
117
+ logged = "\n".join(r.getMessage() + (r.exc_text or "") for r in caplog.records)
118
+ assert "create_tables failed" in logged
119
+ for name in V340_ADDED:
120
+ assert name in logged
121
+ assert "Tables created successfully." not in caplog.text
122
+
123
+
124
+ def test_create_tables_on_current_schema_reports_success(current_engine, monkeypatch, caplog):
125
+ monkeypatch.setattr(auth.database, "engine", current_engine)
126
+ with caplog.at_level(logging.INFO, logger=auth.database.logger.name):
127
+ auth.database.create_tables(raise_on_error=True)
128
+ assert "Tables created successfully." in caplog.text
129
+
130
+
131
+ def test_public_exports():
132
+ assert auth.check_schema is check_schema
133
+ assert auth.SchemaMismatchError is SchemaMismatchError
134
+ assert {"check_schema", "SchemaMismatchError"} <= set(auth.__all__)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes