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:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from starlette.applications import Starlette
|
||||
@@ -19,6 +20,9 @@ from final_report_validator import FINAL_REPORT_TASK_KINDS
|
||||
|
||||
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
||||
from webui.gated_action_views import render_actions_page
|
||||
from webui import console_audit
|
||||
from webui.console_authz import authorize, rbac_matrix, resolve_principal
|
||||
from webui.console_redaction import redaction_policy
|
||||
from webui.audit_validator import audit_report, audit_to_dict
|
||||
from webui.audit_views import render_audit_page
|
||||
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
||||
@@ -215,6 +219,49 @@ async def api_actions(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(load_action_registry().to_dict())
|
||||
|
||||
|
||||
def _request_id() -> str:
|
||||
return f"req-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _audit_target(action_id: str, params: dict[str, object]) -> dict[str, object]:
|
||||
"""Describe the action target for the audit record (never secrets)."""
|
||||
if "pr_number" in params:
|
||||
return {"kind": "pr", "ref": f"#{params['pr_number']}"}
|
||||
if "issue_number" in params:
|
||||
return {"kind": "issue", "ref": f"#{params['issue_number']}"}
|
||||
if "branch_name" in params:
|
||||
return {"kind": "branch", "ref": str(params["branch_name"])}
|
||||
return {"kind": "unspecified", "ref": action_id}
|
||||
|
||||
|
||||
def _authorize_request(
|
||||
request: Request,
|
||||
action_id: str,
|
||||
params: dict[str, object],
|
||||
*,
|
||||
for_execution: bool,
|
||||
result: str,
|
||||
) -> dict[str, object]:
|
||||
"""Resolve principal, decide, and audit. Returns the decision payload.
|
||||
|
||||
Phase 1 records the decision rather than enforcing it as the terminal
|
||||
outcome: ``webui.gated_actions`` already fails closed for every action, so
|
||||
this layer cannot loosen anything. Phase 2 enforces on this same decision.
|
||||
"""
|
||||
principal = resolve_principal(headers=dict(request.headers))
|
||||
decision = authorize(action_id, principal, for_execution=for_execution)
|
||||
console_audit.record_event(
|
||||
action_id=action_id,
|
||||
result=result,
|
||||
decision=decision,
|
||||
principal=principal,
|
||||
target=_audit_target(action_id, params),
|
||||
request_id=_request_id(),
|
||||
detail=decision.detail,
|
||||
)
|
||||
return decision.to_dict()
|
||||
|
||||
|
||||
async def api_action_preview(request: Request) -> JSONResponse:
|
||||
action_id = request.path_params["action_id"]
|
||||
params = dict(request.query_params)
|
||||
@@ -224,6 +271,13 @@ async def api_action_preview(request: Request) -> JSONResponse:
|
||||
result = preview_action(action_id, **params)
|
||||
if "error" in result:
|
||||
return JSONResponse(result, status_code=404)
|
||||
result["authorization"] = _authorize_request(
|
||||
request,
|
||||
action_id,
|
||||
params,
|
||||
for_execution=False,
|
||||
result=console_audit.RESULT_PREVIEWED,
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@@ -237,10 +291,31 @@ async def api_action_attempt(request: Request) -> JSONResponse:
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
result = attempt_action(action_id, **body)
|
||||
authorization = _authorize_request(
|
||||
request,
|
||||
action_id,
|
||||
body,
|
||||
for_execution=True,
|
||||
result=(
|
||||
console_audit.RESULT_DENIED
|
||||
if not result.get("success")
|
||||
else console_audit.RESULT_ALLOWED
|
||||
),
|
||||
)
|
||||
result["authorization"] = authorization
|
||||
status = 403 if not result.get("success") else 200
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def api_console_security_model(_request: Request) -> JSONResponse:
|
||||
"""Read-only publication of the #633 authorization/redaction/audit model."""
|
||||
return JSONResponse({
|
||||
"rbac": rbac_matrix(),
|
||||
"redaction": redaction_policy(),
|
||||
"audit": console_audit.audit_policy(),
|
||||
})
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
path = request.url.path
|
||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||
@@ -291,6 +366,11 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/api/leases", api_leases, methods=["GET"]),
|
||||
Route(
|
||||
"/api/console/security-model",
|
||||
api_console_security_model,
|
||||
methods=["GET"],
|
||||
),
|
||||
],
|
||||
exception_handlers={405: method_not_allowed},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user