auth 3.3.0__tar.gz → 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {auth-3.3.0 → auth-3.4.0}/PKG-INFO +1 -1
  2. {auth-3.3.0 → auth-3.4.0}/auth/__init__.py +11 -0
  3. {auth-3.3.0 → auth-3.4.0}/auth/config.py +14 -0
  4. {auth-3.3.0 → auth-3.4.0}/auth/database.py +64 -39
  5. {auth-3.3.0 → auth-3.4.0}/auth.egg-info/PKG-INFO +1 -1
  6. {auth-3.3.0 → auth-3.4.0}/auth.egg-info/SOURCES.txt +1 -0
  7. {auth-3.3.0 → auth-3.4.0}/pyproject.toml +1 -1
  8. auth-3.4.0/tests/test_database_pool_settings.py +340 -0
  9. {auth-3.3.0 → auth-3.4.0}/LICENSE +0 -0
  10. {auth-3.3.0 → auth-3.4.0}/README.md +0 -0
  11. {auth-3.3.0 → auth-3.4.0}/auth/api_keys.py +0 -0
  12. {auth-3.3.0 → auth-3.4.0}/auth/audit.py +0 -0
  13. {auth-3.3.0 → auth-3.4.0}/auth/circuit_breaker.py +0 -0
  14. {auth-3.3.0 → auth-3.4.0}/auth/client/__init__.py +0 -0
  15. {auth-3.3.0 → auth-3.4.0}/auth/client/_transport.py +0 -0
  16. {auth-3.3.0 → auth-3.4.0}/auth/client/base.py +0 -0
  17. {auth-3.3.0 → auth-3.4.0}/auth/client/keys.py +0 -0
  18. {auth-3.3.0 → auth-3.4.0}/auth/client/rbac.py +0 -0
  19. {auth-3.3.0 → auth-3.4.0}/auth/client/workflow.py +0 -0
  20. {auth-3.3.0 → auth-3.4.0}/auth/cmd/__init__.py +0 -0
  21. {auth-3.3.0 → auth-3.4.0}/auth/cmd/server.py +0 -0
  22. {auth-3.3.0 → auth-3.4.0}/auth/core/REST/__init__.py +0 -0
  23. {auth-3.3.0 → auth-3.4.0}/auth/core/REST/client.py +0 -0
  24. {auth-3.3.0 → auth-3.4.0}/auth/core/__init__.py +0 -0
  25. {auth-3.3.0 → auth-3.4.0}/auth/core/models/__init__.py +0 -0
  26. {auth-3.3.0 → auth-3.4.0}/auth/decorators.py +0 -0
  27. {auth-3.3.0 → auth-3.4.0}/auth/docs_page.py +0 -0
  28. {auth-3.3.0 → auth-3.4.0}/auth/encryption.py +0 -0
  29. {auth-3.3.0 → auth-3.4.0}/auth/keycheck.py +0 -0
  30. {auth-3.3.0 → auth-3.4.0}/auth/logging_config.py +0 -0
  31. {auth-3.3.0 → auth-3.4.0}/auth/main.py +0 -0
  32. {auth-3.3.0 → auth-3.4.0}/auth/models/sql.py +0 -0
  33. {auth-3.3.0 → auth-3.4.0}/auth/response_format.py +0 -0
  34. {auth-3.3.0 → auth-3.4.0}/auth/rls.py +0 -0
  35. {auth-3.3.0 → auth-3.4.0}/auth/routes/__init__.py +0 -0
  36. {auth-3.3.0 → auth-3.4.0}/auth/routes/_common.py +0 -0
  37. {auth-3.3.0 → auth-3.4.0}/auth/routes/apikeys.py +0 -0
  38. {auth-3.3.0 → auth-3.4.0}/auth/routes/membership.py +0 -0
  39. {auth-3.3.0 → auth-3.4.0}/auth/routes/permissions.py +0 -0
  40. {auth-3.3.0 → auth-3.4.0}/auth/routes/public.py +0 -0
  41. {auth-3.3.0 → auth-3.4.0}/auth/routes/roles.py +0 -0
  42. {auth-3.3.0 → auth-3.4.0}/auth/routes/tenant.py +0 -0
  43. {auth-3.3.0 → auth-3.4.0}/auth/routes/workflow.py +0 -0
  44. {auth-3.3.0 → auth-3.4.0}/auth/sanitizer.py +0 -0
  45. {auth-3.3.0 → auth-3.4.0}/auth/schema_reconcile.py +0 -0
  46. {auth-3.3.0 → auth-3.4.0}/auth/server.py +0 -0
  47. {auth-3.3.0 → auth-3.4.0}/auth/services/base.py +0 -0
  48. {auth-3.3.0 → auth-3.4.0}/auth/services/keys.py +0 -0
  49. {auth-3.3.0 → auth-3.4.0}/auth/services/queries.py +0 -0
  50. {auth-3.3.0 → auth-3.4.0}/auth/services/rbac.py +0 -0
  51. {auth-3.3.0 → auth-3.4.0}/auth/services/recovery.py +0 -0
  52. {auth-3.3.0 → auth-3.4.0}/auth/services/rotation.py +0 -0
  53. {auth-3.3.0 → auth-3.4.0}/auth/services/service.py +0 -0
  54. {auth-3.3.0 → auth-3.4.0}/auth/services/tenant.py +0 -0
  55. {auth-3.3.0 → auth-3.4.0}/auth/static_docs/__init__.py +0 -0
  56. {auth-3.3.0 → auth-3.4.0}/auth/static_docs/api_reference.md +0 -0
  57. {auth-3.3.0 → auth-3.4.0}/auth/static_docs/claude_guide.md +0 -0
  58. {auth-3.3.0 → auth-3.4.0}/auth/static_docs/landing.md +0 -0
  59. {auth-3.3.0 → auth-3.4.0}/auth/static_docs/page.html +0 -0
  60. {auth-3.3.0 → auth-3.4.0}/auth/validation.py +0 -0
  61. {auth-3.3.0 → auth-3.4.0}/auth/workflow_checker.py +0 -0
  62. {auth-3.3.0 → auth-3.4.0}/auth.egg-info/dependency_links.txt +0 -0
  63. {auth-3.3.0 → auth-3.4.0}/auth.egg-info/entry_points.txt +0 -0
  64. {auth-3.3.0 → auth-3.4.0}/auth.egg-info/requires.txt +0 -0
  65. {auth-3.3.0 → auth-3.4.0}/auth.egg-info/top_level.txt +0 -0
  66. {auth-3.3.0 → auth-3.4.0}/setup.cfg +0 -0
  67. {auth-3.3.0 → auth-3.4.0}/tests/test_api_keys.py +0 -0
  68. {auth-3.3.0 → auth-3.4.0}/tests/test_api_keys_encryption.py +0 -0
  69. {auth-3.3.0 → auth-3.4.0}/tests/test_audit_integrity.py +0 -0
  70. {auth-3.3.0 → auth-3.4.0}/tests/test_client.py +0 -0
  71. {auth-3.3.0 → auth-3.4.0}/tests/test_client_exceptions_do_not_render_payload.py +0 -0
  72. {auth-3.3.0 → auth-3.4.0}/tests/test_client_rest.py +0 -0
  73. {auth-3.3.0 → auth-3.4.0}/tests/test_cmd_server.py +0 -0
  74. {auth-3.3.0 → auth-3.4.0}/tests/test_config.py +0 -0
  75. {auth-3.3.0 → auth-3.4.0}/tests/test_correctness_hardening.py +0 -0
  76. {auth-3.3.0 → auth-3.4.0}/tests/test_database_sslmode.py +0 -0
  77. {auth-3.3.0 → auth-3.4.0}/tests/test_degraded_response_is_a_contract.py +0 -0
  78. {auth-3.3.0 → auth-3.4.0}/tests/test_docs_page.py +0 -0
  79. {auth-3.3.0 → auth-3.4.0}/tests/test_encryption.py +0 -0
  80. {auth-3.3.0 → auth-3.4.0}/tests/test_encryption_integration.py +0 -0
  81. {auth-3.3.0 → auth-3.4.0}/tests/test_flask.py +0 -0
  82. {auth-3.3.0 → auth-3.4.0}/tests/test_gunicorn_config_invariants.py +0 -0
  83. {auth-3.3.0 → auth-3.4.0}/tests/test_key_rotation.py +0 -0
  84. {auth-3.3.0 → auth-3.4.0}/tests/test_log_redaction.py +0 -0
  85. {auth-3.3.0 → auth-3.4.0}/tests/test_no_bundled_libpq.py +0 -0
  86. {auth-3.3.0 → auth-3.4.0}/tests/test_phase_a_hardening.py +0 -0
  87. {auth-3.3.0 → auth-3.4.0}/tests/test_provision_script_no_dsn_leak.py +0 -0
  88. {auth-3.3.0 → auth-3.4.0}/tests/test_reencryption.py +0 -0
  89. {auth-3.3.0 → auth-3.4.0}/tests/test_routes_errors.py +0 -0
  90. {auth-3.3.0 → auth-3.4.0}/tests/test_self_service_audit.py +0 -0
  91. {auth-3.3.0 → auth-3.4.0}/tests/test_server.py +0 -0
  92. {auth-3.3.0 → auth-3.4.0}/tests/test_service_lifecycle.py +0 -0
  93. {auth-3.3.0 → auth-3.4.0}/tests/test_settings_do_not_echo_secrets.py +0 -0
  94. {auth-3.3.0 → auth-3.4.0}/tests/test_strict_default.py +0 -0
  95. {auth-3.3.0 → auth-3.4.0}/tests/test_strict_users.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -56,6 +56,7 @@ class Authorization:
