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

67 lines
3.7 KiB
Python

"""Freigabeanträge (→ Konzept 10.4, 10.5, 9.2).
Ein Antrag bezieht sich auf genau einen Tool-Aufruf. Parameter, Vorschau (Diff)
und Vorbedingungen sind ein unveränderlicher Snapshot; ausgeführt wird nur,
was zum gespeicherten Hash passt.
"""
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, Boolean, ForeignKey, Index, String, Text, Uuid, false, text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base, now
# pending → approved → executing → executed | failed
# pending → rejected | expired | cancelled (Run abgebrochen)
# executing → unknown: Ausgang unklar, Run wechselt nach needs_reconciliation (→ Konzept 9.2)
PENDING, APPROVED, REJECTED, EXPIRED, CANCELLED = "pending", "approved", "rejected", "expired", "cancelled"
EXECUTING, EXECUTED, FAILED, UNKNOWN = "executing", "executed", "failed", "unknown"
NOT_EXECUTED = (REJECTED, EXPIRED, CANCELLED)
LABELS = {PENDING: "offen", APPROVED: "genehmigt", REJECTED: "abgelehnt", EXPIRED: "abgelaufen",
CANCELLED: "zurückgezogen", EXECUTING: "wird ausgeführt", EXECUTED: "ausgeführt", FAILED: "fehlgeschlagen",
UNKNOWN: "Ausgang unklar"}
class ApprovalRequest(Base):
__tablename__ = "approval_requests"
__table_args__ = (
Index("ix_approval_requests_pending", "workspace_id", postgresql_where=text("status = 'pending'")),
Index("ix_approval_requests_run_id", "run_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
run_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("task_runs.id", ondelete="CASCADE"))
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id", ondelete="CASCADE"))
source_id: Mapped[int | None] = mapped_column(ForeignKey("data_sources.id", ondelete="SET NULL"))
call_id: Mapped[str] = mapped_column(String(200))
tool: Mapped[str] = mapped_column(String(100))
risk_class: Mapped[str] = mapped_column(String(20))
reason: Mapped[str] = mapped_column(String(200)) # warum Freigabe nötig (Policy)
# Snapshot: geprüfte Argumente, echte Vorschau, Vorbedingungen im Zielsystem
arguments: Mapped[dict[str, Any]] = mapped_column(JSON)
preview: Mapped[str] = mapped_column(Text)
preconditions: Mapped[dict[str, Any]] = mapped_column(JSON)
# Vom Modell formulierte Beschreibung, nur als gekennzeichneter Zusatz angezeigt
model_summary: Mapped[str] = mapped_column(Text, default="")
# Policy zum Zeitpunkt des Antrags: Tool-Hash, Risikoklasse, Entscheidung, Modus (→ Konzept 10.4)
policy: Mapped[dict[str, Any]] = mapped_column(JSON)
hash: Mapped[str] = mapped_column(String(64))
status: Mapped[str] = mapped_column(String(20), default=PENDING)
requested_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
requested_at: Mapped[datetime] = mapped_column(default=now)
expires_at: Mapped[datetime]
decided_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
decided_at: Mapped[datetime | None]
comment: Mapped[str] = mapped_column(Text, default="")
self_approved: Mapped[bool] = mapped_column(Boolean, default=False)
# Wer entscheiden darf (→ Konzept 10.5): Workspace-Admins oder globale Approver; mit four_eyes
# nie die Person, die den Antrag gestellt hat
approval_level: Mapped[str] = mapped_column(String(20), default="workspace_admin", server_default="workspace_admin")
four_eyes: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
# Ergebnis der Ausführung (Branch, Commit, Pull Request) oder Fehlermeldung
result: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
# Erneut beantragt aus einem abgelaufenen Antrag
replaces_id: Mapped[int | None] = mapped_column(ForeignKey("approval_requests.id"))