176 lines
9.1 KiB
Python
176 lines
9.1 KiB
Python
"""Datenbanken eines Workspace als MCP-Verbindungen über einen Datenbank-Treiber.
|
|
|
|
Ein Treiber ist ein DB-Dienst aus services/db-mcp (z. B. db-mcp-oracle), den der Plattform-Admin einmal
|
|
registriert (Seite „Datenbank-Treiber“, Rolle database) und dessen Tools er einmal einstuft. Er hat keine
|
|
eigenen Daten; sein Schlüssel öffnet nur den Dienst. Workspace-Admins legen Datenbanken mit Host,
|
|
Datenbank, Nutzer und Passwort an; Bifröst schickt diese je Aufruf als Header X-DB-*, das Modell sieht sie
|
|
nie. Die Tools einer Datenbank sind die des Treibers (übernommen, gleicher Fingerabdruck) und haben dessen
|
|
Einstufung. Den SQL-Dialekt beschreibt der Treiber in seiner Anleitung.
|
|
"""
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app.core.config import Settings
|
|
from app.core.crypto import Vault
|
|
from app.core.database import now
|
|
from app.core.errors import Invalid
|
|
from app.identity import Principal
|
|
from app.mcp import client
|
|
from app.mcp import service as mcp
|
|
from app.mcp.models import APPROVED, McpServer, McpTool
|
|
from app.tools.registry import ToolError
|
|
from app.workspaces import service as workspaces
|
|
|
|
HOST = re.compile(r"^[A-Za-z0-9.-]{1,253}$")
|
|
IDENTIFIER = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.$#-]{0,127}$")
|
|
LOCAL = {"localhost", "127.0.0.1", "::1"}
|
|
SSL_MODES = {"require": "erzwingen", "verify-full": "erzwingen und Zertifikat prüfen",
|
|
"prefer": "wenn möglich (nur lokal, nur PostgreSQL)", "disable": "aus (nur lokal)"}
|
|
PASSWORD = "X-DB-Password"
|
|
|
|
|
|
def drivers(session: Session) -> list[McpServer]:
|
|
"""Nutzbare Treiber: aktiv und Tools abgerufen."""
|
|
return list(session.scalars(select(McpServer).where(
|
|
McpServer.workspace_id.is_(None), McpServer.role == mcp.DATABASE, McpServer.active.is_(True),
|
|
McpServer.status == "ok").order_by(McpServer.label)))
|
|
|
|
|
|
def check_config(host: str, port: str | int | None, database: str, user: str, sslmode: str,
|
|
settings: Settings) -> dict[str, Any]:
|
|
"""Zusätzlich zur Prüfung im Treiber: nur freigegebene Hosts, TLS außer lokal."""
|
|
host, database, user = host.strip().lower(), database.strip(), user.strip()
|
|
if not HOST.match(host):
|
|
raise Invalid("Ungültiger Host.")
|
|
if host in LOCAL:
|
|
if not settings.db_allow_local:
|
|
raise Invalid("Lokale Datenbanken sind nicht freigegeben (AIW_DB_ALLOW_LOCAL).")
|
|
elif host not in settings.allowed_db_hosts:
|
|
raise Invalid(f"Host {host} ist nicht freigegeben (AIW_DB_ALLOWED_HOSTS).")
|
|
if port in (None, ""):
|
|
port = None
|
|
else:
|
|
try:
|
|
port = int(port)
|
|
except ValueError:
|
|
raise Invalid("Der Port muss eine Zahl sein.") from None
|
|
if not 1 <= port <= 65535:
|
|
raise Invalid("Ungültiger Port.")
|
|
if not IDENTIFIER.match(database):
|
|
raise Invalid("Ungültiger Wert für Datenbank bzw. Service-Name.")
|
|
if not IDENTIFIER.match(user):
|
|
raise Invalid("Ungültiger Benutzername.")
|
|
if sslmode not in SSL_MODES:
|
|
raise Invalid("Unbekannter TLS-Modus.")
|
|
if sslmode in ("prefer", "disable") and host not in LOCAL:
|
|
raise Invalid("Ohne erzwungenes TLS nur für lokale Datenbanken.")
|
|
return {"host": host, "port": port, "database": database, "user": user, "sslmode": sslmode}
|
|
|
|
|
|
def headers(server: McpServer) -> dict[str, str]:
|
|
"""Verbindungsdaten als Header; das Passwort liegt verschlüsselt bei den übrigen Headern."""
|
|
c = server.config or {}
|
|
result = {"X-DB-Host": c.get("host", ""), "X-DB-Database": c.get("database", ""), "X-DB-User": c.get("user", ""),
|
|
"X-DB-SSLMode": c.get("sslmode", "verify-full")}
|
|
if c.get("port"):
|
|
result["X-DB-Port"] = str(c["port"])
|
|
return result
|
|
|
|
|
|
def copy_tools(session: Session, driver: McpServer, server: McpServer) -> None:
|
|
"""Die Tools des Treibers für die Datenbank übernehmen (gleicher Fingerabdruck → gleiche Einstufung)."""
|
|
existing = {t.remote_name: t for t in mcp.tools_of(session, server.id)}
|
|
for tool in mcp.tools_of(session, driver.id):
|
|
row = existing.pop(tool.remote_name, None) or McpTool(server_id=server.id, remote_name=tool.remote_name)
|
|
row.name, row.description, row.input_schema = mcp.model_name(server, tool.remote_name), tool.description, \
|
|
tool.input_schema
|
|
row.annotations, row.fingerprint = tool.annotations, tool.fingerprint
|
|
session.add(row)
|
|
for gone in existing.values():
|
|
session.delete(gone)
|
|
server.server_info, server.discovered_at = driver.server_info, now()
|
|
|
|
|
|
def driver_of(session: Session, driver_id: int | str) -> McpServer:
|
|
try:
|
|
driver = session.get(McpServer, int(driver_id))
|
|
except ValueError:
|
|
driver = None
|
|
if driver is None or driver not in drivers(session):
|
|
raise Invalid("Bitte einen Datenbanktyp wählen.")
|
|
return driver
|
|
|
|
|
|
def create(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int, *,
|
|
driver_id: int | str, name: str, label: str, host: str, port: str, database: str, user: str,
|
|
password: str, sslmode: str, exposure_confirmed: bool) -> McpServer:
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
if not exposure_confirmed:
|
|
raise Invalid("Bitte bestätigen, dass alle Mitglieder des Workspace diese Daten sehen dürfen.")
|
|
driver = driver_of(session, driver_id)
|
|
name, config = mcp.check_name(session, name), check_config(host, port, database, user, sslmode, settings)
|
|
server = McpServer(workspace_id=workspace_id, template_id=driver.id, name=name,
|
|
label=label.strip()[:200] or name, url=driver.url, config=config, approval=APPROVED,
|
|
exposure_confirmed_by=principal.user_id, exposure_confirmed_at=now(),
|
|
created_by=principal.user_id)
|
|
mcp.set_headers(server, vault, {PASSWORD: password} if password else {})
|
|
session.add(server)
|
|
session.flush()
|
|
copy_tools(session, driver, server)
|
|
audit.record(session, "mcp.database_created", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="mcp_server", target_id=server.id, name=name, driver=driver.name,
|
|
host=config["host"], database=config["database"], user=config["user"], password=bool(password))
|
|
audit.record(session, "source.exposure_confirmed", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="mcp_server", target_id=server.id)
|
|
return server
|
|
|
|
|
|
def update(session: Session, principal: Principal, settings: Settings, vault: Vault, server_id: int, *,
|
|
label: str, user: str, password: str = "", remove_password: bool = False, sslmode: str,
|
|
active: bool = True) -> McpServer:
|
|
"""Bezeichnung, Nutzer, Passwort und TLS. Treiber, Host und Datenbank bleiben: ein anderes Ziel ist eine
|
|
neue Datenbank mit neuer Bestätigung."""
|
|
server = mcp.connection(session, principal, server_id)
|
|
if not server.template_id:
|
|
raise Invalid("Das ist keine Datenbank-Verbindung.")
|
|
c = server.config
|
|
config = check_config(c["host"], c.get("port"), c["database"], user, sslmode, settings)
|
|
changed = config != c or bool(password) or (remove_password and bool(server.headers))
|
|
server.label, server.config, server.active = label.strip()[:200] or server.name, config, active
|
|
if password:
|
|
mcp.set_headers(server, vault, {PASSWORD: password})
|
|
elif remove_password:
|
|
mcp.set_headers(server, vault, {})
|
|
if changed:
|
|
server.status, server.status_message = "new", "Geändert, bitte neu testen."
|
|
audit.record(session, "mcp.database_updated", user_id=principal.user_id, workspace_id=server.workspace_id,
|
|
target_kind="mcp_server", target_id=server.id, user=config["user"], sslmode=sslmode, active=active,
|
|
password="ersetzt" if password else "entfernt" if remove_password else "unverändert")
|
|
return server
|
|
|
|
|
|
def test(session: Session, settings: Settings, vault: Vault, server: McpServer, user_id: int | None) -> None:
|
|
"""Tools vom Treiber übernehmen und die Verbindung zur Datenbank prüfen (check_connection)."""
|
|
driver = session.get(McpServer, server.template_id)
|
|
copy_tools(session, driver, server)
|
|
try:
|
|
failed, text, _ = client.call_result(mcp.target(server, vault, settings, template=driver),
|
|
"check_connection", {})
|
|
except ToolError as e:
|
|
failed, text = True, str(e).replace("Der MCP-Server", "Der Datenbank-Treiber")
|
|
text = re.sub(r"^Error executing tool [^:]+: ", "", text)[:1000]
|
|
server.status = "error" if failed else "ok"
|
|
server.status_message, server.last_error = text, text if failed else ""
|
|
audit.record(session, "mcp.database_tested", user_id=user_id, workspace_id=server.workspace_id,
|
|
target_kind="mcp_server", target_id=server.id, ok=not failed, message=text[:300])
|
|
|
|
|
|
def driver_refreshed(session: Session, driver: McpServer) -> None:
|
|
"""Nach dem Abruf der Tools eines Treibers: seine Datenbanken angleichen."""
|
|
for server in session.scalars(select(McpServer).where(McpServer.template_id == driver.id)):
|
|
copy_tools(session, driver, server)
|