56
56
 
57
57
  self.client = client
58
58
  # Use provided session or create a new one
59
+ self._owns_session = not db_session
59
60
  self.db = db_session if db_session else SessionLocal()
60
61
  # Use legacy mode (no client validation) for backward compatibility.
61
62
  # strict_users: None reads the tenant's stored setting (identical
@@ -64,6 +65,16 @@ class Authorization:
64
65
  self.db, client, validate_client=False, strict_users=strict_users
65
66
  )
66
67
 
68
+ def close(self) -> None:
69
+ if self._owns_session:
70
+ self.db.close()
71
+
72
+ def __enter__(self) -> "Authorization":
73
+ return self
74
+
75
+ def __exit__(self, exc_type, exc, tb) -> None:
76
+ self.close()
77
+
67
78
  @property
68
79
  def roles(self):
69
80
  return self.service.get_roles()
@@ -5,6 +5,7 @@ Centralized configuration management for the authorization system
5
5
  import os
6
6
  from enum import Enum
7
7
  from functools import lru_cache
8
+ from typing import Optional
8
9
 
9
10
  from pydantic import Field, model_validator
10
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -89,6 +90,19 @@ class Settings(BaseSettings):
89
90
  # Schema settings (for PostgreSQL multi-tenancy)
90
91
  database_schema: str = "" # Optional schema name (e.g., "auth_rbac" for Highway)
