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.
- {auth-3.3.0 → auth-3.4.0}/PKG-INFO +1 -1
- {auth-3.3.0 → auth-3.4.0}/auth/__init__.py +11 -0
- {auth-3.3.0 → auth-3.4.0}/auth/config.py +14 -0
- {auth-3.3.0 → auth-3.4.0}/auth/database.py +64 -39
- {auth-3.3.0 → auth-3.4.0}/auth.egg-info/PKG-INFO +1 -1
- {auth-3.3.0 → auth-3.4.0}/auth.egg-info/SOURCES.txt +1 -0
- {auth-3.3.0 → auth-3.4.0}/pyproject.toml +1 -1
- auth-3.4.0/tests/test_database_pool_settings.py +340 -0
- {auth-3.3.0 → auth-3.4.0}/LICENSE +0 -0
- {auth-3.3.0 → auth-3.4.0}/README.md +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/api_keys.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/audit.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/circuit_breaker.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/client/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/client/_transport.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/client/base.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/client/keys.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/client/rbac.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/client/workflow.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/cmd/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/cmd/server.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/core/REST/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/core/REST/client.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/core/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/core/models/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/decorators.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/docs_page.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/encryption.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/keycheck.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/logging_config.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/main.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/models/sql.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/response_format.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/rls.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/_common.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/apikeys.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/membership.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/permissions.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/public.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/roles.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/tenant.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/routes/workflow.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/sanitizer.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/schema_reconcile.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/server.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/base.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/keys.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/queries.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/rbac.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/recovery.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/rotation.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/service.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/services/tenant.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/static_docs/__init__.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/static_docs/api_reference.md +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/static_docs/claude_guide.md +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/static_docs/landing.md +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/static_docs/page.html +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/validation.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth/workflow_checker.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth.egg-info/requires.txt +0 -0
- {auth-3.3.0 → auth-3.4.0}/auth.egg-info/top_level.txt +0 -0
- {auth-3.3.0 → auth-3.4.0}/setup.cfg +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_api_keys.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_api_keys_encryption.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_audit_integrity.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_client.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_client_exceptions_do_not_render_payload.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_client_rest.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_cmd_server.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_config.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_correctness_hardening.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_database_sslmode.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_degraded_response_is_a_contract.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_docs_page.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_encryption.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_encryption_integration.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_flask.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_gunicorn_config_invariants.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_key_rotation.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_log_redaction.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_no_bundled_libpq.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_provision_script_no_dsn_leak.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_reencryption.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_routes_errors.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_self_service_audit.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_server.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_service_lifecycle.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_settings_do_not_echo_secrets.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_strict_default.py +0 -0
- {auth-3.3.0 → auth-3.4.0}/tests/test_strict_users.py +0 -0
|
@@ -56,6 +56,7 @@ class Authorization:
|
|
|
56
56
|
|
|
57
57
|
self.client = client
|
|
58
58
|
# Use provided session or create a new one
|
|
59
|
+
self._owns_session = not db_session
|
|
59
60
|
self.db = db_session if db_session else SessionLocal()
|
|
60
61
|
# Use legacy mode (no client validation) for backward compatibility.
|
|
61
62
|
# strict_users: None reads the tenant's stored setting (identical
|
|
@@ -64,6 +65,16 @@ class Authorization:
|
|
|
64
65
|
self.db, client, validate_client=False, strict_users=strict_users
|
|
65
66
|
)
|
|
66
67
|
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
if self._owns_session:
|
|
70
|
+
self.db.close()
|
|
71
|
+
|
|
72
|
+
def __enter__(self) -> "Authorization":
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
76
|
+
self.close()
|
|
77
|
+
|
|
67
78
|
@property
|
|
68
79
|
def roles(self):
|
|
69
80
|
return self.service.get_roles()
|
|
@@ -5,6 +5,7 @@ Centralized configuration management for the authorization system
|
|
|
5
5
|
import os
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from functools import lru_cache
|
|
8
|
+
from typing import Optional
|
|
8
9
|
|
|
9
10
|
from pydantic import Field, model_validator
|
|
10
11
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
@@ -89,6 +90,19 @@ class Settings(BaseSettings):
|
|
|
89
90
|
# Schema settings (for PostgreSQL multi-tenancy)
|
|
90
91
|
database_schema: str = "" # Optional schema name (e.g., "auth_rbac" for Highway)
|
|
91
92
|
|
|
93
|
+
database_pool_size: Optional[int] = Field(default=None, ge=1)
|
|
94
|
+
database_max_overflow: Optional[int] = Field(default=None, ge=0)
|
|
95
|
+
database_pool_timeout: Optional[float] = Field(default=None, gt=0)
|
|
96
|
+
database_pool_recycle: Optional[int] = Field(default=None, ge=-1)
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="after")
|
|
99
|
+
def _reject_zero_pool_recycle(self):
|
|
100
|
+
if self.database_pool_recycle == 0:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"database_pool_recycle must be -1 (never recycle) or >= 1 second"
|
|
103
|
+
)
|
|
104
|
+
return self
|
|
105
|
+
|
|
92
106
|
@model_validator(mode="after")
|
|
93
107
|
def assemble_db_connection(self) -> "Settings":
|
|
94
108
|
# If database_url is not explicitly set, construct it from other fields
|
|
@@ -7,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
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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=
|
|
144
|
-
pool_recycle=
|
|
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
|
-
|
|
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=
|
|
173
|
-
pool_timeout=
|
|
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
|
-
|
|
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},
|
|
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):
|
|
@@ -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
|
|
@@ -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
|
|
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
|