"""Console audit event schema, retention, and append-only sink (#633). ``gitea_audit`` records MCP-side *mutations*: which profile and Gitea user performed which tool call. It carries no console actor, no identity source, no correlation identifier, and no retention class, so it cannot answer the question #633 exists to answer — *who sat at the console, what did they attempt, and was it authorized?* An authorization denial is not a mutation and would never appear there at all. This module adds the console-side record. It does not replace ``gitea_audit``: when a Phase 2 action eventually reaches MCP, both fire, correlated by ``correlation.request_id``. Design constraints: - **Redact before persist.** Every record passes through ``webui.console_redaction.redact_payload`` before serialization, so an unredacted field is never durable. - **Append-only.** Records are appended as JSON lines. Nothing here updates or deletes; retention is metadata on each record, enforced by an operator-run policy, never by silent rewriting. - **Never raises.** Auditing must not break the request it describes. A failed write returns ``False``. - **Off by default.** With ``WEBUI_CONSOLE_AUDIT_LOG`` unset, events are still *built* (so callers and tests see the schema) but nothing is written. A record looks like this (synthetic values): {"schema_version": 1, "event_id": "evt-0001", "timestamp": "2026-07-22T10:16:42+00:00", "actor": {"subject": "ops@example.com", "role": "operator", "identity_source": "access_proxy", "authenticated": true}, "action": "merge_pr", "action_class": "privileged", "target": {"kind": "pr", "ref": "#123"}, "result": "denied", "reason_code": "insufficient_role", "correlation": {"request_id": "req-abc", "session_id": null, "mcp_task": "merge_pr", "mcp_permission": "gitea.pr.merge"}, "retention": {"class": "privileged", "days": 365, "expires_at": "2027-07-22T10:16:42+00:00"}, "redacted": true} Timestamps are timezone-aware ISO-8601 in UTC. """ from __future__ import annotations import datetime import json import os import uuid from typing import Any from webui import console_authz from webui.console_redaction import redact_payload, scan_for_secrets SCHEMA_VERSION = 1 AUDIT_LOG_ENV = "WEBUI_CONSOLE_AUDIT_LOG" # Result vocabulary. ``denied`` is the one ``gitea_audit`` has no equivalent # for: an authorization refusal never reaches the MCP layer. RESULT_ALLOWED = "allowed" RESULT_DENIED = "denied" RESULT_PREVIEWED = "previewed" RESULT_FAILED = "failed" RESULT_SUCCEEDED = "succeeded" RESULTS = frozenset( { RESULT_ALLOWED, RESULT_DENIED, RESULT_PREVIEWED, RESULT_FAILED, RESULT_SUCCEEDED, } ) # Retention classes and default lifetimes in days. Privileged and break-glass # records outlive routine ones because they are what an incident review needs. RETENTION_STANDARD = "standard" RETENTION_PRIVILEGED = "privileged" RETENTION_BREAK_GLASS = "break_glass" RETENTION_DAYS: dict[str, int] = { RETENTION_STANDARD: 90, RETENTION_PRIVILEGED: 365, RETENTION_BREAK_GLASS: 730, } # Fields every record must carry. Asserted by the test suite so a future edit # cannot quietly drop one. REQUIRED_FIELDS: tuple[str, ...] = ( "schema_version", "event_id", "timestamp", "actor", "action", "action_class", "target", "result", "reason_code", "correlation", "retention", "redacted", ) REQUIRED_ACTOR_FIELDS: tuple[str, ...] = ( "subject", "role", "identity_source", "authenticated", ) REQUIRED_CORRELATION_FIELDS: tuple[str, ...] = ( "request_id", "session_id", "mcp_task", "mcp_permission", ) def audit_log_path() -> str | None: """Configured sink path, or ``None`` when console auditing is off.""" return (os.environ.get(AUDIT_LOG_ENV) or "").strip() or None def audit_enabled() -> bool: return audit_log_path() is not None def retention_class_for(action: console_authz.ConsoleAction | None) -> str: """Classify retention from the action, defaulting to the longest-lived. An unknown action is treated as privileged rather than standard: for a safety control the conservative direction is to keep the record longer. """ if action is None: return RETENTION_PRIVILEGED if action.break_glass: return RETENTION_BREAK_GLASS if action.privileged: return RETENTION_PRIVILEGED return RETENTION_STANDARD def _retention_block( retention_class: str, now: datetime.datetime ) -> dict[str, Any]: days = RETENTION_DAYS.get( retention_class, RETENTION_DAYS[RETENTION_PRIVILEGED] ) return { "class": retention_class, "days": days, "expires_at": (now + datetime.timedelta(days=days)).isoformat(), } def build_event( *, action_id: str, result: str, decision: console_authz.AuthorizationDecision | None = None, principal: console_authz.Principal | None = None, target: dict[str, Any] | None = None, reason_code: str | None = None, request_id: str | None = None, session_id: str | None = None, detail: str | None = None, metadata: dict[str, Any] | None = None, now: datetime.datetime | None = None, event_id: str | None = None, ) -> dict[str, Any]: """Build one redacted, JSON-able console audit record. Redaction runs here rather than at write time so an in-memory record handed to a template or an API response is already clean. """ ts = now or datetime.datetime.now(datetime.timezone.utc) action = console_authz.get_action(action_id) who = principal or ( decision.principal if decision else console_authz.ANONYMOUS ) resolved_result = result if result in RESULTS else RESULT_FAILED resolved_reason = reason_code or ( decision.reason_code if decision else "unspecified" ) retention_class = retention_class_for(action) event: dict[str, Any] = { "schema_version": SCHEMA_VERSION, "event_id": event_id or f"evt-{uuid.uuid4().hex}", "timestamp": ts.isoformat(), "actor": who.to_dict(), "action": action_id, "action_class": action.action_class if action else "unknown", "target": dict(target or {}), "result": resolved_result, "reason_code": resolved_reason, "correlation": { "request_id": request_id, "session_id": session_id, "mcp_task": action.task_key if action else None, "mcp_permission": action.mcp_permission if action else None, }, "retention": _retention_block(retention_class, ts), "redacted": True, "detail": detail, "metadata": dict(metadata or {}), } if decision is not None: # Deliberately *not* named "authorization": ``gitea_audit`` treats that # substring as a secret key hint (it matches the HTTP Authorization # header) and would replace this whole block with the placeholder. event["decision"] = { "allowed": decision.allowed, "required_role": decision.required_role, "requires_confirmation": decision.requires_confirmation, "dual_control": decision.dual_control, "break_glass": decision.break_glass, "execution_enabled": decision.execution_enabled, } redacted = redact_payload(event) if not isinstance(redacted, dict): # pragma: no cover - defensive return {"schema_version": SCHEMA_VERSION, "redacted": True} return redacted def write_event(event: dict[str, Any], path: str | None = None) -> bool: """Append *event* as one JSON line. Never raises. Returns ``True`` when a line was written, ``False`` when auditing is off or the write failed. A record that still trips a secret detector is dropped rather than persisted. """ sink = path or audit_log_path() if not sink: return False try: if scan_for_secrets(event): return False line = json.dumps(event, default=str, sort_keys=True) with open(sink, "a", encoding="utf-8") as handle: handle.write(line + "\n") return True except Exception: return False def record_event(**kwargs: Any) -> dict[str, Any]: """Build and persist one record; return the record either way. Callers get the record back so it can be surfaced in a response or a test regardless of whether a sink is configured. """ event = build_event(**kwargs) written = write_event(event) return {"event": event, "written": written} def audit_policy() -> dict[str, Any]: """Machine-readable audit schema and retention defaults (never secrets).""" return { "schema_version": SCHEMA_VERSION, "required_fields": list(REQUIRED_FIELDS), "required_actor_fields": list(REQUIRED_ACTOR_FIELDS), "required_correlation_fields": list(REQUIRED_CORRELATION_FIELDS), "results": sorted(RESULTS), "retention_defaults_days": dict(RETENTION_DAYS), "sink_env": AUDIT_LOG_ENV, "enabled": audit_enabled(), "append_only": True, "redact_before_persist": True, "timestamp_format": "ISO-8601, timezone-aware, UTC", "relationship_to_mcp_audit": ( "webui.console_audit records console intent and authorization " "outcomes; gitea_audit records MCP mutations. A Phase 2 action " "emits both, correlated by correlation.request_id." ), }