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.
@@ -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)