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.
- {auth-3.3.0 → auth-3.5.0}/PKG-INFO +1 -1
- {auth-3.3.0 → auth-3.5.0}/auth/__init__.py +14 -0
- {auth-3.3.0 → auth-3.5.0}/auth/config.py +14 -0
- {auth-3.3.0 → auth-3.5.0}/auth/database.py +66 -39
- auth-3.5.0/auth/schema_check.py +61 -0
- {auth-3.3.0 → auth-3.5.0}/auth.egg-info/PKG-INFO +1 -1
- {auth-3.3.0 → auth-3.5.0}/auth.egg-info/SOURCES.txt +3 -0
- {auth-3.3.0 → auth-3.5.0}/pyproject.toml +1 -1
- auth-3.5.0/tests/test_database_pool_settings.py +340 -0
- auth-3.5.0/tests/test_schema_check.py +134 -0
- {auth-3.3.0 → auth-3.5.0}/LICENSE +0 -0
- {auth-3.3.0 → auth-3.5.0}/README.md +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/api_keys.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/audit.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/circuit_breaker.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/client/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/client/_transport.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/client/base.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/client/keys.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/client/rbac.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/client/workflow.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/cmd/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/cmd/server.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/core/REST/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/core/REST/client.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/core/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/core/models/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/decorators.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/docs_page.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/encryption.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/keycheck.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/logging_config.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/main.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/models/sql.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/response_format.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/rls.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/_common.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/apikeys.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/membership.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/permissions.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/public.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/roles.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/tenant.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/routes/workflow.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/sanitizer.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/schema_reconcile.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/server.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/base.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/keys.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/queries.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/rbac.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/recovery.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/rotation.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/service.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/services/tenant.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/static_docs/__init__.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/static_docs/api_reference.md +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/static_docs/claude_guide.md +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/static_docs/landing.md +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/static_docs/page.html +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/validation.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth/workflow_checker.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth.egg-info/requires.txt +0 -0
- {auth-3.3.0 → auth-3.5.0}/auth.egg-info/top_level.txt +0 -0
- {auth-3.3.0 → auth-3.5.0}/setup.cfg +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_api_keys.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_api_keys_encryption.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_audit_integrity.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_client.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_client_exceptions_do_not_render_payload.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_client_rest.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_cmd_server.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_config.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_correctness_hardening.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_database_sslmode.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_degraded_response_is_a_contract.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_docs_page.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_encryption.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_encryption_integration.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_flask.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_gunicorn_config_invariants.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_key_rotation.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_log_redaction.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_no_bundled_libpq.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_provision_script_no_dsn_leak.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_reencryption.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_routes_errors.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_self_service_audit.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_server.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_service_lifecycle.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_settings_do_not_echo_secrets.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_strict_default.py +0 -0
- {auth-3.3.0 → auth-3.5.0}/tests/test_strict_users.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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=
|
|
144
|
-
pool_recycle=
|
|
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
|
-
|
|
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=
|
|
173
|
-
pool_timeout=
|
|
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
|
-
|
|
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},
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
|
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
|