auth 3.1.1__tar.gz → 3.2.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.1.1 → auth-3.2.0}/PKG-INFO +4 -2
- {auth-3.1.1 → auth-3.2.0}/auth/__init__.py +2 -0
- {auth-3.1.1 → auth-3.2.0}/auth/api_keys.py +22 -0
- {auth-3.1.1 → auth-3.2.0}/auth/audit.py +21 -0
- {auth-3.1.1 → auth-3.2.0}/auth/client/__init__.py +2 -0
- {auth-3.1.1 → auth-3.2.0}/auth/client/_transport.py +25 -2
- {auth-3.1.1 → auth-3.2.0}/auth/client/base.py +19 -0
- {auth-3.1.1 → auth-3.2.0}/auth/database.py +29 -146
- auth-3.2.0/auth/keycheck.py +233 -0
- {auth-3.1.1 → auth-3.2.0}/auth/main.py +21 -0
- {auth-3.1.1 → auth-3.2.0}/auth/models/sql.py +8 -0
- auth-3.2.0/auth/rls.py +122 -0
- auth-3.2.0/auth/routes/public.py +166 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/tenant.py +114 -1
- auth-3.2.0/auth/schema_reconcile.py +241 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/base.py +7 -12
- auth-3.2.0/auth/services/recovery.py +127 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/rotation.py +9 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/tenant.py +32 -0
- {auth-3.1.1 → auth-3.2.0}/auth/static_docs/api_reference.md +22 -0
- {auth-3.1.1 → auth-3.2.0}/auth/validation.py +24 -4
- {auth-3.1.1 → auth-3.2.0}/auth/workflow_checker.py +12 -1
- {auth-3.1.1 → auth-3.2.0}/auth.egg-info/PKG-INFO +4 -2
- {auth-3.1.1 → auth-3.2.0}/auth.egg-info/SOURCES.txt +6 -0
- {auth-3.1.1 → auth-3.2.0}/auth.egg-info/requires.txt +4 -1
- {auth-3.1.1 → auth-3.2.0}/pyproject.toml +8 -2
- auth-3.2.0/tests/test_gunicorn_config_invariants.py +92 -0
- auth-3.2.0/tests/test_no_bundled_libpq.py +69 -0
- auth-3.1.1/auth/routes/public.py +0 -74
- {auth-3.1.1 → auth-3.2.0}/LICENSE +0 -0
- {auth-3.1.1 → auth-3.2.0}/README.md +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/circuit_breaker.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/client/keys.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/client/rbac.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/client/workflow.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/cmd/__init__.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/cmd/server.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/config.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/core/REST/__init__.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/core/REST/client.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/core/__init__.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/core/models/__init__.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/decorators.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/docs_page.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/encryption.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/logging_config.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/response_format.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/__init__.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/_common.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/apikeys.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/membership.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/permissions.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/roles.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/routes/workflow.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/sanitizer.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/server.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/keys.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/queries.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/rbac.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/services/service.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/static_docs/__init__.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/static_docs/claude_guide.md +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/static_docs/landing.md +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth/static_docs/page.html +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth.egg-info/dependency_links.txt +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth.egg-info/entry_points.txt +0 -0
- {auth-3.1.1 → auth-3.2.0}/auth.egg-info/top_level.txt +0 -0
- {auth-3.1.1 → auth-3.2.0}/setup.cfg +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_api_keys.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_api_keys_encryption.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_audit_integrity.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_client.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_client_rest.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_cmd_server.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_config.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_correctness_hardening.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_database_sslmode.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_docs_page.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_encryption.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_encryption_integration.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_flask.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_key_rotation.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_log_redaction.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_phase_a_hardening.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_reencryption.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_routes_errors.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_self_service_audit.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_server.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_service_lifecycle.py +0 -0
- {auth-3.1.1 → auth-3.2.0}/tests/test_strict_default.py +0 -0
- {auth-3.1.1 → auth-3.2.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.2.0
|
|
4
4
|
Summary: Authorization for humans
|
|
5
5
|
Author-email: Farshid Ashouri <farsheed.ashouri@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -31,12 +31,14 @@ Requires-Dist: sqlalchemy<3,>=2.0.0
|
|
|
31
31
|
Requires-Dist: waitress<4,>=2.0.0
|
|
32
32
|
Requires-Dist: cryptography>=3.0.0
|
|
33
33
|
Requires-Dist: APScheduler<4,>=3.0.0
|
|
34
|
-
Requires-Dist: psycopg
|
|
34
|
+
Requires-Dist: psycopg<4,>=3.0.0
|
|
35
35
|
Requires-Dist: pydantic<3,>=2.0.0
|
|
36
36
|
Requires-Dist: pydantic-settings<3,>=2.0.0
|
|
37
37
|
Requires-Dist: requests<3,>=2.25.0
|
|
38
38
|
Requires-Dist: bleach<7,>=5.0.0
|
|
39
39
|
Requires-Dist: python-json-logger<4,>=3.1.0
|
|
40
|
+
Provides-Extra: c
|
|
41
|
+
Requires-Dist: psycopg[c]<4,>=3.0.0; extra == "c"
|
|
40
42
|
Provides-Extra: ratelimit
|
|
41
43
|
Requires-Dist: flask-limiter>=3.0.0; extra == "ratelimit"
|
|
42
44
|
Provides-Extra: migrations
|
|
@@ -4,6 +4,7 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from auth.client import ( # Import the new client
|
|
7
|
+
AuthRefused,
|
|
7
8
|
AuthTransportError,
|
|
8
9
|
Client,
|
|
9
10
|
EnhancedAuthClient,
|
|
@@ -140,6 +141,7 @@ class Authorization:
|
|
|
140
141
|
# Export the new client for users who want enhanced features
|
|
141
142
|
__all__ = [
|
|
142
143
|
"Authorization",
|
|
144
|
+
"AuthRefused",
|
|
143
145
|
"AuthTransportError",
|
|
144
146
|
"Client",
|
|
145
147
|
"EnhancedAuthClient",
|
|
@@ -15,7 +15,11 @@ import uuid
|
|
|
15
15
|
from typing import Tuple
|
|
16
16
|
|
|
17
17
|
API_KEY_PREFIX = "rak_"
|
|
18
|
+
#: Recovery ("rotate") keys. A different prefix so a credential is never
|
|
19
|
+
#: mistaken for the other kind, and so a leaked one is identifiable on sight.
|
|
20
|
+
ROTATE_KEY_PREFIX = "rrk_"
|
|
18
21
|
API_KEY_PATTERN = re.compile(r"^rak_[0-9A-Za-z]{43}$")
|
|
22
|
+
ROTATE_KEY_PATTERN = re.compile(r"^rrk_[0-9A-Za-z]{43}$")
|
|
19
23
|
|
|
20
24
|
# "rak_" + first 8 payload chars — safe to store and display in listings.
|
|
21
25
|
KEY_PREFIX_LEN = 12
|
|
@@ -48,3 +52,21 @@ def generate_api_key() -> Tuple[str, str, str, str]:
|
|
|
48
52
|
def hash_api_key(secret: str) -> str:
|
|
49
53
|
"""SHA-256 hex digest of the full secret string (equality-queryable)."""
|
|
50
54
|
return hashlib.sha256(secret.encode()).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate_rotate_key() -> Tuple[str, str]:
|
|
58
|
+
"""Mint a recovery key: returns ``(secret, secret_hash)``.
|
|
59
|
+
|
|
60
|
+
Same construction and entropy as a user API key -- 256 bits from
|
|
61
|
+
``secrets.token_bytes(32)`` -- because it guards more, not less: whoever
|
|
62
|
+
holds it can take over the namespace without the client key. Only the hash
|
|
63
|
+
is stored, and the secret is disclosed exactly once.
|
|
64
|
+
"""
|
|
65
|
+
payload = _base62(secrets.token_bytes(32)).rjust(_PAYLOAD_LEN, _ALPHABET[0])
|
|
66
|
+
secret = ROTATE_KEY_PREFIX + payload
|
|
67
|
+
return secret, hash_api_key(secret)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_rotate_key_format(secret: str) -> bool:
|
|
71
|
+
"""Shape check before any database work."""
|
|
72
|
+
return bool(ROTATE_KEY_PATTERN.match(secret))
|
|
@@ -46,6 +46,8 @@ class AuditAction(Enum):
|
|
|
46
46
|
GET_SETTINGS = "GET_SETTINGS"
|
|
47
47
|
UPDATE_SETTINGS = "UPDATE_SETTINGS"
|
|
48
48
|
GET_AUDIT = "GET_AUDIT"
|
|
49
|
+
GET_ROTATE_KEY = "GET_ROTATE_KEY"
|
|
50
|
+
RECOVER_KEY = "RECOVER_KEY"
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
class AuditLog(Base):
|
|
@@ -176,6 +178,23 @@ def _emit_structured_log(
|
|
|
176
178
|
audit_logger.info(json.dumps(log_msg))
|
|
177
179
|
|
|
178
180
|
|
|
181
|
+
def _bind_audit(session, client_id: str) -> None:
|
|
182
|
+
"""Bind the audit tenant so RLS admits the row (no-op off PostgreSQL).
|
|
183
|
+
|
|
184
|
+
audit_log's policy compares ``client_id`` against a session variable, and
|
|
185
|
+
the value is bound VERBATIM: every caller already passes a fingerprint (see
|
|
186
|
+
``client_ref`` in auth/decorators.py), and ``_build_audit_entry`` stores it
|
|
187
|
+
unchanged. Fingerprinting it again here would bind a fingerprint OF a
|
|
188
|
+
fingerprint, which matches no row and rejects every audit write.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
from auth.rls import AUDIT_GUC, bind_session
|
|
192
|
+
|
|
193
|
+
bind_session(session, **{AUDIT_GUC: client_id})
|
|
194
|
+
except Exception: # pragma: no cover - binding must never break an audit write
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
179
198
|
def record_audit(
|
|
180
199
|
session,
|
|
181
200
|
*,
|
|
@@ -196,6 +215,7 @@ def record_audit(
|
|
|
196
215
|
surrounding request (fail-closed), never leave a committed mutation
|
|
197
216
|
unaudited.
|
|
198
217
|
"""
|
|
218
|
+
_bind_audit(session, client_id)
|
|
199
219
|
session.add(
|
|
200
220
|
_build_audit_entry(
|
|
201
221
|
client_id, user, action, resource, details, ip_address, user_agent, success
|
|
@@ -224,6 +244,7 @@ def log_audit_event(
|
|
|
224
244
|
"""
|
|
225
245
|
session = SessionLocal()
|
|
226
246
|
try:
|
|
247
|
+
_bind_audit(session, client_id)
|
|
227
248
|
session.add(
|
|
228
249
|
_build_audit_entry(
|
|
229
250
|
client_id, user, action, resource, details, ip_address, user_agent, success
|
|
@@ -13,6 +13,7 @@ their ``__module__``.
|
|
|
13
13
|
from auth.circuit_breaker import circuit_breaker
|
|
14
14
|
from auth.client._transport import (
|
|
15
15
|
_RETRY_METHODS,
|
|
16
|
+
AuthRefused,
|
|
16
17
|
AuthTransportError,
|
|
17
18
|
RetryableHTTPAdapter,
|
|
18
19
|
_build_retry,
|
|
@@ -44,6 +45,7 @@ class Client(EnhancedAuthClient):
|
|
|
44
45
|
|
|
45
46
|
__all__ = [
|
|
46
47
|
"ApiKeyMixin",
|
|
48
|
+
"AuthRefused",
|
|
47
49
|
"AuthTransportError",
|
|
48
50
|
"Client",
|
|
49
51
|
"ClientBase",
|
|
@@ -11,14 +11,37 @@ _RETRY_METHODS = ["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
|
|
|
11
11
|
class AuthTransportError(Exception):
|
|
12
12
|
"""The client could not get an answer from the auth service.
|
|
13
13
|
|
|
14
|
-
Raised by every client method
|
|
15
|
-
exhausted retries, open circuit breaker,
|
|
14
|
+
Raised by every client method when no answer could be obtained:
|
|
15
|
+
connection failure, exhausted retries, open circuit breaker, or a 5xx.
|
|
16
|
+
A 4xx raises :class:`AuthRefused`, a subclass — the service DID answer,
|
|
17
|
+
it refused the request.
|
|
16
18
|
Distinguishes "we could not ask" from a genuine negative answer such as
|
|
17
19
|
``has_permission: false`` — map it to your unavailable/503 path, never
|
|
18
20
|
to a denial.
|
|
19
21
|
"""
|
|
20
22
|
|
|
21
23
|
|
|
24
|
+
class AuthRefused(AuthTransportError):
|
|
25
|
+
"""The auth service answered and refused the request (a 4xx).
|
|
26
|
+
|
|
27
|
+
Subclasses :class:`AuthTransportError` so existing handlers keep working,
|
|
28
|
+
but lets a caller tell "auth would not accept this" from "auth could not be
|
|
29
|
+
reached". Reported by a consumer whose platform-admin check logged "auth
|
|
30
|
+
unreachable at login" while auth was up, answering, and simply rejecting a
|
|
31
|
+
subject containing ``|``: that sends whoever reads the log to the health
|
|
32
|
+
endpoint and the network when the cause is the shape of a string.
|
|
33
|
+
|
|
34
|
+
``status`` is the HTTP status; ``payload`` is the decoded body when there
|
|
35
|
+
was one, so actionable errors (such as the 409 ``user_not_key_backed`` hint)
|
|
36
|
+
survive instead of being flattened into a transport message.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, status: int, payload=None):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status = status
|
|
42
|
+
self.payload = payload
|
|
43
|
+
|
|
44
|
+
|
|
22
45
|
def _build_retry(total, backoff_factor, status_forcelist) -> Retry:
|
|
23
46
|
"""Build a Retry that works on both urllib3 1.x and 2.x.
|
|
24
47
|
|
|
@@ -11,6 +11,7 @@ from requests.exceptions import ConnectionError
|
|
|
11
11
|
|
|
12
12
|
from auth.circuit_breaker import circuit_breaker
|
|
13
13
|
from auth.client._transport import (
|
|
14
|
+
AuthRefused,
|
|
14
15
|
AuthTransportError,
|
|
15
16
|
RetryableHTTPAdapter,
|
|
16
17
|
_build_retry,
|
|
@@ -168,6 +169,20 @@ class ClientBase:
|
|
|
168
169
|
return text_response
|
|
169
170
|
|
|
170
171
|
except requests.exceptions.RequestException as e:
|
|
172
|
+
# A 4xx means the service ANSWERED and refused. Collapsing that
|
|
173
|
+
# into a transport failure sends the reader to the health
|
|
174
|
+
# endpoint and the network when the cause is the request itself.
|
|
175
|
+
resp = getattr(e, "response", None)
|
|
176
|
+
if resp is not None and 400 <= resp.status_code < 500:
|
|
177
|
+
try:
|
|
178
|
+
payload = resp.json()
|
|
179
|
+
except Exception:
|
|
180
|
+
payload = None
|
|
181
|
+
raise AuthRefused(
|
|
182
|
+
f"auth refused the request: HTTP {resp.status_code}",
|
|
183
|
+
resp.status_code,
|
|
184
|
+
payload,
|
|
185
|
+
) from e
|
|
171
186
|
# Convert to our expected exception type
|
|
172
187
|
raise ConnectionError(f"Request failed: {str(e)}") from e
|
|
173
188
|
|
|
@@ -176,6 +191,8 @@ class ClientBase:
|
|
|
176
191
|
try:
|
|
177
192
|
cb_result: Dict[str, Any] = circuit_breaker("api_call")(request_func)()
|
|
178
193
|
return cb_result
|
|
194
|
+
except AuthRefused:
|
|
195
|
+
raise # a refusal is an answer; it is not a breaker event
|
|
179
196
|
except Exception as e:
|
|
180
197
|
raise ConnectionError(
|
|
181
198
|
f"Circuit breaker prevented request: {str(e)}"
|
|
@@ -195,6 +212,8 @@ class ClientBase:
|
|
|
195
212
|
compatibility and intentionally unused: inputs like key material must
|
|
196
213
|
never ride on an exception.
|
|
197
214
|
"""
|
|
215
|
+
if isinstance(exc, AuthRefused):
|
|
216
|
+
raise exc
|
|
198
217
|
raise AuthTransportError(str(exc)) from exc
|
|
199
218
|
|
|
200
219
|
def ping(self) -> Dict[str, Any]:
|
|
@@ -15,6 +15,12 @@ from sqlalchemy.orm import Session, sessionmaker
|
|
|
15
15
|
from sqlalchemy.pool import Pool
|
|
16
16
|
|
|
17
17
|
from auth.config import DatabaseType, get_settings, warn_on_weak_secrets
|
|
18
|
+
from auth.schema_reconcile import (
|
|
19
|
+
GRANDFATHER_MARKER, # noqa: F401 - re-exported; tests and embedded callers import it from here
|
|
20
|
+
_apply_tenant_rls,
|
|
21
|
+
_grandfather_strict_users,
|
|
22
|
+
_reconcile_text_columns,
|
|
23
|
+
)
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
def _forced_sslmode(database_url: str) -> Optional[str]:
|
|
@@ -92,8 +98,12 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
92
98
|
settings = get_settings()
|
|
93
99
|
|
|
94
100
|
if settings.database_type == DatabaseType.POSTGRESQL:
|
|
95
|
-
#
|
|
96
|
-
|
|
101
|
+
# Sized for ONE request per worker, because the workers are sync.
|
|
102
|
+
# A request can hold two connections at once: its own session, and
|
|
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)
|
|
97
107
|
else:
|
|
98
108
|
# SQLite - smaller pool since it's file-based
|
|
99
109
|
return (5, 10)
|
|
@@ -106,7 +116,17 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
106
116
|
"connect_timeout": 30,
|
|
107
117
|
"application_name": "auth_server",
|
|
108
118
|
# Set statement timeout to prevent long-running queries
|
|
109
|
-
|
|
119
|
+
# 10s: the slowest legitimate operation is a key rotation, measured
|
|
120
|
+
# at ~0.12 ms/row (~1s for the largest namespace on this deployment),
|
|
121
|
+
# so this leaves 10x headroom.
|
|
122
|
+
#
|
|
123
|
+
# NOTE: statement_timeout does NOT bound COMMIT. A commit stall is
|
|
124
|
+
# therefore unaffected by this value, which is why worker
|
|
125
|
+
# concurrency -- not this timeout -- is the mitigation for a stalled
|
|
126
|
+
# database. tcp_user_timeout would bound it at the socket, but
|
|
127
|
+
# dropping a connection mid-COMMIT leaves the outcome ambiguous, and
|
|
128
|
+
# an ambiguous permission grant is a worse failure than a slow one.
|
|
129
|
+
"options": "-c statement_timeout=10000", # 10 seconds
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
forced = _forced_sslmode(database_url)
|
|
@@ -119,7 +139,7 @@ class DatabaseEngine(metaclass=SingletonMeta):
|
|
|
119
139
|
poolclass=pool.QueuePool, # Explicitly use QueuePool
|
|
120
140
|
pool_size=pool_size,
|
|
121
141
|
max_overflow=max_overflow,
|
|
122
|
-
pool_timeout=
|
|
142
|
+
pool_timeout=10, # Fail fast rather than queueing behind a stalled DB
|
|
123
143
|
pool_recycle=3600, # Recycle connections after 1 hour
|
|
124
144
|
pool_pre_ping=True, # Verify connections before using them
|
|
125
145
|
# Performance settings
|
|
@@ -325,6 +345,11 @@ def create_tables(raise_on_error: bool = False):
|
|
|
325
345
|
text(f'CREATE SCHEMA IF NOT EXISTS "{settings.database_schema}"')
|
|
326
346
|
)
|
|
327
347
|
Base.metadata.create_all(bind=engine, checkfirst=True)
|
|
348
|
+
# Row level security first, and each step separately: these are
|
|
349
|
+
# independent reconciliations, and letting one exception abort the rest
|
|
350
|
+
# is how a fresh database came up with the junction tables unprotected
|
|
351
|
+
# -- grandfathering raised, and the RLS step below it never ran.
|
|
352
|
+
_apply_tenant_rls(engine)
|
|
328
353
|
_reconcile_text_columns(engine)
|
|
329
354
|
_grandfather_strict_users(engine)
|
|
330
355
|
logger.info("Tables created successfully.")
|
|
@@ -334,148 +359,6 @@ def create_tables(raise_on_error: bool = False):
|
|
|
334
359
|
raise
|
|
335
360
|
|
|
336
361
|
|
|
337
|
-
def _reconcile_text_columns(target_engine: Engine) -> None:
|
|
338
|
-
"""Widen live ``character varying`` columns to TEXT where the current models
|
|
339
|
-
declare :class:`~sqlalchemy.Text` (issuedb #21).
|
|
340
|
-
|
|
341
|
-
``create_all(checkfirst=True)`` creates missing tables but never ALTERs an
|
|
342
|
-
existing one, so an embedded database created by a pre-2.x version keeps the
|
|
343
|
-
narrow ``varchar`` widths those versions declared. Encryption made several of
|
|
344
|
-
those columns hold ciphertext far longer than the plaintext they used to, so
|
|
345
|
-
the mismatch surfaces as ``StringDataRightTruncation`` on write — highway hit
|
|
346
|
-
exactly this on ``auth_membership.user`` (varchar(64)) when a longer email
|
|
347
|
-
was encrypted, inside ``add_membership`` (agent-mail thr-d99bb6c79b894ff69f16).
|
|
348
|
-
|
|
349
|
-
Only columns the models declare as Text are touched. A bounded ``String`` is
|
|
350
|
-
a deliberate width — ``audit_log.user`` is a 64-char fingerprint, not a user
|
|
351
|
-
identifier — and is left alone. PostgreSQL only: SQLite does not enforce
|
|
352
|
-
varchar length, so there is nothing to reconcile there.
|
|
353
|
-
|
|
354
|
-
Non-raising, like everything else in ``create_tables``: a runtime role
|
|
355
|
-
without DDL rights must still be able to start the app.
|
|
356
|
-
"""
|
|
357
|
-
from sqlalchemy import Text, inspect, text
|
|
358
|
-
|
|
359
|
-
import auth.audit # noqa: F401 (registers AuditLog in Base.metadata)
|
|
360
|
-
from auth.models.sql import Base
|
|
361
|
-
|
|
362
|
-
if target_engine.dialect.name != "postgresql":
|
|
363
|
-
return
|
|
364
|
-
|
|
365
|
-
settings = get_settings()
|
|
366
|
-
schema = settings.database_schema or None
|
|
367
|
-
inspector = inspect(target_engine)
|
|
368
|
-
try:
|
|
369
|
-
existing_tables = set(inspector.get_table_names(schema=schema))
|
|
370
|
-
except Exception:
|
|
371
|
-
logger.exception("text-column reconciliation could not list tables")
|
|
372
|
-
return
|
|
373
|
-
|
|
374
|
-
widened: list[str] = []
|
|
375
|
-
for table in Base.metadata.sorted_tables:
|
|
376
|
-
if table.name not in existing_tables:
|
|
377
|
-
continue
|
|
378
|
-
wanted = {c.name for c in table.columns if isinstance(c.type, Text)}
|
|
379
|
-
if not wanted:
|
|
380
|
-
continue
|
|
381
|
-
try:
|
|
382
|
-
live = inspector.get_columns(table.name, schema=schema)
|
|
383
|
-
except Exception:
|
|
384
|
-
logger.exception(
|
|
385
|
-
"text-column reconciliation could not inspect %s", table.name
|
|
386
|
-
)
|
|
387
|
-
continue
|
|
388
|
-
for col in live:
|
|
389
|
-
if col["name"] not in wanted:
|
|
390
|
-
continue
|
|
391
|
-
# VARCHAR carries a length; TEXT does not. Anything already
|
|
392
|
-
# unbounded needs no ALTER, which keeps this pass a no-op on a
|
|
393
|
-
# database that already matches the models.
|
|
394
|
-
if getattr(col["type"], "length", None) is None:
|
|
395
|
-
continue
|
|
396
|
-
qualified = f'"{schema}".' if schema else ""
|
|
397
|
-
stmt = (
|
|
398
|
-
f'ALTER TABLE {qualified}"{table.name}" '
|
|
399
|
-
f'ALTER COLUMN "{col["name"]}" TYPE TEXT'
|
|
400
|
-
)
|
|
401
|
-
try:
|
|
402
|
-
with target_engine.begin() as conn:
|
|
403
|
-
conn.execute(text(stmt))
|
|
404
|
-
widened.append(f"{table.name}.{col['name']}")
|
|
405
|
-
except Exception:
|
|
406
|
-
logger.exception(
|
|
407
|
-
"could not widen %s.%s to TEXT; a pre-2.x column width "
|
|
408
|
-
"remains and long encrypted values may fail to write",
|
|
409
|
-
table.name,
|
|
410
|
-
col["name"],
|
|
411
|
-
)
|
|
412
|
-
|
|
413
|
-
if widened:
|
|
414
|
-
logger.warning(
|
|
415
|
-
"widened %d pre-2.x varchar column(s) to TEXT to match the current "
|
|
416
|
-
"models: %s",
|
|
417
|
-
len(widened),
|
|
418
|
-
", ".join(widened),
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
# Marker creator recording that the one-shot 3.0.0 grandfathering pass ran on
|
|
423
|
-
# this database. Reserved — never use it as a real tenant identifier.
|
|
424
|
-
GRANDFATHER_MARKER = "__meta:grandfathered-3.0__"
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def _grandfather_strict_users(target_engine: Engine) -> None:
|
|
428
|
-
"""One-shot 3.0.0 flip protection (SPEC 0012): write explicit
|
|
429
|
-
``strict_users = false`` rows for every creator that exists on this
|
|
430
|
-
database, then record a marker so the pass never runs again.
|
|
431
|
-
|
|
432
|
-
3.0.0 makes no-settings-row tenants strict by default; this pass is what
|
|
433
|
-
guarantees that flip reaches ONLY tenants created after it ran — every
|
|
434
|
-
pre-existing tenant keeps its behavior as an explicit, auditable opt-out
|
|
435
|
-
it can change later. Runs inside create_tables so embedded consumers get
|
|
436
|
-
the same protection our deployment gets from the migretti migration
|
|
437
|
-
(both are marker-guarded, so they compose idempotently).
|
|
438
|
-
"""
|
|
439
|
-
from typing import cast
|
|
440
|
-
|
|
441
|
-
from sqlalchemy import Table, literal, select, union
|
|
442
|
-
|
|
443
|
-
from auth.models.sql import (
|
|
444
|
-
AuthApiKey,
|
|
445
|
-
AuthGroup,
|
|
446
|
-
AuthMembership,
|
|
447
|
-
AuthPermission,
|
|
448
|
-
AuthTenantSettings,
|
|
449
|
-
)
|
|
450
|
-
|
|
451
|
-
settings_t = cast(Table, AuthTenantSettings.__table__)
|
|
452
|
-
with target_engine.begin() as conn:
|
|
453
|
-
marker_exists = conn.execute(
|
|
454
|
-
select(settings_t.c.id).where(settings_t.c.creator == GRANDFATHER_MARKER)
|
|
455
|
-
).first()
|
|
456
|
-
if marker_exists:
|
|
457
|
-
return
|
|
458
|
-
creators = union(
|
|
459
|
-
*(
|
|
460
|
-
select(t.__table__.c.creator)
|
|
461
|
-
for t in (AuthGroup, AuthMembership, AuthPermission, AuthApiKey)
|
|
462
|
-
)
|
|
463
|
-
).subquery()
|
|
464
|
-
already = select(settings_t.c.creator)
|
|
465
|
-
conn.execute(
|
|
466
|
-
settings_t.insert().from_select(
|
|
467
|
-
["creator", "strict_users"],
|
|
468
|
-
select(creators.c.creator, literal(False)).where(
|
|
469
|
-
creators.c.creator.notin_(already)
|
|
470
|
-
),
|
|
471
|
-
)
|
|
472
|
-
)
|
|
473
|
-
conn.execute(
|
|
474
|
-
settings_t.insert().values(creator=GRANDFATHER_MARKER, strict_users=False)
|
|
475
|
-
)
|
|
476
|
-
logger.info("strict_users grandfathering pass completed (one-shot).")
|
|
477
|
-
|
|
478
|
-
|
|
479
362
|
def log_pool_stats():
|
|
480
363
|
"""Log current connection pool statistics"""
|
|
481
364
|
stats = get_pool_status()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Boot-time verification that the configured encryption key matches stored data.
|
|
2
|
+
|
|
3
|
+
Field encryption is deterministic and used for equality lookups, so
|
|
4
|
+
encrypt-to-store and encrypt-to-query share one key derivation. If the key
|
|
5
|
+
changes, both sides move together into a namespace where nothing matches: every
|
|
6
|
+
membership lookup misses, every authorization answer becomes "denied", and each
|
|
7
|
+
one is returned with HTTP 200. Nothing in the request path can notice, because
|
|
8
|
+
the query path never decrypts — it compares ciphertext.
|
|
9
|
+
|
|
10
|
+
This module samples rows that are actually encrypted and decrypts them. A wrong
|
|
11
|
+
key fails authentication in ``DeterministicEncryption._open`` (the IV is an HMAC
|
|
12
|
+
of the plaintext, re-derived and constant-time compared), so the failure is
|
|
13
|
+
loud here even though it is silent everywhere else.
|
|
14
|
+
|
|
15
|
+
The states are distinguished deliberately:
|
|
16
|
+
|
|
17
|
+
``ok``
|
|
18
|
+
Encrypted rows exist and decrypt, or nothing is encrypted yet.
|
|
19
|
+
``mismatch``
|
|
20
|
+
Encrypted rows exist and this key cannot read them. Serving would answer
|
|
21
|
+
every authorization question wrongly, so ``/api`` is refused instead.
|
|
22
|
+
``unverified``
|
|
23
|
+
The check could not run (database unavailable at boot). Not an assertion
|
|
24
|
+
that the key is good — the service starts, because refusing to boot on a
|
|
25
|
+
transient database problem turns a blip into a restart loop.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
import auth.encryption as _encryption
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
OK = "ok"
|
|
36
|
+
MISMATCH = "mismatch"
|
|
37
|
+
UNVERIFIED = "unverified"
|
|
38
|
+
|
|
39
|
+
_V2_PREFIX = "v2:"
|
|
40
|
+
|
|
41
|
+
#: Reserved tenant for the canary row. The client-key validator requires a real
|
|
42
|
+
#: UUID4, so the nil UUID is refused at the door and can never be a caller's
|
|
43
|
+
#: namespace -- which is what makes it safe to reserve.
|
|
44
|
+
SENTINEL_TENANT = "00000000-0000-0000-0000-000000000000"
|
|
45
|
+
_CANARY_PLAINTEXT = "auth-encryption-canary-v1"
|
|
46
|
+
|
|
47
|
+
#: Tables that must be protected once the RLS migration has run.
|
|
48
|
+
_RLS_TABLES = (
|
|
49
|
+
"auth_group",
|
|
50
|
+
"auth_membership",
|
|
51
|
+
"auth_permission",
|
|
52
|
+
"auth_api_key",
|
|
53
|
+
"auth_tenant_settings",
|
|
54
|
+
"membership_groups",
|
|
55
|
+
"permission_groups",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
#: Checked the same way, but only when present. audit_log is created by
|
|
59
|
+
#: ``setup_audit_tables`` rather than by create_all, so a deployment with audit
|
|
60
|
+
#: logging switched off legitimately has no such table -- while one that DOES
|
|
61
|
+
#: have it is holding tenant data and must protect it like any other.
|
|
62
|
+
_RLS_OPTIONAL_TABLES = ("audit_log",)
|
|
63
|
+
|
|
64
|
+
_state = OK
|
|
65
|
+
_detail = "not yet checked"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def state() -> str:
|
|
69
|
+
"""Current verdict: ``ok``, ``mismatch`` or ``unverified``."""
|
|
70
|
+
return _state
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detail() -> str:
|
|
74
|
+
"""Human-readable reason behind :func:`state`."""
|
|
75
|
+
return _detail
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_degraded() -> bool:
|
|
79
|
+
"""True when this deployment cannot safely answer authorization questions."""
|
|
80
|
+
return _state == MISMATCH
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _set(new_state: str, why: str) -> str:
|
|
84
|
+
global _state, _detail
|
|
85
|
+
# A later OK must never clear an earlier failure. Boot runs several checks
|
|
86
|
+
# in sequence and the last one would otherwise decide the verdict for all
|
|
87
|
+
# of them -- which is how the RLS check silently stopped mattering the first
|
|
88
|
+
# time this ran: it reported a problem and the encryption check overwrote it.
|
|
89
|
+
if _state == MISMATCH and new_state != MISMATCH:
|
|
90
|
+
return _state
|
|
91
|
+
if _state == UNVERIFIED and new_state == OK:
|
|
92
|
+
return _state
|
|
93
|
+
_state, _detail = new_state, why
|
|
94
|
+
if new_state == MISMATCH:
|
|
95
|
+
logger.critical("STARTUP CHECK FAILED: %s", why)
|
|
96
|
+
elif new_state == UNVERIFIED:
|
|
97
|
+
logger.warning("startup check inconclusive: %s", why)
|
|
98
|
+
else:
|
|
99
|
+
logger.info("startup check: %s", why)
|
|
100
|
+
return new_state
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def verify_encryption_key(db) -> str:
|
|
104
|
+
"""Check the configured key against this deployment's canary.
|
|
105
|
+
|
|
106
|
+
The canary lives under :data:`SENTINEL_TENANT` so it can be read with Row
|
|
107
|
+
Level Security in force -- the previous implementation sampled rows across
|
|
108
|
+
tenants, which RLS would reduce to "nothing to verify against", passing
|
|
109
|
+
vacuously and silently disabling this check.
|
|
110
|
+
|
|
111
|
+
Never raises: a database problem yields ``unverified`` rather than
|
|
112
|
+
preventing startup, because refusing to boot on a transient outage turns a
|
|
113
|
+
blip into a restart loop.
|
|
114
|
+
"""
|
|
115
|
+
from auth.models.sql import AuthTenantSettings
|
|
116
|
+
from auth.rls import bind_tenant
|
|
117
|
+
|
|
118
|
+
fe = _encryption.field_encryption
|
|
119
|
+
try:
|
|
120
|
+
bind_tenant(db, SENTINEL_TENANT)
|
|
121
|
+
row = (
|
|
122
|
+
db.query(AuthTenantSettings)
|
|
123
|
+
.filter(AuthTenantSettings.creator == SENTINEL_TENANT)
|
|
124
|
+
.first()
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
return _set(UNVERIFIED, f"could not read the canary: {exc.__class__.__name__}")
|
|
128
|
+
|
|
129
|
+
stored = getattr(row, "canary", None) if row is not None else None
|
|
130
|
+
|
|
131
|
+
if not stored:
|
|
132
|
+
if not fe.enabled or fe.encryptor is None:
|
|
133
|
+
return _set(OK, "encryption disabled and no canary stored")
|
|
134
|
+
try:
|
|
135
|
+
value = fe.encryptor.encrypt(_CANARY_PLAINTEXT, SENTINEL_TENANT)
|
|
136
|
+
if row is None:
|
|
137
|
+
row = AuthTenantSettings(creator=SENTINEL_TENANT, strict_users=False)
|
|
138
|
+
db.add(row)
|
|
139
|
+
row.canary = value
|
|
140
|
+
db.commit()
|
|
141
|
+
return _set(OK, "canary written for this deployment")
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
db.rollback()
|
|
144
|
+
return _set(UNVERIFIED, f"could not write the canary: {exc.__class__.__name__}")
|
|
145
|
+
|
|
146
|
+
if not fe.enabled:
|
|
147
|
+
return _set(
|
|
148
|
+
MISMATCH,
|
|
149
|
+
"this deployment has an encryption canary but field encryption is "
|
|
150
|
+
"disabled - every authorization lookup would miss and answer "
|
|
151
|
+
"negatively",
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
recovered = fe.encryptor.decrypt(str(stored), SENTINEL_TENANT) # type: ignore[union-attr]
|
|
155
|
+
except Exception:
|
|
156
|
+
return _set(
|
|
157
|
+
MISMATCH,
|
|
158
|
+
"the configured AUTH_ENCRYPTION_KEY cannot decrypt this "
|
|
159
|
+
"deployment's canary - every authorization lookup would miss and "
|
|
160
|
+
"answer negatively",
|
|
161
|
+
)
|
|
162
|
+
if recovered != _CANARY_PLAINTEXT:
|
|
163
|
+
return _set(MISMATCH, "the canary decrypted to an unexpected value")
|
|
164
|
+
return _set(OK, "encryption key verified against the deployment canary")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def verify_row_level_security(db) -> str:
|
|
168
|
+
"""Confirm RLS is actually in force, not merely enabled.
|
|
169
|
+
|
|
170
|
+
The application owns these tables and PostgreSQL does not apply RLS to a
|
|
171
|
+
table's owner, so ``ENABLE`` without ``FORCE`` protects nothing while every
|
|
172
|
+
casual check looks correct. That is the one way this feature fails silently,
|
|
173
|
+
so it is asserted at boot rather than assumed.
|
|
174
|
+
|
|
175
|
+
Skipped on SQLite, which has no RLS.
|
|
176
|
+
"""
|
|
177
|
+
from sqlalchemy import text
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
if db.get_bind().dialect.name != "postgresql":
|
|
181
|
+
return OK
|
|
182
|
+
from auth.config import get_settings
|
|
183
|
+
|
|
184
|
+
# The configured schema, NOT current_schema(): search_path rarely points
|
|
185
|
+
# at it, so current_schema() matched no tables and the check reported
|
|
186
|
+
# "nothing to check" -- passing while RLS was off.
|
|
187
|
+
schema = get_settings().database_schema or "public"
|
|
188
|
+
rows = db.execute(
|
|
189
|
+
text(
|
|
190
|
+
"SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, "
|
|
191
|
+
"(SELECT count(*) FROM pg_policy p WHERE p.polrelid = c.oid) "
|
|
192
|
+
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
|
|
193
|
+
"WHERE c.relname = ANY(:names) AND n.nspname = :schema"
|
|
194
|
+
),
|
|
195
|
+
{"names": list(_RLS_TABLES + _RLS_OPTIONAL_TABLES), "schema": schema},
|
|
196
|
+
).fetchall()
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
return _set(UNVERIFIED, f"could not read RLS state: {exc.__class__.__name__}")
|
|
199
|
+
|
|
200
|
+
if not rows:
|
|
201
|
+
return _set(UNVERIFIED, "no tenant tables found to check RLS on")
|
|
202
|
+
|
|
203
|
+
# A table that is absent is not a table that is safe. The junction tables
|
|
204
|
+
# are created by the ORM rather than by a migration, so on a fresh database
|
|
205
|
+
# they appeared after the RLS migration had run -- and a check that only
|
|
206
|
+
# inspects the rows it happens to find reports every remaining table forced
|
|
207
|
+
# and calls that a pass.
|
|
208
|
+
missing = sorted(set(_RLS_TABLES) - {r[0] for r in rows})
|
|
209
|
+
if missing:
|
|
210
|
+
return _set(
|
|
211
|
+
MISMATCH,
|
|
212
|
+
"expected tenant tables are absent, so nothing protects them: "
|
|
213
|
+
+ ", ".join(missing),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
unprotected = [
|
|
217
|
+
r[0] for r in rows if not (r[1] and r[2] and r[3] > 0)
|
|
218
|
+
]
|
|
219
|
+
if unprotected:
|
|
220
|
+
return _set(
|
|
221
|
+
MISMATCH,
|
|
222
|
+
"row level security is not in force on: "
|
|
223
|
+
+ ", ".join(sorted(unprotected))
|
|
224
|
+
+ " - tenant isolation would rest on query convention alone",
|
|
225
|
+
)
|
|
226
|
+
return _set(OK, f"row level security forced on {len(rows)} tables")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def reset_for_tests(new_state: Optional[str] = None) -> None:
|
|
230
|
+
"""Test hook: force the recorded verdict, bypassing the sticky rule."""
|
|
231
|
+
global _state, _detail
|
|
232
|
+
_state = new_state or OK
|
|
233
|
+
_detail = "set by test"
|