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