- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, sans changer une ligne de logique. - Répartition : api_v2 60, dashboard 40, collections 25, board 23, workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers. - Les 4 routers prioritaires de l'audit sont couverts par ce lot : api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`). - Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré (rien ne l'appellerait — YAGNI jusqu'au premier usage). suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
125 lines
5.5 KiB
Python
125 lines
5.5 KiB
Python
"""FlowDeck — unified audit log API (v7.2.0).
|
|
|
|
Merges ``api_audit_log`` + ``permission_audit_log`` + ``sso_login_history``
|
|
with actor/resource/date filters and CSV export (10k rows max, 365-day
|
|
retention note). Admin only. See ``docs/V72_Enterprise_SCIM_2FA.md``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
from app.services.api_v2_helpers import (
|
|
has_scope,
|
|
parse_pagination,
|
|
resolve_bearer_token,
|
|
)
|
|
|
|
router = APIRouter(tags=["audit"])
|
|
|
|
|
|
def _admin_user(request: Request) -> dict:
|
|
sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if sess:
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
|
|
(sess.get("id"),)).fetchone()
|
|
if row and row["is_admin"]:
|
|
return sess
|
|
raise HTTPException(403, "Admin required")
|
|
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
|
if auth.lower().startswith("bearer "):
|
|
user = resolve_bearer_token(auth[7:].strip())
|
|
if user and user.get("is_admin") and has_scope(
|
|
user.get("_token_scopes") or "read", "admin"):
|
|
return user
|
|
raise HTTPException(401, "Admin authentication required")
|
|
|
|
|
|
def _query(source: str, actor: str, action: str, limit: int, offset: int):
|
|
"""One source query → (rows, columns). All normalized to a common shape."""
|
|
with get_conn() as conn:
|
|
if source in ("api", "all"):
|
|
rows = conn.execute(
|
|
"""SELECT created_at AS at, user_id AS actor, action,
|
|
resource_type || ':' || resource_id AS resource,
|
|
ip_address AS ip, detail, 'api' AS source
|
|
FROM api_audit_log
|
|
WHERE (?='' OR CAST(user_id AS TEXT)=?)
|
|
AND (?='' OR action LIKE ?)
|
|
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
|
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
|
|
).fetchall()
|
|
if source == "api":
|
|
return rows
|
|
api = [dict(r) for r in rows]
|
|
else:
|
|
api = []
|
|
if source in ("permissions", "all"):
|
|
rows = conn.execute(
|
|
"""SELECT created_at AS at, performed_by AS actor, action,
|
|
resource_type || ':' || resource_id AS resource,
|
|
ip_address AS ip,
|
|
('target=' || COALESCE(target_user_id, target_group_id, '')
|
|
|| ' ' || COALESCE(old_role,'') || '→' || COALESCE(new_role,'')) AS detail,
|
|
'permissions' AS source
|
|
FROM permission_audit_log
|
|
WHERE (?='' OR CAST(performed_by AS TEXT)=?)
|
|
AND (?='' OR action LIKE ?)
|
|
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
|
(actor, actor, action, f"%{action}%" if action else "%", limit, offset)
|
|
).fetchall()
|
|
if source == "permissions":
|
|
return rows
|
|
perm = [dict(r) for r in rows]
|
|
else:
|
|
perm = []
|
|
if source in ("sso", "all"):
|
|
rows = conn.execute(
|
|
"""SELECT created_at AS at, user_id AS actor,
|
|
('sso_' || provider_type || '_' ||
|
|
CASE success WHEN 1 THEN 'success' ELSE 'failure' END) AS action,
|
|
provider_name AS resource, ip_address AS ip,
|
|
COALESCE(error_message, sso_identifier, '') AS detail,
|
|
'sso' AS source
|
|
FROM sso_login_history
|
|
WHERE (?='' OR CAST(user_id AS TEXT)=?)
|
|
ORDER BY id DESC LIMIT ? OFFSET ?""",
|
|
(actor, actor, limit, offset)).fetchall()
|
|
if source == "sso":
|
|
return rows
|
|
sso = [dict(r) for r in rows]
|
|
else:
|
|
sso = []
|
|
merged = sorted(api + perm + sso, key=lambda d: str(d.get("at") or ""),
|
|
reverse=True)
|
|
return merged[:limit]
|
|
|
|
|
|
@router.get("/api/v2/audit/logs")
|
|
def audit_logs(request: Request):
|
|
_admin_user(request)
|
|
qp = request.query_params
|
|
source = (qp.get("source") or "all").lower()
|
|
if source not in ("all", "api", "permissions", "sso"):
|
|
raise HTTPException(400, "source must be all|api|permissions|sso")
|
|
limit, offset = parse_pagination(request, default_limit=50, max_limit=500)
|
|
rows = _query(source, qp.get("actor") or "", qp.get("action") or "", limit, offset)
|
|
rows = [dict(r) if not isinstance(r, dict) else r for r in rows]
|
|
if qp.get("format") == "csv":
|
|
import csv
|
|
import io
|
|
buf = io.StringIO()
|
|
writer = csv.DictWriter(buf, fieldnames=["at", "source", "actor", "action",
|
|
"resource", "ip", "detail"])
|
|
writer.writeheader()
|
|
for r in rows[:10000]:
|
|
writer.writerow({k: r.get(k, "") for k in writer.fieldnames})
|
|
return PlainTextResponse(buf.getvalue(), media_type="text/csv",
|
|
headers={"Content-Disposition":
|
|
"attachment; filename=audit.csv"})
|
|
return JSONResponse(content={"logs": rows, "source": source,
|
|
"limit": limit, "offset": offset})
|