v0.5.0: Add RBAC auth, LLM router, audit logging, output sanitization
- 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
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
#!/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}
|
||||
Reference in New Issue
Block a user