Files
flowdeck/app/routers/governance.py
T
Spark 913d6137b1
FlowDeck CI / test (push) Failing after 3h11m27s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Skipped
feat(fusion): format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), contrat panneau (ACC-4.4) — 7.84.0
2026-10-26 16:19:16 -04:00

100 lines
4.4 KiB
Python

"""FlowDeck — agent governance API (v7.2.0): policies + approval queue."""
from __future__ import annotations
import json
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import agent_policies as policies
from app.services.api_v2_helpers import audit_log
router = APIRouter(tags=["governance"])
def _owner_or_admin(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
is_admin = bool(row and row["is_admin"])
if not is_admin and request.query_params.get("workspace_id"):
member = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(request.query_params.get("workspace_id"), user["id"])).fetchone()
owner = conn.execute("SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(request.query_params.get("workspace_id"),
user["id"])).fetchone()
if not member and not owner:
raise HTTPException(403, "Workspace access required")
if member and member["role"] not in ("admin", "editor", "owner"):
raise HTTPException(403, "Editor role required")
user["is_admin"] = is_admin
return user
@router.get("/api/v2/agent-policies")
def list_policies(request: Request):
_owner_or_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
return {"policies": [dict(r) for r in rows]}
@router.post("/api/v2/agent-policies")
def upsert_policy(request: Request, body: dict = Body(default={})):
user = _owner_or_admin(request)
wid = body.get("workspace_id")
tools = body.get("allowed_tools")
if tools is not None and not isinstance(tools, list):
raise HTTPException(400, "allowed_tools must be a list or null")
with get_conn() as conn:
conn.execute(
"""INSERT INTO agent_policies (workspace_id, allowed_tools_json, max_steps,
require_approval)
VALUES (?,?,?,?)
ON CONFLICT(workspace_id) DO UPDATE SET
allowed_tools_json=excluded.allowed_tools_json,
max_steps=excluded.max_steps, require_approval=excluded.require_approval""",
(wid, json.dumps(tools) if tools is not None else None,
max(1, min(int(body.get("max_steps") or 12), 50)),
1 if body.get("require_approval") else 0))
conn.commit()
row = conn.execute("SELECT * FROM agent_policies WHERE workspace_id IS ?",
(wid,)).fetchone()
audit_log(user, "agent.policy.upsert", "workspace", wid or 0, "", request)
return JSONResponse(status_code=201, content=dict(row))
@router.get("/api/v2/agent-approvals")
def list_approvals(request: Request):
_owner_or_admin(request)
status = request.query_params.get("status", "pending")
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_approvals WHERE status=? ORDER BY id DESC"
" LIMIT 100", (status,)).fetchall()
return {"approvals": [dict(r) for r in rows]}
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})):
user = _owner_or_admin(request)
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
if out is None:
raise HTTPException(404, "Pending approval not found")
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
out["status"], request)
# Pilote ACC-6.2 : une demande d'archivage de réunion approuvée
# s'exécute ici (proposition d'écriture vers ObsiGate) ; le
# résultat accompagne la réponse, sans changer le contrat de la
# file pour les autres outils.
from app.services import meeting_archive
archive = meeting_archive.traiter_decision_sync(out)
if archive is not None:
return {**out, "archive": archive}
return out