auth 3.2.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.2.0 → auth-3.4.0}/PKG-INFO +2 -3
- {auth-3.2.0 → auth-3.4.0}/auth/__init__.py +11 -0
- {auth-3.2.0 → auth-3.4.0}/auth/config.py +14 -0
- {auth-3.2.0 → auth-3.4.0}/auth/database.py +66 -39
- {auth-3.2.0 → auth-3.4.0}/auth/routes/public.py +32 -2
- {auth-3.2.0 → auth-3.4.0}/auth/schema_reconcile.py +93 -1
- {auth-3.2.0 → auth-3.4.0}/auth/services/rbac.py +3 -3
- {auth-3.2.0 → auth-3.4.0}/auth/services/tenant.py +16 -4
- {auth-3.2.0 → auth-3.4.0}/auth.egg-info/PKG-INFO +2 -3
- {auth-3.2.0 → auth-3.4.0}/auth.egg-info/SOURCES.txt +5 -0
- {auth-3.2.0 → auth-3.4.0}/pyproject.toml +3 -4
- auth-3.4.0/tests/test_client_exceptions_do_not_render_payload.py +52 -0
- auth-3.4.0/tests/test_database_pool_settings.py +340 -0
- auth-3.4.0/tests/test_degraded_response_is_a_contract.py +82 -0
- auth-3.4.0/tests/test_provision_script_no_dsn_leak.py +53 -0
- auth-3.4.0/tests/test_settings_do_not_echo_secrets.py +59 -0
- {auth-3.2.0 → auth-3.4.0}/LICENSE +0 -0
- {auth-3.2.0 → auth-3.4.0}/README.md +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/api_keys.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/audit.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/circuit_breaker.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/client/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/client/_transport.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/client/base.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/client/keys.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/client/rbac.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/client/workflow.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/cmd/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/cmd/server.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/core/REST/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/core/REST/client.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/core/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/core/models/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/decorators.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/docs_page.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/encryption.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/keycheck.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/logging_config.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/main.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/models/sql.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/response_format.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/rls.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/_common.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/apikeys.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/membership.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/permissions.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/roles.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/tenant.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/routes/workflow.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/sanitizer.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/server.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/services/base.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/services/keys.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/services/queries.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/services/recovery.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/services/rotation.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/services/service.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/static_docs/__init__.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/static_docs/api_reference.md +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/static_docs/claude_guide.md +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/static_docs/landing.md +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/static_docs/page.html +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/validation.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth/workflow_checker.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth.egg-info/requires.txt +0 -0
- {auth-3.2.0 → auth-3.4.0}/auth.egg-info/top_level.txt +0 -0
- {auth-3.2.0 → auth-3.4.0}/setup.cfg +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_api_keys.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_api_keys_encryption.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_audit_integrity.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_client.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_client_rest.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_cmd_server.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_config.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_correctness_hardening.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_database_sslmode.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_docs_page.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_encryption.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_encryption_integration.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_flask.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_gunicorn_config_invariants.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_key_rotation.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_log_redaction.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_no_bundled_libpq.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_reencryption.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_routes_errors.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_self_service_audit.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_server.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_service_lifecycle.py +0 -0
- {auth-3.2.0 → auth-3.4.0}/tests/test_strict_default.py +0 -0
- {auth-3.2.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
|
+
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
|
|
@@ -16,13 +16,12 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
24
23
|
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.10
|
|
26
25
|
Description-Content-Type: text/markdown
|
|
27
26
|
License-File: LICENSE
|
|
28
27
|
Requires-Dist: flask<4,>=2.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,17 +7,18 @@ 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,
|
|
21
|
+
_ensure_audit_partition_runway,
|
|
21
22
|
_grandfather_strict_users,
|
|
22
23
|
_reconcile_text_columns,
|
|
23
24
|
)
|
|
@@ -46,6 +47,47 @@ def _forced_sslmode(database_url: str) -> Optional[str]:
|
|
|
46
47
|
logger = logging.getLogger(__name__)
|
|
47
48
|
|
|
48
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
|
+
|
|
49
91
|
class SingletonMeta(type):
|
|
50
92
|
"""
|
|
51
93
|
Thread-safe Singleton metaclass for database engine
|
|
@@ -82,35 +124,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
82
124
|
self._initialize_engine()
|
|
83
125
|
self._setup_pool_monitoring()
|
|
84
126
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
Calculate optimal pool size for Gunicorn workers
|
|
88
|
-
|
|
89
|
-
For 8 workers with PostgreSQL:
|
|
90
|
-
- pool_size=5 per worker = 40 base connections
|
|
91
|
-
- max_overflow=5 per worker = 80 total max connections
|
|
92
|
-
- Leaves headroom for PostgreSQL default max_connections (100)
|
|
93
|
-
- Also accounts for other services using the database
|
|
94
|
-
|
|
95
|
-
Returns:
|
|
96
|
-
tuple: (pool_size, max_overflow)
|
|
97
|
-
"""
|
|
98
|
-
settings = get_settings()
|
|
127
|
+
def _pool_settings(self) -> PoolSettings:
|
|
128
|
+
return resolve_pool_settings(get_settings())
|
|
99
129
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# the separate committed session `audit.py` opens on the decorator's
|
|
104
|
-
# failure path. Two plus two spare, so 8 workers reach at most 32
|
|
105
|
-
# connections against max_connections=200.
|
|
106
|
-
return (2, 2)
|
|
107
|
-
else:
|
|
108
|
-
# SQLite - smaller pool since it's file-based
|
|
109
|
-
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)
|
|
110
133
|
|
|
111
134
|
def _create_postgresql_engine(self, database_url: str) -> Engine:
|
|
112
135
|
"""Create optimized PostgreSQL engine with enterprise pooling"""
|
|
113
|
-
|
|
136
|
+
pool_settings = self._pool_settings()
|
|
114
137
|
|
|
115
138
|
connect_args = {
|
|
116
139
|
"connect_timeout": 30,
|
|
@@ -137,10 +160,10 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
137
160
|
database_url,
|
|
138
161
|
# Connection pool settings
|
|
139
162
|
poolclass=pool.QueuePool, # Explicitly use QueuePool
|
|
140
|
-
pool_size=pool_size,
|
|
141
|
-
max_overflow=max_overflow,
|
|
142
|
-
pool_timeout=
|
|
143
|
-
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,
|
|
144
167
|
pool_pre_ping=True, # Verify connections before using them
|
|
145
168
|
# Performance settings
|
|
146
169
|
echo=False, # Disable SQL logging in production
|
|
@@ -160,16 +183,16 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
160
183
|
connect_args={"check_same_thread": False},
|
|
161
184
|
)
|
|
162
185
|
|
|
163
|
-
|
|
186
|
+
pool_settings = self._pool_settings()
|
|
164
187
|
|
|
165
188
|
return create_engine(
|
|
166
189
|
database_url,
|
|
167
190
|
poolclass=pool.QueuePool,
|
|
168
|
-
pool_size=pool_size,
|
|
169
|
-
max_overflow=max_overflow,
|
|
191
|
+
pool_size=pool_settings.pool_size,
|
|
192
|
+
max_overflow=pool_settings.max_overflow,
|
|
170
193
|
pool_pre_ping=True,
|
|
171
|
-
pool_recycle=
|
|
172
|
-
pool_timeout=
|
|
194
|
+
pool_recycle=pool_settings.pool_recycle,
|
|
195
|
+
pool_timeout=pool_settings.pool_timeout,
|
|
173
196
|
connect_args={
|
|
174
197
|
"check_same_thread": False,
|
|
175
198
|
"timeout": 60,
|
|
@@ -199,10 +222,13 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
199
222
|
expire_on_commit=False, # Prevent unnecessary queries
|
|
200
223
|
)
|
|
201
224
|
|
|
202
|
-
|
|
225
|
+
pool_settings = self._pool_settings()
|
|
203
226
|
logger.info(
|
|
204
|
-
f"Database engine initialized: pool_size={pool_size}, "
|
|
205
|
-
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}"
|
|
206
232
|
)
|
|
207
233
|
|
|
208
234
|
def _setup_pool_monitoring(self):
|
|
@@ -350,6 +376,7 @@ def create_tables(raise_on_error: bool = False):
|
|
|
350
376
|
# is how a fresh database came up with the junction tables unprotected
|
|
351
377
|
# -- grandfathering raised, and the RLS step below it never ran.
|
|
352
378
|
_apply_tenant_rls(engine)
|
|
379
|
+
_ensure_audit_partition_runway(engine)
|
|
353
380
|
_reconcile_text_columns(engine)
|
|
354
381
|
_grandfather_strict_users(engine)
|
|
355
382
|
logger.info("Tables created successfully.")
|
|
@@ -19,6 +19,26 @@ _COMMIT_PROBE_LOCK = threading.Lock()
|
|
|
19
19
|
_last_commit_verdict = ["ready"]
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
|
|
23
|
+
def _audit_partitions():
|
|
24
|
+
"""What the boot-time runway check saw, for /readyz.
|
|
25
|
+
|
|
26
|
+
Reported as a FIELD, never as the verdict: rows stranded in the default
|
|
27
|
+
partition are a retention problem, and pulling a healthy node out of the
|
|
28
|
+
load balancer over one would turn a slow leak into an outage.
|
|
29
|
+
|
|
30
|
+
It exists at all because logging is not a channel everywhere -- vm-2
|
|
31
|
+
captures no application log line, so the CRITICAL this condition also emits
|
|
32
|
+
is unreachable there.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from auth.schema_reconcile import audit_partition_state
|
|
36
|
+
|
|
37
|
+
return dict(audit_partition_state)
|
|
38
|
+
except Exception:
|
|
39
|
+
return {"checked": False, "stranded": None, "newest": None}
|
|
40
|
+
|
|
41
|
+
|
|
22
42
|
def _pool_depth():
|
|
23
43
|
"""Connection-pool saturation. Needs no database round trip.
|
|
24
44
|
|
|
@@ -146,6 +166,7 @@ def register(app):
|
|
|
146
166
|
"status": _last_commit_verdict[0],
|
|
147
167
|
"reason": "commit probe already in flight",
|
|
148
168
|
"pool": pool_depth,
|
|
169
|
+
"audit_partitions": _audit_partitions(),
|
|
149
170
|
}
|
|
150
171
|
return jsonify(body), (200 if _last_commit_verdict[0] == "ready" else 503)
|
|
151
172
|
try:
|
|
@@ -160,7 +181,16 @@ def register(app):
|
|
|
160
181
|
except Exception:
|
|
161
182
|
logger.exception("readiness check failed: commit path unavailable")
|
|
162
183
|
_last_commit_verdict[0] = "unready"
|
|
163
|
-
return jsonify({
|
|
184
|
+
return jsonify({
|
|
185
|
+
"status": "unready",
|
|
186
|
+
"reason": "commit path",
|
|
187
|
+
"pool": pool_depth,
|
|
188
|
+
"audit_partitions": _audit_partitions(),
|
|
189
|
+
}), 503
|
|
164
190
|
finally:
|
|
165
191
|
_COMMIT_PROBE_LOCK.release()
|
|
166
|
-
return jsonify({
|
|
192
|
+
return jsonify({
|
|
193
|
+
"status": "ready",
|
|
194
|
+
"pool": pool_depth,
|
|
195
|
+
"audit_partitions": _audit_partitions(),
|
|
196
|
+
})
|
|
@@ -15,6 +15,7 @@ junction tables holding every tenant's authorization edges under no policy.
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
import logging
|
|
18
|
+
from typing import Any
|
|
18
19
|
|
|
19
20
|
from sqlalchemy.engine import Engine
|
|
20
21
|
|
|
@@ -22,6 +23,11 @@ from auth.config import DatabaseType, get_settings
|
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
26
|
+
#: What the last audit-partition runway check saw, so /readyz can report it.
|
|
27
|
+
#: Logging is not a channel on every deployment -- vm-2 captures no application
|
|
28
|
+
#: log line at all, which makes a CRITICAL there unreachable by anyone.
|
|
29
|
+
audit_partition_state: dict = {"checked": False, "stranded": None, "newest": None}
|
|
30
|
+
|
|
25
31
|
def _apply_tenant_rls(target_engine: Engine) -> None:
|
|
26
32
|
"""Secure any tenant-scoped table that ``create_all`` has just created.
|
|
27
33
|
|
|
@@ -50,7 +56,7 @@ def _apply_tenant_rls(target_engine: Engine) -> None:
|
|
|
50
56
|
schema = settings.database_schema or "public"
|
|
51
57
|
try:
|
|
52
58
|
with target_engine.begin() as conn:
|
|
53
|
-
result = conn.execute(
|
|
59
|
+
result: Any = conn.execute(
|
|
54
60
|
text(f'SELECT {schema}.apply_tenant_rls()') # noqa: S608
|
|
55
61
|
).scalar_one()
|
|
56
62
|
logger.info("apply_tenant_rls: %s", result)
|
|
@@ -62,6 +68,92 @@ def _apply_tenant_rls(target_engine: Engine) -> None:
|
|
|
62
68
|
)
|
|
63
69
|
|
|
64
70
|
|
|
71
|
+
def _ensure_audit_partition_runway(target_engine: Engine, months: int = 6) -> None:
|
|
72
|
+
"""Keep monthly audit_log partitions provisioned ahead of now.
|
|
73
|
+
|
|
74
|
+
Partitions are finite and nothing renewed them. When they run out, rows do
|
|
75
|
+
not fail -- they land in audit_log_default, which
|
|
76
|
+
drop_audit_log_partitions_before deliberately skips, so retention can never
|
|
77
|
+
reclaim them and the growth problem partitioning solved returns with no
|
|
78
|
+
visible symptom (issuedb #15; production runway ended 2027-08).
|
|
79
|
+
|
|
80
|
+
Called here rather than scheduled in-process: create_tables runs pre-fork
|
|
81
|
+
under preload_app, so this executes once per service start, not once per
|
|
82
|
+
worker, and holds no thread across fork. A cron on the database-adjacent
|
|
83
|
+
host is still the durable answer for a deployment that runs longer than its
|
|
84
|
+
runway without restarting; this removes the cliff for one that restarts.
|
|
85
|
+
|
|
86
|
+
Non-raising, like everything beside it. A deployment whose role cannot
|
|
87
|
+
create partitions still starts, and rows in the default partition are
|
|
88
|
+
reported loudly below rather than silently absorbed.
|
|
89
|
+
"""
|
|
90
|
+
from sqlalchemy import text
|
|
91
|
+
|
|
92
|
+
settings = get_settings()
|
|
93
|
+
if settings.database_type != DatabaseType.POSTGRESQL:
|
|
94
|
+
return
|
|
95
|
+
schema = settings.database_schema or "public"
|
|
96
|
+
try:
|
|
97
|
+
with target_engine.begin() as conn:
|
|
98
|
+
if conn.execute(
|
|
99
|
+
text("SELECT to_regproc(:fn) IS NULL"),
|
|
100
|
+
{"fn": f"{schema}.provision_audit_log_partition"},
|
|
101
|
+
).scalar():
|
|
102
|
+
logger.info(
|
|
103
|
+
"audit partition runway: provisioner absent, skipping"
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
created = []
|
|
107
|
+
for ahead in range(months + 1):
|
|
108
|
+
result: Any = conn.execute(
|
|
109
|
+
text(
|
|
110
|
+
f"SELECT {schema}.provision_audit_log_partition(" # noqa: S608
|
|
111
|
+
"(date_trunc('month', now()) + make_interval(months => :n))::date)"
|
|
112
|
+
),
|
|
113
|
+
{"n": ahead},
|
|
114
|
+
).scalar_one()
|
|
115
|
+
if "created" in str(result):
|
|
116
|
+
created.append(str(result))
|
|
117
|
+
stranded: int = conn.execute(
|
|
118
|
+
text(f"SELECT count(*) FROM {schema}.audit_log_default") # noqa: S608
|
|
119
|
+
).scalar_one()
|
|
120
|
+
newest = conn.execute(
|
|
121
|
+
text(
|
|
122
|
+
"SELECT c.relname FROM pg_class c "
|
|
123
|
+
"JOIN pg_inherits i ON i.inhrelid = c.oid "
|
|
124
|
+
"WHERE i.inhparent = to_regclass(:p) "
|
|
125
|
+
"AND c.relname <> 'audit_log_default' "
|
|
126
|
+
"ORDER BY c.relname DESC LIMIT 1"
|
|
127
|
+
),
|
|
128
|
+
{"p": f"{schema}.audit_log"},
|
|
129
|
+
).scalar()
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"audit partition runway could not be ensured (%s); "
|
|
133
|
+
"if partitions run out, rows land in audit_log_default and "
|
|
134
|
+
"retention cannot reclaim them",
|
|
135
|
+
exc.__class__.__name__,
|
|
136
|
+
)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
audit_partition_state.update(
|
|
140
|
+
{"checked": True, "stranded": int(stranded), "newest": newest}
|
|
141
|
+
)
|
|
142
|
+
logger.info(
|
|
143
|
+
"audit partition runway: %d month(s) ahead, %d created this start",
|
|
144
|
+
months,
|
|
145
|
+
len(created),
|
|
146
|
+
)
|
|
147
|
+
if stranded:
|
|
148
|
+
logger.critical(
|
|
149
|
+
"AUDIT PARTITION RUNWAY WAS EXHAUSTED: %d row(s) are in "
|
|
150
|
+
"audit_log_default. Retention skips that partition, so they can "
|
|
151
|
+
"never be reclaimed. They must be moved into a real partition by "
|
|
152
|
+
"hand once one covers their timestamps.",
|
|
153
|
+
stranded,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
65
157
|
def _reconcile_text_columns(target_engine: Engine) -> None:
|
|
66
158
|
"""Widen live ``character varying`` columns to TEXT where the current models
|
|
67
159
|
declare :class:`~sqlalchemy.Text` (issuedb #21).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Roles, memberships and permissions — the mutating RBAC core."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import Optional
|
|
4
|
+
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
from sqlalchemy import func, select
|
|
7
7
|
|
|
@@ -139,7 +139,7 @@ class RbacMixin(QueryMixin):
|
|
|
139
139
|
set_={"is_active": True, "modified": func.now()},
|
|
140
140
|
)
|
|
141
141
|
self.db.execute(stmt)
|
|
142
|
-
membership_id = self.db.execute(
|
|
142
|
+
membership_id: Any = self.db.execute(
|
|
143
143
|
select(m_table.c.id).where(
|
|
144
144
|
m_table.c.creator == self.client,
|
|
145
145
|
m_table.c.user == encrypted_user,
|
|
@@ -258,7 +258,7 @@ class RbacMixin(QueryMixin):
|
|
|
258
258
|
)
|
|
259
259
|
)
|
|
260
260
|
self.db.execute(stmt)
|
|
261
|
-
perm_id = self.db.execute(
|
|
261
|
+
perm_id: Any = self.db.execute(
|
|
262
262
|
select(p_table.c.id).where(
|
|
263
263
|
p_table.c.creator == self.client,
|
|
264
264
|
p_table.c.name == encrypted_name,
|
|
@@ -4,6 +4,8 @@ import json
|
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from typing import Any, Dict, List, Optional, Tuple, cast
|
|
6
6
|
|
|
7
|
+
from sqlalchemy import desc, or_
|
|
8
|
+
|
|
7
9
|
from auth.api_keys import generate_rotate_key
|
|
8
10
|
from auth.audit import AuditLog, client_fingerprint
|
|
9
11
|
from auth.models.sql import (
|
|
@@ -12,6 +14,8 @@ from auth.models.sql import (
|
|
|
12
14
|
)
|
|
13
15
|
from auth.services.base import ServiceBase, _utcnow
|
|
14
16
|
|
|
17
|
+
_api_key_table = AuthApiKey.__table__
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class TenantMixin(ServiceBase):
|
|
17
21
|
"""Per-tenant settings, strict-identity checks and audit reads."""
|
|
@@ -112,7 +116,7 @@ class TenantMixin(ServiceBase):
|
|
|
112
116
|
if action:
|
|
113
117
|
query = query.filter(AuditLog.action == action.upper())
|
|
114
118
|
total = query.count()
|
|
115
|
-
rows = query.order_by(AuditLog.id
|
|
119
|
+
rows: List[Any] = query.order_by(desc(AuditLog.id)).offset(offset).limit(limit).all()
|
|
116
120
|
entries = [
|
|
117
121
|
{
|
|
118
122
|
"id": row.id,
|
|
@@ -131,15 +135,23 @@ class TenantMixin(ServiceBase):
|
|
|
131
135
|
|
|
132
136
|
def user_is_key_backed(self, user: str) -> bool:
|
|
133
137
|
"""True when the user holds ≥1 active, unexpired API key here."""
|
|
134
|
-
row = (
|
|
138
|
+
row: Any = (
|
|
135
139
|
self.db.query(AuthApiKey.id)
|
|
136
140
|
.filter(
|
|
137
141
|
AuthApiKey.creator == self.client,
|
|
138
142
|
AuthApiKey._user == self._get_encrypted_user(user),
|
|
139
|
-
|
|
143
|
+
# Core column rather than the mapped attribute. SQLAlchemy 2.1's
|
|
144
|
+
# stubs resolve a legacy `x = Column(...)` attribute to Never, so
|
|
145
|
+
# the bare `AuthApiKey.is_active` poisons the whole chain and every
|
|
146
|
+
# statement after it reads as unreachable. __table__.c types as a
|
|
147
|
+
# KeyedColumnElement and emits byte-identical SQL, verified.
|
|
148
|
+
_api_key_table.c.is_active.is_(True),
|
|
140
149
|
)
|
|
141
150
|
.filter(
|
|
142
|
-
(
|
|
151
|
+
or_(
|
|
152
|
+
_api_key_table.c.expires_at.is_(None),
|
|
153
|
+
_api_key_table.c.expires_at > _utcnow(),
|
|
154
|
+
)
|
|
143
155
|
)
|
|
144
156
|
.first()
|
|
145
157
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auth
|
|
3
|
-
Version: 3.
|
|
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
|
|
@@ -16,13 +16,12 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
22
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
24
23
|
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
-
Requires-Python: >=3.
|
|
24
|
+
Requires-Python: >=3.10
|
|
26
25
|
Description-Content-Type: text/markdown
|
|
27
26
|
License-File: LICENSE
|
|
28
27
|
Requires-Dist: flask<4,>=2.0.0
|
|
@@ -65,11 +65,14 @@ tests/test_api_keys.py
|
|
|
65
65
|
tests/test_api_keys_encryption.py
|
|
66
66
|
tests/test_audit_integrity.py
|
|
67
67
|
tests/test_client.py
|
|
68
|
+
tests/test_client_exceptions_do_not_render_payload.py
|
|
68
69
|
tests/test_client_rest.py
|
|
69
70
|
tests/test_cmd_server.py
|
|
70
71
|
tests/test_config.py
|
|
71
72
|
tests/test_correctness_hardening.py
|
|
73
|
+
tests/test_database_pool_settings.py
|
|
72
74
|
tests/test_database_sslmode.py
|
|
75
|
+
tests/test_degraded_response_is_a_contract.py
|
|
73
76
|
tests/test_docs_page.py
|
|
74
77
|
tests/test_encryption.py
|
|
75
78
|
tests/test_encryption_integration.py
|
|
@@ -79,10 +82,12 @@ tests/test_key_rotation.py
|
|
|
79
82
|
tests/test_log_redaction.py
|
|
80
83
|
tests/test_no_bundled_libpq.py
|
|
81
84
|
tests/test_phase_a_hardening.py
|
|
85
|
+
tests/test_provision_script_no_dsn_leak.py
|
|
82
86
|
tests/test_reencryption.py
|
|
83
87
|
tests/test_routes_errors.py
|
|
84
88
|
tests/test_self_service_audit.py
|
|
85
89
|
tests/test_server.py
|
|
86
90
|
tests/test_service_lifecycle.py
|
|
91
|
+
tests/test_settings_do_not_echo_secrets.py
|
|
87
92
|
tests/test_strict_default.py
|
|
88
93
|
tests/test_strict_users.py
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "auth"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.4.0"
|
|
8
8
|
description = "Authorization for humans"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [
|
|
13
13
|
{name = "Farshid Ashouri", email = "farsheed.ashouri@gmail.com"}
|
|
@@ -25,7 +25,6 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python",
|
|
26
26
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
27
27
|
"Programming Language :: Python :: 3",
|
|
28
|
-
"Programming Language :: Python :: 3.9",
|
|
29
28
|
"Programming Language :: Python :: 3.10",
|
|
30
29
|
"Programming Language :: Python :: 3.11",
|
|
31
30
|
"Programming Language :: Python :: 3.12",
|
|
@@ -185,7 +184,7 @@ line_length = 88
|
|
|
185
184
|
|
|
186
185
|
[tool.mypy]
|
|
187
186
|
# Lowest version current mypy can target; the service deploys on 3.11 and CI
|
|
188
|
-
# also runs 3.12. (Package metadata
|
|
187
|
+
# also runs 3.12. (Package metadata advertises >=3.10.)
|
|
189
188
|
python_version = "3.10"
|
|
190
189
|
# `python -m build` leaves a copy of the package under build/lib/, which mypy
|
|
191
190
|
# would otherwise report as a duplicate module — so `make build` broke the next
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from auth.client._transport import AuthRefused, AuthTransportError
|
|
4
|
+
|
|
5
|
+
SENTINEL = "Zq7SentinelCredential9xK"
|
|
6
|
+
PAYLOAD = {"message": "denied", "details": {"echoed": SENTINEL}}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_refusal_renders_only_its_message():
|
|
10
|
+
"""auth ships this client; other platforms run it and log its exceptions.
|
|
11
|
+
|
|
12
|
+
A three-argument exception normally renders its whole args tuple through
|
|
13
|
+
Exception.__str__, which would put the upstream response body into every
|
|
14
|
+
``log.error(e)`` a consumer writes. AuthRefused passes only the message to
|
|
15
|
+
super().__init__ and keeps status and payload as attributes, so the safe
|
|
16
|
+
rendering is the default one and the body is opt-in.
|
|
17
|
+
"""
|
|
18
|
+
exc = AuthRefused("auth refused the request: HTTP 401", 401, PAYLOAD)
|
|
19
|
+
|
|
20
|
+
assert SENTINEL not in str(exc)
|
|
21
|
+
assert SENTINEL not in repr(exc)
|
|
22
|
+
assert SENTINEL not in str(exc.args)
|
|
23
|
+
assert str(exc) == "auth refused the request: HTTP 401"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_the_payload_is_still_reachable_deliberately():
|
|
27
|
+
"""The other direction: redaction must not cost the caller the detail.
|
|
28
|
+
|
|
29
|
+
A guard that only proved the body is absent would pass equally if payload
|
|
30
|
+
were dropped entirely, which would break the 409 hints this argument exists
|
|
31
|
+
to carry.
|
|
32
|
+
"""
|
|
33
|
+
exc = AuthRefused("auth refused the request: HTTP 409", 409, PAYLOAD)
|
|
34
|
+
assert exc.payload == PAYLOAD
|
|
35
|
+
assert exc.status == 409
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_the_detector_would_see_a_leak():
|
|
39
|
+
"""Known-positive control.
|
|
40
|
+
|
|
41
|
+
If Exception.__str__ ever stopped rendering extra args, the assertions
|
|
42
|
+
above would hold for a reason that has nothing to do with AuthRefused, and
|
|
43
|
+
the guard would be protecting nothing.
|
|
44
|
+
"""
|
|
45
|
+
leaky = AuthTransportError("upstream said", PAYLOAD)
|
|
46
|
+
assert SENTINEL in str(leaky)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_refusal_is_still_a_transport_error_subclass():
|
|
50
|
+
assert issubclass(AuthRefused, AuthTransportError)
|
|
51
|
+
with pytest.raises(AuthTransportError):
|
|
52
|
+
raise AuthRefused("m", 400, None)
|