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]>
14 KiB
Web console authorization, RBAC, redaction, and audit model (#633)
Phase 1. Read-only. This document defines the model that future console writes must pass through; it enables none of them.
The MVP deployment boundary (webui-deployment.md, #435)
documents internal-only serving and states plainly that MVP authentication is
none — protection comes from network placement. That is adequate while every
route is a GET, and inadequate the moment a gated write ships. This document
and the three modules it describes land before any write exists, so no
Phase 2 action can be added without an authority to check it against.
| Concern | Module |
|---|---|
| Identity, roles, authorization decision | webui/console_authz.py |
| Secret redaction for every surface | webui/console_redaction.py |
| Audit event schema, retention, sink | webui/console_audit.py |
| Machine-readable publication | GET /api/console/security-model |
Two invariants hold everywhere and are non-negotiable for every child of #631:
- No secrets reach the browser. Credentials are resolved server-side and redacted before any payload, page, log line, or audit record leaves.
- No ungated mutations. Authorization is necessary but never sufficient; execution stays disabled until the Phase 2 framework ships.
Identity sources
The console performs authorization. Authentication is delegated, because a console that mints its own sessions is a credential store, and this one must not be.
| Source | Mode value | Authenticated | Shared host | Phase |
|---|---|---|---|---|
| None | none (default) |
No — anonymous, capped at viewer |
No | 1 |
| Local dev | local-dev / local_dev |
Yes, asserted not verified | No | 1 |
| Access proxy | access-proxy / access_proxy |
Yes, asserted by trusted proxy | Yes | 2 |
Selected by WEBUI_AUTH_MODE. An unrecognised value falls back to none
rather than erroring open.
Access-proxy mode reads the subject from the
Cf-Access-Authenticated-User-Email header, set by Cloudflare Access, WARP, or
an equivalent org portal that terminates authentication in front of the
console. If the header is absent the request did not traverse the proxy, so the
principal degrades to anonymous — it is never trusted by default.
The role is always server-side configuration, never a client assertion. It
comes from WEBUI_ROLE_MAP, a JSON object of subject → role:
{"[email protected]": "operator", "[email protected]": "controller"}
An unmapped subject gets viewer. Malformed JSON yields an empty map, so
everyone gets viewer — a parse failure loses authority rather than granting
it.
Full SSO is explicitly a non-goal of this issue.
Role matrix
Four roles, ordered least to most authority. Each role inherits every lower role's actions; the table states the minimum rank required.
| Role | Authority |
|---|---|
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. |
viewer holds the empty write set by construction, and a test asserts it stays
empty.
Privileged actions
Every console action maps to a task_key in task_capability_map.py, the same
single source of truth gitea_resolve_task_capability and the MCP tool gates
use. The console therefore cannot invent an authority the MCP layer does not
already define, and a regression test asserts each mapping matches.
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|---|---|---|---|---|---|---|---|
claim_issue |
operator | gated_write | gitea.issue.comment |
Yes | No | No | 2 |
comment_issue |
operator | gated_write | gitea.issue.comment |
Yes | No | No | 2 |
create_issue |
operator | gated_write | gitea.issue.create |
Yes | No | No | 2 |
comment_pr |
operator | gated_write | gitea.pr.comment |
Yes | No | No | 2 |
create_pr |
operator | gated_write | gitea.pr.create |
Yes | No | No | 2 |
review_pr |
controller | privileged | gitea.pr.review |
Yes | No | No | 3 |
close_pr |
controller | privileged | gitea.pr.close |
Yes | No | No | 3 |
merge_pr |
controller | privileged | gitea.pr.merge |
Yes | Yes | Yes | 3 |
delete_branch |
admin | destructive | gitea.branch.delete |
Yes | Yes | Yes | 3 |
Dual control means the acting principal may not be the sole authority: a second distinct principal must confirm. Break-glass means the action is expected to be unavailable in normal operation and its use is retained for two years. Both are declared here and enforced by the Phase 2 framework; Phase 1 records the requirement on every decision so the framework cannot ship without honouring it.
delete_branch is admin-only rather than controller because it is the one
irreversible action in the set.
Authorization decision
authorize(action_id, principal, for_execution=False) returns a decision
record and denies by default. The deny reasons are closed and enumerated:
| Reason code | Meaning |
|---|---|
unknown_action |
No such console action is registered. |
unauthenticated |
The principal is anonymous. |
unknown_role |
The role is not in the matrix. |
insufficient_role |
The role ranks below the action's minimum. |
phase_not_active |
Execution requested for an action whose phase is not open. |
allowed_preview_only |
Authorized — preview only, execution still disabled. |
There is no implicit allow branch. Even the allow result reports
execution_enabled: false while the console is in Phase 1, so no caller can
read an allow as permission to mutate.
Secret redaction
One pass applies to API payloads, rendered HTML, server logs, and audit records — the four surfaces where a credential could escape.
Redaction reuses gitea_audit.redact rather than forking it: that remains the
authority for secret-looking dict keys, Authorization material, and raw URLs.
The console layer then applies its own patterns:
Each rule below matches an assignment form: the named key, followed by = or
:, followed by the value. The keys are listed bare rather than spelled out as
complete assignments, because this document is itself scanned by
scan_for_secrets — writing the examples in full assignment form would make the
documentation trip the very detectors it documents.
| Rule | Catches (as an assignment) |
|---|---|
credential_assignment |
token, password, passwd, secret, api_key, access_key, client_secret, private_key |
credential_env_assignment |
GITEA_TOKEN, GITEA_PASS, GITEA_PASSWORD and suffixed variants |
keychain_reference |
keychain: entry references |
keychain_command |
macOS security keychain lookups (find-generic-password, find-internet-password) |
private_key_block |
PEM BEGIN ... PRIVATE KEY blocks |
json_web_token |
Three-segment eyJ... JWTs |
bearer_credential |
Bearer / Basic credentials |
Assignments keep the key and replace only the value, so an operator can still see what was removed. Two behaviours are deliberate:
- Fail closed. A value that cannot be redacted becomes
[REDACTED]outright rather than being emitted raw. Redaction never raises. - Redact before persist.
console_audit.build_eventredacts before serialization, andwrite_eventre-scans and drops any record that still trips a detector. An unredacted record is never durable.
scan_for_secrets is the assertion helper: it returns the detector names still
matching a payload, and already-redacted hits are not findings. Tests use it to
prove the published policy, the security-model endpoint, and this document
itself carry no secret material.
Audit event schema
gitea_audit records MCP-side mutations — which profile and Gitea user
performed which tool call. It has no console actor, no identity source, no
correlation identifier, and no retention class, and an authorization denial
is not a mutation, so it would never appear there at all. The console record is
additive, not a replacement: a Phase 2 action emits both, joined on
correlation.request_id.
Required fields, all asserted by tests so an edit cannot quietly drop one:
| Field | Content |
|---|---|
schema_version |
Currently 1. |
event_id |
Unique per record. |
timestamp |
Timezone-aware ISO-8601, UTC. |
actor |
subject, role, identity_source, authenticated. |
action |
Console action id. |
action_class |
gated_write, privileged, destructive, or unknown. |
target |
{kind, ref}, e.g. {"kind": "pr", "ref": "#123"}. |
result |
allowed, denied, previewed, failed, succeeded. |
reason_code |
The authorization reason code above. |
correlation |
request_id, session_id, mcp_task, mcp_permission. |
retention |
class, days, expires_at. |
redacted |
Always true; records are redacted at build time. |
An unrecognised result degrades to failed rather than being stored
verbatim.
The sink is an append-only JSON Lines file named by
WEBUI_CONSOLE_AUDIT_LOG. It is off by default: with the variable unset,
events are still built — so callers and tests exercise the schema — but nothing
is written. Auditing never raises; a failed write returns False rather than
breaking the request it describes.
Retention
| Class | Applies to | Default |
|---|---|---|
standard |
Routine gated writes | 90 days |
privileged |
review_pr, close_pr, and any unclassifiable action |
365 days |
break_glass |
merge_pr, delete_branch |
730 days |
Each record carries its own class, day count, and computed expires_at, so
retention is auditable per record rather than inferred from file age. An
unknown action is retained as privileged, not standard — for a safety
control the conservative direction is to keep the record longer.
Nothing in this module updates or deletes. Expiry is enforced by an
operator-run policy against expires_at, never by the console silently
rewriting its own history.
Phase 2 integration
Phase 2 opens gated writes. It must reuse this model rather than introduce a second one. The integration points are already wired and observable:
GET /api/actions/{action_id}/previewattaches anauthorizationblock to the existing preview payload and records apreviewedaudit event.POST /api/actions/{action_id}/attemptattaches the same block and records adeniedevent. The terminal outcome is unchanged — the MVP registry inwebui/gated_actions.pystill fails closed for every action — so Phase 1 cannot loosen anything. Phase 2 enforces on this same decision instead of adding a parallel check.GET /api/console/security-modelpublishes the RBAC matrix, redaction policy, and audit policy as JSON for operators and tests.
To open Phase 2, a child issue must: raise ACTIVE_PHASE, implement the
confirmation and dual-control flow the matrix already declares, emit a
succeeded or failed record alongside the gitea_audit mutation record, and
keep viewer unable to reach any of it. Turning on execution without the
confirmation flow contradicts a declared requirement and is a review failure,
not a shortcut.
Local-dev mode
WEBUI_AUTH_MODE=local-dev reads the principal straight from the environment:
| Variable | Purpose |
|---|---|
WEBUI_DEV_SUBJECT |
Subject string; absent ⇒ anonymous |
WEBUI_DEV_ROLE |
One of viewer, operator, controller, admin; unrecognised ⇒ viewer |
INSECURE — this mode is for loopback development only. The subject and role
are asserted by the developer running the process and verified by nothing.
Anyone able to set an environment variable on the host is an admin, and
anyone able to reach the port inherits that principal. It provides no
authentication whatsoever; it exists so Phase 2 authorization paths can be
exercised without standing up a proxy.
Never enable local-dev mode on a non-loopback bind. Combining it with
WEBUI_ALLOW_PUBLIC_BIND=1 or WEBUI_ALLOW_REMOTE_BIND=1 publishes an
unauthenticated admin console.
For anything beyond a laptop use access-proxy mode behind Cloudflare Access,
WARP, or a VPN, as webui-deployment.md requires.
Probe authentication
WEBUI_REQUIRE_PROBE_AUTH=1 declares that non-public probes should require an
authenticated principal. It is opt-in: the default is off so the MVP
/health contract is unchanged.
This flag is declarative in Phase 1 and enforces nothing today.
console_authz.probe_auth_required() reports the operator's intent, and no
route consults it — setting the variable does not currently change the
behaviour of /health or any other endpoint. It is published here so the Phase
2 action framework has a declared policy to honour rather than inventing a
second one, exactly as ACTIVE_PHASE gates execution while the matrix is
already declared. A regression test pins this "declared, not enforced" status,
so wiring it later is a deliberate change rather than a silent one.
Until Phase 2 wires it, probe protection rests on network placement alone, as
webui-deployment.md (#435) states.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
WEBUI_AUTH_MODE |
none |
Identity source selection |
WEBUI_DEV_SUBJECT |
unset | Local-dev subject (insecure) |
WEBUI_DEV_ROLE |
viewer |
Local-dev role (insecure) |
WEBUI_ROLE_MAP |
unset | JSON subject → role map |
WEBUI_REQUIRE_PROBE_AUTH |
unset | Require auth for non-public probes |
WEBUI_CONSOLE_AUDIT_LOG |
unset | Append-only audit sink path |
All are read server-side only. None is ever rendered into a page or returned by an API.
Non-goals
- No full SSO product; authentication stays delegated to the proxy.
- No browser-initiated merges or approvals in any phase covered here.
- No tokens in the frontend, in browser storage, or in committed config.