feat(webui): console authorization, RBAC, redaction, and audit model (Closes #633)
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]>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"""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."
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user