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,537 @@
|
||||
"""Console authorization and RBAC model (#633, Phase 1).
|
||||
|
||||
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 gated write. This module is the
|
||||
authorization model those writes must go through, landed *before* any of them
|
||||
exists so no write can be added without an authority to check against.
|
||||
|
||||
Phase 1 scope is the model itself: identity resolution, the role matrix, the
|
||||
privileged-action list, and a fail-closed :func:`authorize`. It deliberately
|
||||
does **not** enable any write. ``webui.gated_actions`` stays globally disabled,
|
||||
so an allow decision here is necessary but never sufficient.
|
||||
|
||||
Two invariants hold for every caller:
|
||||
|
||||
- **Default deny.** An unrecognised action, an unknown role, or an absent
|
||||
principal denies. There is no implicit allow branch and no "unless" clause.
|
||||
- **Authorization is not execution.** :func:`authorize` returns a decision
|
||||
record. It never calls MCP, never mutates, and never consults credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from task_capability_map import required_permission, required_role
|
||||
|
||||
# --- Roles ------------------------------------------------------------------
|
||||
# Ordered least to most authority. Higher ranks inherit every lower rank's
|
||||
# permitted actions; the matrix below is expressed as a minimum required rank.
|
||||
VIEWER = "viewer"
|
||||
OPERATOR = "operator"
|
||||
CONTROLLER = "controller"
|
||||
ADMIN = "admin"
|
||||
|
||||
ROLE_ORDER: tuple[str, ...] = (VIEWER, OPERATOR, CONTROLLER, ADMIN)
|
||||
_ROLE_RANK: dict[str, int] = {role: idx for idx, role in enumerate(ROLE_ORDER)}
|
||||
|
||||
ROLE_DESCRIPTIONS: dict[str, str] = {
|
||||
VIEWER: "Read every console view. No write, ever, in any phase.",
|
||||
OPERATOR: "Viewer, plus author-class work: claim, comment, open a PR.",
|
||||
CONTROLLER: "Operator, plus reviewer/merger-class decisions on a PR.",
|
||||
ADMIN: "Controller, plus destructive and policy-editing actions.",
|
||||
}
|
||||
|
||||
# --- Identity sources -------------------------------------------------------
|
||||
IDENTITY_NONE = "none"
|
||||
IDENTITY_LOCAL_DEV = "local_dev"
|
||||
IDENTITY_ACCESS_PROXY = "access_proxy"
|
||||
|
||||
IDENTITY_SOURCES: dict[str, dict[str, Any]] = {
|
||||
IDENTITY_NONE: {
|
||||
"description": (
|
||||
"No authentication configured. Every request is anonymous and "
|
||||
"capped at viewer. This is the MVP default and the only mode "
|
||||
"whose safety rests entirely on network placement (#435)."
|
||||
),
|
||||
"authenticated": False,
|
||||
"safe_for_shared_host": False,
|
||||
"phase_available": 1,
|
||||
},
|
||||
IDENTITY_LOCAL_DEV: {
|
||||
"description": (
|
||||
"Developer-supplied principal read from the environment. INSECURE: "
|
||||
"the subject and role are asserted, never verified. Loopback only."
|
||||
),
|
||||
"authenticated": True,
|
||||
"safe_for_shared_host": False,
|
||||
"phase_available": 1,
|
||||
},
|
||||
IDENTITY_ACCESS_PROXY: {
|
||||
"description": (
|
||||
"Subject asserted by a trusted access proxy (Cloudflare Access, "
|
||||
"WARP, or an org VPN portal) via a verified request header. The "
|
||||
"proxy performs authentication; the console performs authorization."
|
||||
),
|
||||
"authenticated": True,
|
||||
"safe_for_shared_host": True,
|
||||
"phase_available": 2,
|
||||
},
|
||||
}
|
||||
|
||||
# Environment configuration. All are read server-side and never rendered.
|
||||
AUTH_MODE_ENV = "WEBUI_AUTH_MODE"
|
||||
DEV_SUBJECT_ENV = "WEBUI_DEV_SUBJECT"
|
||||
DEV_ROLE_ENV = "WEBUI_DEV_ROLE"
|
||||
ROLE_MAP_ENV = "WEBUI_ROLE_MAP"
|
||||
REQUIRE_PROBE_AUTH_ENV = "WEBUI_REQUIRE_PROBE_AUTH"
|
||||
ACCESS_SUBJECT_HEADER = "cf-access-authenticated-user-email"
|
||||
|
||||
# --- Action classes ---------------------------------------------------------
|
||||
CLASS_READ = "read"
|
||||
CLASS_WRITE = "gated_write"
|
||||
CLASS_PRIVILEGED = "privileged"
|
||||
CLASS_DESTRUCTIVE = "destructive"
|
||||
|
||||
# --- Privileged action list -------------------------------------------------
|
||||
# ``task_key`` ties each console action back to ``task_capability_map``, so the
|
||||
# console cannot invent an authority the MCP layer does not already define.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConsoleAction:
|
||||
"""One console action and the authority required to invoke it."""
|
||||
|
||||
action_id: str
|
||||
task_key: str
|
||||
action_class: str
|
||||
minimum_role: str
|
||||
requires_confirmation: bool
|
||||
dual_control: bool
|
||||
break_glass: bool
|
||||
phase: int
|
||||
summary: str
|
||||
|
||||
@property
|
||||
def mcp_permission(self) -> str:
|
||||
return required_permission(self.task_key)
|
||||
|
||||
@property
|
||||
def mcp_role(self) -> str:
|
||||
return required_role(self.task_key)
|
||||
|
||||
@property
|
||||
def privileged(self) -> bool:
|
||||
return self.action_class in {CLASS_PRIVILEGED, CLASS_DESTRUCTIVE}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["mcp_permission"] = self.mcp_permission
|
||||
data["mcp_role"] = self.mcp_role
|
||||
data["privileged"] = self.privileged
|
||||
return data
|
||||
|
||||
|
||||
_ACTION_SPECS: tuple[ConsoleAction, ...] = (
|
||||
ConsoleAction(
|
||||
action_id="claim_issue",
|
||||
task_key="claim_issue",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=2,
|
||||
summary="Apply status:in-progress to an issue.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="comment_issue",
|
||||
task_key="comment_issue",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=2,
|
||||
summary="Post an issue comment.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="create_issue",
|
||||
task_key="create_issue",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=2,
|
||||
summary="Open a new tracking issue.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="comment_pr",
|
||||
task_key="comment_pr",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=2,
|
||||
summary="Post a PR thread comment.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="create_pr",
|
||||
task_key="create_pr",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=2,
|
||||
summary="Open a PR from a locked feature branch.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="review_pr",
|
||||
task_key="review_pr",
|
||||
action_class=CLASS_PRIVILEGED,
|
||||
minimum_role=CONTROLLER,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=3,
|
||||
summary="Submit an approve / request-changes verdict.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="close_pr",
|
||||
task_key="close_pr",
|
||||
action_class=CLASS_PRIVILEGED,
|
||||
minimum_role=CONTROLLER,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=3,
|
||||
summary="Close a pull request without merging.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="merge_pr",
|
||||
task_key="merge_pr",
|
||||
action_class=CLASS_PRIVILEGED,
|
||||
minimum_role=CONTROLLER,
|
||||
requires_confirmation=True,
|
||||
dual_control=True,
|
||||
break_glass=True,
|
||||
phase=3,
|
||||
summary="Merge an approved pull request.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="delete_branch",
|
||||
task_key="delete_branch",
|
||||
action_class=CLASS_DESTRUCTIVE,
|
||||
minimum_role=ADMIN,
|
||||
requires_confirmation=True,
|
||||
dual_control=True,
|
||||
break_glass=True,
|
||||
phase=3,
|
||||
summary="Remove a remote feature branch.",
|
||||
),
|
||||
)
|
||||
|
||||
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
||||
|
||||
|
||||
def privileged_actions() -> tuple[ConsoleAction, ...]:
|
||||
"""Actions requiring dual control, break-glass, or controller+ authority."""
|
||||
return tuple(a for a in _ACTION_SPECS if a.privileged)
|
||||
|
||||
|
||||
def get_action(action_id: str) -> ConsoleAction | None:
|
||||
return ACTIONS.get(action_id)
|
||||
|
||||
|
||||
# --- Principals -------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
"""Who is making a request, and how strongly that is known."""
|
||||
|
||||
subject: str
|
||||
role: str
|
||||
identity_source: str
|
||||
authenticated: bool
|
||||
warnings: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def rank(self) -> int:
|
||||
return _ROLE_RANK.get(self.role, -1)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"subject": self.subject,
|
||||
"role": self.role,
|
||||
"identity_source": self.identity_source,
|
||||
"authenticated": self.authenticated,
|
||||
"warnings": list(self.warnings),
|
||||
}
|
||||
|
||||
|
||||
ANONYMOUS = Principal(
|
||||
subject="anonymous",
|
||||
role=VIEWER,
|
||||
identity_source=IDENTITY_NONE,
|
||||
authenticated=False,
|
||||
warnings=("No authentication configured; capped at viewer.",),
|
||||
)
|
||||
|
||||
|
||||
def auth_mode(env: dict[str, str] | None = None) -> str:
|
||||
"""Resolve the configured identity source, defaulting to ``none``."""
|
||||
source = env if env is not None else os.environ
|
||||
raw = (source.get(AUTH_MODE_ENV) or "").strip().lower().replace("-", "_")
|
||||
if raw in IDENTITY_SOURCES:
|
||||
return raw
|
||||
return IDENTITY_NONE
|
||||
|
||||
|
||||
def _role_map(env: dict[str, str]) -> dict[str, str]:
|
||||
"""Parse ``WEBUI_ROLE_MAP`` (JSON subject→role). Invalid config yields {}."""
|
||||
raw = (env.get(ROLE_MAP_ENV) or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return {
|
||||
str(k): str(v).strip().lower()
|
||||
for k, v in parsed.items()
|
||||
if str(v).strip().lower() in _ROLE_RANK
|
||||
}
|
||||
|
||||
|
||||
def resolve_principal(
|
||||
headers: dict[str, str] | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> Principal:
|
||||
"""Resolve the requesting principal. Unknown or unconfigured → anonymous.
|
||||
|
||||
Never raises and never trusts a client-supplied role: the role always comes
|
||||
from server-side configuration keyed by the resolved subject.
|
||||
"""
|
||||
source_env = dict(env) if env is not None else dict(os.environ)
|
||||
lowered = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
|
||||
mode = auth_mode(source_env)
|
||||
|
||||
if mode == IDENTITY_LOCAL_DEV:
|
||||
subject = (source_env.get(DEV_SUBJECT_ENV) or "").strip()
|
||||
if not subject:
|
||||
return ANONYMOUS
|
||||
role = (source_env.get(DEV_ROLE_ENV) or VIEWER).strip().lower()
|
||||
if role not in _ROLE_RANK:
|
||||
role = VIEWER
|
||||
return Principal(
|
||||
subject=subject,
|
||||
role=role,
|
||||
identity_source=IDENTITY_LOCAL_DEV,
|
||||
authenticated=True,
|
||||
warnings=(
|
||||
"local-dev identity is asserted, not verified; never use "
|
||||
"outside loopback.",
|
||||
),
|
||||
)
|
||||
|
||||
if mode == IDENTITY_ACCESS_PROXY:
|
||||
subject = (lowered.get(ACCESS_SUBJECT_HEADER) or "").strip()
|
||||
if not subject:
|
||||
# Proxy mode with no proxy header means the request did not
|
||||
# traverse the proxy. Fail closed rather than trust it.
|
||||
return ANONYMOUS
|
||||
role = _role_map(source_env).get(subject, VIEWER)
|
||||
return Principal(
|
||||
subject=subject,
|
||||
role=role,
|
||||
identity_source=IDENTITY_ACCESS_PROXY,
|
||||
authenticated=True,
|
||||
)
|
||||
|
||||
return ANONYMOUS
|
||||
|
||||
|
||||
def probe_auth_required(env: dict[str, str] | None = None) -> bool:
|
||||
"""Whether non-public probes must be authenticated. Default False.
|
||||
|
||||
#633 requires the console to *fail closed on missing auth for non-public
|
||||
health probes if configured*. The default stays off so the MVP ``/health``
|
||||
contract is unchanged; an operator opts in explicitly.
|
||||
"""
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(REQUIRE_PROBE_AUTH_ENV) or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
# --- Authorization ----------------------------------------------------------
|
||||
|
||||
DENY_UNKNOWN_ACTION = "unknown_action"
|
||||
DENY_UNAUTHENTICATED = "unauthenticated"
|
||||
DENY_INSUFFICIENT_ROLE = "insufficient_role"
|
||||
DENY_UNKNOWN_ROLE = "unknown_role"
|
||||
DENY_PHASE_NOT_ACTIVE = "phase_not_active"
|
||||
ALLOW_PREVIEW = "allowed_preview_only"
|
||||
|
||||
# Phase 1 is the only active console phase. Phase 2 opens gated writes and is
|
||||
# gated on this model landing; nothing here enables it.
|
||||
ACTIVE_PHASE = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationDecision:
|
||||
"""Result of an authorization check. Never an execution grant."""
|
||||
|
||||
allowed: bool
|
||||
reason_code: str
|
||||
detail: str
|
||||
action_id: str
|
||||
principal: Principal
|
||||
required_role: str | None = None
|
||||
action_class: str | None = None
|
||||
requires_confirmation: bool = False
|
||||
dual_control: bool = False
|
||||
break_glass: bool = False
|
||||
execution_enabled: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed": self.allowed,
|
||||
"reason_code": self.reason_code,
|
||||
"detail": self.detail,
|
||||
"action_id": self.action_id,
|
||||
"principal": self.principal.to_dict(),
|
||||
"required_role": self.required_role,
|
||||
"action_class": self.action_class,
|
||||
"requires_confirmation": self.requires_confirmation,
|
||||
"dual_control": self.dual_control,
|
||||
"break_glass": self.break_glass,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"active_phase": ACTIVE_PHASE,
|
||||
}
|
||||
|
||||
|
||||
def authorize(
|
||||
action_id: str,
|
||||
principal: Principal | None = None,
|
||||
*,
|
||||
for_execution: bool = False,
|
||||
) -> AuthorizationDecision:
|
||||
"""Decide whether *principal* may invoke *action_id*. Deny by default.
|
||||
|
||||
``for_execution`` distinguishes a read-only preview from a real invocation.
|
||||
Even an allowed decision reports ``execution_enabled=False`` while the
|
||||
console is in Phase 1, so no caller can read an allow as permission to
|
||||
mutate.
|
||||
"""
|
||||
who = principal if principal is not None else ANONYMOUS
|
||||
action = get_action(action_id)
|
||||
|
||||
if action is None:
|
||||
return AuthorizationDecision(
|
||||
allowed=False,
|
||||
reason_code=DENY_UNKNOWN_ACTION,
|
||||
detail=f"No console action registered as {action_id!r}.",
|
||||
action_id=action_id,
|
||||
principal=who,
|
||||
)
|
||||
|
||||
base: dict[str, Any] = {
|
||||
"action_id": action_id,
|
||||
"principal": who,
|
||||
"required_role": action.minimum_role,
|
||||
"action_class": action.action_class,
|
||||
"requires_confirmation": action.requires_confirmation,
|
||||
"dual_control": action.dual_control,
|
||||
"break_glass": action.break_glass,
|
||||
"execution_enabled": False,
|
||||
}
|
||||
|
||||
if not who.authenticated:
|
||||
return AuthorizationDecision(
|
||||
allowed=False,
|
||||
reason_code=DENY_UNAUTHENTICATED,
|
||||
detail=(
|
||||
"Write actions require an authenticated principal; this "
|
||||
"request is anonymous."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
|
||||
if who.rank < 0:
|
||||
return AuthorizationDecision(
|
||||
allowed=False,
|
||||
reason_code=DENY_UNKNOWN_ROLE,
|
||||
detail=f"Role {who.role!r} is not in the console role matrix.",
|
||||
**base,
|
||||
)
|
||||
|
||||
if who.rank < _ROLE_RANK[action.minimum_role]:
|
||||
return AuthorizationDecision(
|
||||
allowed=False,
|
||||
reason_code=DENY_INSUFFICIENT_ROLE,
|
||||
detail=(
|
||||
f"Action {action_id!r} requires {action.minimum_role!r}; "
|
||||
f"principal holds {who.role!r}."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
|
||||
if for_execution and action.phase > ACTIVE_PHASE:
|
||||
return AuthorizationDecision(
|
||||
allowed=False,
|
||||
reason_code=DENY_PHASE_NOT_ACTIVE,
|
||||
detail=(
|
||||
f"Action {action_id!r} belongs to phase {action.phase}; the "
|
||||
f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
|
||||
return AuthorizationDecision(
|
||||
allowed=True,
|
||||
reason_code=ALLOW_PREVIEW,
|
||||
detail=(
|
||||
"Principal holds the required role. Preview only — execution "
|
||||
"remains disabled until the Phase 2 action framework ships."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
|
||||
|
||||
def rbac_matrix() -> dict[str, Any]:
|
||||
"""Machine-readable RBAC matrix and privileged-action list."""
|
||||
return {
|
||||
"model_version": 1,
|
||||
"active_phase": ACTIVE_PHASE,
|
||||
"roles": [
|
||||
{
|
||||
"role": role,
|
||||
"rank": _ROLE_RANK[role],
|
||||
"description": ROLE_DESCRIPTIONS[role],
|
||||
"permitted_actions": sorted(
|
||||
a.action_id
|
||||
for a in _ACTION_SPECS
|
||||
if _ROLE_RANK[role] >= _ROLE_RANK[a.minimum_role]
|
||||
),
|
||||
}
|
||||
for role in ROLE_ORDER
|
||||
],
|
||||
"identity_sources": IDENTITY_SOURCES,
|
||||
"actions": [a.to_dict() for a in _ACTION_SPECS],
|
||||
"privileged_actions": [a.action_id for a in privileged_actions()],
|
||||
"default_decision": "deny",
|
||||
"execution_enabled": False,
|
||||
}
|
||||
Reference in New Issue
Block a user