"""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, }