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]>
296 lines
14 KiB
Markdown
296 lines
14 KiB
Markdown
# 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
|
|
{"[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`](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.
|