Phase 1 of the MCP Control Plane Web Console (#631) defines the model that future gated writes must pass through, and enables none of them. The read-only MVP (#426-#436) ships with no authentication; protection comes from network placement alone (#435). That is adequate while every route is a GET and inadequate the moment Phase 2 wires a write. This lands the authority first, so no write can later be added without something to check it against. webui/console_authz.py Identity sources (none / local-dev / access-proxy), a four-role matrix (viewer, operator, controller, admin), the privileged-action list, and a fail-closed authorize(). Every action maps to a task_key in task_capability_map, so the console cannot invent an authority the MCP layer does not already define. Roles are always server-side configuration, never a client assertion. Deny reasons are closed and enumerated; there is no implicit allow branch, and even an allow reports execution_enabled=false while ACTIVE_PHASE is 1. webui/console_redaction.py One redaction pass for API payloads, rendered HTML, logs, and audit records. Reuses gitea_audit.redact as the shared authority rather than forking it, then adds console patterns for keychain references, credential assignments, PEM private-key blocks, and JWTs. Never raises: an unredactable value degrades to the placeholder rather than being emitted raw. webui/console_audit.py Console-side audit records, which gitea_audit cannot supply: it records MCP mutations and carries no console actor, identity source, correlation id, or retention class, and an authorization denial is not a mutation at all. The two are additive and join on correlation.request_id. Records are redacted at build time, re-scanned at write time, and dropped rather than persisted if they still trip a detector. Retention is per-record; an unknown action is retained as privileged rather than standard. webui/app.py Attaches an authorization block to the existing preview and attempt routes and records the decision. The terminal outcome is unchanged - gated_actions still fails closed for every action - so this cannot loosen anything. Adds GET /api/console/security-model publishing the three policies as JSON. Probe authentication is deliberately declarative in this slice: probe_auth_required() reports operator intent and no route consults it. The documentation says so plainly and a regression test pins the not-enforced status, so wiring it in Phase 2 is a deliberate change rather than a silent one. An operator who sets the variable believing it protects a probe would be worse off than one who knows it does not. Tests: tests/test_webui_console_authz_audit.py - 75 passed, 93 subtests, covering each acceptance criterion and each test the issue requires (redaction units, default-deny for unauthenticated write stubs, audit record creation for a simulated privileged preview). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
282 lines
9.4 KiB
Python
282 lines
9.4 KiB
Python
"""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": "[email protected]", "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."
|
|
),
|
|
}
|