399 lines
20 KiB
Python
399 lines
20 KiB
Python
"""Freigaben beantragen, entscheiden, ablaufen lassen und ausführen (→ Konzept 10.4–10.6).
|
||
|
||
Ausgeführt wird nur der gespeicherte Snapshot. Vor der Ausführung werden Hash,
|
||
Policy, Berechtigungen und – im Tool selbst – die Vorbedingungen im Zielsystem
|
||
erneut geprüft.
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from datetime import timedelta
|
||
from typing import Any
|
||
|
||
from sqlalchemy import func, select, update
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app import audit
|
||
from app.approvals.models import (
|
||
APPROVED,
|
||
CANCELLED,
|
||
EXECUTED,
|
||
EXECUTING,
|
||
EXPIRED,
|
||
FAILED,
|
||
NOT_EXECUTED,
|
||
PENDING,
|
||
REJECTED,
|
||
UNKNOWN,
|
||
ApprovalRequest,
|
||
)
|
||
from app.chat.models import Message
|
||
from app.core.database import Database, now
|
||
from app.core.errors import Forbidden, Invalid, NotFound
|
||
from app.identity import Principal
|
||
from app.identity.models import User
|
||
from app.identity.service import principal_for
|
||
from app.notify import service as notify
|
||
from app.security import untrusted
|
||
from app.tasks import engine, states
|
||
from app.tasks.models import TaskRun
|
||
from app.tools import service as tools
|
||
from app.tools.context import ToolContext
|
||
from app.tools.models import DENY, GLOBAL_APPROVER, REQUIRE_APPROVAL, WORKSPACE_ADMIN, ToolCall, ToolDefinition
|
||
from app.tools.registry import Proposal, ToolError
|
||
from app.workspaces import service as workspaces
|
||
from app.workspaces.models import APPROVER_ROLES, Workspace, WorkspaceMembership
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
class Unclear(Exception):
|
||
"""Ausgang einer Schreibaktion unbekannt; der Run muss mit dem Zielsystem abgeglichen werden."""
|
||
|
||
def __init__(self, approval_id: int, detail: str):
|
||
super().__init__(detail)
|
||
self.approval_id = approval_id
|
||
|
||
|
||
def snapshot_hash(tool: str, source_id: int | None, arguments: dict, preview: str, preconditions: dict,
|
||
policy: dict) -> str:
|
||
data = json.dumps({"tool": tool, "source": source_id, "arguments": arguments, "preview": preview,
|
||
"preconditions": preconditions, "tool_hash": policy.get("tool_hash")},
|
||
sort_keys=True, ensure_ascii=False)
|
||
return hashlib.sha256(data.encode()).hexdigest()
|
||
|
||
|
||
def rejected_before(session: Session, run_id: uuid.UUID, tool: str, arguments: dict[str, Any]) -> bool:
|
||
"""Abgelehnte Aktionen nicht automatisch erneut mit denselben Parametern beantragen (→ Konzept 9.2)."""
|
||
rejected = session.scalars(select(ApprovalRequest.arguments).where(
|
||
ApprovalRequest.run_id == run_id, ApprovalRequest.tool == tool, ApprovalRequest.status == REJECTED))
|
||
return any(previous == arguments for previous in rejected)
|
||
|
||
|
||
def propose(session: Session, *, run_id: uuid.UUID, workspace: Workspace, user_id: int, call_id: str, tool: str,
|
||
proposal: Proposal, verdict: tools.Verdict, hours: int, replaces_id: int | None = None) -> ApprovalRequest:
|
||
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == tool))
|
||
policy = {"tool_hash": definition.hash, "risk_class": definition.risk_class, "decision": verdict.decision,
|
||
"mode": workspace.approval_mode, "level": verdict.level, "four_eyes": verdict.four_eyes}
|
||
approval = ApprovalRequest(
|
||
run_id=run_id, workspace_id=workspace.id, source_id=proposal.source_id, call_id=call_id[:200], tool=tool,
|
||
risk_class=definition.risk_class, reason=verdict.reason, arguments=proposal.arguments,
|
||
preview=proposal.preview, preconditions=proposal.preconditions, model_summary=proposal.summary,
|
||
policy=policy, requested_by=user_id, expires_at=now() + timedelta(hours=hours), replaces_id=replaces_id,
|
||
approval_level=verdict.level, four_eyes=verdict.four_eyes,
|
||
hash=snapshot_hash(tool, proposal.source_id, proposal.arguments, proposal.preview, proposal.preconditions,
|
||
policy))
|
||
if verdict.decision != REQUIRE_APPROVAL:
|
||
# Policy erlaubt die Aktion ohne Rückfrage: nachvollziehbar als automatisch genehmigt
|
||
approval.status, approval.decided_at, approval.comment = APPROVED, now(), "Durch Policy erlaubt"
|
||
session.add(approval)
|
||
session.flush()
|
||
audit.record(session, "approval.requested", user_id=user_id, workspace_id=workspace.id,
|
||
target_kind="approval", target_id=approval.id, tool=tool, run_id=str(run_id),
|
||
automatic=approval.status == APPROVED)
|
||
if approval.status == PENDING:
|
||
notify.approval_requested(session, approval, workspace)
|
||
return approval
|
||
|
||
|
||
# --- Sichtbarkeit und Entscheidung ---
|
||
|
||
def is_global_approver(session: Session, user_id: int) -> bool:
|
||
user = session.get(User, user_id)
|
||
return bool(user and user.is_global_approver and user.active)
|
||
|
||
|
||
def get(session: Session, principal: Principal, approval_id: int) -> tuple[ApprovalRequest, bool]:
|
||
"""Antrag und ob der Nutzer im Workspace entscheiden darf (Admin oder Freigeber). Sichtbar für Mitglieder des Workspace und, bei
|
||
Freigabestufe global_approver, für globale Approver."""
|
||
approval = session.get(ApprovalRequest, approval_id)
|
||
if approval is None:
|
||
raise NotFound
|
||
try:
|
||
return approval, workspaces.get(session, principal, approval.workspace_id).may_approve
|
||
except (NotFound, Forbidden):
|
||
workspace = session.get(Workspace, approval.workspace_id)
|
||
if approval.approval_level == GLOBAL_APPROVER and is_global_approver(session, principal.user_id) \
|
||
and workspace.deleted is None:
|
||
return approval, False
|
||
raise
|
||
|
||
|
||
DECIDER_ONLY = "Diesen Antrag kann nur ein Workspace-Admin oder Freigeber entscheiden."
|
||
|
||
|
||
def blocked(session: Session, user_id: int, approval: ApprovalRequest) -> str:
|
||
"""Leer = darf entscheiden; sonst der Grund (→ Konzept 10.5)."""
|
||
if approval.four_eyes and user_id == approval.requested_by:
|
||
return "Vier-Augen-Prinzip: Den Antrag muss jemand anderes entscheiden als die Person, die ihn ausgelöst hat."
|
||
if approval.approval_level == GLOBAL_APPROVER:
|
||
if not is_global_approver(session, user_id):
|
||
return "Diesen Antrag kann nur ein globaler Approver entscheiden."
|
||
return ""
|
||
principal = principal_for(session, user_id)
|
||
try:
|
||
if principal is None or not workspaces.get(session, principal, approval.workspace_id).may_approve:
|
||
return DECIDER_ONLY
|
||
except (NotFound, Forbidden):
|
||
return DECIDER_ONLY
|
||
return ""
|
||
|
||
|
||
def decidable(session: Session, principal: Principal):
|
||
"""Bedingung für offene Anträge, die der Nutzer entscheiden darf (ohne Vier-Augen-Prüfung)."""
|
||
ids = approver_workspace_ids(session, principal)
|
||
condition = ApprovalRequest.workspace_id.in_(ids) & (ApprovalRequest.approval_level == WORKSPACE_ADMIN) \
|
||
if ids else None
|
||
if is_global_approver(session, principal.user_id):
|
||
global_ = ApprovalRequest.approval_level == GLOBAL_APPROVER
|
||
condition = global_ if condition is None else condition | global_
|
||
if condition is None:
|
||
return None
|
||
# Workspaces im Papierkorb haben keine entscheidbaren Anträge
|
||
live = ApprovalRequest.workspace_id.in_(select(Workspace.id).where(Workspace.deleted.is_(None)))
|
||
return condition & live & ~(ApprovalRequest.four_eyes & (ApprovalRequest.requested_by == principal.user_id))
|
||
|
||
|
||
def approver_workspace_ids(session: Session, principal: Principal) -> list[int]:
|
||
"""Workspaces, in denen der Nutzer Anträge entscheiden darf (Admin oder Freigeber)."""
|
||
return list(session.scalars(
|
||
select(WorkspaceMembership.workspace_id)
|
||
.join(Workspace, Workspace.id == WorkspaceMembership.workspace_id)
|
||
.where(WorkspaceMembership.user_id == principal.user_id, WorkspaceMembership.role.in_(APPROVER_ROLES),
|
||
Workspace.team_id.in_(principal.team_ids), Workspace.deleted.is_(None))))
|
||
|
||
|
||
def count_open(session: Session, principal: Principal) -> int:
|
||
condition = decidable(session, principal)
|
||
if condition is None:
|
||
return 0
|
||
return session.scalar(select(func.count()).select_from(ApprovalRequest).where(
|
||
condition, ApprovalRequest.status == PENDING, ApprovalRequest.expires_at > now())) or 0
|
||
|
||
|
||
def overview(session: Session, principal: Principal) -> tuple[list[ApprovalRequest], list[ApprovalRequest]]:
|
||
"""Offene Anträge zum Entscheiden und die letzten Anträge (eigene und aus administrierten Workspaces)."""
|
||
expire_due_session(session)
|
||
ids = approver_workspace_ids(session, principal)
|
||
condition = decidable(session, principal)
|
||
open_ = list(session.scalars(select(ApprovalRequest).where(condition, ApprovalRequest.status == PENDING)
|
||
.order_by(ApprovalRequest.requested_at))) if condition is not None else []
|
||
approver = is_global_approver(session, principal.user_id)
|
||
visible = ApprovalRequest.requested_by == principal.user_id
|
||
if ids:
|
||
visible = visible | ApprovalRequest.workspace_id.in_(ids)
|
||
if approver:
|
||
visible = visible | (ApprovalRequest.approval_level == GLOBAL_APPROVER)
|
||
recent = list(session.scalars(select(ApprovalRequest).where(visible, ApprovalRequest.id.not_in([a.id for a in open_]))
|
||
.order_by(ApprovalRequest.requested_at.desc()).limit(30)))
|
||
# Nur Workspaces, in denen der Nutzer (noch) Mitglied ist, oder globale Anträge für Approver
|
||
member_of = {m.workspace.id for m in workspaces.list_for(session, principal)}
|
||
return open_, [a for a in recent if a.workspace_id in member_of
|
||
or (approver and a.approval_level == GLOBAL_APPROVER)]
|
||
|
||
|
||
def decide(session: Session, principal: Principal, approval_id: int, approve: bool, comment: str = "") -> ApprovalRequest:
|
||
approval = session.get(ApprovalRequest, approval_id, with_for_update=True)
|
||
if approval is None:
|
||
raise NotFound
|
||
get(session, principal, approval_id) # NotFound, wenn nicht sichtbar
|
||
problem = blocked(session, principal.user_id, approval)
|
||
if problem:
|
||
raise Forbidden(problem)
|
||
if approval.status == PENDING and approval.expires_at <= now():
|
||
expire(session, approval)
|
||
if approval.status != PENDING:
|
||
raise Invalid("Über diesen Antrag ist bereits entschieden oder er ist abgelaufen.")
|
||
if not approve and not comment.strip():
|
||
raise Invalid("Bitte die Ablehnung kurz begründen; die Begründung erhält auch die KI.")
|
||
approval.status = APPROVED if approve else REJECTED
|
||
approval.decided_by, approval.decided_at = principal.user_id, now()
|
||
approval.comment = comment.strip()[:2000]
|
||
approval.self_approved = approve and principal.user_id == approval.requested_by
|
||
audit.record(session, f"approval.{approval.status}", user_id=principal.user_id,
|
||
workspace_id=approval.workspace_id, target_kind="approval", target_id=approval.id,
|
||
self_approved=approval.self_approved, hash=approval.hash)
|
||
run = session.get(TaskRun, approval.run_id)
|
||
notify.approval_decided(session, approval, principal.user_id, run.chat_session_id if run else None)
|
||
resume_if_decided(session, approval.run_id)
|
||
return approval
|
||
|
||
|
||
def expire(session: Session, approval: ApprovalRequest) -> None:
|
||
approval.status, approval.decided_at = EXPIRED, now()
|
||
audit.record(session, "approval.expired", workspace_id=approval.workspace_id, target_kind="approval",
|
||
target_id=approval.id)
|
||
|
||
|
||
def expire_due_session(session: Session) -> list[uuid.UUID]:
|
||
due = session.scalars(select(ApprovalRequest).where(ApprovalRequest.status == PENDING,
|
||
ApprovalRequest.expires_at <= now())
|
||
.with_for_update(skip_locked=True)).all()
|
||
for approval in due:
|
||
expire(session, approval)
|
||
session.flush()
|
||
runs = {a.run_id for a in due}
|
||
for run_id in runs:
|
||
resume_if_decided(session, run_id)
|
||
return list(runs)
|
||
|
||
|
||
def expire_due(db: Database) -> int:
|
||
"""Abgelaufene Anträge schließen und ihre Runs fortsetzen; läuft im Worker."""
|
||
with db.session() as session:
|
||
return len(expire_due_session(session))
|
||
|
||
|
||
def resume_if_decided(session: Session, run_id: uuid.UUID) -> None:
|
||
"""Sobald kein Antrag des Runs mehr offen ist, wird der Run wieder eingereiht (→ Konzept 9.3)."""
|
||
session.flush()
|
||
run = session.get(TaskRun, run_id, with_for_update=True)
|
||
if run is None or run.status != states.WAITING_APPROVAL:
|
||
return
|
||
still_open = session.scalar(select(func.count()).select_from(ApprovalRequest).where(
|
||
ApprovalRequest.run_id == run_id, ApprovalRequest.status == PENDING))
|
||
if not still_open:
|
||
engine.set_status(session, run, states.QUEUED, resumed=True)
|
||
run.available_at = func.now()
|
||
|
||
|
||
# --- Ausführung im Worker ---
|
||
|
||
def recheck(session: Session, approval: ApprovalRequest) -> str:
|
||
"""Leerer Text = darf ausgeführt werden; sonst der Grund, warum nicht."""
|
||
if approval.hash != snapshot_hash(approval.tool, approval.source_id, approval.arguments, approval.preview,
|
||
approval.preconditions, approval.policy):
|
||
return "Der Snapshot passt nicht mehr zum freigegebenen Hash."
|
||
tool = tools.catalog(session).get(approval.tool)
|
||
if tool is None or tool.hash != approval.policy.get("tool_hash"):
|
||
return "Das Tool hat sich seit der Freigabe geändert; eine neue Freigabe ist nötig."
|
||
workspace = session.get(Workspace, approval.workspace_id)
|
||
if tools.evaluate(session, workspace, approval.tool, approval.source_id).decision == DENY:
|
||
return "Das Tool ist in diesem Workspace inzwischen gesperrt."
|
||
requester = principal_for(session, approval.requested_by)
|
||
try:
|
||
if requester is None:
|
||
raise NotFound
|
||
workspaces.get(session, requester, approval.workspace_id)
|
||
if approval.decided_by is not None:
|
||
problem = blocked(session, approval.decided_by, approval)
|
||
if problem:
|
||
return f"Die Freigabe gilt nicht mehr: {problem}"
|
||
except (NotFound, Forbidden):
|
||
return "Antragsteller oder Entscheider hat keinen Zugriff mehr auf den Workspace."
|
||
return ""
|
||
|
||
|
||
def message_for_model(approval: ApprovalRequest, decider: str = "") -> str:
|
||
if approval.status == REJECTED:
|
||
return (f"Abgelehnt{f' von {decider}' if decider else ''}: {approval.comment or 'ohne Begründung'}. "
|
||
"Die Änderung wurde nicht ausgeführt. Nicht unverändert erneut einreichen.")
|
||
if approval.status == EXPIRED:
|
||
return ("Die Freigabe ist ohne Entscheidung abgelaufen; die Änderung wurde nicht ausgeführt. "
|
||
"Der Nutzer kann sie in der App erneut beantragen.")
|
||
if approval.status == CANCELLED:
|
||
return "Der Antrag wurde zurückgezogen; die Änderung wurde nicht ausgeführt."
|
||
return approval.result.get("output", "")
|
||
|
||
|
||
def carry_out(db: Database, context: ToolContext, approval_id: int, *, for_model: bool = True) -> tuple[str, str]:
|
||
"""Freigegebenen Antrag ausführen; liefert Text für das Modell und Tool-Status.
|
||
|
||
Gespeichert wird das Ergebnis unverändert; für das Modell wird es als fremder
|
||
Inhalt eingefasst (→ app.security.untrusted), für Menschen (for_model=False) nicht.
|
||
"""
|
||
output, status = execute_approved(db, context, approval_id)
|
||
if for_model and status == "ok":
|
||
with db.session() as session:
|
||
name = session.get(ApprovalRequest, approval_id).tool
|
||
tool = tools.catalog(session).get(name)
|
||
if tool is None or tool.untrusted:
|
||
output = untrusted.fence(output, name)
|
||
return output, status
|
||
|
||
|
||
def execute_approved(db: Database, context: ToolContext, approval_id: int) -> tuple[str, str]:
|
||
"""Ausführung ohne Einfassung.
|
||
|
||
Bereits ausgeführte Anträge werden nicht erneut ausgeführt. Anträge im Zustand
|
||
executing oder unknown (Absturz während der Ausführung) werden über das
|
||
idempotente execute des Tools mit dem Zielsystem abgeglichen.
|
||
"""
|
||
with db.session() as session:
|
||
approval = session.get(ApprovalRequest, approval_id, with_for_update=True)
|
||
decider = session.get(User, approval.decided_by).display_name if approval.decided_by else ""
|
||
if approval.status in NOT_EXECUTED:
|
||
return finish_call(session, approval, message_for_model(approval, decider), "denied")
|
||
if approval.status in (EXECUTED, FAILED):
|
||
return approval.result.get("output", ""), "ok" if approval.status == EXECUTED else "error"
|
||
problem = recheck(session, approval)
|
||
if problem:
|
||
approval.status, approval.result = FAILED, {"output": f"Nicht ausgeführt: {problem}"}
|
||
audit.record(session, "approval.recheck_failed", workspace_id=approval.workspace_id,
|
||
target_kind="approval", target_id=approval.id, reason=problem)
|
||
return finish_call(session, approval, approval.result["output"], "error")
|
||
approval.status = EXECUTING
|
||
snapshot = {"source_id": approval.source_id, "arguments": approval.arguments,
|
||
"preconditions": approval.preconditions, "approver": decider}
|
||
tool = tools.catalog(session)[approval.tool] # von recheck geprüft
|
||
try:
|
||
output, result = tool.carry_out(context, snapshot)
|
||
status, tool_status = EXECUTED, "ok"
|
||
except (ToolError, Invalid) as e:
|
||
output, result, status, tool_status = f"Fehler: {e}", {}, FAILED, "error"
|
||
except Exception as e:
|
||
log.exception("Ausführung von Antrag %s mit unklarem Ausgang", approval_id)
|
||
with db.session() as session:
|
||
session.get(ApprovalRequest, approval_id).status = UNKNOWN
|
||
raise Unclear(approval_id, str(e)) from e
|
||
with db.session() as session:
|
||
approval = session.get(ApprovalRequest, approval_id, with_for_update=True)
|
||
approval.status, approval.result = status, {**result, "output": output}
|
||
audit.record(session, f"approval.{status}", workspace_id=approval.workspace_id, target_kind="approval",
|
||
target_id=approval.id, **{k: v for k, v in result.items() if k in ("branch", "commit")})
|
||
return finish_call(session, approval, output, tool_status)
|
||
|
||
|
||
def finish_call(session: Session, approval: ApprovalRequest, output: str, status: str) -> tuple[str, str]:
|
||
"""Den protokollierten Tool-Aufruf auf das Ergebnis aktualisieren."""
|
||
session.execute(update(ToolCall).where(ToolCall.run_id == approval.run_id, ToolCall.call_id == approval.call_id)
|
||
.values(status=status, output=output[:4000]))
|
||
return output, status
|
||
|
||
|
||
# --- Erneut beantragen (→ Konzept 4.5) ---
|
||
|
||
def rerequest(session: Session, principal: Principal, approval_id: int) -> TaskRun:
|
||
"""Abgelaufenen Vorschlag erneut beantragen. Der Worker baut ihn gegen den aktuellen Stand neu."""
|
||
approval, may_approve = get(session, principal, approval_id)
|
||
if approval.status != EXPIRED:
|
||
raise Invalid("Nur abgelaufene Anträge können erneut beantragt werden.")
|
||
if not may_approve and approval.requested_by != principal.user_id:
|
||
raise Forbidden
|
||
if session.scalar(select(ApprovalRequest.id).where(ApprovalRequest.replaces_id == approval.id)):
|
||
raise Invalid("Dieser Antrag wurde bereits erneut beantragt.")
|
||
original = session.get(TaskRun, approval.run_id)
|
||
if original.chat_session_id and session.scalar(select(TaskRun.id).where(
|
||
TaskRun.chat_session_id == original.chat_session_id,
|
||
TaskRun.status.in_((states.QUEUED, states.RUNNING, states.WAITING_APPROVAL)))):
|
||
raise Invalid("Im Chat läuft gerade eine Antwort; bitte danach erneut versuchen.")
|
||
workspace = session.get(Workspace, approval.workspace_id)
|
||
run = engine.create(session, workspace, principal.user_id, "proposal", {"replaces": approval.id},
|
||
chat_session_id=original.chat_session_id, parent_run_id=original.id)
|
||
audit.record(session, "approval.rerequested", user_id=principal.user_id, workspace_id=workspace.id,
|
||
target_kind="approval", target_id=approval.id, run_id=str(run.id))
|
||
return run
|
||
|
||
|
||
def post_message(session: Session, run: TaskRun, text: str) -> Message | None:
|
||
if run.chat_session_id is None:
|
||
return None
|
||
message = Message(chat_session_id=run.chat_session_id, role="assistant", content=text, run_id=run.id)
|
||
session.add(message)
|
||
session.flush()
|
||
return message
|
||
|
||
|
||
__all__ = ["APPROVED", "PENDING", "Unclear", "carry_out", "decide", "propose"]
|