Files
Gitea-Tools/docs/webui-authz-audit.md
T
jcwalker3andClaude Opus 4.8 479e434f92 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]>
2026-07-22 14:37:35 -05:00

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:

  1. No secrets reach the browser. Credentials are resolved server-side and redacted before any payload, page, log line, or audit record leaves.
  2. 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_event redacts before serialization, and write_event re-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}/preview attaches an authorization block to the existing preview payload and records a previewed audit event.
  • POST /api/actions/{action_id}/attempt attaches the same block and records a denied event. The terminal outcome is unchanged — the MVP registry in webui/gated_actions.py still 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-model publishes 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.