303 lines
15 KiB
Python
303 lines
15 KiB
Python
"""Einstufung und Policy Engine für Tools (→ Konzept 10.1, 10.3, 10.7).
|
|
|
|
Entscheidung für ein Tool in einem Workspace, in dieser Reihenfolge:
|
|
1. nicht eingestuft oder Definition geändert → DENY
|
|
2. Risikoklasse destructive → DENY (im MVP vollständig gesperrt)
|
|
3. globale Entscheidung des Plattform-Admins
|
|
4. Verschärfung des Workspace (nie lockernd)
|
|
5. Verschärfung für die konkrete Quelle des Aufrufs (nie lockernd)
|
|
6. Modus B: write_external → mindestens REQUIRE_APPROVAL
|
|
Dazu, wer freigeben darf: Freigabestufe aus der globalen Einstufung (Workspace-Admin bzw. Freigeber oder globaler
|
|
Approver) und Vier-Augen-Prinzip, das global, im Workspace oder je Quelle verlangt werden kann.
|
|
Ausgewertet wird bei jedem einzelnen Aufruf, nicht nur beim Anbieten.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app.core.database import now
|
|
from app.core.errors import Invalid, NotFound
|
|
from app.data_sources.models import DataSource
|
|
from app.identity import Principal
|
|
from app.identity.admin import require as require_platform_admin
|
|
from app.mcp import service as mcp
|
|
from app.tools.models import (
|
|
ALLOW,
|
|
DECISIONS,
|
|
DENY,
|
|
LEVELS,
|
|
REQUIRE_APPROVAL,
|
|
RISK_CLASSES,
|
|
WORKSPACE_ADMIN,
|
|
SourceToolPolicy,
|
|
ToolDefinition,
|
|
WorkspaceToolPolicy,
|
|
)
|
|
from app.tools.registry import Tool, load
|
|
from app.workspaces import service as workspaces
|
|
from app.workspaces.models import Workspace
|
|
|
|
# Vorschlag aus Konzept 10.7; übernommen wird er nur durch einen Admin
|
|
DEFAULT_DECISION = {"read": ALLOW, "write_internal": ALLOW, "write_external": REQUIRE_APPROVAL,
|
|
"destructive": DENY}
|
|
|
|
|
|
def catalog(session: Session) -> dict[str, Tool]:
|
|
"""Alle bekannten Tools: eingebaute und die zuletzt entdeckten Tools aktiver MCP-Server."""
|
|
return load() | mcp.catalog_tools(session)
|
|
|
|
|
|
def stricter(a: str, b: str) -> str:
|
|
return max(a, b, key=DECISIONS.index)
|
|
|
|
|
|
def sync(session: Session) -> list[ToolDefinition]:
|
|
"""Tools mit der Tabelle abgleichen; geänderte Definitionen verlieren ihre Einstufung."""
|
|
known = {d.name: d for d in session.scalars(select(ToolDefinition))}
|
|
for tool in catalog(session).values():
|
|
definition = known.get(tool.name)
|
|
if definition is None:
|
|
# Mehrere Worker können ein neues Tool gleichzeitig sehen: nur einer legt es an
|
|
session.execute(insert(ToolDefinition).values(
|
|
name=tool.name, kind=tool.kind, description=tool.description, schema=tool.schema, hash=tool.hash,
|
|
fingerprint=tool.fingerprint, suggested_risk=tool.suggested_risk, updated=now())
|
|
.on_conflict_do_nothing(index_elements=["name"]))
|
|
continue
|
|
if definition.fingerprint != tool.fingerprint:
|
|
definition.fingerprint = tool.fingerprint
|
|
session.add(definition)
|
|
if definition.hash != tool.hash:
|
|
if definition.classified_hash:
|
|
audit.record(session, "tool.definition_changed", target_kind="tool", target_id=tool.name,
|
|
old_hash=definition.hash, new_hash=tool.hash)
|
|
definition.kind, definition.description, definition.schema = tool.kind, tool.description, tool.schema
|
|
definition.hash, definition.suggested_risk, definition.updated = tool.hash, tool.suggested_risk, now()
|
|
session.add(definition)
|
|
session.flush()
|
|
definitions = list(session.scalars(select(ToolDefinition).order_by(ToolDefinition.name)))
|
|
inherit(session, definitions)
|
|
return definitions
|
|
|
|
|
|
def inherit(session: Session, definitions: list[ToolDefinition]) -> None:
|
|
"""Nicht eingestufte MCP-Tools übernehmen die Einstufung eines gleichen Tools desselben Dienstes
|
|
(gleicher Fingerabdruck), z. B. query jeder weiteren Oracle-Verbindung."""
|
|
classified: dict[str, ToolDefinition] = {}
|
|
for d in definitions:
|
|
# Vorbild ist bevorzugt ein selbst eingestuftes Tool (z. B. das des Datenbank-Treibers)
|
|
if d.fingerprint and d.classified and (d.fingerprint not in classified or not d.inherited_from):
|
|
classified[d.fingerprint] = d
|
|
for d in definitions:
|
|
model = classified.get(d.fingerprint) if d.fingerprint else None
|
|
if model is None or d.classified or model is d:
|
|
continue
|
|
copy_classification(model, d)
|
|
audit.record(session, "tool.classification_inherited", target_kind="tool", target_id=d.name,
|
|
inherited_from=model.name, risk_class=d.risk_class, decision=d.decision, hash=d.hash)
|
|
|
|
|
|
def copy_classification(model: ToolDefinition, target: ToolDefinition) -> None:
|
|
target.risk_class, target.decision = model.risk_class, model.decision
|
|
target.approval_level, target.four_eyes = model.approval_level, model.four_eyes
|
|
target.classified_hash, target.classified_by, target.classified_at = target.hash, model.classified_by, now()
|
|
target.inherited_from = model.inherited_from or model.name
|
|
|
|
|
|
def classify(session: Session, principal: Principal, name: str, risk_class: str, decision: str,
|
|
approval_level: str | None = None, four_eyes: bool | None = None) -> None:
|
|
"""Einstufen; approval_level/four_eyes None = bisherigen Wert behalten."""
|
|
require_platform_admin(principal)
|
|
if risk_class not in RISK_CLASSES or decision not in DECISIONS:
|
|
raise Invalid("Unbekannte Risikoklasse oder Entscheidung.")
|
|
if approval_level is not None and approval_level not in LEVELS:
|
|
raise Invalid("Unbekannte Freigabestufe.")
|
|
if risk_class == "destructive" and decision != DENY:
|
|
raise Invalid("destructive bleibt im MVP gesperrt.")
|
|
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name))
|
|
if definition is None:
|
|
raise NotFound
|
|
if definition.kind.startswith("mcp:"):
|
|
server = mcp.origins(session).get(definition.kind)
|
|
if server is not None and server.template_id:
|
|
raise Invalid("Tools einer Datenbank werden über ihren Treiber eingestuft.")
|
|
definition.risk_class, definition.decision = risk_class, decision
|
|
if approval_level is not None:
|
|
definition.approval_level = approval_level
|
|
if four_eyes is not None:
|
|
definition.four_eyes = four_eyes
|
|
definition.classified_hash, definition.classified_by, definition.classified_at = definition.hash, \
|
|
principal.user_id, now()
|
|
definition.inherited_from = None
|
|
audit.record(session, "tool.classified", user_id=principal.user_id, target_kind="tool", target_id=name,
|
|
risk_class=risk_class, decision=decision, approval_level=definition.approval_level,
|
|
four_eyes=definition.four_eyes, hash=definition.hash)
|
|
# Gleiche Tools desselben Dienstes folgen der Einstufung
|
|
if definition.fingerprint:
|
|
for twin in session.scalars(select(ToolDefinition).where(ToolDefinition.fingerprint == definition.fingerprint,
|
|
ToolDefinition.id != definition.id)):
|
|
copy_classification(definition, twin)
|
|
audit.record(session, "tool.classification_inherited", user_id=principal.user_id, target_kind="tool",
|
|
target_id=twin.name, inherited_from=name, risk_class=risk_class, decision=decision,
|
|
hash=twin.hash)
|
|
|
|
|
|
def classify_suggested(session: Session, principal: Principal) -> int:
|
|
"""Alle nicht eingestuften eingebauten Tools nach dem Vorschlag einstufen (bewusste Admin-Aktion,
|
|
auditiert). MCP-Tools nicht: ihre Annotations sind nur ein Vorschlag (→ Konzept 7.2)."""
|
|
count = 0
|
|
for definition in sync(session):
|
|
if not definition.classified and not definition.kind.startswith("mcp:"):
|
|
classify(session, principal, definition.name, definition.suggested_risk,
|
|
DEFAULT_DECISION[definition.suggested_risk])
|
|
count += 1
|
|
return count
|
|
|
|
|
|
def check_tightening(definition: ToolDefinition, decision: str) -> None:
|
|
if decision and decision not in DECISIONS:
|
|
raise Invalid("Unbekannte Entscheidung.")
|
|
if decision == ALLOW:
|
|
raise Invalid("Eine Vorgabe kann nur verschärfen: „Freigabe nötig“ oder „sperren“.")
|
|
if decision and definition.decision and stricter(decision, definition.decision) != decision:
|
|
raise Invalid("Workspaces dürfen die globale Vorgabe nur verschärfen.")
|
|
|
|
|
|
def definition_of(session: Session, name: str) -> ToolDefinition:
|
|
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name))
|
|
if definition is None:
|
|
raise NotFound
|
|
return definition
|
|
|
|
|
|
def set_workspace_policy(session: Session, principal: Principal, workspace_id: int, name: str,
|
|
decision: str, four_eyes: bool = False) -> None:
|
|
"""Workspace-Admin verschärft; '' ohne Vier-Augen-Prinzip setzt auf die globale Vorgabe zurück."""
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
definition = definition_of(session, name)
|
|
if definition.kind in mcp.foreign_kinds(session, workspace_id):
|
|
raise NotFound
|
|
check_tightening(definition, decision)
|
|
policy = session.get(WorkspaceToolPolicy, (workspace_id, name))
|
|
if not decision and not four_eyes:
|
|
if policy:
|
|
session.delete(policy)
|
|
else:
|
|
policy = policy or WorkspaceToolPolicy(workspace_id=workspace_id, tool_name=name)
|
|
policy.decision, policy.four_eyes = decision, four_eyes
|
|
policy.updated_by, policy.updated = principal.user_id, now()
|
|
session.add(policy)
|
|
audit.record(session, "tool.workspace_policy_set", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="tool", target_id=name, decision=decision or "global", four_eyes=four_eyes)
|
|
|
|
|
|
def set_source_policy(session: Session, principal: Principal, workspace_id: int, source_id: int, name: str,
|
|
decision: str, four_eyes: bool = False) -> None:
|
|
"""Regel für ein Tool auf einer Quelle; nur Verschärfung. '' ohne Vier-Augen-Prinzip entfernt sie."""
|
|
workspaces.require_admin(session, principal, workspace_id)
|
|
source = session.get(DataSource, source_id)
|
|
if source is None or source.workspace_id != workspace_id:
|
|
raise NotFound
|
|
definition = definition_of(session, name)
|
|
if definition.kind != source.kind:
|
|
raise Invalid(f"{name} gehört nicht zu Quellen der Art {source.kind}.")
|
|
check_tightening(definition, decision)
|
|
policy = session.get(SourceToolPolicy, (source_id, name))
|
|
if not decision and not four_eyes:
|
|
if policy:
|
|
session.delete(policy)
|
|
else:
|
|
policy = policy or SourceToolPolicy(source_id=source_id, tool_name=name, workspace_id=workspace_id)
|
|
policy.decision, policy.four_eyes = decision, four_eyes
|
|
policy.updated_by, policy.updated = principal.user_id, now()
|
|
session.add(policy)
|
|
audit.record(session, "tool.source_policy_set", user_id=principal.user_id, workspace_id=workspace_id,
|
|
target_kind="tool", target_id=name, source_id=source_id, decision=decision or "global",
|
|
four_eyes=four_eyes)
|
|
|
|
|
|
def source_policies(session: Session, workspace_id: int) -> list[SourceToolPolicy]:
|
|
return list(session.scalars(select(SourceToolPolicy).where(SourceToolPolicy.workspace_id == workspace_id)
|
|
.order_by(SourceToolPolicy.source_id, SourceToolPolicy.tool_name)))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Verdict:
|
|
decision: str
|
|
reason: str
|
|
# Wer einen Antrag entscheiden darf
|
|
level: str = WORKSPACE_ADMIN
|
|
four_eyes: bool = False
|
|
|
|
|
|
def evaluate(session: Session, workspace: Workspace, name: str, source_id: int | None = None) -> Verdict:
|
|
"""source_id: Quelle des konkreten Aufrufs; ohne sie gelten nur globale und Workspace-Vorgaben."""
|
|
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name))
|
|
tool = catalog(session).get(name)
|
|
if definition is None or tool is None:
|
|
return Verdict(DENY, "unbekanntes Tool")
|
|
if not definition.classified or definition.hash != tool.hash:
|
|
return Verdict(DENY, "nicht eingestuft")
|
|
if definition.risk_class == "destructive":
|
|
return Verdict(DENY, "destructive ist gesperrt")
|
|
decision, reason, four_eyes = definition.decision, "globale Einstufung", definition.four_eyes
|
|
policy = session.get(WorkspaceToolPolicy, (workspace.id, name))
|
|
if policy:
|
|
four_eyes = four_eyes or policy.four_eyes
|
|
if policy.decision and stricter(policy.decision, decision) != decision:
|
|
decision, reason = policy.decision, "Vorgabe des Workspace"
|
|
scoped = session.get(SourceToolPolicy, (source_id, name)) if source_id is not None else None
|
|
if scoped and scoped.workspace_id == workspace.id:
|
|
four_eyes = four_eyes or scoped.four_eyes
|
|
if scoped.decision and stricter(scoped.decision, decision) != decision:
|
|
decision, reason = scoped.decision, "Regel für diese Quelle"
|
|
if (workspace.approval_mode == "all_writes" and definition.risk_class == "write_external"
|
|
and stricter(decision, REQUIRE_APPROVAL) != decision):
|
|
decision, reason = REQUIRE_APPROVAL, "Modus B"
|
|
return Verdict(decision, reason, definition.approval_level, four_eyes)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolStatus:
|
|
definition: ToolDefinition
|
|
verdict: Verdict
|
|
override: str
|
|
available: bool # Workspace hat eine passende, getestete Quelle
|
|
four_eyes_override: bool = False
|
|
|
|
|
|
def overview(session: Session, workspace: Workspace, source_kinds: set[str]) -> list[ToolStatus]:
|
|
overrides = {p.tool_name: p for p in session.scalars(
|
|
select(WorkspaceToolPolicy).where(WorkspaceToolPolicy.workspace_id == workspace.id))}
|
|
hidden = mcp.foreign_kinds(session, workspace.id)
|
|
result = []
|
|
for d in sync(session):
|
|
if d.kind in hidden: # Verbindungen anderer Workspaces und DB-Dienste
|
|
continue
|
|
policy = overrides.get(d.name)
|
|
result.append(ToolStatus(d, evaluate(session, workspace, d.name), policy.decision if policy else "",
|
|
d.kind in source_kinds, bool(policy and policy.four_eyes)))
|
|
return result
|
|
|
|
|
|
def offered(session: Session, workspace: Workspace, source_kinds: set[str], *, read_only: bool = False,
|
|
keep: frozenset[str] = frozenset()) -> list[Tool]:
|
|
"""Tools, die das Modell in diesem Run sehen darf: erlaubte, und solche mit Freigabepflicht, die
|
|
einen Freigabeantrag vorbereiten können. Nur solche aus Quellen und MCP-Servern des Workspace.
|
|
read_only (Planungsmodus): nur Tools der Klasse read, dazu die in keep."""
|
|
tools = catalog(session)
|
|
result = []
|
|
for status in overview(session, workspace, source_kinds):
|
|
tool = tools.get(status.definition.name)
|
|
decision = status.verdict.decision
|
|
# Regeln je Quelle verschärfen nur; sie werden beim einzelnen Aufruf geprüft
|
|
runnable = tool and ((decision == ALLOW and (tool.run or tool.prepare))
|
|
or (decision == REQUIRE_APPROVAL and tool.approvable))
|
|
if read_only and status.definition.risk_class != "read" and status.definition.name not in keep:
|
|
continue
|
|
if runnable and status.available:
|
|
result.append(tool)
|
|
return result
|