Operators had to paste a role prompt into a terminal to start work, and
nothing enforced that the allocator had been consulted first, so two sessions
could reach for the same issue and each believe it was theirs. This adds a
request surface: a desired role, an issue or PR, and a stated intent, answered
by an authorization decision and - on confirmation - an exclusive assignment
from the allocator.
Preview (POST /api/v1/requests/preview, and the /requests form) runs five
checks and reports authorize/deny with a reason for each: console
authorization, capability resolution for the desired role, lease availability,
whether the allocator would independently select this work unit, and head
pinning for PR work. It is read-only - it calls the allocator with apply=false
and writes only an audit line. An unauthorized principal never reaches the
allocator or the control-plane DB, so a denial cannot enumerate the queue.
Initiation (POST /api/v1/requests/apply) never assigns the requested item
directly. It runs a dry-run first and proceeds only when the allocator would
independently pick that exact work unit, carrying the dry-run's
candidate_set_fingerprint as a CAS pin; otherwise it returns wait or blocked
and mutates nothing. An active claim on the work unit rejects a duplicate
assign before one is attempted. A returned assignment carries a handoff block
naming the required profile, namespace, and the actions that stay forbidden.
Authorization reuses the #633 model rather than adding a second one. The new
initiate_workflow action is operator-class because its outcome is a claim, not
a Gitea verdict: requesting reviewer or merger work reserves that work but
grants no right to approve or merge. Execution is gated by a new per-action
execution_env_flag (WEBUI_REQUESTS_EXECUTION), deliberately in place of raising
ACTIVE_PHASE - a phase bump would enable execution for every phase-2 action at
once, including ones whose execution path is not implemented. Actions that
declare no flag are unchanged and still report execution_enabled false.
Every preview and apply emits a console audit record correlated to the
resulting assignment by correlation.request_id.
Fail-closed throughout: an unreadable control-plane DB, an incomplete queue
inventory (#758), an allocator that raises, an unpinned PR head, a moved PR
head, and an unconfirmed apply all deny without mutating.
Files:
- webui/request_service.py (new) - request model, preview, initiation
- webui/request_views.py (new) - form and preview rendering, escaped
- tests/test_webui_request_initiation.py (new) - 52 tests
- webui/console_authz.py - initiate_workflow action, execution_wired()
- webui/app.py - /requests, /api/v1/requests/preview, /api/v1/requests/apply
- webui/nav.py - Requests nav entry
- webui/traffic_loader.py - public candidates_from_queue_snapshot alias
- docs/webui-requests.md (new), docs/webui-authz-audit.md
Validation: full suite on this branch 5242 passed, 6 skipped, 899 subtests, 23
failed. Clean master baseline at 2f4dec83 in an equivalent branches/ worktree:
5190 passed, 6 skipped, 867 subtests, the same 23 tests failed. The branch adds
52 passing tests and introduces no new full-suite failure signature.
Closes #643
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
338 lines
17 KiB
Markdown
338 lines
17 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 |
|
|
| `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 |
|
|
| `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 |
|
|
| `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 |
|
|
| `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
|
|
|
**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.
|
|
|
|
`system.restart_namespace` is admin-only for the same reason: restarting a
|
|
namespace drops every in-flight request on it. `system.reload_namespace` drains
|
|
first, so it is privileged but not destructive. Neither action is ever executed
|
|
by the console — both hand off to a host supervisor, and neither exposes a raw
|
|
process kill. See
|
|
[`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642).
|
|
|
|
`initiate_workflow` (#643) is operator-class because its outcome is a *claim*,
|
|
not a Gitea verdict. Requesting reviewer or merger work reserves that work
|
|
through the allocator; it does not grant the right to approve or merge, which
|
|
stays with the MCP role profile and its own capability gates. See
|
|
[`webui-requests.md`](webui-requests.md).
|
|
|
|
### 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.
|
|
|
|
`execution_enabled` on the decision reports whether the action has a live
|
|
execution path at all, and is computed by `execution_wired(action)`. There are
|
|
exactly two ways to be wired:
|
|
|
|
1. the action's `phase` is at or below `ACTIVE_PHASE`; or
|
|
2. the action declares an `execution_env_flag` **and** that variable is set.
|
|
|
|
Every action that declares no flag therefore reports `execution_enabled: false`
|
|
while the console is in Phase 1, so no caller can read an allow as permission
|
|
to mutate. The per-action flag exists because raising `ACTIVE_PHASE` would
|
|
enable execution for every action of that phase at once, including ones whose
|
|
execution path is not implemented. One implemented action goes live on its own
|
|
flag instead of dragging its unimplemented phase-mates with it.
|
|
|
|
`initiate_workflow` is the only action that currently declares a flag
|
|
(`WEBUI_REQUESTS_EXECUTION`), and it stays denied until an operator sets it.
|
|
|
|
## 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`, `system.reload_namespace`, and any unclassifiable action | 365 days |
|
|
| `break_glass` | `merge_pr`, `delete_branch`, `system.restart_namespace` | 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.
|
|
- **`POST /api/v1/requests/preview` and `.../apply`** (#643) are the first
|
|
actions to use this model for a real execution path. Preview always returns a
|
|
decision and an audited `previewed` record; apply requires `confirm=true`,
|
|
emits `succeeded` or `denied`, and reserves work only through the allocator.
|
|
See [`webui-requests.md`](webui-requests.md).
|
|
|
|
A Phase 2 action must: use `execution_wired` rather than a private enable flag,
|
|
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.
|
|
|
|
Raising `ACTIVE_PHASE` remains the way to open a whole phase at once, and is
|
|
deliberately *not* what #643 did: an action-scoped opt-in cannot enable an
|
|
action whose execution path nobody wrote.
|
|
|
|
## 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 |
|
|
| `WEBUI_REQUESTS_EXECUTION` | unset | Opt in to `initiate_workflow` execution (#643) |
|
|
|
|
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.
|