91
92
 
93
+ database_pool_size: Optional[int] = Field(default=None, ge=1)
94
+ database_max_overflow: Optional[int] = Field(default=None, ge=0)
95
+ database_pool_timeout: Optional[float] = Field(default=None, gt=0)
96
+ database_pool_recycle: Optional[int] = Field(default=None, ge=-1)
97
+
98
+ @model_validator(mode="after")
99
+ def _reject_zero_pool_recycle(self):
100
+ if self.database_pool_recycle == 0:
101
+ raise ValueError(
102
+ "database_pool_recycle must be -1 (never recycle) or >= 1 second"
103
+ )
104
+ return self
105
+
92
106
  @model_validator(mode="after")
93
107
  def assemble_db_connection(self) -> "Settings":
94
108
  # If database_url is not explicitly set, construct it from other fields
@@ -7,14 +7,14 @@ import os
7
7
  import sqlite3
8
8
  import threading
9
9
  from contextlib import contextmanager
10
- from typing import Generator, Optional
10
+ from typing import Generator, NamedTuple, Optional
11
11
 
12
12
  from sqlalchemy import create_engine, event, pool
13
13
  from sqlalchemy.engine import Engine, make_url
14
14
  from sqlalchemy.orm import Session, sessionmaker
15
15
  from sqlalchemy.pool import Pool
16
16
 
