"""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"]