"""Append-only audit log for workflow edits. One JSONL file per workflow at /workflows/audit/.jsonl. We diff before/after rather than snapshotting the full record so the file stays small even after dozens of edits. Read path tails the file; we don't keep this in memory because audits are inspected rarely. """ import json import logging import os from datetime import datetime, timezone from threading import Lock from typing import Any from backend.apps.workflows.storage import DATA_DIR logger = logging.getLogger(__name__) AUDIT_DIR = os.path.join(DATA_DIR, "audit") _io_lock = Lock() # Soft cap on bytes per audit file. When exceeded we truncate to the last CAP/2 bytes on next write so attackers (or a runaway PATCH loop) can't fill the disk. 256 KiB is ~2000 edits; we never expect to hit it. SOFT_CAP_BYTES = 256 * 1024 def _audit_path(wid: str) -> str: return os.path.join(AUDIT_DIR, f"{wid}.jsonl") def _diff(before: dict, after: dict) -> dict[str, dict[str, Any]]: """Return only the keys whose value changed. Nested dicts are diffed shallowly; the schedule/actions/permissions blocks are small so we just record the whole sub-dict when any sub-key changes. """ changed: dict[str, dict[str, Any]] = {} keys = set(before) | set(after) for k in keys: b = before.get(k) a = after.get(k) if b != a: changed[k] = {"before": b, "after": a} return changed def log_change(wid: str, who: str, before: dict, after: dict) -> None: diff = _diff(before, after) if not diff: return entry = { "ts": datetime.now(timezone.utc).isoformat(), "who": who, "diff": diff, } try: with _io_lock: os.makedirs(AUDIT_DIR, exist_ok=True) path = _audit_path(wid) if os.path.exists(path) and os.path.getsize(path) > SOFT_CAP_BYTES: # Keep the tail half. Cheap, lossy, prevents pathological disk growth without crashing on a corrupt file. with open(path, "rb") as f: f.seek(-(SOFT_CAP_BYTES // 2), os.SEEK_END) tail = f.read() first_nl = tail.find(b"\n") tail = tail[first_nl + 1:] if first_nl >= 0 else b"" with open(path, "wb") as f: f.write(tail) with open(path, "a") as f: f.write(json.dumps(entry) + "\n") except Exception: logger.debug("audit log_change failed", exc_info=True) def read_tail(wid: str, limit: int = 50) -> list[dict]: path = _audit_path(wid) if not os.path.exists(path): return [] try: with open(path) as f: lines = f.readlines() except Exception: return [] out: list[dict] = [] for line in lines[-limit:]: line = line.strip() if not line: continue try: out.append(json.loads(line)) except Exception: continue out.reverse() return out