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]>
538 lines
17 KiB
Python
538 lines
17 KiB
Python
"""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,
|
||
}
|