bifroest/tests/test_policies.py
2026-10-27 08:34:17 +01:00

280 lines
16 KiB
Python

"""Feinere Policies: Freigabestufe, Vier-Augen-Prinzip, Regeln je Quelle, globale Approver
(→ Konzept 5.3, 10.3, 10.5; Phase 5)."""
import re
import uuid
import pytest
from sqlalchemy import select
from app.approvals.models import ApprovalRequest
from app.audit.models import AuditEvent
from app.data_sources.models import DataSource
from app.identity import Principal
from app.identity.models import User
from app.tasks.models import TaskRun
from app.tasks.worker import Worker
from app.tools import service as tools
from app.tools.models import ToolDefinition
from helpers import connect_repo, create_workspace
CHANGE = {"title": "README ergänzen", "text": "Neue Zeile."}
@pytest.fixture
def setup(browser, people, db, git_repo, tmp_path):
"""anna Workspace-Admin, ben Mitglied, clara zweite Admin, dora (anderes Team) globale Approverin.
Zwei Git-Quellen: main und zweit."""
with db.session() as session:
session.get(User, people["anna"]).is_admin = True
anna = browser("anna")
workspace_id = create_workspace(anna, people["team_demo"])
connect_repo(anna, workspace_id, git_repo)
anna.post(f"/workspaces/{workspace_id}/sources", {"name": "zweit", "url": git_repo, "branch": "main",
"exposure_confirmed": "1"})
anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"})
anna.post(f"/workspaces/{workspace_id}/members", {"username": "clara", "role": "admin"})
admin = Principal(user_id=people["anna"], username="anna", display_name="Anna", is_admin=True,
team_ids=frozenset({people["team_demo"]}))
with db.session() as session:
tools.classify_suggested(session, admin)
sources = {s.name: s.id for s in session.scalars(select(DataSource))}
chat_id = int(browser("ben").post(f"/workspaces/{workspace_id}/chats").headers["location"].rsplit("/", 1)[1])
return {"anna": anna, "ben": browser("ben"), "clara": browser("clara"), "dora": browser("dora"),
"workspace_id": workspace_id, "chat_id": chat_id, "admin": admin, "sources": sources, "people": people}
def ask(b, chat_id: int, text: str = "Bitte") -> uuid.UUID:
response = b.post(f"/chats/{chat_id}/messages", {"content": text}, headers={"HX-Request": "true"})
return uuid.UUID(re.search(r"/runs/([0-9a-f-]+)/events", response.text).group(1))
def last_approval(db) -> ApprovalRequest:
with db.session() as session:
approval = session.scalar(select(ApprovalRequest).order_by(ApprovalRequest.id.desc()))
session.expunge(approval)
return approval
def run_status(db, run_id) -> str:
with db.session() as session:
return session.get(TaskRun, run_id).status
def classify(setup, db, name, **extra):
with db.session() as session:
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name))
tools.classify(session, setup["admin"], name, definition.risk_class, definition.decision, **extra)
def test_vier_augen_prinzip(setup, db, settings, fake_provider):
classify(setup, db, "demo_write", four_eyes=True)
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "Wartet."]
# anna (Workspace-Admin) fragt selbst: darf ihren eigenen Antrag nicht freigeben
chat = int(setup["anna"].post(f"/workspaces/{setup['workspace_id']}/chats").headers["location"].rsplit("/", 1)[1])
run_id = ask(setup["anna"], chat)
Worker(db, settings).run_once()
approval = last_approval(db)
assert approval.four_eyes and approval.status == "pending"
page = setup["anna"].get(f"/approvals/{approval.id}").text
assert "Vier-Augen-Prinzip" in page and "Freigeben</button>" not in page
response = setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"})
assert response.status_code == 403 and "jemand anderes" in response.text
# Nicht in annas Liste zum Entscheiden, wohl aber in claras
assert setup["anna"].get("/approvals/count").text.count(">1<") == 0
assert f"/approvals/{approval.id}" in setup["clara"].get("/approvals").text.split("Zuletzt")[0]
assert setup["clara"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 303
Worker(db, settings).run_once()
assert last_approval(db).status == "executed" and not last_approval(db).self_approved
assert run_status(db, run_id) == "completed"
def test_vier_augen_im_workspace(setup, db, settings, fake_provider):
setup["anna"].post(f"/workspaces/{setup['workspace_id']}/tools/demo_write", {"decision": "", "four_eyes": "1"})
page = setup["anna"].get(f"/workspaces/{setup['workspace_id']}/settings?tab=tools").text
assert "Vier-Augen-Prinzip" in page
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "Wartet."]
chat = int(setup["anna"].post(f"/workspaces/{setup['workspace_id']}/chats").headers["location"].rsplit("/", 1)[1])
ask(setup["anna"], chat)
Worker(db, settings).run_once()
assert last_approval(db).four_eyes
# Zurücksetzen entfernt die Vorgabe
setup["anna"].post(f"/workspaces/{setup['workspace_id']}/tools/demo_write", {"decision": ""})
with db.session() as session:
assert not tools.evaluate(session, session.get(__import__("app.workspaces.models", fromlist=["Workspace"]).Workspace,
setup["workspace_id"]), "demo_write").four_eyes
def test_globaler_approver(setup, db, settings, fake_provider):
classify(setup, db, "demo_write", approval_level="global_approver")
setup["anna"].post(f"/admin/users/{setup['people']['dora']}", {"display_name": "Dora", "active": "1",
"is_global_approver": "1"})
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "Erledigt."]
run_id = ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
approval = last_approval(db)
assert approval.approval_level == "global_approver"
# Workspace-Admin reicht nicht
assert "nur ein globaler Approver" in setup["anna"].get(f"/approvals/{approval.id}").text
assert setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 403
assert f"/approvals/{approval.id}" not in setup["anna"].get("/approvals").text.split("Zuletzt")[0]
# dora ist nicht im Workspace und nicht im Team, sieht und entscheidet aber als globale Approverin
assert setup["dora"].get(f"/workspaces/{setup['workspace_id']}").status_code == 404
assert f"/approvals/{approval.id}" in setup["dora"].get("/approvals").text.split("Zuletzt")[0]
assert ">1<" in setup["dora"].get("/approvals/count").text
assert setup["dora"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 303
Worker(db, settings).run_once()
assert last_approval(db).status == "executed" and run_status(db, run_id) == "completed"
def test_approver_rechte_werden_vor_ausfuehrung_geprueft(setup, db, settings, fake_provider):
classify(setup, db, "demo_write", approval_level="global_approver")
with db.session() as session:
session.get(User, setup["people"]["dora"]).is_global_approver = True
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "Ende."]
ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
approval = last_approval(db)
setup["dora"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"})
# Rolle wird entzogen, bevor der Worker ausführt
with db.session() as session:
session.get(User, setup["people"]["dora"]).is_global_approver = False
Worker(db, settings).run_once()
approval = last_approval(db)
assert approval.status == "failed" and "globaler Approver" in approval.result["output"]
def test_ohne_rechte_kein_zugriff(setup, db, settings, fake_provider):
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "x"]
ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
approval = last_approval(db)
# Workspace-Admin-Stufe: dora (fremd, kein Approver) sieht nichts; als globale Approverin auch nicht,
# weil der Antrag nur Workspace-Admins betrifft
assert setup["dora"].get(f"/approvals/{approval.id}").status_code == 404
with db.session() as session:
session.get(User, setup["people"]["dora"]).is_global_approver = True
assert setup["dora"].get(f"/approvals/{approval.id}").status_code == 404
assert setup["ben"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 403
def test_regel_je_quelle_mit_lesendem_tool(setup, db, settings, fake_provider):
ws, anna = setup["workspace_id"], setup["anna"]
zweit = setup["sources"]["zweit"]
assert anna.post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:git_read_file",
"decision": "require_approval"}).status_code == 303
assert "Regeln je Quelle" in anna.get(f"/workspaces/{ws}/settings?tab=tools").text
# Auf Repo direkt, auf zweit erst nach Freigabe
fake_provider.script = [[("git_read_file", {"source": "Repo", "path": "README.md"}),
("git_read_file", {"source": "zweit", "path": "README.md"})], "Gelesen."]
run_id = ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
assert run_status(db, run_id) == "waiting_approval"
approval = last_approval(db)
assert approval.tool == "git_read_file" and approval.reason == "Regel für diese Quelle"
assert approval.source_id == zweit and '"path": "README.md"' in approval.preview
page = anna.get(f"/approvals/{approval.id}").text
assert "nach der Freigabe ausgeführt" in page
first = [i["output"] for i in fake_provider.calls[0]["input"] if i.get("type") == "function_call_output"]
assert first == [] # Vor der Freigabe ging nichts ans Modell zurück
anna.post(f"/approvals/{approval.id}/decide", {"decision": "approve"})
Worker(db, settings).run_once()
assert run_status(db, run_id) == "completed"
outputs = [i["output"] for i in fake_provider.calls[-1]["input"] if i.get("type") == "function_call_output"]
assert len(outputs) == 2 and all("# Test" in o for o in outputs)
def test_regel_je_quelle_sperrt(setup, db, settings, fake_provider):
ws, anna = setup["workspace_id"], setup["anna"]
zweit = setup["sources"]["zweit"]
anna.post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:git_list_dir", "decision": "deny"})
fake_provider.script = [[("git_list_dir", {"source": "zweit"}), ("git_list_dir", {"source": "Repo"})], "ok"]
ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
outputs = [i["output"] for i in fake_provider.calls[-1]["input"] if i.get("type") == "function_call_output"]
assert "nicht verfügbar (Regel für diese Quelle)" in outputs[0] and "README.md" in outputs[1]
# Entfernen
anna.post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:git_list_dir", "decision": ""})
with db.session() as session:
assert not tools.source_policies(session, ws)
actions = [e.action for e in session.scalars(select(AuditEvent))]
assert actions.count("tool.source_policy_set") == 2
def test_regeln_nur_verschaerfend_und_passend(setup, db):
ws, anna, zweit = setup["workspace_id"], setup["anna"], setup["sources"]["zweit"]
assert anna.post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:git_list_dir",
"decision": "allow"}).status_code == 400
with db.session() as session:
tools.classify(session, setup["admin"], "git_log", "read", "require_approval")
assert anna.post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:git_log",
"decision": "allow"}).status_code == 400
assert anna.post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:data_query",
"decision": "deny"}).status_code == 400
assert anna.post(f"/workspaces/{ws}/source-rules", {"rule": "999:git_log", "decision": "deny"}).status_code == 404
assert setup["ben"].post(f"/workspaces/{ws}/source-rules", {"rule": f"{zweit}:git_log",
"decision": "deny"}).status_code == 403
assert anna.post(f"/workspaces/{ws}/source-rules", {"rule": "kaputt"}).status_code == 400
def test_einstufung_mit_stufe_im_ui(setup, db):
anna = setup["anna"]
page = anna.post("/admin/tools/demo_write", {"risk_class": "write_external", "decision": "require_approval",
"approval_level": "global_approver", "four_eyes": "1"}).text
assert "demo_write eingestuft" in page
with db.session() as session:
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == "demo_write"))
assert definition.approval_level == "global_approver" and definition.four_eyes
assert anna.post("/admin/tools/demo_write", {"risk_class": "write_external", "decision": "require_approval",
"approval_level": "chef"}).status_code == 400
page = anna.post(f"/admin/users/{setup['people']['dora']}", {"display_name": "Dora", "active": "1",
"is_global_approver": "1"}).text
assert "Globaler Approver</span>" in page
def test_freigeber_entscheidet_ohne_verwaltungsrechte(setup, db, settings, fake_provider):
from app.notify import service as notify
setup["anna"].post(f"/workspaces/{setup['workspace_id']}/members/{setup['people']['clara']}/role",
{"role": "approver"})
assert "Freigeber" in setup["clara"].get(f"/workspaces/{setup['workspace_id']}").text
assert setup["clara"].get(f"/workspaces/{setup['workspace_id']}/settings").status_code == 403
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "Wartet."]
ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
approval = last_approval(db)
with db.session() as session:
assert {u.username for u in notify.deciders(session, approval)} == {"anna", "clara"}
# Mitglied darf nicht entscheiden, Freigeber schon
assert setup["ben"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 403
assert f"/approvals/{approval.id}" in setup["clara"].get("/approvals").text.split("Zuletzt")[0]
assert setup["clara"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 303
Worker(db, settings).run_once()
assert last_approval(db).status == "executed"
# Wird der Freigeber zum Mitglied, gilt eine noch nicht ausgeführte Freigabe nicht mehr
setup["anna"].post(f"/workspaces/{setup['workspace_id']}/members/{setup['people']['clara']}/role",
{"role": "member"})
with db.session() as session:
from app.approvals import service as approvals
approval = session.get(ApprovalRequest, approval.id)
assert approvals.blocked(session, setup["people"]["clara"], approval).startswith("Diesen Antrag kann nur")
def test_modus_b_im_ui(setup, db, settings, fake_provider):
from app.workspaces.models import Workspace
url = f"/workspaces/{setup['workspace_id']}/approval-mode"
assert setup["ben"].post(url, {"mode": "all_writes"}).status_code == 403
assert setup["anna"].post(url, {"mode": "unsinn"}).status_code == 400
assert setup["anna"].post(url, {"mode": "all_writes"}).status_code == 303
page = setup["anna"].get(f"/workspaces/{setup['workspace_id']}/settings").text
assert 'value="all_writes" checked' in page
# Auch ein erlaubtes write_external-Tool braucht jetzt eine Freigabe
with db.session() as session:
assert session.get(Workspace, setup["workspace_id"]).approval_mode == "all_writes"
definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == "demo_write"))
tools.classify(session, setup["admin"], "demo_write", definition.risk_class, "allow")
fake_provider.script = [[("demo_write", {**CHANGE, "source": "Repo"})], "Wartet."]
ask(setup["ben"], setup["chat_id"])
Worker(db, settings).run_once()
assert last_approval(db).status == "pending" and last_approval(db).reason == "Modus B"
with db.session() as session:
assert session.scalar(select(AuditEvent).where(AuditEvent.action == "workspace.approval_mode_set"))