280 lines
16 KiB
Python
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"))
|