lomberk 0.2.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.
lomberk/__init__.py ADDED
File without changes
lomberk/api.py ADDED
@@ -0,0 +1,613 @@
1
+ import asyncio
2
+ import hmac
3
+ import inspect
4
+ import logging
5
+ import os
6
+ import secrets
7
+ from contextlib import asynccontextmanager
8
+ from datetime import datetime
9
+ from typing import Literal
10
+
11
+ from fastapi import Depends, FastAPI, HTTPException, Query, Security
12
+ from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
13
+ from pydantic import BaseModel
14
+ from starlette.routing import Route
15
+
16
+ from lomberk.audit import audit
17
+ from lomberk.config import PRICING_URL, load_config, weak_key_reason
18
+ from lomberk.executor import LomberkExecutor
19
+ from lomberk.policy import PolicyEngine
20
+ from lomberk.sentinel import AISentinel
21
+ from lomberk.server import build_mcp, package_version
22
+ from lomberk.sql.classify import classify_sql
23
+ from lomberk.startup import check_config, check_db_roles, new_executor, resolve_plan
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Global references for lifespan
28
+ _executor_instance = None
29
+ _slack_transport_instance = None
30
+ _policy_engine_instance = None
31
+ _slack_transport_instance = None
32
+
33
+
34
+ # Immutable runtime config
35
+ _API_KEY = None
36
+ _ADMIN_KEY = None
37
+ # Panel session-cookie signing secret. Random per process and independent of _ADMIN_KEY:
38
+ # the admin key is deliberately excluded from the approval quorum, so it must not be able to
39
+ # mint (forge) panel sessions — least of all an `oidc:` identity. A restart ends all sessions.
40
+ _SESSION_SECRET = None
41
+ _METRICS_KEY = None # LOMBERK_METRICS_KEY; unset = /metrics is off
42
+ _SSO = None # ee OidcVerifier when sso.issuer is set
43
+ _ALLOW_ADMIN_KEY = True
44
+ _PRO = False # a valid Lomberk Pro license is present
45
+ _PLAN = "free" # "free", "pro" or "enterprise": sets how far back undo reaches
46
+
47
+ @asynccontextmanager
48
+ async def lifespan(app: FastAPI):
49
+ global _executor_instance, _policy_engine_instance, _API_KEY, _ADMIN_KEY, _SESSION_SECRET, _slack_transport_instance, _SSO, _ALLOW_ADMIN_KEY, _PRO, _PLAN, _METRICS_KEY
50
+
51
+ # Local registry ownership for safe and strict shutdown drainage
52
+ local_db_workers = set()
53
+ local_shutdown_event = asyncio.Event()
54
+ publisher_task = None
55
+
56
+ # Still attach to app.state for testing and observability,
57
+ # but internal processing strictly uses the local references.
58
+ app.state._db_workers = local_db_workers
59
+ app.state._shutdown_event = local_shutdown_event
60
+
61
+ config = load_config()
62
+ dsn = os.environ.get(config.database.agent_dsn_env)
63
+
64
+ agent_key = os.environ.get("LOMBERK_API_KEY")
65
+ admin_key = os.environ.get("LOMBERK_ADMIN_KEY")
66
+
67
+ if not dsn:
68
+ raise ValueError(f"Startup Refusal: Database DSN must be provided via {config.database.agent_dsn_env}.")
69
+ if not agent_key or not admin_key:
70
+ raise ValueError("Startup Refusal: Both LOMBERK_API_KEY and LOMBERK_ADMIN_KEY must be configured.")
71
+ if agent_key == admin_key:
72
+ raise ValueError("Startup Refusal: Agent and Admin keys must NOT be identical.")
73
+ # A guessable key is the one thing a rate limit would have to save; a random 32+ character
74
+ # key can't be brute-forced, so refuse anything else. Demos opt out explicitly.
75
+ metrics_key = os.environ.get("LOMBERK_METRICS_KEY") or None
76
+ if metrics_key in (agent_key, admin_key):
77
+ raise ValueError("Startup Refusal: LOMBERK_METRICS_KEY must differ from the agent and admin keys.")
78
+ if os.environ.get("LOMBERK_ALLOW_WEAK_KEYS", "").lower() != "true":
79
+ for env, key in (("LOMBERK_API_KEY", agent_key), ("LOMBERK_ADMIN_KEY", admin_key), ("LOMBERK_METRICS_KEY", metrics_key)):
80
+ if key and (reason := weak_key_reason(key)):
81
+ raise ValueError(f"Startup Refusal: {env} {reason}. Generate one with: openssl rand -hex 32")
82
+ _METRICS_KEY = metrics_key
83
+
84
+ _API_KEY = agent_key
85
+ _ADMIN_KEY = admin_key
86
+ _SESSION_SECRET = secrets.token_bytes(32)
87
+ slack_enabled = os.environ.get("LOMBERK_SLACK_ENABLED", "").lower() == "true"
88
+ _PLAN = await asyncio.to_thread(resolve_plan, config)
89
+ _PRO = _PLAN != "free"
90
+ _SSO, _ALLOW_ADMIN_KEY = None, True
91
+ if config.sso.issuer:
92
+ from lomberk.ee.sso import OidcVerifier
93
+ _SSO = await asyncio.to_thread(OidcVerifier, config.sso)
94
+ _ALLOW_ADMIN_KEY = bool(config.sso.allow_admin_key)
95
+ elif config.sso.allow_admin_key is False:
96
+ raise ValueError("Startup Refusal: sso.allow_admin_key is false but no sso.issuer is configured.")
97
+ check_config(config)
98
+
99
+ try:
100
+ admin_dsn = os.environ.get(config.database.admin_dsn_env)
101
+ if not admin_dsn:
102
+ raise ValueError('Startup Refusal: Admin DSN missing')
103
+
104
+ await asyncio.to_thread(check_db_roles, admin_dsn, dsn)
105
+ _executor_instance = new_executor(config, admin_dsn, dsn, _PLAN)
106
+ _policy_engine_instance = PolicyEngine(config)
107
+
108
+ # Production Slack Transport Wiring
109
+ if slack_enabled:
110
+ app_token = os.environ.get("LOMBERK_SLACK_APP_TOKEN")
111
+ bot_token = os.environ.get("LOMBERK_SLACK_BOT_TOKEN")
112
+
113
+ if not app_token or not bot_token:
114
+ raise ValueError("Startup Refusal: LOMBERK_SLACK_APP_TOKEN and LOMBERK_SLACK_BOT_TOKEN are required when Slack is enabled.")
115
+
116
+ if not hasattr(config, "slack") or not config.slack.admin_allowlist:
117
+ raise ValueError("Startup Refusal: config.slack.admin_allowlist is missing or empty.")
118
+ if not config.slack.team_allowlist:
119
+ raise ValueError("Startup Refusal: config.slack.team_allowlist is missing or empty.")
120
+
121
+ slack_update_cb = getattr(app.state, "slack_update_callback", None)
122
+ slack_resolver = getattr(app.state, "slack_target_resolver", None)
123
+ # Built-in publisher mode: config names a channel and nothing is injected.
124
+ use_publisher = bool(config.slack.channel_id) and slack_update_cb is None and slack_resolver is None
125
+ if not use_publisher:
126
+ if not slack_update_cb or not inspect.iscoroutinefunction(slack_update_cb):
127
+ raise ValueError("Startup Refusal: slack_update_callback dependency missing or not an async function.")
128
+ if not slack_resolver or not inspect.iscoroutinefunction(slack_resolver):
129
+ raise ValueError("Startup Refusal: slack_target_resolver dependency missing or not an async function.")
130
+
131
+ try:
132
+ from slack_sdk.socket_mode.aiohttp import SocketModeClient
133
+ from slack_sdk.web.async_client import AsyncWebClient
134
+
135
+ from lomberk.ee.slack import SlackInteractionAdapter, slack_actor
136
+ from lomberk.ee.slack_transport import SlackTransport
137
+ # Bind local registry to callbacks
138
+ def make_callbacks(executor, policy, resolver, updater, workers_set, shutdown_evt):
139
+ async def resolve_cb(team, channel, ts):
140
+ return await resolver(team, channel, ts)
141
+
142
+ async def _run_tracked(func, *args):
143
+ if shutdown_evt.is_set():
144
+ from lomberk.ee.slack import TaskCapacityExceededError
145
+ raise TaskCapacityExceededError("Server is shutting down. Rejecting new DB workers.")
146
+ if len(workers_set) >= 50:
147
+ from lomberk.ee.slack import TaskCapacityExceededError
148
+ raise TaskCapacityExceededError("DB worker capacity exceeded. Server is busy.")
149
+
150
+ t = asyncio.create_task(asyncio.to_thread(func, *args))
151
+ workers_set.add(t)
152
+ def _done_cb(fut):
153
+ workers_set.discard(fut)
154
+ if not fut.cancelled() and fut.exception():
155
+ logger.error(f"Background DB worker failed: {type(fut.exception()).__name__}")
156
+ t.add_done_callback(_done_cb)
157
+ return await asyncio.shield(t)
158
+
159
+ async def status_cb(pid):
160
+ return await _run_tracked(executor.check_status, pid)
161
+
162
+ async def approve_cb(pid):
163
+ res = await _run_tracked(executor.execute_pending, pid, slack_actor.get() or "slack")
164
+ return {"status": res.status}
165
+
166
+ async def reject_cb(pid):
167
+ await _run_tracked(executor.reject, pid, slack_actor.get() or "slack")
168
+ return {"status": "denied"}
169
+
170
+ async def update_cb(channel, ts, msg):
171
+ return await updater(channel, ts, msg)
172
+
173
+ return resolve_cb, status_cb, approve_cb, reject_cb, update_cb
174
+
175
+ web_factory = getattr(app.state, "slack_web_client_factory", None) or AsyncWebClient
176
+ if use_publisher:
177
+ from lomberk.ee.slack_publisher import SlackPublisher
178
+ publisher = SlackPublisher(
179
+ admin_dsn, web_factory(token=bot_token), config.slack.channel_id,
180
+ set(config.slack.team_allowlist), include_sql=config.slack.include_sql,
181
+ )
182
+ slack_resolver, slack_update_cb = publisher.resolve, publisher.update
183
+ publisher_task = asyncio.create_task(publisher.run(local_shutdown_event))
184
+
185
+ resolve_cb, status_cb, approve_cb, reject_cb, update_cb = make_callbacks(
186
+ _executor_instance, _policy_engine_instance, slack_resolver, slack_update_cb, local_db_workers, local_shutdown_event
187
+ )
188
+
189
+ adapter = SlackInteractionAdapter(
190
+ admin_allowlist=set(config.slack.admin_allowlist),
191
+ team_allowlist=set(config.slack.team_allowlist) if hasattr(config.slack, "team_allowlist") else set(),
192
+ target_resolver=resolve_cb,
193
+ approve_callback=approve_cb,
194
+ reject_callback=reject_cb,
195
+ status_callback=status_cb,
196
+ slack_update_callback=update_cb
197
+ )
198
+
199
+ def client_factory(token: str):
200
+ return SocketModeClient(app_token=token, web_client=web_factory(token=bot_token))
201
+
202
+ factory_override = getattr(app.state, "slack_client_factory", None)
203
+
204
+ _slack_transport_instance = SlackTransport(
205
+ app_token=app_token,
206
+ business_adapter=adapter,
207
+ client_factory=factory_override or client_factory,
208
+ enabled=True
209
+ )
210
+ await _slack_transport_instance.connect()
211
+ except Exception as e:
212
+ import logging
213
+ logging.getLogger("lomberk.api").error(f"Failed to start Slack transport: {e}")
214
+ # A failed connect is logged, not fatal. Config errors (missing tokens) are raised
215
+ # above, before this try, so a misconfigured Slack still refuses startup.
216
+ else:
217
+ _slack_transport_instance = None
218
+
219
+ # Remote MCP: the agent gets only a URL + agent key, never DSNs or the admin key.
220
+ # The SDK session manager runs once per instance, so build a fresh one per lifespan.
221
+ mcp = build_mcp(_mcp_deps, principal="remote_mcp_agent")
222
+ # host="0.0.0.0" skips the SDK's localhost-only Host check; the agent key gate authenticates instead.
223
+ mcp_http = mcp.streamable_http_app(streamable_http_path="/mcp", stateless_http=True, json_response=True, host="0.0.0.0") # container bind; agent-key gate authenticates # nosec B104
224
+ async with mcp.session_manager.run():
225
+ _mcp_gate.inner = mcp_http.routes[0].app
226
+ try:
227
+ yield
228
+ finally:
229
+ _mcp_gate.inner = None
230
+ finally:
231
+ local_shutdown_event.set()
232
+ if publisher_task:
233
+ await asyncio.gather(publisher_task, return_exceptions=True)
234
+
235
+ if _slack_transport_instance:
236
+ await _slack_transport_instance.disconnect()
237
+ _slack_transport_instance = None
238
+
239
+ if local_db_workers:
240
+ logger.info(f"Draining {len(local_db_workers)} outstanding DB workers...")
241
+ _done, pending = await asyncio.wait(local_db_workers, timeout=5.0)
242
+ if pending:
243
+ logger.error(f"Shutdown timeout: {len(pending)} DB workers remain outstanding/unknown.")
244
+
245
+ _executor_instance = None
246
+ _policy_engine_instance = None
247
+
248
+ # No /docs or /openapi.json: they map every endpoint for anyone who can reach the port.
249
+ app = FastAPI(title="Lomberk API", version=package_version(), lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
250
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
251
+
252
+
253
+ def _key_matches(presented: str | None, expected: str | None) -> bool:
254
+ return bool(presented and expected) and hmac.compare_digest(presented.encode(), expected.encode())
255
+
256
+
257
+ class _AgentKeyGate:
258
+ """ASGI guard for the MCP endpoint: agent key via X-API-Key or Authorization: Bearer."""
259
+
260
+ def __init__(self):
261
+ self.inner = None # set while the app lifespan is running
262
+
263
+ async def __call__(self, scope, receive, send):
264
+ headers = dict(scope.get("headers") or [])
265
+ key = headers.get(b"x-api-key", b"").decode()
266
+ auth = headers.get(b"authorization", b"").decode()
267
+ if not key and auth.lower().startswith("bearer "):
268
+ key = auth[7:].strip()
269
+ from starlette.responses import JSONResponse
270
+ if not _key_matches(key, _API_KEY):
271
+ await JSONResponse({"detail": "Valid agent key required"}, status_code=401)(scope, receive, send)
272
+ return
273
+ if self.inner is None:
274
+ await JSONResponse({"detail": "Gateway not ready"}, status_code=503)(scope, receive, send)
275
+ return
276
+ await self.inner(scope, receive, send)
277
+
278
+
279
+ def _mcp_deps():
280
+ if not _executor_instance or not _policy_engine_instance:
281
+ raise RuntimeError("Gateway not initialized")
282
+ return _executor_instance, _policy_engine_instance
283
+
284
+
285
+ _mcp_gate = _AgentKeyGate()
286
+ app.router.routes.append(Route("/mcp", endpoint=_mcp_gate))
287
+
288
+ def get_executor():
289
+ if not _executor_instance:
290
+ raise HTTPException(status_code=500, detail="Configuration Error: Agent DSN not provided or pool is closed.")
291
+ return _executor_instance
292
+
293
+ def get_policy_engine():
294
+ if not _policy_engine_instance:
295
+ raise HTTPException(status_code=500, detail="Configuration Error: Policy Engine not initialized.")
296
+ return _policy_engine_instance
297
+
298
+ def verify_api_key(api_key: str = Security(api_key_header)):
299
+ if not _API_KEY:
300
+ raise HTTPException(status_code=500, detail="Configuration Error: API Key not loaded.")
301
+ if not api_key:
302
+ raise HTTPException(status_code=401, detail="API Key required")
303
+ if not _key_matches(api_key, _API_KEY):
304
+ raise HTTPException(status_code=403, detail="Invalid API Key")
305
+ return api_key
306
+
307
+ _bearer = HTTPBearer(auto_error=False)
308
+
309
+
310
+ def verify_approver(api_key: str = Security(api_key_header),
311
+ cred: HTTPAuthorizationCredentials | None = Security(_bearer)) -> str:
312
+ """Who is approving: a person via SSO ("oidc:<email>"), or the shared admin key."""
313
+ if _SSO is not None and cred is not None:
314
+ from lomberk.ee.sso import SsoError
315
+ try:
316
+ return _SSO.verify(cred.credentials)
317
+ except SsoError:
318
+ raise HTTPException(status_code=401, detail="Invalid SSO token") from None
319
+ if not _ALLOW_ADMIN_KEY:
320
+ raise HTTPException(status_code=401, detail="SSO token required")
321
+ verify_admin_key(api_key)
322
+ return "api_admin"
323
+
324
+
325
+ def verify_admin_key(api_key: str = Security(api_key_header)):
326
+ if not _ADMIN_KEY:
327
+ raise HTTPException(status_code=500, detail="Configuration Error: Admin Key not loaded.")
328
+ if not api_key:
329
+ raise HTTPException(status_code=401, detail="Admin Key required")
330
+ if not _key_matches(api_key, _ADMIN_KEY):
331
+ raise HTTPException(status_code=403, detail="Invalid Admin Key")
332
+ return api_key
333
+
334
+
335
+ class ExecuteRequest(BaseModel):
336
+ sql: str
337
+ agent_label: str = "agent"
338
+ operation_id: str | None = None
339
+
340
+ @app.post("/v1/execute")
341
+ def execute_query(
342
+ req: ExecuteRequest,
343
+ api_key: str = Depends(verify_api_key),
344
+ executor: LomberkExecutor = Depends(get_executor),
345
+ policy_engine: PolicyEngine = Depends(get_policy_engine)
346
+ ):
347
+ # Step 1: Prevent multi-statement at the API boundary
348
+ safe, reason = AISentinel()._ast_firewall(req.sql)
349
+ if not safe:
350
+ return {"results": [{"sql": req.sql, "status": "denied", "message": f"Hard Gate Denied: {reason}"}]}
351
+
352
+ statements = classify_sql(req.sql)
353
+ results = []
354
+ for stmt in statements:
355
+ eval_result = policy_engine.evaluate(stmt)
356
+ # Note: the hard gate evaluation is done inside executor.execute()
357
+ # It will return 'denied' if AST fails
358
+ exec_res = executor.execute(
359
+ eval_result,
360
+ agent_label=req.agent_label,
361
+ principal="api_user",
362
+ operation_id=req.operation_id
363
+ )
364
+
365
+ if exec_res.status == "failed" and exec_res.error_code == "conflict":
366
+ raise HTTPException(status_code=409, detail=exec_res.message)
367
+
368
+ results.append({
369
+ "sql": stmt.sql,
370
+ "classification": stmt.stmt_class.value,
371
+ "decision": eval_result.decision.value,
372
+ "status": exec_res.status,
373
+ "message": exec_res.message,
374
+ "checkpoint_id": exec_res.public_id,
375
+ "operation_id": exec_res.operation_id,
376
+ "error_code": exec_res.error_code,
377
+ "rows_affected": exec_res.rows_affected,
378
+ "data": exec_res.data
379
+ })
380
+ return {"results": results}
381
+
382
+ @app.get("/v1/checkpoints/pending")
383
+ def list_pending_checkpoints(
384
+ actor: str = Depends(verify_approver),
385
+ executor: LomberkExecutor = Depends(get_executor)
386
+ ):
387
+ import psycopg
388
+ with psycopg.connect(executor.dsn) as conn:
389
+ with conn.cursor() as cur:
390
+ cur.execute("SELECT id, public_id, sql_text, agent_label, created_at FROM lomberk.list_pending_checkpoints()")
391
+ rows = cur.fetchall()
392
+ return [{"id": r[0], "public_id": r[1], "sql": r[2], "agent": r[3], "created_at": r[4]} for r in rows]
393
+
394
+
395
+ CHECKPOINT_STATUSES = Literal["pending", "approved", "executing", "applied", "rejected", "failed", "undone"]
396
+
397
+
398
+ @app.get("/v1/checkpoints")
399
+ def list_checkpoints(
400
+ status: CHECKPOINT_STATUSES | None = None,
401
+ limit: int = Query(50, ge=1, le=500),
402
+ actor: str = Depends(verify_approver),
403
+ executor: LomberkExecutor = Depends(get_executor)
404
+ ):
405
+ """Recent checkpoints, newest first: find the cp_ id to undo (`?status=applied`)."""
406
+ return [{"public_id": c["public_id"], "created_at": c["created_at"], "status": c["status"],
407
+ "classification": c["classification"], "agent": c["agent_label"], "principal": c["principal"],
408
+ "sql": c["sql_text"], "rows_affected": c["rows_affected"], "approved_by": c["approved_by"]}
409
+ for c in executor.list_checkpoints(limit=limit, status=status)]
410
+
411
+
412
+ @app.get("/v1/checkpoints/{public_id}/status")
413
+ def check_checkpoint_status(
414
+ public_id: str,
415
+ actor: str = Depends(verify_approver),
416
+ executor: LomberkExecutor = Depends(get_executor)
417
+ ):
418
+ try:
419
+ status = executor.check_status(public_id)
420
+ if status == "not_found":
421
+ raise HTTPException(status_code=404, detail="Token not found")
422
+ return {"public_id": public_id, "status": status}
423
+ except HTTPException:
424
+ raise
425
+ except Exception:
426
+ from fastapi.responses import JSONResponse
427
+ return JSONResponse(status_code=502, content={"status": "unknown", "message": "Database unreachable"})
428
+
429
+ @app.post("/v1/checkpoints/{public_id}/approve")
430
+
431
+ def approve_checkpoint(
432
+ public_id: str,
433
+ actor: str = Depends(verify_approver),
434
+ executor: LomberkExecutor = Depends(get_executor)
435
+ ):
436
+ try:
437
+ res = executor.execute_pending(public_id, actor=actor)
438
+ if res.status == "applied":
439
+ return {"status": "approved", "rows_affected": res.rows_affected}
440
+ elif res.status == "awaiting_approval":
441
+ from fastapi.responses import JSONResponse
442
+ return JSONResponse(status_code=202, content={"status": "awaiting_approval", "message": res.message})
443
+ elif res.status == "denied":
444
+ return {"status": "denied", "message": res.message}
445
+ elif res.status == "unknown":
446
+ from fastapi.responses import JSONResponse
447
+ return JSONResponse(status_code=502, content={"status": "unknown", "message": res.message, "public_id": res.public_id, "operation_id": res.operation_id})
448
+ elif res.status == "failed":
449
+ raise HTTPException(status_code=400, detail=res.message)
450
+ else:
451
+ raise HTTPException(status_code=500, detail=f"Unexpected status: {res.status}")
452
+ except HTTPException:
453
+ raise
454
+ except Exception as e:
455
+ raise HTTPException(status_code=500, detail=str(e)) from e
456
+
457
+ @app.post("/v1/checkpoints/{public_id}/reject")
458
+ def reject_checkpoint(
459
+ public_id: str,
460
+ actor: str = Depends(verify_approver),
461
+ executor: LomberkExecutor = Depends(get_executor)
462
+ ):
463
+ if not executor.reject(public_id, actor=actor):
464
+ raise HTTPException(status_code=404, detail="Token not found or not pending")
465
+ return {"status": "denied"}
466
+
467
+ _UNDO_HTTP_STATUS = {"expired": 403, "already_undone": 409, "conflict": 409, "busy": 423, "denied": 404, "failed": 400}
468
+
469
+
470
+ @app.get("/v1/checkpoints/{public_id}")
471
+ def get_checkpoint(
472
+ public_id: str,
473
+ actor: str = Depends(verify_approver),
474
+ executor: LomberkExecutor = Depends(get_executor)
475
+ ):
476
+ """What an approver reviews before deciding: the exact SQL and who sent it."""
477
+ cp = executor.get_checkpoint(public_id)
478
+ if not cp:
479
+ raise HTTPException(status_code=404, detail="Token not found")
480
+ return cp
481
+
482
+
483
+ @app.post("/v1/checkpoints/{public_id}/undo")
484
+ def undo_checkpoint(
485
+ public_id: str,
486
+ actor: str = Depends(verify_approver),
487
+ executor: LomberkExecutor = Depends(get_executor)
488
+ ):
489
+ res = executor.execute_undo(public_id, agent_label=actor)
490
+ if res.status == "undone":
491
+ return {"status": "undone", "rows_affected": res.rows_affected}
492
+ from fastapi.responses import JSONResponse
493
+ return JSONResponse(status_code=_UNDO_HTTP_STATUS.get(res.status, 502), content={"status": res.status, "message": res.message})
494
+
495
+
496
+ def _require_pro(feature: str) -> None:
497
+ if not _PRO:
498
+ raise HTTPException(status_code=403, detail=f"{feature} needs a Lomberk Solo or Pro license: set LOMBERK_LICENSE_KEY. Plans: {PRICING_URL}")
499
+
500
+
501
+ @app.get("/v1/audit/export")
502
+ def export_audit(
503
+ format: str = "csv",
504
+ since: datetime | None = None,
505
+ actor: str = Depends(verify_approver),
506
+ executor: LomberkExecutor = Depends(get_executor)
507
+ ):
508
+ """Every checkpoint with who approved it (Pro)."""
509
+ _require_pro("Audit export")
510
+ from fastapi.responses import Response
511
+
512
+ from lomberk.ee.export import FORMATS, render
513
+ if format not in FORMATS:
514
+ raise HTTPException(status_code=422, detail=f"format must be one of {sorted(FORMATS)}")
515
+ rows = executor.list_checkpoints(since=since)
516
+ audit("exported", actor=actor, rows=len(rows), format=format)
517
+ return Response(render(rows, format), media_type=FORMATS[format],
518
+ headers={"Content-Disposition": f'attachment; filename="lomberk-audit.{format}"'})
519
+
520
+
521
+ class _PanelGate:
522
+ """Mounts the web panel (ee) lazily, so the core never imports it at module load."""
523
+
524
+ def __init__(self):
525
+ self.inner = None
526
+
527
+ async def __call__(self, scope, receive, send):
528
+ if not _PRO or _executor_instance is None:
529
+ from starlette.responses import HTMLResponse
530
+ await HTMLResponse(_PRO_ONLY_PAGE, status_code=403)(scope, receive, send)
531
+ return
532
+ if self.inner is None:
533
+ from lomberk.ee.panel import build_panel
534
+ self.inner = build_panel()
535
+ await self.inner(scope, receive, send)
536
+
537
+
538
+ _PRO_ONLY_PAGE = """<!doctype html><meta charset="utf-8"><title>Lomberk</title>
539
+ <body style="font-family: system-ui, sans-serif; padding: 2rem; max-width: 40rem">
540
+ <h1>Lomberk</h1><p>The web approval panel comes with Lomberk Solo and Pro. Set <code>LOMBERK_LICENSE_KEY</code>
541
+ and restart the gateway, or approve from the API or CLI.</p>
542
+ <p><a href="https://ege-usta.github.io/lomberk/#pricing">See the plans</a></p></body>"""
543
+
544
+ app.mount("/panel", _PanelGate())
545
+
546
+
547
+ @app.get("/healthz", include_in_schema=False)
548
+ def healthz():
549
+ """Liveness: the process is up."""
550
+ return {"status": "ok"}
551
+
552
+
553
+ @app.get("/metrics", include_in_schema=False)
554
+ def metrics(cred: HTTPAuthorizationCredentials | None = Security(_bearer)):
555
+ """Prometheus text format, scraped with `Authorization: Bearer $LOMBERK_METRICS_KEY`. Counts
556
+ only: no SQL, rows or identities. Off (404) unless that key is set."""
557
+ from fastapi.responses import PlainTextResponse
558
+
559
+ from lomberk.audit import EVENTS
560
+ if not _METRICS_KEY:
561
+ raise HTTPException(status_code=404)
562
+ if not _key_matches(cred.credentials if cred else None, _METRICS_KEY):
563
+ raise HTTPException(status_code=401, detail="Invalid metrics key")
564
+ out = ["# HELP lomberk_events_total Security decisions since the gateway started, by audit event.",
565
+ "# TYPE lomberk_events_total counter"]
566
+ out += [f'lomberk_events_total{{event="{e}"}} {n}' for e, n in sorted(EVENTS.items())] # event names are fixed strings
567
+ import psycopg
568
+ try:
569
+ with psycopg.connect(_executor_instance.dsn, connect_timeout=3) as conn:
570
+ pending, oldest = conn.execute(
571
+ "SELECT count(*), coalesce(extract(epoch FROM now() - min(created_at)), 0) "
572
+ "FROM lomberk.checkpoints WHERE status = 'pending'").fetchone()
573
+ up = 1
574
+ except psycopg.Error:
575
+ pending, oldest, up = 0, 0, 0
576
+ out += ["# HELP lomberk_database_up Whether the control role could reach the database for this scrape.",
577
+ "# TYPE lomberk_database_up gauge", f"lomberk_database_up {up}",
578
+ "# HELP lomberk_pending_checkpoints Held writes waiting for a person.",
579
+ "# TYPE lomberk_pending_checkpoints gauge", f"lomberk_pending_checkpoints {pending}",
580
+ "# HELP lomberk_oldest_pending_seconds How long the oldest held write has waited.",
581
+ "# TYPE lomberk_oldest_pending_seconds gauge", f"lomberk_oldest_pending_seconds {float(oldest):.0f}"]
582
+ return PlainTextResponse("\n".join(out) + "\n", media_type="text/plain; version=0.0.4")
583
+
584
+
585
+ @app.get("/readyz", include_in_schema=False)
586
+ def readyz():
587
+ """Readiness: both database roles are reachable."""
588
+ from fastapi.responses import JSONResponse
589
+ if not _executor_instance:
590
+ return JSONResponse(status_code=503, content={"status": "starting"})
591
+ import psycopg
592
+ try:
593
+ for dsn in (_executor_instance.dsn, _executor_instance.runtime_dsn):
594
+ with psycopg.connect(dsn, connect_timeout=3) as conn:
595
+ conn.execute("SELECT 1")
596
+ except psycopg.Error:
597
+ return JSONResponse(status_code=503, content={"status": "database_unreachable"})
598
+ return {"status": "ready"}
599
+
600
+
601
+ @app.get("/", include_in_schema=False)
602
+ def dashboard():
603
+ from fastapi.responses import HTMLResponse
604
+ html = """
605
+ <html>
606
+ <head><title>Lomberk</title></head>
607
+ <body style="font-family: sans-serif; padding: 2rem;">
608
+ <h1>Lomberk gateway</h1>
609
+ <p style="color: #666;">Approve from the <a href="/panel/">web panel</a> (Pro), the API or the CLI.</p>
610
+ </body>
611
+ </html>
612
+ """
613
+ return HTMLResponse(content=html)
lomberk/audit.py ADDED
@@ -0,0 +1,41 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from collections import Counter
5
+ from datetime import UTC, datetime
6
+
7
+ # Configure standard logger to write JSON to a file or stream
8
+ logger = logging.getLogger("lomberk_audit")
9
+ logger.setLevel(logging.INFO)
10
+ # Audit lines carry full SQL (possible secrets/PII). Never let them propagate to the
11
+ # root logger, where an operator's INFO handler would copy them to stdout/journald.
12
+ logger.propagate = False
13
+
14
+ # Audit lines contain full SQL text (may include PII): keep them owner-only, never in /tmp.
15
+ log_path = os.environ.get("LOMBERK_AUDIT_LOG") or os.path.expanduser("~/.local/state/lomberk/audit.jsonl")
16
+ os.makedirs(os.path.dirname(log_path) or ".", mode=0o700, exist_ok=True)
17
+ os.close(os.open(log_path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600))
18
+ handler = logging.FileHandler(log_path)
19
+ handler.setFormatter(logging.Formatter('%(message)s'))
20
+ logger.addHandler(handler)
21
+
22
+ # Per-event counts since the process started, for /metrics.
23
+ # ponytail: Counter += under the GIL can drop a count under heavy thread contention; good enough for a dashboard.
24
+ EVENTS: Counter[str] = Counter()
25
+
26
+ def audit(event: str, *, actor: str, public_id: str | None = None, **fields) -> None:
27
+ """One JSON line per security-relevant decision, for shipping to a SIEM.
28
+
29
+ event: denied | held | applied | failed | approval_recorded | approved | rejected | expired | undone |
30
+ undo_failed | exported | panel_login | panel_login_failed
31
+ actor: who caused it (agent principal, or approver such as "cli:alice" / "api_admin").
32
+ """
33
+ EVENTS[event] += 1
34
+ record = {
35
+ "timestamp": datetime.now(UTC).isoformat(),
36
+ "event_type": f"lomberk.{event}",
37
+ "actor": actor,
38
+ "public_id": public_id,
39
+ **fields,
40
+ }
41
+ logger.info(json.dumps(record, default=str))