# 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`](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: ```json {"ops@example.com": "operator", "lead@example.com": "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`](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`](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.