synapse-memory-engine 1.3.0__py3-none-any.whl
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.
- synapse_memory/__init__.py +31 -0
- synapse_memory/api/fastapi_server.py +1334 -0
- synapse_memory/cli/synapse_cli.py +112 -0
- synapse_memory/client.py +127 -0
- synapse_memory/core/__init__.py +50 -0
- synapse_memory/core/auth.py +1273 -0
- synapse_memory/core/decay_engine.py +123 -0
- synapse_memory/core/deduplicator.py +98 -0
- synapse_memory/core/embedder.py +119 -0
- synapse_memory/core/embedding_provider.py +241 -0
- synapse_memory/core/encryption.py +226 -0
- synapse_memory/core/hybrid_search.py +199 -0
- synapse_memory/core/knapsack_packer.py +103 -0
- synapse_memory/core/memory_manager.py +233 -0
- synapse_memory/core/migrations.py +241 -0
- synapse_memory/core/observability.py +48 -0
- synapse_memory/core/pgvector_db.py +1276 -0
- synapse_memory/core/pruner.py +68 -0
- synapse_memory/core/rate_limiter.py +180 -0
- synapse_memory/core/sqlite_db.py +564 -0
- synapse_memory/examples/langchain_integration.py +12 -0
- synapse_memory/examples/llamaindex_integration.py +12 -0
- synapse_memory/integrations/__init__.py +9 -0
- synapse_memory/integrations/langchain.py +209 -0
- synapse_memory/integrations/llamaindex.py +295 -0
- synapse_memory/main.py +112 -0
- synapse_memory/py.typed +0 -0
- synapse_memory_engine-1.3.0.dist-info/METADATA +75 -0
- synapse_memory_engine-1.3.0.dist-info/RECORD +32 -0
- synapse_memory_engine-1.3.0.dist-info/WHEEL +5 -0
- synapse_memory_engine-1.3.0.dist-info/entry_points.txt +4 -0
- synapse_memory_engine-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enterprise Security, Cryptographic API Key Management, RBAC, Scopes & Audit Logging.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("SynapseSecurity")
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import psycopg2
|
|
22
|
+
from psycopg2 import pool
|
|
23
|
+
from psycopg2.extras import RealDictCursor
|
|
24
|
+
HAS_PSYCOPG2 = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_PSYCOPG2 = False
|
|
27
|
+
psycopg2 = None
|
|
28
|
+
pool = None
|
|
29
|
+
RealDictCursor = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class APIKeyRecord:
|
|
34
|
+
key_id: str
|
|
35
|
+
key_hash: str
|
|
36
|
+
name: str
|
|
37
|
+
organization_id: str
|
|
38
|
+
project_id: str
|
|
39
|
+
tenant_id: str
|
|
40
|
+
scopes: List[str]
|
|
41
|
+
role: str # admin, developer, service, viewer
|
|
42
|
+
is_active: bool
|
|
43
|
+
expires_at: Optional[float]
|
|
44
|
+
created_at: float
|
|
45
|
+
last_used_at: Optional[float]
|
|
46
|
+
# Owner identity bound to this key (user id or service principal id).
|
|
47
|
+
# Used to gate who may mint/rotate/revoke the key and what it may delegate.
|
|
48
|
+
owner_id: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AuthAuditLog:
|
|
53
|
+
id: str
|
|
54
|
+
timestamp: float
|
|
55
|
+
key_id: Optional[str]
|
|
56
|
+
tenant_id: Optional[str]
|
|
57
|
+
route: str
|
|
58
|
+
action: str
|
|
59
|
+
outcome: str # ALLOWED, DENIED_INVALID_KEY, DENIED_EXPIRED, DENIED_REVOKED, DENIED_INSUFFICIENT_SCOPE, DENIED_TENANT_MISMATCH
|
|
60
|
+
client_ip: Optional[str] = None
|
|
61
|
+
metadata_json: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Canonical role hierarchy: higher index = more privilege. A principal may only
|
|
65
|
+
# delegate down (or within) this hierarchy, never up, and never to a role whose
|
|
66
|
+
# index is higher than their own. Legacy role "admin" (pre-multi-user data)
|
|
67
|
+
# normalizes to workspace_admin unless bound to a system/org-root principal.
|
|
68
|
+
_ROLE_RANK = {"platform_admin": 5, "organization_admin": 4, "workspace_admin": 3, "developer": 2, "service": 2, "viewer": 1, "member": 1}
|
|
69
|
+
|
|
70
|
+
# Roles allowed to mint new API keys (v1 secure default). Members, viewers,
|
|
71
|
+
# developers and service keys cannot mint; only workspace admins and above.
|
|
72
|
+
_KEY_MINT_ROLES = frozenset({"platform_admin", "organization_admin", "workspace_admin"})
|
|
73
|
+
|
|
74
|
+
# Scopes a non-platform caller may delegate. "admin" is never delegable
|
|
75
|
+
# except by a platform_admin holding it.
|
|
76
|
+
_ADMIN_ONLY_SCOPES = frozenset({"admin", "security:audit"})
|
|
77
|
+
|
|
78
|
+
# Canonical, closed allow-lists (F-01). Authorization is deny-by-default: a
|
|
79
|
+
# role or scope that is not listed here is never granted, never delegated and
|
|
80
|
+
# never minted. The legacy stored role "admin" is tolerated on READ (it
|
|
81
|
+
# normalizes to workspace_admin) but can no longer be assigned.
|
|
82
|
+
_ASSIGNABLE_ROLES = frozenset({
|
|
83
|
+
"platform_admin",
|
|
84
|
+
"organization_admin",
|
|
85
|
+
"workspace_admin",
|
|
86
|
+
"developer",
|
|
87
|
+
"service",
|
|
88
|
+
"viewer",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
_KNOWN_SCOPES = frozenset({
|
|
92
|
+
"admin",
|
|
93
|
+
"security:audit",
|
|
94
|
+
"auth:keys:create",
|
|
95
|
+
"auth:keys:rotate",
|
|
96
|
+
"auth:keys:revoke",
|
|
97
|
+
"memories:read",
|
|
98
|
+
"memories:write",
|
|
99
|
+
"memories:delete",
|
|
100
|
+
"memories:list",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- Auth hot-path hardening (P2) ---
|
|
105
|
+
# Brief TTL for in-memory API-key lookups (seconds). A revoked/expired key can
|
|
106
|
+
# still validate until its cache entry expires or is evicted (revoke evicts).
|
|
107
|
+
_AUTH_KEY_CACHE_TTL = 30.0
|
|
108
|
+
# F-10: a cached key is re-validated against the database at least this often,
|
|
109
|
+
# even if its TTL has not expired. revoke_api_key() only evicts the cache of the
|
|
110
|
+
# replica that served the revoke, so WITHOUT this a key revoked on replica A
|
|
111
|
+
# stays usable on replica B for up to the full TTL. With it, the maximum
|
|
112
|
+
# cross-replica revocation delay is this value (default 5s), independent of TTL.
|
|
113
|
+
# Set SYNAPSE_AUTH_REVALIDATE_SECONDS to trade DB reads for a tighter bound.
|
|
114
|
+
try:
|
|
115
|
+
_AUTH_REVALIDATE_SECONDS = max(
|
|
116
|
+
0.5, float(os.environ.get("SYNAPSE_AUTH_REVALIDATE_SECONDS", "5"))
|
|
117
|
+
)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
_AUTH_REVALIDATE_SECONDS = 5.0
|
|
120
|
+
# F-13: hard cap on per-IP auth state so spoofed/rotating source addresses
|
|
121
|
+
# cannot grow the failure maps without bound.
|
|
122
|
+
_MAX_TRACKED_IPS = 8192
|
|
123
|
+
# Flush buffered audit rows when the buffer reaches this size or this many
|
|
124
|
+
# seconds have elapsed since the last flush, whichever comes first.
|
|
125
|
+
_AUDIT_FLUSH_MAX = 50
|
|
126
|
+
_AUDIT_FLUSH_INTERVAL = 2.0
|
|
127
|
+
# IP-based rate limiting for unauthenticated attempts (per IP, sliding window).
|
|
128
|
+
_IP_RATE_WINDOW = 60.0
|
|
129
|
+
_IP_RATE_MAX_FAILURES = 15
|
|
130
|
+
# Max failed-lookup audit rows written per IP per window (caps log flooding).
|
|
131
|
+
_IP_MAX_FAILED_AUDITS = 5
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _role_rank(role: str) -> int:
|
|
135
|
+
return _ROLE_RANK.get(role, 0)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _can_delegate(role_a: str, role_b: str) -> Tuple[bool, str]:
|
|
139
|
+
"""Return (allowed, reason): caller with role_a may only delegate to role_b
|
|
140
|
+
if role_b is at-or-below role_a in the hierarchy (equal allowed, higher
|
|
141
|
+
denied). This enforces 'a workspace admin cannot mint a platform admin key'. """
|
|
142
|
+
rank_a = _role_rank(role_a)
|
|
143
|
+
rank_b = _role_rank(role_b)
|
|
144
|
+
if rank_b > rank_a:
|
|
145
|
+
return False, f"Caller role '{role_a}' cannot mint role '{role_b}'."
|
|
146
|
+
if rank_b == 5 and role_a not in ("platform_admin",):
|
|
147
|
+
return False, "Only platform_admin may mint platform-wide keys."
|
|
148
|
+
return True, "Delegate check passed"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normalize_role(principal: APIKeyRecord) -> str:
|
|
152
|
+
"""Resolve a key's stored role to its effective canonical role.
|
|
153
|
+
|
|
154
|
+
System/org-root bindings resolve to platform_admin; a legacy stored role
|
|
155
|
+
of "admin" outside a system binding normalizes to workspace_admin;
|
|
156
|
+
every other known role passes through unchanged. Unknown roles map to
|
|
157
|
+
"member" (least privilege) so a crafted role string can never escalate.
|
|
158
|
+
"""
|
|
159
|
+
if principal.organization_id in ("org_root", "org_system") and principal.project_id in ("proj_root",):
|
|
160
|
+
return "platform_admin"
|
|
161
|
+
if principal.organization_id == "org_root":
|
|
162
|
+
return "organization_admin"
|
|
163
|
+
stored = (principal.role or "").strip()
|
|
164
|
+
if stored == "admin":
|
|
165
|
+
return "workspace_admin"
|
|
166
|
+
if stored in _ROLE_RANK:
|
|
167
|
+
return stored
|
|
168
|
+
return "member"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _role_from_scoped_key(principal: APIKeyRecord) -> str:
|
|
172
|
+
# Backward-compatible alias: effective role always derives from the
|
|
173
|
+
# stored role + binding, never defaults to workspace_admin.
|
|
174
|
+
return normalize_role(principal)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Canonical authorization primitives (F-01) -------------------------------
|
|
178
|
+
# Every authorization decision must go through these helpers. They read the
|
|
179
|
+
# NORMALIZED role, never the raw stored string, so a record whose role column
|
|
180
|
+
# says "admin" is evaluated as workspace_admin unless its binding is a system
|
|
181
|
+
# binding. This is the single place where "what may this key do" is decided.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def effective_role(principal: APIKeyRecord) -> str:
|
|
185
|
+
"""The one canonical role for a principal (see normalize_role)."""
|
|
186
|
+
return normalize_role(principal)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def is_platform_admin(principal: APIKeyRecord) -> bool:
|
|
190
|
+
"""Only the canonical platform_admin may act across tenants."""
|
|
191
|
+
return effective_role(principal) == "platform_admin"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def tenant_selection_allowed(principal: APIKeyRecord, requested_tenant: Optional[str]) -> bool:
|
|
195
|
+
"""Whether ``principal`` may act on ``requested_tenant``.
|
|
196
|
+
|
|
197
|
+
Cross-tenant selection requires the canonical platform_admin role. Holders
|
|
198
|
+
of the legacy ``admin`` *scope* do not gain tenant breadth, and the raw
|
|
199
|
+
stored role is never consulted — otherwise a delegated workspace key minted
|
|
200
|
+
with role ``admin`` (or scopes ``["admin"]``) becomes a global key.
|
|
201
|
+
"""
|
|
202
|
+
if not requested_tenant:
|
|
203
|
+
return True
|
|
204
|
+
if is_platform_admin(principal):
|
|
205
|
+
return True
|
|
206
|
+
bound = (principal.tenant_id or "").strip()
|
|
207
|
+
return bound == "*" or requested_tenant == bound
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def scope_satisfied(principal: APIKeyRecord, required_scope: Optional[str]) -> bool:
|
|
211
|
+
"""Whether the key's scopes satisfy ``required_scope``.
|
|
212
|
+
|
|
213
|
+
The legacy ``admin`` scope is a *memory-domain* superuser only: it implies
|
|
214
|
+
the ``memories:*`` scopes, never key-management (``auth:keys:*``) or audit
|
|
215
|
+
(``security:audit``) scopes. Those require an explicit grant from a
|
|
216
|
+
platform_admin, so an admin-scoped workspace key cannot mint or revoke keys.
|
|
217
|
+
"""
|
|
218
|
+
if not required_scope:
|
|
219
|
+
return True
|
|
220
|
+
scopes = set(principal.scopes or ())
|
|
221
|
+
if required_scope in scopes:
|
|
222
|
+
return True
|
|
223
|
+
return "admin" in scopes and required_scope.startswith("memories:")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def canonicalize_role(role: Optional[str]) -> str:
|
|
227
|
+
"""Validate a role for *minting*; raises ValueError outside the allow-list."""
|
|
228
|
+
candidate = (role or "").strip()
|
|
229
|
+
if candidate not in _ASSIGNABLE_ROLES:
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Role {role!r} is not assignable; allowed roles: {sorted(_ASSIGNABLE_ROLES)}"
|
|
232
|
+
)
|
|
233
|
+
return candidate
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def canonicalize_scopes(scopes: Optional[List[str]]) -> List[str]:
|
|
237
|
+
"""Validate scopes for *minting*; raises ValueError outside the allow-list."""
|
|
238
|
+
if scopes is None:
|
|
239
|
+
return ["memories:read", "memories:write"]
|
|
240
|
+
unknown = sorted({s for s in scopes if s not in _KNOWN_SCOPES})
|
|
241
|
+
if unknown:
|
|
242
|
+
raise ValueError(f"Unknown scope(s) {unknown}; allowed: {sorted(_KNOWN_SCOPES)}")
|
|
243
|
+
return list(scopes)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def can_create_api_key(
|
|
247
|
+
principal: APIKeyRecord,
|
|
248
|
+
target_org: str,
|
|
249
|
+
target_workspace: Optional[str],
|
|
250
|
+
requested_role: str,
|
|
251
|
+
requested_scopes: Optional[List[str]] = None,
|
|
252
|
+
) -> Tuple[bool, str]:
|
|
253
|
+
"""Authorize a caller to mint a new API key.
|
|
254
|
+
|
|
255
|
+
Rules:
|
|
256
|
+
- A member/viewer/developer/service key cannot mint any key.
|
|
257
|
+
- An organization admin can only mint keys inside its own organization,
|
|
258
|
+
at or below organization_admin.
|
|
259
|
+
- A workspace admin can only mint keys inside their own workspace and
|
|
260
|
+
only at or below workspace_admin (never platform/org-wide).
|
|
261
|
+
- A platform admin can mint anywhere, and is the ONLY role that may
|
|
262
|
+
grant platform_admin or the admin/security:audit scopes.
|
|
263
|
+
- The requested role must be grantable by the caller (delegation check).
|
|
264
|
+
"""
|
|
265
|
+
# Resolve the caller's effective role from their key binding + stored role.
|
|
266
|
+
caller_role = _role_from_scoped_key(principal)
|
|
267
|
+
if caller_role not in _KEY_MINT_ROLES:
|
|
268
|
+
return False, f"Role '{caller_role}' is not permitted to create API keys."
|
|
269
|
+
|
|
270
|
+
# Deny-by-default on the requested role (F-01). The legacy "admin" string is
|
|
271
|
+
# NOT assignable: a workspace admin that submits role="admin" is rejected
|
|
272
|
+
# instead of minting a key that a later authorization step might read as
|
|
273
|
+
# platform-wide. _role_rank() returns 0 for unknown strings, so this check
|
|
274
|
+
# must come before the delegation ranking.
|
|
275
|
+
target_role = (requested_role or "").strip()
|
|
276
|
+
if target_role not in _ASSIGNABLE_ROLES:
|
|
277
|
+
return False, f"Role '{target_role}' is not assignable; privilege elevation denied."
|
|
278
|
+
|
|
279
|
+
allowed, reason = _can_delegate(caller_role, target_role)
|
|
280
|
+
if not allowed:
|
|
281
|
+
return False, reason
|
|
282
|
+
|
|
283
|
+
# Scopes must be known, and the admin/security:audit scopes may only be
|
|
284
|
+
# delegated by a platform_admin. The route previously omitted
|
|
285
|
+
# requested_scopes entirely, so this guard never ran.
|
|
286
|
+
if requested_scopes:
|
|
287
|
+
unknown = sorted({s for s in requested_scopes if s not in _KNOWN_SCOPES})
|
|
288
|
+
if unknown:
|
|
289
|
+
return False, f"Unknown scope(s) {unknown}; privilege elevation denied."
|
|
290
|
+
leaked = sorted({s for s in requested_scopes if s in _ADMIN_ONLY_SCOPES})
|
|
291
|
+
if leaked and caller_role != "platform_admin":
|
|
292
|
+
return False, f"Scope(s) {leaked} require platform_admin; caller is '{caller_role}'."
|
|
293
|
+
|
|
294
|
+
# platform_admin is the only role that may mint across organizations.
|
|
295
|
+
if caller_role == "platform_admin":
|
|
296
|
+
return True, "Platform admin allowed."
|
|
297
|
+
|
|
298
|
+
# A principal bound to org_root/proj_root acts as org admin for target_org.
|
|
299
|
+
if principal.organization_id in ("org_root", "org_system") and principal.project_id in ("proj_root",):
|
|
300
|
+
return True, f"Org admin for {target_org} allowed."
|
|
301
|
+
|
|
302
|
+
# Otherwise the caller is bound to a concrete workspace; restrict to that org.
|
|
303
|
+
if target_org != principal.organization_id:
|
|
304
|
+
return False, f"Organization boundary violation. Key organization {target_org} not controlled by caller {principal.organization_id}."
|
|
305
|
+
|
|
306
|
+
# Workspace admins and below may only mint into workspaces they control:
|
|
307
|
+
# the same workspace they are bound to (plus org-scoped workspaces where the
|
|
308
|
+
# caller is a workspace admin). For the v1 check, require exact workspace match.
|
|
309
|
+
if target_workspace is not None and target_workspace != principal.tenant_id:
|
|
310
|
+
return False, f"Workspace boundary violation. Caller bound to workspace {principal.tenant_id}, target {target_workspace}."
|
|
311
|
+
|
|
312
|
+
return True, "Key mint permission granted."
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class SecurityManager:
|
|
316
|
+
"""
|
|
317
|
+
Enterprise identity and cryptographic authorization engine.
|
|
318
|
+
Provides hashed API key validation, multi-tenant RBAC, fine-grained scopes,
|
|
319
|
+
and immutable structured authorization audit trails.
|
|
320
|
+
Supports centralized PostgreSQL cluster storage and SQLite local storage.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
def __init__(
|
|
324
|
+
self,
|
|
325
|
+
db_path: str = "synapse_memory.db",
|
|
326
|
+
connection_string: Optional[str] = None,
|
|
327
|
+
fail_closed: bool = False,
|
|
328
|
+
validate_schema_only: bool = False,
|
|
329
|
+
):
|
|
330
|
+
self.db_path = db_path
|
|
331
|
+
self.connection_string = connection_string
|
|
332
|
+
self.fail_closed = fail_closed
|
|
333
|
+
self._pool: Optional[Any] = None
|
|
334
|
+
self._is_postgres = False
|
|
335
|
+
# When validate_schema_only=True, the engine does not create auth tables
|
|
336
|
+
# at startup; it only verifies they exist and are accessible. DDL must be
|
|
337
|
+
# run ahead of time by the admin migration step (scripts/provision_db_roles.py
|
|
338
|
+
# plus a dedicated auth-schema migration). Required for least-privilege
|
|
339
|
+
# runtime roles that cannot execute DDL.
|
|
340
|
+
self.validate_schema_only = validate_schema_only
|
|
341
|
+
|
|
342
|
+
# Auth hot-path / abuse-protection state (P2). The gateway runs a single
|
|
343
|
+
# shared SecurityManager instance, so this is shared across requests and
|
|
344
|
+
# guarded by self._auth_lock.
|
|
345
|
+
# token_hash -> (expires_at, record, cached_at); cached_at drives the
|
|
346
|
+
# periodic revalidation that bounds cross-replica revocation delay (F-10).
|
|
347
|
+
self._key_cache: Dict[str, Tuple[float, APIKeyRecord, float]] = {}
|
|
348
|
+
self._audit_buffer: List[
|
|
349
|
+
Tuple[str, float, Optional[str], Optional[str], str, str, str, Optional[str]]
|
|
350
|
+
] = []
|
|
351
|
+
self._auth_lock = threading.Lock()
|
|
352
|
+
self._last_audit_flush = 0.0
|
|
353
|
+
self._ip_failures: Dict[str, List[float]] = defaultdict(list)
|
|
354
|
+
self._ip_audited_failures: Dict[str, int] = defaultdict(int)
|
|
355
|
+
|
|
356
|
+
if self.connection_string:
|
|
357
|
+
if HAS_PSYCOPG2 and pool is not None:
|
|
358
|
+
try:
|
|
359
|
+
pool_cls = getattr(pool, "ThreadedConnectionPool", None) or pool.SimpleConnectionPool
|
|
360
|
+
self._pool = pool_cls(1, 10, self.connection_string, connect_timeout=5)
|
|
361
|
+
self._is_postgres = True
|
|
362
|
+
logger.info("SecurityManager connected to centralized PostgreSQL authentication datastore.")
|
|
363
|
+
except Exception as e:
|
|
364
|
+
self._is_postgres = False
|
|
365
|
+
self._pool = None
|
|
366
|
+
if self.fail_closed:
|
|
367
|
+
raise RuntimeError(
|
|
368
|
+
f"Central PostgreSQL authentication datastore unavailable: {e}"
|
|
369
|
+
) from e
|
|
370
|
+
logger.warning(
|
|
371
|
+
f"Could not connect SecurityManager to PostgreSQL ({e}); falling back to SQLite {db_path}."
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
self._is_postgres = False
|
|
375
|
+
self._pool = None
|
|
376
|
+
if self.fail_closed:
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
"Central PostgreSQL authentication datastore requested but psycopg2 database driver is unavailable."
|
|
379
|
+
)
|
|
380
|
+
logger.warning(
|
|
381
|
+
f"psycopg2 unavailable; falling back to SQLite {db_path}."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Initialize or validate auth tables. In validate_schema_only mode we only
|
|
385
|
+
# confirm the tables exist and are readable; DDL is never issued here and
|
|
386
|
+
# the root-key bootstrap happens through an explicit admin setup procedure.
|
|
387
|
+
if self.validate_schema_only:
|
|
388
|
+
self._validate_auth_tables()
|
|
389
|
+
self._verify_db_usable()
|
|
390
|
+
else:
|
|
391
|
+
self._initialize_auth_tables()
|
|
392
|
+
self._bootstrap_root_key()
|
|
393
|
+
|
|
394
|
+
def _get_connection(self) -> Any:
|
|
395
|
+
if self._is_postgres and self._pool is not None:
|
|
396
|
+
return self._pool.getconn()
|
|
397
|
+
conn = sqlite3.connect(self.db_path)
|
|
398
|
+
conn.row_factory = sqlite3.Row
|
|
399
|
+
return conn
|
|
400
|
+
|
|
401
|
+
def _return_connection(self, conn: Any) -> None:
|
|
402
|
+
if self._is_postgres and self._pool is not None:
|
|
403
|
+
try:
|
|
404
|
+
# Roll back any aborted or still-open transaction so the
|
|
405
|
+
# connection returned to the pool is clean for the next borrower.
|
|
406
|
+
# Without this, a failed query leaves the connection in a
|
|
407
|
+
# poisoned state (e.g. an aborted transaction) that breaks the
|
|
408
|
+
# next request that borrows it.
|
|
409
|
+
conn.rollback()
|
|
410
|
+
except Exception:
|
|
411
|
+
pass
|
|
412
|
+
try:
|
|
413
|
+
self._pool.putconn(conn)
|
|
414
|
+
except Exception:
|
|
415
|
+
try:
|
|
416
|
+
conn.close()
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
else:
|
|
420
|
+
try:
|
|
421
|
+
conn.close()
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
def pool_stats(self) -> Dict[str, int]:
|
|
426
|
+
"""Live pool utilization for /metrics (no secrets)."""
|
|
427
|
+
if not self._is_postgres or self._pool is None:
|
|
428
|
+
return {"pool_size": 0, "in_use": 0}
|
|
429
|
+
try:
|
|
430
|
+
maxconn = getattr(self._pool, "maxconn", 10)
|
|
431
|
+
used = maxconn - len(getattr(self._pool, "_pool", []))
|
|
432
|
+
return {"pool_size": int(maxconn), "in_use": max(0, int(used))}
|
|
433
|
+
except Exception:
|
|
434
|
+
return {"pool_size": 0, "in_use": 0}
|
|
435
|
+
|
|
436
|
+
def _initialize_auth_tables(self) -> None:
|
|
437
|
+
conn = self._get_connection()
|
|
438
|
+
try:
|
|
439
|
+
cur = conn.cursor()
|
|
440
|
+
if self._is_postgres:
|
|
441
|
+
cur.execute("""
|
|
442
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
443
|
+
key_id VARCHAR(128) PRIMARY KEY,
|
|
444
|
+
key_hash VARCHAR(128) NOT NULL UNIQUE,
|
|
445
|
+
name VARCHAR(256) NOT NULL,
|
|
446
|
+
organization_id VARCHAR(128) NOT NULL,
|
|
447
|
+
project_id VARCHAR(128) NOT NULL,
|
|
448
|
+
tenant_id VARCHAR(128) NOT NULL,
|
|
449
|
+
scopes TEXT NOT NULL,
|
|
450
|
+
role VARCHAR(64) NOT NULL,
|
|
451
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
452
|
+
expires_at DOUBLE PRECISION,
|
|
453
|
+
created_at DOUBLE PRECISION NOT NULL,
|
|
454
|
+
last_used_at DOUBLE PRECISION,
|
|
455
|
+
owner_id TEXT
|
|
456
|
+
);
|
|
457
|
+
""")
|
|
458
|
+
cur.execute("""
|
|
459
|
+
CREATE TABLE IF NOT EXISTS auth_audit_logs (
|
|
460
|
+
id VARCHAR(128) PRIMARY KEY,
|
|
461
|
+
timestamp DOUBLE PRECISION NOT NULL,
|
|
462
|
+
key_id VARCHAR(128),
|
|
463
|
+
tenant_id VARCHAR(128),
|
|
464
|
+
route VARCHAR(256) NOT NULL,
|
|
465
|
+
action VARCHAR(64) NOT NULL,
|
|
466
|
+
outcome VARCHAR(64) NOT NULL,
|
|
467
|
+
client_ip VARCHAR(64),
|
|
468
|
+
metadata_json TEXT
|
|
469
|
+
);
|
|
470
|
+
""")
|
|
471
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash);")
|
|
472
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_keys_tenant ON api_keys(tenant_id);")
|
|
473
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_audit_time ON auth_audit_logs(timestamp DESC);")
|
|
474
|
+
conn.commit()
|
|
475
|
+
else:
|
|
476
|
+
cur.execute("""
|
|
477
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
478
|
+
key_id TEXT PRIMARY KEY,
|
|
479
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
480
|
+
name TEXT NOT NULL,
|
|
481
|
+
organization_id TEXT NOT NULL,
|
|
482
|
+
project_id TEXT NOT NULL,
|
|
483
|
+
tenant_id TEXT NOT NULL,
|
|
484
|
+
scopes TEXT NOT NULL,
|
|
485
|
+
role TEXT NOT NULL,
|
|
486
|
+
is_active INTEGER NOT NULL DEFAULT 1,
|
|
487
|
+
expires_at REAL,
|
|
488
|
+
created_at REAL NOT NULL,
|
|
489
|
+
last_used_at REAL,
|
|
490
|
+
owner_id TEXT
|
|
491
|
+
)
|
|
492
|
+
""")
|
|
493
|
+
cur.execute("""
|
|
494
|
+
CREATE TABLE IF NOT EXISTS auth_audit_logs (
|
|
495
|
+
id TEXT PRIMARY KEY,
|
|
496
|
+
timestamp REAL NOT NULL,
|
|
497
|
+
key_id TEXT,
|
|
498
|
+
tenant_id TEXT,
|
|
499
|
+
route TEXT NOT NULL,
|
|
500
|
+
action TEXT NOT NULL,
|
|
501
|
+
outcome TEXT NOT NULL,
|
|
502
|
+
client_ip TEXT,
|
|
503
|
+
metadata_json TEXT
|
|
504
|
+
)
|
|
505
|
+
""")
|
|
506
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash)")
|
|
507
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_keys_tenant ON api_keys(tenant_id)")
|
|
508
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_audit_time ON auth_audit_logs(timestamp DESC)")
|
|
509
|
+
conn.commit()
|
|
510
|
+
except Exception as e:
|
|
511
|
+
try:
|
|
512
|
+
conn.rollback()
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
if self.fail_closed:
|
|
516
|
+
raise RuntimeError(f"Failed to initialize centralized authentication tables: {e}") from e
|
|
517
|
+
logger.warning(f"Error initializing auth tables: {e}")
|
|
518
|
+
finally:
|
|
519
|
+
self._return_connection(conn)
|
|
520
|
+
|
|
521
|
+
def _validate_auth_tables(self) -> None:
|
|
522
|
+
"""Read-only schema validation for least-privilege runtime mode.
|
|
523
|
+
|
|
524
|
+
Confirms that api_keys and auth_audit_logs exist and are readable by the
|
|
525
|
+
current role. Never issues DDL. Raises RuntimeError (fail-closed) if
|
|
526
|
+
the schema is missing or unreadable, so production traffic is not served
|
|
527
|
+
against an incomplete auth schema.
|
|
528
|
+
"""
|
|
529
|
+
conn = self._get_connection()
|
|
530
|
+
try:
|
|
531
|
+
cur = conn.cursor()
|
|
532
|
+
required_tables = ("api_keys", "auth_audit_logs")
|
|
533
|
+
for table in required_tables:
|
|
534
|
+
if self._is_postgres:
|
|
535
|
+
cur.execute(
|
|
536
|
+
"SELECT to_regclass(%s)", (f"public.{table}",)
|
|
537
|
+
)
|
|
538
|
+
if cur.fetchone()[0] is None:
|
|
539
|
+
raise RuntimeError(
|
|
540
|
+
f"Auth table 'public.{table}' is missing. Run the admin "
|
|
541
|
+
"auth-schema migration before starting the engine with "
|
|
542
|
+
"SYNAPSE_APPLY_DB_SCHEMA=false."
|
|
543
|
+
)
|
|
544
|
+
else:
|
|
545
|
+
cur.execute(
|
|
546
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
547
|
+
(table,),
|
|
548
|
+
)
|
|
549
|
+
if cur.fetchone() is None:
|
|
550
|
+
raise RuntimeError(
|
|
551
|
+
f"Auth table '{table}' is missing. Run the admin "
|
|
552
|
+
"auth-schema migration before starting the engine."
|
|
553
|
+
)
|
|
554
|
+
# Verify a simple SELECT works (read permission).
|
|
555
|
+
cur.execute("SELECT 1 FROM api_keys LIMIT 1")
|
|
556
|
+
cur.execute("SELECT 1 FROM auth_audit_logs LIMIT 1")
|
|
557
|
+
logger.info("Auth schema validation passed: api_keys and auth_audit_logs are readable.")
|
|
558
|
+
except Exception as e:
|
|
559
|
+
if self.fail_closed:
|
|
560
|
+
raise RuntimeError(f"Auth schema validation failed: {e}") from e
|
|
561
|
+
logger.warning(f"Auth schema validation warning (non-fail-closed): {e}")
|
|
562
|
+
finally:
|
|
563
|
+
self._return_connection(conn)
|
|
564
|
+
|
|
565
|
+
def _verify_db_usable(self) -> None:
|
|
566
|
+
"""Fail-closed verification that the PostgreSQL/authorization store is
|
|
567
|
+
usable by this runtime role and is **not** a superuser/DDL role.
|
|
568
|
+
|
|
569
|
+
Bootstrapping the engine against a schema it cannot read, or against a
|
|
570
|
+
superuser that silently bypasses RLS, must fail clearly rather than
|
|
571
|
+
silently degrading to an in-memory fallback.
|
|
572
|
+
"""
|
|
573
|
+
conn = self._get_connection()
|
|
574
|
+
try:
|
|
575
|
+
cur = conn.cursor()
|
|
576
|
+
cur.execute(
|
|
577
|
+
"SELECT rolsuper, rolcanlogin FROM pg_roles WHERE rolname = current_user;"
|
|
578
|
+
)
|
|
579
|
+
row = cur.fetchone()
|
|
580
|
+
if row is None:
|
|
581
|
+
raise RuntimeError(
|
|
582
|
+
"Cannot resolve the current PostgreSQL role; authentication "
|
|
583
|
+
"store is unusable. Run the migration step as a DBA role first."
|
|
584
|
+
)
|
|
585
|
+
is_super, can_login = row
|
|
586
|
+
if is_super:
|
|
587
|
+
raise RuntimeError(
|
|
588
|
+
"Authentication store is connected as a superuser. The runtime "
|
|
589
|
+
"role must be a least-privilege non-superuser with no "
|
|
590
|
+
"BYPASSRLS. Do not run the engine as 'synapse_admin'."
|
|
591
|
+
)
|
|
592
|
+
if not can_login:
|
|
593
|
+
raise RuntimeError(
|
|
594
|
+
"Authentication store role is not a login role. "
|
|
595
|
+
"Run the migration step as a DBA role first."
|
|
596
|
+
)
|
|
597
|
+
logger.info("Auth store verification passed: non-superuser, login role is usable.")
|
|
598
|
+
except Exception as e:
|
|
599
|
+
if self.fail_closed:
|
|
600
|
+
raise RuntimeError(f"Auth store verification failed: {e}") from e
|
|
601
|
+
logger.warning(f"Auth store verification warning (non-fail-closed): {e}")
|
|
602
|
+
finally:
|
|
603
|
+
self._return_connection(conn)
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def hash_secret(secret: str) -> str:
|
|
607
|
+
"""Computes SHA-256 digest of secret token."""
|
|
608
|
+
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
609
|
+
|
|
610
|
+
def _format_sql(self, sql: str) -> str:
|
|
611
|
+
if self._is_postgres:
|
|
612
|
+
return sql.replace("?", "%s")
|
|
613
|
+
return sql
|
|
614
|
+
|
|
615
|
+
def generate_api_key(
|
|
616
|
+
self,
|
|
617
|
+
name: str,
|
|
618
|
+
organization_id: str = "org_default",
|
|
619
|
+
project_id: str = "proj_default",
|
|
620
|
+
tenant_id: str = "default",
|
|
621
|
+
scopes: Optional[List[str]] = None,
|
|
622
|
+
role: str = "developer",
|
|
623
|
+
ttl_days: Optional[int] = 365,
|
|
624
|
+
owner_id: Optional[str] = None,
|
|
625
|
+
) -> Tuple[str, APIKeyRecord]:
|
|
626
|
+
"""
|
|
627
|
+
Generates a secure API key with prefix: syn_live_<key_id>_<secret>
|
|
628
|
+
Only the SHA-256 hash is persisted. The full token is returned once.
|
|
629
|
+
|
|
630
|
+
The role and scopes are taken verbatim from the authorized caller: they are
|
|
631
|
+
NOT upgraded automatically, and a caller can never mint an 'admin' key by
|
|
632
|
+
submitting role='admin' or including 'admin' in scopes by accident. The
|
|
633
|
+
caller's own role (see _can_delegate) is validated by the route that
|
|
634
|
+
invokes this method.
|
|
635
|
+
"""
|
|
636
|
+
key_id = f"key_{secrets.token_hex(6)}"
|
|
637
|
+
secret_part = secrets.token_urlsafe(32)
|
|
638
|
+
full_token = f"syn_live_{key_id}_{secret_part}"
|
|
639
|
+
key_hash = self.hash_secret(full_token)
|
|
640
|
+
|
|
641
|
+
now = time.time()
|
|
642
|
+
expires_at = (now + (ttl_days * 86400)) if ttl_days else None
|
|
643
|
+
# NEVER auto-promote 'admin': the role/scopes above are authoritative and
|
|
644
|
+
# come from the authenticated caller, so submitting role='admin' yields an
|
|
645
|
+
# admin-scoped key only if that caller is actually allowed to delegate it.
|
|
646
|
+
# Deny-by-default at the storage boundary as well (F-01): only canonical
|
|
647
|
+
# roles and known scopes are ever persisted, so no downstream reader can
|
|
648
|
+
# meet an ambiguous "admin" role or an unknown scope. Raise before any
|
|
649
|
+
# DB write happens.
|
|
650
|
+
scopes_list = canonicalize_scopes(scopes)
|
|
651
|
+
canonical_role = canonicalize_role(role)
|
|
652
|
+
|
|
653
|
+
record = APIKeyRecord(
|
|
654
|
+
key_id=key_id,
|
|
655
|
+
key_hash=key_hash,
|
|
656
|
+
name=name,
|
|
657
|
+
organization_id=organization_id,
|
|
658
|
+
project_id=project_id,
|
|
659
|
+
tenant_id=tenant_id,
|
|
660
|
+
scopes=scopes_list,
|
|
661
|
+
role=canonical_role,
|
|
662
|
+
is_active=True,
|
|
663
|
+
expires_at=expires_at,
|
|
664
|
+
created_at=now,
|
|
665
|
+
last_used_at=None,
|
|
666
|
+
owner_id=owner_id,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
conn = self._get_connection()
|
|
670
|
+
try:
|
|
671
|
+
cur = conn.cursor()
|
|
672
|
+
sql = self._format_sql("""
|
|
673
|
+
INSERT INTO api_keys (
|
|
674
|
+
key_id, key_hash, name, organization_id, project_id,
|
|
675
|
+
tenant_id, scopes, role, is_active, expires_at,
|
|
676
|
+
created_at, last_used_at, owner_id
|
|
677
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
678
|
+
""")
|
|
679
|
+
cur.execute(sql, (
|
|
680
|
+
record.key_id,
|
|
681
|
+
record.key_hash,
|
|
682
|
+
record.name,
|
|
683
|
+
record.organization_id,
|
|
684
|
+
record.project_id,
|
|
685
|
+
record.tenant_id,
|
|
686
|
+
json.dumps(record.scopes),
|
|
687
|
+
record.role,
|
|
688
|
+
1 if record.is_active else 0,
|
|
689
|
+
record.expires_at,
|
|
690
|
+
record.created_at,
|
|
691
|
+
record.last_used_at,
|
|
692
|
+
record.owner_id,
|
|
693
|
+
))
|
|
694
|
+
conn.commit()
|
|
695
|
+
finally:
|
|
696
|
+
self._return_connection(conn)
|
|
697
|
+
|
|
698
|
+
return full_token, record
|
|
699
|
+
|
|
700
|
+
def _bootstrap_root_key(self) -> None:
|
|
701
|
+
"""Bootstraps default root/admin key from environment if table is empty."""
|
|
702
|
+
conn = self._get_connection()
|
|
703
|
+
try:
|
|
704
|
+
cur = conn.cursor()
|
|
705
|
+
cur.execute("SELECT count(*) FROM api_keys")
|
|
706
|
+
row = cur.fetchone()
|
|
707
|
+
count = int(row[0] if isinstance(row, (tuple, list)) else row["count"] if isinstance(row, dict) else row[0])
|
|
708
|
+
if count == 0:
|
|
709
|
+
env_key = os.environ.get("SYNAPSE_API_KEY")
|
|
710
|
+
if env_key:
|
|
711
|
+
key_hash = self.hash_secret(env_key)
|
|
712
|
+
sql = self._format_sql("""
|
|
713
|
+
INSERT INTO api_keys (
|
|
714
|
+
key_id, key_hash, name, organization_id, project_id,
|
|
715
|
+
tenant_id, scopes, role, is_active, expires_at,
|
|
716
|
+
created_at, last_used_at
|
|
717
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, NULL, ?, NULL)
|
|
718
|
+
""")
|
|
719
|
+
cur.execute(sql, (
|
|
720
|
+
"key_root_bootstrap",
|
|
721
|
+
key_hash,
|
|
722
|
+
"Bootstrap Root API Key",
|
|
723
|
+
"org_root",
|
|
724
|
+
"proj_root",
|
|
725
|
+
"default",
|
|
726
|
+
json.dumps(["admin", "memories:read", "memories:write", "memories:delete", "security:audit"]),
|
|
727
|
+
"admin",
|
|
728
|
+
time.time()
|
|
729
|
+
))
|
|
730
|
+
conn.commit()
|
|
731
|
+
logger.info("Bootstrapped root API key from SYNAPSE_API_KEY.")
|
|
732
|
+
except Exception as e:
|
|
733
|
+
logger.warning(f"Failed to bootstrap root key: {e}")
|
|
734
|
+
finally:
|
|
735
|
+
self._return_connection(conn)
|
|
736
|
+
|
|
737
|
+
def _env_master_revoked_or_expired(self, env_token: str) -> Optional[str]:
|
|
738
|
+
"""Honor DB revocation/expiry for the env master key hash, if present.
|
|
739
|
+
|
|
740
|
+
Returns the DENIED_* outcome string when a matching api_keys row is
|
|
741
|
+
revoked or expired, else None (env master still valid).
|
|
742
|
+
"""
|
|
743
|
+
try:
|
|
744
|
+
conn = self._get_connection()
|
|
745
|
+
try:
|
|
746
|
+
cur = conn.cursor()
|
|
747
|
+
sql = self._format_sql("SELECT is_active, expires_at FROM api_keys WHERE key_hash = ?")
|
|
748
|
+
cur.execute(sql, (self.hash_secret(env_token),))
|
|
749
|
+
row = cur.fetchone()
|
|
750
|
+
if not row:
|
|
751
|
+
return None
|
|
752
|
+
data = dict(row) if not isinstance(row, (tuple, list)) else {"is_active": row[0], "expires_at": row[1]}
|
|
753
|
+
if not data.get("is_active"):
|
|
754
|
+
return "DENIED_REVOKED"
|
|
755
|
+
if data.get("expires_at") and time.time() > float(data["expires_at"]):
|
|
756
|
+
return "DENIED_EXPIRED"
|
|
757
|
+
return None
|
|
758
|
+
finally:
|
|
759
|
+
self._return_connection(conn)
|
|
760
|
+
except Exception as e:
|
|
761
|
+
logger.warning(f"Env-master revocation check failed (fail-open to DB auth): {e}")
|
|
762
|
+
return None
|
|
763
|
+
|
|
764
|
+
def validate_api_key(
|
|
765
|
+
self,
|
|
766
|
+
token: str,
|
|
767
|
+
required_scope: Optional[str] = None,
|
|
768
|
+
requested_tenant_id: Optional[str] = None,
|
|
769
|
+
route: str = "/",
|
|
770
|
+
action: str = "ACCESS",
|
|
771
|
+
client_ip: Optional[str] = None
|
|
772
|
+
) -> Tuple[bool, Optional[APIKeyRecord], str]:
|
|
773
|
+
"""
|
|
774
|
+
Validates API token against cryptographic hash, checks expiration, revocation,
|
|
775
|
+
scopes, and multi-tenant authorization boundaries.
|
|
776
|
+
|
|
777
|
+
Successful lookups are cached briefly (_AUTH_KEY_CACHE_TTL) so a hot key
|
|
778
|
+
does not hit the database, the last_used_at write, or the audit sink on
|
|
779
|
+
every request. Revoked keys are evicted from the cache by revoke_api_key.
|
|
780
|
+
"""
|
|
781
|
+
if not token:
|
|
782
|
+
self._log_audit(None, requested_tenant_id, route, action, "DENIED_MISSING_TOKEN", client_ip)
|
|
783
|
+
return False, None, "Missing API token"
|
|
784
|
+
|
|
785
|
+
# Rate limit unauthenticated attempts by IP before any database/auth work.
|
|
786
|
+
# A flood of invalid keys is rejected here instead of reaching the keys
|
|
787
|
+
# table or writing an audit row per attempt.
|
|
788
|
+
if client_ip and self._is_ip_rate_limited(client_ip):
|
|
789
|
+
return False, None, "Too many failed attempts. Try again later."
|
|
790
|
+
|
|
791
|
+
# Environment master-key fallback. This is an explicit bootstrap
|
|
792
|
+
# credential, not a permanent bypass: it is only honored when the
|
|
793
|
+
# caller explicitly opts in via SYNAPSE_ALLOW_ENV_MASTER_KEY=true, it
|
|
794
|
+
# is rejected when a DB row with the same hash is revoked/expired,
|
|
795
|
+
# and every use is audit-logged. Prefer DB-backed keys with an owner,
|
|
796
|
+
# expiry and revocation state; disable the env master in production.
|
|
797
|
+
expected_env_key = os.environ.get("SYNAPSE_API_KEY")
|
|
798
|
+
allow_env_master = os.environ.get("SYNAPSE_ALLOW_ENV_MASTER_KEY", "false").lower() == "true"
|
|
799
|
+
if expected_env_key and allow_env_master and hmac.compare_digest(token.encode("utf-8"), expected_env_key.encode("utf-8")):
|
|
800
|
+
revoked_or_expired = self._env_master_revoked_or_expired(expected_env_key)
|
|
801
|
+
if revoked_or_expired is not None:
|
|
802
|
+
self._log_audit("key_env_master", requested_tenant_id, route, action, revoked_or_expired, client_ip)
|
|
803
|
+
return False, None, "Environment master key is revoked or expired"
|
|
804
|
+
if required_scope and required_scope not in ("admin", "memories:read", "memories:write", "memories:delete", "security:audit"):
|
|
805
|
+
self._log_audit("key_env_master", requested_tenant_id, route, action, f"DENIED_INSUFFICIENT_SCOPE:{required_scope}", client_ip)
|
|
806
|
+
return False, None, f"Insufficient scopes. Required: {required_scope}"
|
|
807
|
+
admin_record = APIKeyRecord(
|
|
808
|
+
key_id="key_env_master",
|
|
809
|
+
key_hash=self.hash_secret(token),
|
|
810
|
+
name="Environment Master Key",
|
|
811
|
+
organization_id="org_system",
|
|
812
|
+
project_id="proj_system",
|
|
813
|
+
tenant_id=requested_tenant_id or "default",
|
|
814
|
+
scopes=["admin", "memories:read", "memories:write", "memories:delete", "security:audit"],
|
|
815
|
+
role="platform_admin",
|
|
816
|
+
is_active=True,
|
|
817
|
+
expires_at=None,
|
|
818
|
+
created_at=time.time(),
|
|
819
|
+
last_used_at=time.time()
|
|
820
|
+
)
|
|
821
|
+
self._log_audit(admin_record.key_id, requested_tenant_id, route, action, "ALLOWED", client_ip)
|
|
822
|
+
return True, admin_record, "Authenticated"
|
|
823
|
+
|
|
824
|
+
token_hash = self.hash_secret(token)
|
|
825
|
+
now = time.time()
|
|
826
|
+
|
|
827
|
+
# Brief key-lookup cache: skip the DB, the last_used_at write and the
|
|
828
|
+
# audit write on a cache hit for an active, non-expired key. Scope and
|
|
829
|
+
# tenant checks are still enforced in memory so a cached key can never
|
|
830
|
+
# bypass authorization.
|
|
831
|
+
cached = self._get_cached_key(token_hash, now)
|
|
832
|
+
if cached is not None:
|
|
833
|
+
record = cached
|
|
834
|
+
if record.expires_at and now > record.expires_at:
|
|
835
|
+
self._evict_cached_key(record.key_id)
|
|
836
|
+
self._log_audit(record.key_id, requested_tenant_id, route, action, "DENIED_EXPIRED", client_ip)
|
|
837
|
+
return False, record, "API token has expired"
|
|
838
|
+
if required_scope:
|
|
839
|
+
if not scope_satisfied(record, required_scope):
|
|
840
|
+
self._log_audit(
|
|
841
|
+
record.key_id, requested_tenant_id, route, action,
|
|
842
|
+
f"DENIED_INSUFFICIENT_SCOPE:{required_scope}", client_ip
|
|
843
|
+
)
|
|
844
|
+
return False, record, f"Insufficient scopes. Required: {required_scope}"
|
|
845
|
+
if requested_tenant_id and not tenant_selection_allowed(record, requested_tenant_id):
|
|
846
|
+
self._log_audit(
|
|
847
|
+
record.key_id, requested_tenant_id, route, action,
|
|
848
|
+
"DENIED_TENANT_MISMATCH", client_ip
|
|
849
|
+
)
|
|
850
|
+
return False, record, f"Tenant authorization boundary violation. Key bound to: {record.tenant_id}"
|
|
851
|
+
self._log_audit(record.key_id, requested_tenant_id or record.tenant_id, route, action, "ALLOWED", client_ip)
|
|
852
|
+
return True, record, "Authenticated"
|
|
853
|
+
|
|
854
|
+
conn = self._get_connection()
|
|
855
|
+
try:
|
|
856
|
+
if self._is_postgres and RealDictCursor is not None:
|
|
857
|
+
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
858
|
+
else:
|
|
859
|
+
cur = conn.cursor()
|
|
860
|
+
|
|
861
|
+
sql = self._format_sql("SELECT * FROM api_keys WHERE key_hash = ?")
|
|
862
|
+
cur.execute(sql, (token_hash,))
|
|
863
|
+
row = cur.fetchone()
|
|
864
|
+
if not row:
|
|
865
|
+
# Invalid key: count the failure for IP rate limiting and cap
|
|
866
|
+
# audit writes so a flood of bad keys does not flood the log.
|
|
867
|
+
if client_ip:
|
|
868
|
+
self._record_ip_failure(client_ip)
|
|
869
|
+
if self._is_ip_rate_limited(client_ip):
|
|
870
|
+
return False, None, "Too many failed attempts. Try again later."
|
|
871
|
+
if self._should_audit_failure(client_ip):
|
|
872
|
+
self._log_audit(None, requested_tenant_id, route, action, "DENIED_INVALID_KEY", client_ip)
|
|
873
|
+
return False, None, "Invalid API token"
|
|
874
|
+
|
|
875
|
+
data = dict(row)
|
|
876
|
+
scopes = json.loads(data["scopes"]) if isinstance(data["scopes"], str) else data["scopes"]
|
|
877
|
+
record = APIKeyRecord(
|
|
878
|
+
key_id=data["key_id"],
|
|
879
|
+
key_hash=data["key_hash"],
|
|
880
|
+
name=data["name"],
|
|
881
|
+
organization_id=data["organization_id"],
|
|
882
|
+
project_id=data["project_id"],
|
|
883
|
+
tenant_id=data["tenant_id"],
|
|
884
|
+
scopes=scopes,
|
|
885
|
+
role=data["role"],
|
|
886
|
+
is_active=bool(data["is_active"]),
|
|
887
|
+
expires_at=data["expires_at"],
|
|
888
|
+
created_at=data["created_at"],
|
|
889
|
+
last_used_at=data["last_used_at"]
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
# Check revocation
|
|
893
|
+
if not record.is_active:
|
|
894
|
+
self._log_audit(record.key_id, requested_tenant_id, route, action, "DENIED_REVOKED", client_ip)
|
|
895
|
+
return False, record, "API token has been revoked"
|
|
896
|
+
|
|
897
|
+
# Check expiration
|
|
898
|
+
if record.expires_at and now > record.expires_at:
|
|
899
|
+
self._log_audit(record.key_id, requested_tenant_id, route, action, "DENIED_EXPIRED", client_ip)
|
|
900
|
+
return False, record, "API token has expired"
|
|
901
|
+
|
|
902
|
+
# Check RBAC scopes
|
|
903
|
+
if required_scope:
|
|
904
|
+
if not scope_satisfied(record, required_scope):
|
|
905
|
+
self._log_audit(
|
|
906
|
+
record.key_id, requested_tenant_id, route, action,
|
|
907
|
+
f"DENIED_INSUFFICIENT_SCOPE:{required_scope}", client_ip
|
|
908
|
+
)
|
|
909
|
+
return False, record, f"Insufficient scopes. Required: {required_scope}"
|
|
910
|
+
|
|
911
|
+
# Tenant isolation (F-01): only the canonical platform_admin role may
|
|
912
|
+
# act across tenants; an "admin" role/scope on a workspace-bound key
|
|
913
|
+
# no longer widens the boundary.
|
|
914
|
+
if requested_tenant_id and not tenant_selection_allowed(record, requested_tenant_id):
|
|
915
|
+
self._log_audit(
|
|
916
|
+
record.key_id, requested_tenant_id, route, action,
|
|
917
|
+
"DENIED_TENANT_MISMATCH", client_ip
|
|
918
|
+
)
|
|
919
|
+
return False, record, f"Tenant authorization boundary violation. Key bound to: {record.tenant_id}"
|
|
920
|
+
|
|
921
|
+
# Cache the validated record briefly; persist last_used_at once on
|
|
922
|
+
# this (cache-miss) load. Cache hits skip both the DB and this write.
|
|
923
|
+
self._cache_key(token_hash, now, record)
|
|
924
|
+
upd_sql = self._format_sql("UPDATE api_keys SET last_used_at = ? WHERE key_id = ?")
|
|
925
|
+
cur.execute(upd_sql, (now, record.key_id))
|
|
926
|
+
conn.commit()
|
|
927
|
+
|
|
928
|
+
self._log_audit(record.key_id, requested_tenant_id or record.tenant_id, route, action, "ALLOWED", client_ip)
|
|
929
|
+
return True, record, "Authenticated"
|
|
930
|
+
finally:
|
|
931
|
+
self._return_connection(conn)
|
|
932
|
+
|
|
933
|
+
def _get_cached_key(self, token_hash: str, now: float) -> Optional[APIKeyRecord]:
|
|
934
|
+
"""Return a cached, still-valid key record, or None on cache miss.
|
|
935
|
+
|
|
936
|
+
An entry is treated as a miss when either its TTL expired OR it is older
|
|
937
|
+
than ``_AUTH_REVALIDATE_SECONDS``. The second condition is what bounds
|
|
938
|
+
cross-replica revocation delay (F-10): it forces a database re-read, so a
|
|
939
|
+
key revoked on another replica is denied here within that window instead
|
|
940
|
+
of living out the full 30s TTL.
|
|
941
|
+
"""
|
|
942
|
+
with self._auth_lock:
|
|
943
|
+
entry = self._key_cache.get(token_hash)
|
|
944
|
+
if entry is None:
|
|
945
|
+
return None
|
|
946
|
+
expires_at, record, cached_at = entry
|
|
947
|
+
if now > expires_at or (now - cached_at) > _AUTH_REVALIDATE_SECONDS:
|
|
948
|
+
del self._key_cache[token_hash]
|
|
949
|
+
return None
|
|
950
|
+
return record
|
|
951
|
+
|
|
952
|
+
def _cache_key(self, token_hash: str, now: float, record: APIKeyRecord) -> None:
|
|
953
|
+
with self._auth_lock:
|
|
954
|
+
self._key_cache[token_hash] = (now + _AUTH_KEY_CACHE_TTL, record, now)
|
|
955
|
+
|
|
956
|
+
def close(self) -> None:
|
|
957
|
+
"""Flush buffered audit rows and drop in-process caches.
|
|
958
|
+
|
|
959
|
+
Called from the FastAPI shutdown hook (F-13). Without it an abrupt
|
|
960
|
+
process stop loses the pending audit batch, which matters because those
|
|
961
|
+
rows are the security event record.
|
|
962
|
+
"""
|
|
963
|
+
try:
|
|
964
|
+
with self._auth_lock:
|
|
965
|
+
self._flush_audit_buffer_locked()
|
|
966
|
+
except Exception: # pragma: no cover - shutdown must never raise
|
|
967
|
+
pass
|
|
968
|
+
with self._auth_lock:
|
|
969
|
+
self._key_cache.clear()
|
|
970
|
+
self._ip_failures.clear()
|
|
971
|
+
self._ip_audited_failures.clear()
|
|
972
|
+
|
|
973
|
+
def _evict_cached_key(self, key_id: str) -> None:
|
|
974
|
+
with self._auth_lock:
|
|
975
|
+
for token_hash, entry in list(self._key_cache.items()):
|
|
976
|
+
record = entry[1]
|
|
977
|
+
if record.key_id == key_id:
|
|
978
|
+
del self._key_cache[token_hash]
|
|
979
|
+
|
|
980
|
+
def _record_ip_failure(self, client_ip: str) -> None:
|
|
981
|
+
with self._auth_lock:
|
|
982
|
+
self._ip_failures[client_ip].append(time.time())
|
|
983
|
+
self._prune_ip_state_locked()
|
|
984
|
+
|
|
985
|
+
def _prune_ip_state_locked(self) -> None:
|
|
986
|
+
"""Bound per-IP auth state (F-13).
|
|
987
|
+
|
|
988
|
+
Drops expired failure windows and, if the map is still larger than
|
|
989
|
+
_MAX_TRACKED_IPS, evicts the least-recently-seen source addresses. Called
|
|
990
|
+
with ``_auth_lock`` held.
|
|
991
|
+
"""
|
|
992
|
+
if len(self._ip_failures) <= _MAX_TRACKED_IPS and len(self._ip_audited_failures) <= _MAX_TRACKED_IPS:
|
|
993
|
+
return
|
|
994
|
+
now = time.time()
|
|
995
|
+
window_start = now - _IP_RATE_WINDOW
|
|
996
|
+
for ip in list(self._ip_failures.keys()):
|
|
997
|
+
hits = [t for t in self._ip_failures[ip] if t >= window_start]
|
|
998
|
+
if hits:
|
|
999
|
+
self._ip_failures[ip] = hits
|
|
1000
|
+
else:
|
|
1001
|
+
del self._ip_failures[ip]
|
|
1002
|
+
# Still over the ceiling: evict the oldest half (dicts keep insertion
|
|
1003
|
+
# order, so the earliest entries are the least recently created).
|
|
1004
|
+
for mapping in (self._ip_failures, self._ip_audited_failures):
|
|
1005
|
+
excess = len(mapping) - _MAX_TRACKED_IPS
|
|
1006
|
+
if excess > 0:
|
|
1007
|
+
for ip in list(mapping.keys())[:excess]:
|
|
1008
|
+
del mapping[ip]
|
|
1009
|
+
|
|
1010
|
+
def _is_ip_rate_limited(self, client_ip: str) -> bool:
|
|
1011
|
+
"""Sliding-window per-IP failure count. True = deny before auth/DB."""
|
|
1012
|
+
with self._auth_lock:
|
|
1013
|
+
now = time.time()
|
|
1014
|
+
window_start = now - _IP_RATE_WINDOW
|
|
1015
|
+
hits = [t for t in self._ip_failures.get(client_ip, []) if t >= window_start]
|
|
1016
|
+
self._ip_failures[client_ip] = hits
|
|
1017
|
+
return len(hits) >= _IP_RATE_MAX_FAILURES
|
|
1018
|
+
|
|
1019
|
+
def _should_audit_failure(self, client_ip: Optional[str]) -> bool:
|
|
1020
|
+
"""Cap audit writes for failed lookups so a flood of bad keys cannot
|
|
1021
|
+
flood the audit log. Returns True if this failure should be audited."""
|
|
1022
|
+
if client_ip is None:
|
|
1023
|
+
return True
|
|
1024
|
+
with self._auth_lock:
|
|
1025
|
+
if self._ip_audited_failures[client_ip] >= _IP_MAX_FAILED_AUDITS:
|
|
1026
|
+
return False
|
|
1027
|
+
self._ip_audited_failures[client_ip] += 1
|
|
1028
|
+
return True
|
|
1029
|
+
|
|
1030
|
+
def revoke_api_key(self, key_id: str) -> bool:
|
|
1031
|
+
conn = self._get_connection()
|
|
1032
|
+
try:
|
|
1033
|
+
cur = conn.cursor()
|
|
1034
|
+
sql = self._format_sql("UPDATE api_keys SET is_active = 0 WHERE key_id = ?")
|
|
1035
|
+
cur.execute(sql, (key_id,))
|
|
1036
|
+
conn.commit()
|
|
1037
|
+
revoked = cur.rowcount > 0
|
|
1038
|
+
if revoked:
|
|
1039
|
+
# Evict from the in-memory key cache so a revoked key is denied
|
|
1040
|
+
# immediately instead of living out its cache TTL.
|
|
1041
|
+
self._evict_cached_key(key_id)
|
|
1042
|
+
return revoked
|
|
1043
|
+
finally:
|
|
1044
|
+
self._return_connection(conn)
|
|
1045
|
+
|
|
1046
|
+
def check_hierarchy(
|
|
1047
|
+
self,
|
|
1048
|
+
record: APIKeyRecord,
|
|
1049
|
+
requested_org: Optional[str] = None,
|
|
1050
|
+
requested_project: Optional[str] = None,
|
|
1051
|
+
requested_tenant: Optional[str] = None,
|
|
1052
|
+
) -> Tuple[bool, str]:
|
|
1053
|
+
"""Enforce org → project → tenant ownership on a request context.
|
|
1054
|
+
|
|
1055
|
+
A key is bound to one (organization_id, project_id, tenant_id) triple.
|
|
1056
|
+
An empty/None requested value inherits the key's own binding; a concrete
|
|
1057
|
+
requested value must equal the binding (admins with tenant "*" bypass
|
|
1058
|
+
the tenant leg only). Returns (allowed, reason).
|
|
1059
|
+
"""
|
|
1060
|
+
if self.is_system_principal(record):
|
|
1061
|
+
return True, "System principal bypass"
|
|
1062
|
+
if requested_org and record.organization_id not in ("*", "", "org_system"):
|
|
1063
|
+
if requested_org != record.organization_id:
|
|
1064
|
+
return False, f"Organization boundary violation. Key bound to: {record.organization_id}"
|
|
1065
|
+
if requested_project and record.project_id not in ("*", ""):
|
|
1066
|
+
if requested_project != record.project_id:
|
|
1067
|
+
return False, f"Project boundary violation. Key bound to: {record.project_id}"
|
|
1068
|
+
if requested_tenant and record.tenant_id not in ("*", ""):
|
|
1069
|
+
if requested_tenant != record.tenant_id:
|
|
1070
|
+
return False, f"Tenant boundary violation. Key bound to: {record.tenant_id}"
|
|
1071
|
+
return True, "Hierarchy check passed"
|
|
1072
|
+
|
|
1073
|
+
def is_system_principal(self, record: APIKeyRecord) -> bool:
|
|
1074
|
+
"""Platform-internal principals (env master key / org_system) are operators.
|
|
1075
|
+
|
|
1076
|
+
They predate per-org key bindings and exist to administer the whole
|
|
1077
|
+
deployment; every hierarchy-gated admin endpoint bypasses the org →
|
|
1078
|
+
project → tenant legs for them.
|
|
1079
|
+
"""
|
|
1080
|
+
return record.key_id == "key_env_master" or record.organization_id == "org_system"
|
|
1081
|
+
|
|
1082
|
+
def list_api_keys(self, organization_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
1083
|
+
conn = self._get_connection()
|
|
1084
|
+
try:
|
|
1085
|
+
if self._is_postgres and RealDictCursor is not None:
|
|
1086
|
+
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
1087
|
+
else:
|
|
1088
|
+
cur = conn.cursor()
|
|
1089
|
+
|
|
1090
|
+
if organization_id:
|
|
1091
|
+
sql = self._format_sql("SELECT * FROM api_keys WHERE organization_id = ? ORDER BY created_at DESC")
|
|
1092
|
+
cur.execute(sql, (organization_id,))
|
|
1093
|
+
else:
|
|
1094
|
+
cur.execute("SELECT * FROM api_keys ORDER BY created_at DESC")
|
|
1095
|
+
rows = cur.fetchall()
|
|
1096
|
+
results = []
|
|
1097
|
+
for r in rows:
|
|
1098
|
+
d = dict(r)
|
|
1099
|
+
d.pop("key_hash", None) # Never expose hash
|
|
1100
|
+
d["scopes"] = json.loads(d["scopes"]) if isinstance(d["scopes"], str) else d["scopes"]
|
|
1101
|
+
results.append(d)
|
|
1102
|
+
return results
|
|
1103
|
+
finally:
|
|
1104
|
+
self._return_connection(conn)
|
|
1105
|
+
|
|
1106
|
+
def _log_audit(
|
|
1107
|
+
self,
|
|
1108
|
+
key_id: Optional[str],
|
|
1109
|
+
tenant_id: Optional[str],
|
|
1110
|
+
route: str,
|
|
1111
|
+
action: str,
|
|
1112
|
+
outcome: str,
|
|
1113
|
+
client_ip: Optional[str]
|
|
1114
|
+
) -> None:
|
|
1115
|
+
"""Buffer an audit row. Rows are flushed in batches (one connection,
|
|
1116
|
+
one transaction) so the auth hot path never opens a dedicated
|
|
1117
|
+
connection per audit event. Never raises."""
|
|
1118
|
+
record = (
|
|
1119
|
+
f"audit_{secrets.token_hex(8)}",
|
|
1120
|
+
time.time(),
|
|
1121
|
+
key_id,
|
|
1122
|
+
tenant_id,
|
|
1123
|
+
route,
|
|
1124
|
+
action,
|
|
1125
|
+
outcome,
|
|
1126
|
+
client_ip,
|
|
1127
|
+
)
|
|
1128
|
+
with self._auth_lock:
|
|
1129
|
+
self._audit_buffer.append(record)
|
|
1130
|
+
if (
|
|
1131
|
+
len(self._audit_buffer) >= _AUDIT_FLUSH_MAX
|
|
1132
|
+
or (time.time() - self._last_audit_flush) >= _AUDIT_FLUSH_INTERVAL
|
|
1133
|
+
):
|
|
1134
|
+
self._flush_audit_buffer_locked()
|
|
1135
|
+
|
|
1136
|
+
def _flush_audit_buffer_locked(self) -> None:
|
|
1137
|
+
"""Write all buffered audit rows in a single connection/transaction.
|
|
1138
|
+
|
|
1139
|
+
Must be called with self._auth_lock held. On failure the batch is put
|
|
1140
|
+
back at the head of the buffer so a later flush retries it (audit
|
|
1141
|
+
evidence is never silently dropped).
|
|
1142
|
+
"""
|
|
1143
|
+
if not self._audit_buffer:
|
|
1144
|
+
return
|
|
1145
|
+
batch = self._audit_buffer
|
|
1146
|
+
self._audit_buffer = []
|
|
1147
|
+
self._last_audit_flush = time.time()
|
|
1148
|
+
conn = self._get_connection()
|
|
1149
|
+
try:
|
|
1150
|
+
cur = conn.cursor()
|
|
1151
|
+
sql = self._format_sql(
|
|
1152
|
+
"INSERT INTO auth_audit_logs (id, timestamp, key_id, tenant_id, route, action, outcome, client_ip) "
|
|
1153
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
1154
|
+
)
|
|
1155
|
+
cur.executemany(sql, batch)
|
|
1156
|
+
conn.commit()
|
|
1157
|
+
except Exception as e:
|
|
1158
|
+
self._audit_buffer = batch + self._audit_buffer
|
|
1159
|
+
logger.warning(f"Failed to flush auth audit batch ({len(batch)} rows): {e}")
|
|
1160
|
+
finally:
|
|
1161
|
+
self._return_connection(conn)
|
|
1162
|
+
|
|
1163
|
+
def log_audit_action(
|
|
1164
|
+
self,
|
|
1165
|
+
actor_key_id: Optional[str],
|
|
1166
|
+
tenant_id: Optional[str],
|
|
1167
|
+
action: str,
|
|
1168
|
+
resource: str = "memories",
|
|
1169
|
+
status: str = "SUCCESS",
|
|
1170
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
1171
|
+
route: str = "/",
|
|
1172
|
+
client_ip: Optional[str] = None,
|
|
1173
|
+
) -> None:
|
|
1174
|
+
"""Public action-level audit writer for gateway data-plane events.
|
|
1175
|
+
|
|
1176
|
+
Wraps the internal _log_audit record with resource/status context so
|
|
1177
|
+
destructive operations (bulk clears, GDPR deletes, retention prunes)
|
|
1178
|
+
leave a queryable compliance trail. Never raises — audit must not
|
|
1179
|
+
break the data path it observes.
|
|
1180
|
+
"""
|
|
1181
|
+
outcome = f"{action}:{status}" if resource == "memories" else f"{action}:{status}:{resource}"
|
|
1182
|
+
try:
|
|
1183
|
+
self._log_audit(
|
|
1184
|
+
actor_key_id, tenant_id, route, action, outcome, client_ip,
|
|
1185
|
+
)
|
|
1186
|
+
if metadata:
|
|
1187
|
+
logger.info("audit action=%s actor=%s tenant=%s meta=%s", action, actor_key_id, tenant_id, metadata)
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
logger.warning(f"Failed to record audit action {action}: {e}")
|
|
1190
|
+
|
|
1191
|
+
def get_audit_logs(self, limit: int = 100, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
1192
|
+
conn = self._get_connection()
|
|
1193
|
+
try:
|
|
1194
|
+
if self._is_postgres and RealDictCursor is not None:
|
|
1195
|
+
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
1196
|
+
else:
|
|
1197
|
+
cur = conn.cursor()
|
|
1198
|
+
|
|
1199
|
+
if tenant_id:
|
|
1200
|
+
sql = self._format_sql(
|
|
1201
|
+
"SELECT * FROM auth_audit_logs WHERE tenant_id = ? ORDER BY timestamp DESC LIMIT ?"
|
|
1202
|
+
)
|
|
1203
|
+
cur.execute(sql, (tenant_id, limit))
|
|
1204
|
+
else:
|
|
1205
|
+
sql = self._format_sql("SELECT * FROM auth_audit_logs ORDER BY timestamp DESC LIMIT ?")
|
|
1206
|
+
cur.execute(sql, (limit,))
|
|
1207
|
+
return [dict(r) for r in cur.fetchall()]
|
|
1208
|
+
finally:
|
|
1209
|
+
self._return_connection(conn)
|
|
1210
|
+
|
|
1211
|
+
def export_audit_logs(
|
|
1212
|
+
self,
|
|
1213
|
+
tenant_id: Optional[str] = None,
|
|
1214
|
+
since: Optional[float] = None,
|
|
1215
|
+
limit: int = 10000,
|
|
1216
|
+
) -> List[Dict[str, Any]]:
|
|
1217
|
+
"""Compliance export of auth audit records for retention/archival.
|
|
1218
|
+
|
|
1219
|
+
Bounded to `limit` rows so an export can never OOM the engine on a
|
|
1220
|
+
years-old tenant. Callers stream/export in pages keyed on the rows'
|
|
1221
|
+
`timestamp` values.
|
|
1222
|
+
"""
|
|
1223
|
+
conn = self._get_connection()
|
|
1224
|
+
try:
|
|
1225
|
+
if self._is_postgres and RealDictCursor is not None:
|
|
1226
|
+
cur = conn.cursor(cursor_factory=RealDictCursor)
|
|
1227
|
+
else:
|
|
1228
|
+
cur = conn.cursor()
|
|
1229
|
+
|
|
1230
|
+
clauses: List[str] = []
|
|
1231
|
+
params: List[Any] = []
|
|
1232
|
+
if tenant_id:
|
|
1233
|
+
clauses.append("tenant_id = ?")
|
|
1234
|
+
params.append(tenant_id)
|
|
1235
|
+
if since is not None:
|
|
1236
|
+
clauses.append("timestamp >= ?")
|
|
1237
|
+
params.append(since)
|
|
1238
|
+
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
1239
|
+
sql = self._format_sql(f"SELECT * FROM auth_audit_logs {where} ORDER BY timestamp ASC LIMIT ?")
|
|
1240
|
+
params.append(min(limit, 50000))
|
|
1241
|
+
cur.execute(sql, tuple(params))
|
|
1242
|
+
return [dict(r) for r in cur.fetchall()]
|
|
1243
|
+
finally:
|
|
1244
|
+
self._return_connection(conn)
|
|
1245
|
+
|
|
1246
|
+
def prune_audit_logs(
|
|
1247
|
+
self, older_than_seconds: float = 90 * 86400, tenant_id: Optional[str] = None
|
|
1248
|
+
) -> int:
|
|
1249
|
+
"""Delete audit records older than the retention window.
|
|
1250
|
+
|
|
1251
|
+
Default 90 days. Returns the deleted row count so the gateway can
|
|
1252
|
+
confirm and log the retention pass. Always logged as an audit event.
|
|
1253
|
+
P1.1: pass tenant_id to scope the retention pass to one tenant —
|
|
1254
|
+
tenant-bound admins must not be able to destroy other tenants'
|
|
1255
|
+
audit evidence. None (system principals only) prunes globally.
|
|
1256
|
+
"""
|
|
1257
|
+
cutoff = time.time() - max(0.0, float(older_than_seconds))
|
|
1258
|
+
conn = self._get_connection()
|
|
1259
|
+
try:
|
|
1260
|
+
cur = conn.cursor()
|
|
1261
|
+
if tenant_id is not None:
|
|
1262
|
+
sql = self._format_sql(
|
|
1263
|
+
"DELETE FROM auth_audit_logs WHERE timestamp < ? AND tenant_id = ?"
|
|
1264
|
+
)
|
|
1265
|
+
cur.execute(sql, (cutoff, tenant_id))
|
|
1266
|
+
else:
|
|
1267
|
+
sql = self._format_sql("DELETE FROM auth_audit_logs WHERE timestamp < ?")
|
|
1268
|
+
cur.execute(sql, (cutoff,))
|
|
1269
|
+
deleted = cur.rowcount if cur.rowcount is not None else 0
|
|
1270
|
+
conn.commit()
|
|
1271
|
+
return int(deleted)
|
|
1272
|
+
finally:
|
|
1273
|
+
self._return_connection(conn)
|