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