feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"""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})
|
||||
Reference in New Issue
Block a user