mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-18 02:35:41 +02:00
93 lines
3.0 KiB
Python
93 lines
3.0 KiB
Python
"""Append-only audit log for workflow edits.
|
|
|
|
One JSONL file per workflow at <DATA_ROOT>/workflows/audit/<wid>.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
|