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 +0 -0
- lomberk/api.py +613 -0
- lomberk/audit.py +41 -0
- lomberk/capture/install.py +67 -0
- lomberk/capture/sql/01_schema.sql +154 -0
- lomberk/capture/sql/02_functions.sql +1061 -0
- lomberk/cli.py +443 -0
- lomberk/config.py +159 -0
- lomberk/doctor.py +269 -0
- lomberk/ee/LICENSE +104 -0
- lomberk/ee/__init__.py +5 -0
- lomberk/ee/export.py +28 -0
- lomberk/ee/license.py +180 -0
- lomberk/ee/panel.py +285 -0
- lomberk/ee/panel_static/app.css +59 -0
- lomberk/ee/panel_static/app.js +165 -0
- lomberk/ee/panel_static/index.html +54 -0
- lomberk/ee/slack.py +166 -0
- lomberk/ee/slack_publisher.py +140 -0
- lomberk/ee/slack_transport.py +200 -0
- lomberk/ee/sso.py +106 -0
- lomberk/executor.py +836 -0
- lomberk/models.py +29 -0
- lomberk/policy.py +58 -0
- lomberk/recovery.py +129 -0
- lomberk/sentinel.py +227 -0
- lomberk/server.py +207 -0
- lomberk/setup.py +170 -0
- lomberk/sql/classify.py +342 -0
- lomberk/startup.py +82 -0
- lomberk-0.2.0.dist-info/METADATA +289 -0
- lomberk-0.2.0.dist-info/RECORD +36 -0
- lomberk-0.2.0.dist-info/WHEEL +4 -0
- lomberk-0.2.0.dist-info/entry_points.txt +2 -0
- lomberk-0.2.0.dist-info/licenses/LICENSE +202 -0
- lomberk-0.2.0.dist-info/licenses/src/lomberk/ee/LICENSE +104 -0
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))
|