17
- from auth.config import DatabaseType, get_settings, warn_on_weak_secrets
17
+ from auth.config import DatabaseType, Settings, get_settings, warn_on_weak_secrets
18
18
  from auth.schema_reconcile import (
19
19
  GRANDFATHER_MARKER, # noqa: F401 - re-exported; tests and embedded callers import it from here
20
20
  _apply_tenant_rls,
@@ -47,6 +47,47 @@ def _forced_sslmode(database_url: str) -> Optional[str]:
47
47
  logger = logging.getLogger(__name__)
48
48
 
49
49
 
50
+ class PoolSettings(NamedTuple):
51
+ pool_size: int
52
+ max_overflow: int
53
+ pool_timeout: float
54
+ pool_recycle: int
55
+
56
+
57
+ _POSTGRESQL_POOL_DEFAULTS = PoolSettings(2, 2, 10.0, 3600)
58
+ _SQLITE_POOL_DEFAULTS = PoolSettings(5, 10, 30.0, 300)
59
+
60
+
61
+ def resolve_pool_settings(settings: Settings) -> PoolSettings:
62
+ defaults = (
63
+ _POSTGRESQL_POOL_DEFAULTS
64
+ if settings.database_type == DatabaseType.POSTGRESQL
65
+ else _SQLITE_POOL_DEFAULTS
66
+ )
67
+ return PoolSettings(
68
+ pool_size=(
69
+ defaults.pool_size
70
+ if settings.database_pool_size is None
71
+ else settings.database_pool_size
72
+ ),
73
+ max_overflow=(
74
+ defaults.max_overflow
75
+ if settings.database_max_overflow is None
76
+ else settings.database_max_overflow
77
+ ),
78
+ pool_timeout=(
79
+ defaults.pool_timeout
80
+ if settings.database_pool_timeout is None
81
+ else settings.database_pool_timeout
82
+ ),
83
+ pool_recycle=(
84
+ defaults.pool_recycle
85
+ if settings.database_pool_recycle is None
86
+ else settings.database_pool_recycle
87
+ ),
88
+ )
89
+
90
+
50
91
  class SingletonMeta(type):
51
92
  """
52
93
  Thread-safe Singleton metaclass for database engine
@@ -83,35 +124,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
83
124
  self._initialize_engine()
84
125
  self._setup_pool_monitoring()
85
126
 
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()
127
+ def _pool_settings(self) -> PoolSettings:
128
+ return resolve_pool_settings(get_settings())
100
129
 
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)
130
+ def _calculate_pool_size(self) -> tuple[int, int]:
131
+ pool_settings = self._pool_settings()
132
+ return (pool_settings.pool_size, pool_settings.max_overflow)
111
133
 
112
134
  def _create_postgresql_engine(self, database_url: str) -> Engine:
113
135
  """Create optimized PostgreSQL engine with enterprise pooling"""
114
- pool_size, max_overflow = self._calculate_pool_size()
136
+ pool_settings = self._pool_settings()
115
137
 
116
138
  connect_args = {
117
139
  "connect_timeout": 30,
@@ -138,10 +160,10 @@ class DatabaseEngine(metaclass=SingletonMeta):
138
160
  database_url,
139
161
  # Connection pool settings
140
162
  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
163
+ pool_size=pool_settings.pool_size,
164
+ max_overflow=pool_settings.max_overflow,
165
+ pool_timeout=pool_settings.pool_timeout,
166
+ pool_recycle=pool_settings.pool_recycle,
145
167
  pool_pre_ping=True, # Verify connections before using them
146
168
  # Performance settings
147
169
  echo=False, # Disable SQL logging in production
@@ -161,16 +183,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
161
183
  connect_args={"check_same_thread": False},
162
184
  )
163
185
 
164
- pool_size, max_overflow = self._calculate_pool_size()
186
+ pool_settings = self._pool_settings()
165
187
 
166
188
  return create_engine(
167
189
  database_url,
168
190
  poolclass=pool.QueuePool,
169
- pool_size=pool_size,
170
- max_overflow=max_overflow,
191
+ pool_size=pool_settings.pool_size,
192
+ max_overflow=pool_settings.max_overflow,
171
193
  pool_pre_ping=True,
172
- pool_recycle=300, # 5 minutes for SQLite
173
- pool_timeout=30,
194
+ pool_recycle=pool_settings.pool_recycle,
195
+ pool_timeout=pool_settings.pool_timeout,
174
196
  connect_args={
175
197
  "check_same_thread": False,
176
198
  "timeout": 60,
@@ -200,10 +222,13 @@ class DatabaseEngine(metaclass=SingletonMeta):
200
222
  expire_on_commit=False, # Prevent unnecessary queries
201
223
  )
202
224
 
203
- pool_size, max_overflow = self._calculate_pool_size()
225
+ pool_settings = self._pool_settings()
204
226
  logger.info(
205
- f"Database engine initialized: pool_size={pool_size}, "
206
- f"max_overflow={max_overflow}, max_connections={pool_size + max_overflow}"
227
+ f"Database engine initialized: pool_size={pool_settings.pool_size}, "
228
+ f"max_overflow={pool_settings.max_overflow}, "
229
+ f"max_connections={pool_settings.pool_size + pool_settings.max_overflow}, "
230
+ f"pool_timeout={pool_settings.pool_timeout}, "
231
+ f"pool_recycle={pool_settings.pool_recycle}"
207
232
  )
208
233
 
209
234
  def _setup_pool_monitoring(self):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth
3
- Version: 3.3.0
3
+ Version: 3.4.0
4
4
  Summary: Authorization for humans
5
5
  Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
6
6
  License-Expression: MIT
@@ -70,6 +70,7 @@ tests/test_client_rest.py
70
70
  tests/test_cmd_server.py
71
71
  tests/test_config.py
72
72
  tests/test_correctness_hardening.py
73
+ tests/test_database_pool_settings.py
73
74
  tests/test_database_sslmode.py
74
75
  tests/test_degraded_response_is_a_contract.py
75
76
  tests/test_docs_page.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.4.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__)}"
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