- New: auth.py – Role-based access (operator/approver/viewer) - New: llm_router.py – Configurable primary/fallback provider chain - New: audit_log.py + audit_query.py – DSGVO-compliant audit trail - New: sanitize_output() – masks secrets in bot responses - Updated: bot.py – Integrated auth guards, router, audit logging - Updated: ollama_client.py – Router-compatible interface - Updated: CLOWNFISCHSERVER.md – Documentation updated - Updated: README.md – Documentation updated
190 lines
6.2 KiB
Python
190 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
🐠 Clownfischserver – Audit Trail Query (Phase 2b)
|
||
Author: Mehlzoerwer-Claude (https://github.com/mehlzoerwer-claude)
|
||
License: GPL-3.0
|
||
|
||
Read-side companion to audit_log.py. Filters/sorts JSONL audit records for
|
||
the `/logs` admin command.
|
||
|
||
The query layer is intentionally separate from the writer so that:
|
||
- Tests can exercise filtering without touching real audit data.
|
||
- The writer stays append-only and minimal.
|
||
- Future query backends (SQLite, OpenSearch) can swap in without touching
|
||
the writer.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
from datetime import datetime, timezone, timedelta
|
||
from typing import Any, Iterable
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _default_audit_path() -> str:
|
||
install_dir = os.getenv("INSTALL_DIR", "/opt/clownfischserver")
|
||
return os.getenv("AUDIT_LOG_PATH", os.path.join(install_dir, "audit.jsonl"))
|
||
|
||
|
||
def _parse_ts(record: dict) -> datetime | None:
|
||
"""Best-effort parse of the record's ts field. Returns None on
|
||
malformed timestamps (caller decides how to handle)."""
|
||
ts = record.get("ts")
|
||
if not isinstance(ts, str):
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def read_all(path: str | None = None) -> list[dict]:
|
||
"""Load every audit record from the JSONL file. Malformed lines are
|
||
skipped with a debug log (the writer keeps them for forensic purposes,
|
||
but the query layer prefers a clean result set)."""
|
||
target = path or _default_audit_path()
|
||
if not os.path.exists(target):
|
||
return []
|
||
|
||
out: list[dict] = []
|
||
try:
|
||
with open(target, "r", encoding="utf-8") as fh:
|
||
for line in fh:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
out.append(json.loads(line))
|
||
except json.JSONDecodeError:
|
||
logger.debug(f"audit_query: skipping malformed line")
|
||
continue
|
||
except OSError as e:
|
||
logger.error(f"audit_query: cannot read {target}: {e}")
|
||
return []
|
||
return out
|
||
|
||
|
||
def filter_records(
|
||
records: Iterable[dict],
|
||
*,
|
||
user_id: str | int | None = None,
|
||
action: str | None = None,
|
||
action_prefix: str | None = None,
|
||
result: str | None = None,
|
||
since: datetime | None = None,
|
||
until: datetime | None = None,
|
||
) -> list[dict]:
|
||
"""Apply filters and return matching records, newest first.
|
||
|
||
Filters are AND-combined. None values are ignored. action/result match
|
||
exactly; action_prefix matches if the record's action starts with it.
|
||
"""
|
||
target_user = str(user_id) if user_id is not None else None
|
||
|
||
matched: list[dict] = []
|
||
for rec in records:
|
||
if target_user is not None and rec.get("user_id") != target_user:
|
||
continue
|
||
if action is not None and rec.get("action") != action:
|
||
continue
|
||
if action_prefix is not None:
|
||
rec_action = rec.get("action", "")
|
||
if not isinstance(rec_action, str) or not rec_action.startswith(action_prefix):
|
||
continue
|
||
if result is not None and rec.get("result") != result:
|
||
continue
|
||
if since is not None or until is not None:
|
||
dt = _parse_ts(rec)
|
||
if dt is None:
|
||
# Malformed ts – exclude when caller asked for a date window.
|
||
continue
|
||
if since is not None and dt < since:
|
||
continue
|
||
if until is not None and dt > until:
|
||
continue
|
||
matched.append(rec)
|
||
|
||
# Newest first – stable sort by ts (records without ts go to the bottom).
|
||
matched.sort(key=lambda r: r.get("ts", ""), reverse=True)
|
||
return matched
|
||
|
||
|
||
def query(
|
||
*,
|
||
user_id: str | int | None = None,
|
||
action: str | None = None,
|
||
action_prefix: str | None = None,
|
||
result: str | None = None,
|
||
days: int | None = None,
|
||
limit: int = 20,
|
||
path: str | None = None,
|
||
) -> list[dict]:
|
||
"""Convenience wrapper: read + filter in one call.
|
||
|
||
`days` is a shortcut for `since = now - days`. limit clamps the
|
||
returned record count (use limit=0 for no limit)."""
|
||
since = None
|
||
if days is not None and days > 0:
|
||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||
records = read_all(path=path)
|
||
matched = filter_records(
|
||
records,
|
||
user_id=user_id,
|
||
action=action,
|
||
action_prefix=action_prefix,
|
||
result=result,
|
||
since=since,
|
||
)
|
||
if limit and limit > 0:
|
||
matched = matched[:limit]
|
||
return matched
|
||
|
||
|
||
def format_record(rec: dict) -> str:
|
||
"""Render one audit record as a single compact line for Telegram."""
|
||
ts = rec.get("ts", "?")
|
||
# Trim ts down to HH:MM:SS or YYYY-MM-DD HH:MM:SS for readability.
|
||
if isinstance(ts, str) and len(ts) >= 19:
|
||
ts_short = ts[:19].replace("T", " ")
|
||
else:
|
||
ts_short = str(ts)
|
||
uid = rec.get("user_id") or "—"
|
||
action = rec.get("action", "?")
|
||
result = rec.get("result", "?")
|
||
role = rec.get("role")
|
||
role_part = f" [{role}]" if role else ""
|
||
|
||
details = rec.get("details") or {}
|
||
detail_str = ""
|
||
if isinstance(details, dict):
|
||
# Pick the single most-informative key per common action.
|
||
for key in ("command", "task", "reason", "snapshot", "method", "tier"):
|
||
if key in details:
|
||
val = str(details[key])
|
||
if len(val) > 80:
|
||
val = val[:77] + "..."
|
||
detail_str = f" {val}"
|
||
break
|
||
|
||
return f"{ts_short} {uid}{role_part} {action}={result}{detail_str}"
|
||
|
||
|
||
def summarize(records: list[dict]) -> dict[str, Any]:
|
||
"""Aggregate counts useful for the /logs header."""
|
||
by_action: dict[str, int] = {}
|
||
by_user: dict[str, int] = {}
|
||
by_result: dict[str, int] = {}
|
||
for r in records:
|
||
act = r.get("action", "?")
|
||
by_action[act] = by_action.get(act, 0) + 1
|
||
uid = r.get("user_id") or "—"
|
||
by_user[uid] = by_user.get(uid, 0) + 1
|
||
res = r.get("result", "?")
|
||
by_result[res] = by_result.get(res, 0) + 1
|
||
return {"total": len(records), "by_action": by_action,
|
||
"by_user": by_user, "by_result": by_result}
|