bifroest/app/approvals/service.py
2026-10-27 08:34:17 +01:00

399 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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