Merge branch 'master' into fix/issue-790-slice-a-heartbeat-policy
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# ADR: MCP Control Plane Web Console architecture and information architecture
|
||||
|
||||
- **Status:** Proposed (documentation only; blocks no code, gates every #631 child)
|
||||
- **Date:** 2026-07-22
|
||||
- **Tracking issue:** [#632](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/632) — architecture and information architecture (Phase 1)
|
||||
- **Parent epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) — MCP Control Plane Web Console
|
||||
- **Foundation (closed, extend — do not recreate):** #425 tracker and children #426 skeleton, #427 projects, #428 prompts, #429 queue, #430 runtime, #431 audit paste, #432 worktrees, #433 leases, #434 gated actions, #435 auth/deployment boundary, #436 tests/CI
|
||||
- **Related:** `mcp-allocator-control-plane-observability-adr.md`, `mcp-stable-control-runtime-policy-adr.md`, `control-plane-db-substrate.md`, `../safety-model.md`, `../tool-boundaries.md`, `../credential-isolation.md`, `../webui-local-dev.md`, `../webui-deployment.md`
|
||||
|
||||
## 1. Context
|
||||
|
||||
The MVP web UI shipped under `webui/` as a read-only Starlette application with ten operator routes and a JSON export beside most of them. It is a working foundation, not the console product described by epic #631, and it carries no durable architecture record: no layer contract, no authority boundary, no API versioning rule, no page map, and no statement of which phase may open a write path.
|
||||
|
||||
Twenty children (#632–#651) hang off #631. Without one architecture document each implementer re-derives boundaries, and the most likely failure is not a bad view — it is a privileged action wired into the browser before the authorization and audit model of #633 exists.
|
||||
|
||||
This ADR is the single retrievable design source for the console. It decides structure only. It implements no UI, no API, and no change to deployment topology.
|
||||
|
||||
## 2. Decision summary (core)
|
||||
|
||||
| Layer | Owns | Must not |
|
||||
|-------|------|----------|
|
||||
| **Browser UI** | Rendering, navigation, operator affordances | Hold tokens, call Gitea/providers directly, or execute an action the server did not gate |
|
||||
| **HTTP route layer** (`webui/app.py`) | Versioned routing, authentication, authorization, redaction boundary, audit emission | Contain domain logic or reach past a loader to a raw credential |
|
||||
| **Domain loaders** (`webui/*_loader.py`, `*_scanner.py`, `runtime_health.py`, `project_registry.py`) | Assembling read models from authoritative sources | Mutate anything, or emit unredacted secrets across the boundary |
|
||||
| **Gitea** | Durable work record: issues, PRs, comments, reviews, labels, merges | Be the concurrency lock under multi-session load |
|
||||
| **Control-plane DB** | Sessions, assignment, leases, heartbeats, events | Replace Gitea history |
|
||||
| **MCP tools / capability gates** | Mutation authorization | Be re-implemented, mirrored, or bypassed by console code |
|
||||
| **External providers** (Sentry/GlitchTip, AI providers) | Incident and usage data | Assign work or mutate Gitea outside the #612 bridge |
|
||||
|
||||
**One-liner:** **Gitea records. The DB coordinates. MCP tools authorize. The console projects state and executes only capability-checked, audited actions. Providers observe.**
|
||||
|
||||
## 3. Console surface today versus target
|
||||
|
||||
`webui/app.py` currently registers these routes (see `../webui-local-dev.md` for the operator-facing table): `/`, `/health`, `/queue`, `/projects`, `/projects/{id}`, `/prompts`, `/runtime`, `/audit`, `/worktrees`, `/leases`, `/actions`, and the unversioned exports `/api/queue`, `/api/projects`, `/api/prompts`, `/api/runtime`, `/api/audit`, `/api/worktrees`, `/api/leases`, `/api/actions`, `/api/actions/{id}/preview`, `/api/actions/{id}/attempt`.
|
||||
|
||||
Every one of these is **retained and evolved**. No child issue may recreate a route from scratch; each states in its PR which MVP surface it extends and what it changes.
|
||||
|
||||
## 4. Authority boundaries
|
||||
|
||||
### 4.1 Gitea (durable record)
|
||||
|
||||
Authoritative for issue and PR identity and state, comments, reviews and verdicts, labels, merges, and branch refs. When the console and Gitea disagree about durable state, Gitea wins and the console view is refreshed — never the reverse.
|
||||
|
||||
### 4.2 Control-plane DB (coordination)
|
||||
|
||||
Authoritative for live coordination: which session holds which assignment or lease, heartbeat freshness, expiry, and the allocation event log. The console reads it; only allocator and lease tools write it.
|
||||
|
||||
### 4.3 MCP capability gates (authorization)
|
||||
|
||||
`task_capability_map.py` and `gitea_resolve_task_capability` remain the only authority that decides whether a mutation may run. The console asks; it never answers. A console action that cannot name the MCP tool it delegates to is not an action — it is a defect.
|
||||
|
||||
### 4.4 Filesystem and git (local state)
|
||||
|
||||
Issue lock files, `branches/` worktrees, and registered git worktrees are read through existing scanners. The console never deletes, rebinds, or force-clears local state outside a Phase 2 gated action.
|
||||
|
||||
### 4.5 Providers (observe only)
|
||||
|
||||
Sentry/GlitchTip and AI providers are read surfaces. The #612 incident bridge is the only path that turns an observation into Gitea work.
|
||||
|
||||
## 5. Request flow and the redaction boundary
|
||||
|
||||
```text
|
||||
browser ──HTTP──> route layer ──> domain loader ──> Gitea REST
|
||||
│ ├──> control-plane DB
|
||||
│ ├──> filesystem / git
|
||||
│ └──> providers
|
||||
│
|
||||
[redaction boundary]
|
||||
│
|
||||
audit event
|
||||
```
|
||||
|
||||
| Stage | May hold credentials | Emits |
|
||||
|-------|----------------------|-------|
|
||||
| Loader → route layer | yes (server-side, via `gitea_auth`) | domain objects |
|
||||
| Route layer → browser | **no** | redacted DTOs, HTML |
|
||||
|
||||
Two invariants govern the boundary and are non-negotiable for every child:
|
||||
|
||||
1. **No secrets to the browser.** Tokens, keychain identifiers, Authorization headers, raw provider endpoints, and credential-bearing URLs are redacted by default, consistent with `../safety-model.md` §3 and `../credential-isolation.md`. Serializers redact; templates do not sanitize after the fact.
|
||||
2. **No ungated mutations.** A write reaches an authoritative system only by delegating to an MCP tool that passed its own capability gate. HTML forms and JSON endpoints are transport, never authority.
|
||||
|
||||
## 6. API naming and versioning
|
||||
|
||||
**Decision:** all console APIs added from Phase 1 onward are served under `/api/v1/...`.
|
||||
|
||||
- Nouns are plural and hierarchical: `/api/v1/inventory/leases`, `/api/v1/system/health`.
|
||||
- Read endpoints are `GET` and side-effect free.
|
||||
- Phase 2 action endpoints are `POST /api/v1/actions/{action_id}/preview` and `POST /api/v1/actions/{action_id}/execute`; `preview` stays side-effect free and returns a mutation ledger.
|
||||
- The existing unversioned MVP exports remain as **compatibility aliases** for the whole of Phase 1 so the current operator flow never breaks. They may be retired no earlier than Phase 2, and only after the replacing `v1` route ships and `../webui-local-dev.md` records the swap.
|
||||
- A breaking change to a `v1` payload requires `/api/v2/...`, not an in-place edit.
|
||||
- Every JSON payload carries enough provenance for an auditor to tell where the data came from — at minimum the source system and whether the inventory was complete, matching the pagination-proof habit the MVP queue export already established.
|
||||
|
||||
## 7. Page map
|
||||
|
||||
| Page | Purpose | Owning child | Evolves |
|
||||
|------|---------|--------------|---------|
|
||||
| `/` | Console shell, navigation, next-safe-action summary | #638 | MVP `/` (#426) |
|
||||
| `/system` | System-health dashboard | #639 | new, backed by #634 |
|
||||
| `/traffic` | Workflow traffic control, queues, blockers | #640 | MVP `/queue` (#429) |
|
||||
| `/runtime` | Runtime and session view | #641 | MVP `/runtime` (#430) |
|
||||
| `/projects`, `/projects/{id}` | Project registry and onboarding | #635 | MVP `/projects` (#427) |
|
||||
| `/inventory` | Sessions, leases, locks, worktrees in one surface | #636 | MVP `/leases` (#433) + `/worktrees` (#432) |
|
||||
| `/timeline` | Workflow events and conversation timeline | #637 | new |
|
||||
| `/actions` | Gated action registry, preview, execution | #642, #643, #644 | MVP `/actions` (#434) |
|
||||
| `/gitea` | Issue and PR linkage console | #645 | new |
|
||||
| `/policy` | Guardrail visibility, then versioned editing | #646, #647 | new |
|
||||
| `/notifications` | Human-attention routing | #648 | new |
|
||||
| `/observability` | Sentry/GlitchTip correlation and durable issue creation | #649 | new |
|
||||
| `/providers` | AI-provider connections and insights | #650 | new |
|
||||
| `/analytics` | Usage, token cost, latency, workflow performance | #651 | new |
|
||||
| `/audit` | Final-report validator preview and audit log | #431 foundation, extended by #633 | MVP `/audit` (#431) |
|
||||
| `/prompts`, `/prompts/{id}` | Canonical prompt library | #638 | MVP `/prompts` (#428) |
|
||||
| `/health` | Liveness and deployment metadata | #634 | MVP `/health` (#435) |
|
||||
|
||||
## 8. Component ownership for every epic child
|
||||
|
||||
Each #631 child maps to at least one architectural component defined above.
|
||||
|
||||
| Child | Capability area | Primary component | Phase |
|
||||
|-------|-----------------|-------------------|-------|
|
||||
| #632 | Architecture and information architecture | this ADR | 1 |
|
||||
| #633 | Authorization, RBAC, secret redaction, audit and retention | route layer + redaction boundary (§5) | 1 |
|
||||
| #634 | Read-only system-health API | `/api/v1/system/health` + health loader | 1 |
|
||||
| #635 | Project registry API evolution | `/api/v1/projects` + `project_registry.py` | 1 |
|
||||
| #636 | Session, lease, lock, worktree inventory API | `/api/v1/inventory/*` + `lease_loader.py`, `worktree_scanner.py` | 1 |
|
||||
| #637 | Workflow-event and conversation timeline model | `/api/v1/events` + control-plane DB event log | 1 |
|
||||
| #638 | Application shell evolution | browser UI layer + `layout.py` | 1 |
|
||||
| #639 | System-health dashboard | `/system` page over #634 | 1 |
|
||||
| #640 | Workflow traffic-control view | `/traffic` page over the queue loader | 1 |
|
||||
| #641 | Runtime and session view | `/runtime` page over `runtime_health.py` | 1 |
|
||||
| #642 | Sanctioned restart and graceful reload controls | gated action framework, restart class | 2 |
|
||||
| #643 | Requests, intent preview, authorization, workflow initiation | `/api/v1/actions/*` execute path | 2 |
|
||||
| #644 | Stale-runtime recovery, worktree rebinding, reconciliation controls | gated actions over filesystem/git authority | 2 |
|
||||
| #645 | Gitea issue and PR linkage console | `/gitea` page over Gitea authority | 3 |
|
||||
| #646 | Workflow policy and guardrail visibility | `/policy` read view over the capability map | 3 |
|
||||
| #647 | Versioned policy editing, validation, simulation, approval, rollback | `/policy` write path, gated | 3 |
|
||||
| #648 | Notifications and human-attention routing | notification component over the event model | 3 |
|
||||
| #649 | Sentry/GlitchTip connections, correlation, durable issue creation | provider layer + #612 incident bridge | 4 |
|
||||
| #650 | AI-provider connections and operational insights | provider layer | 4 |
|
||||
| #651 | Model usage, token cost, latency, workflow analytics | analytics component over the event model | 4 |
|
||||
|
||||
Related but **outside** this epic: #667 (restart status, impact preview, and approval controls) belongs to the #655 restart-governance umbrella and must reuse the #642 action class rather than adding a second restart surface.
|
||||
|
||||
## 9. Phase gates
|
||||
|
||||
| Phase | May ship | Entry condition |
|
||||
|-------|----------|-----------------|
|
||||
| **1 — read-only visibility** | `GET` pages and `GET /api/v1/...` | this ADR accepted |
|
||||
| **2 — controlled actions** | gated `POST` action execution | #633 authorization, RBAC, and audit model landed |
|
||||
| **3 — orchestration and policy** | linkage, policy visibility, versioned policy editing | Phase 1 inventory plus the Phase 2 action framework |
|
||||
| **4 — insights** | provider correlation, analytics | evidence-backed sources from Phases 1–3 |
|
||||
|
||||
Phase 1 must not open a mutation endpoint, and the read-only guard that returns `405 read-only-mvp` stays in force until the Phase 2 entry condition is met. A phase is not entered by exception; if a control is urgent, the entry condition is what gets prioritized.
|
||||
|
||||
## 10. Security and workflow safety
|
||||
|
||||
- **Fail closed** on unknown authentication, missing RBAC mapping, or ambiguous lease ownership. An unknown state renders as blocked, never as permitted.
|
||||
- **Redact by default**, per §5.
|
||||
- **Every privileged action** requires a resolved capability, an explicit operator confirmation, and a durable audit event naming actor, action, target, and outcome.
|
||||
- **Contamination surfaces.** Session contamination — including a manually killed MCP daemon (#630) — must be shown and must block clean claims rather than being silently repaired.
|
||||
- **Deployment boundary unchanged.** Loopback by default, with the existing refusal of public binds (#435). This ADR documents that target; it does not widen it.
|
||||
|
||||
## 11. Forbidden paths
|
||||
|
||||
These are rejected designs, not preferences:
|
||||
|
||||
1. **Raw provider incidents as work.** The allocator never receives an unclassified Sentry/GlitchTip incident; only the #612 bridge turns an observation into a Gitea issue.
|
||||
2. **Browser-held tokens.** No credential, keychain identifier, or Authorization header is ever sent to the browser or embedded in a client bundle.
|
||||
3. **Process-kill recovery.** The console must not expose `pkill`, process-identifier termination, or any host process kill as a recovery affordance (#630). Restart is the sanctioned, operator-owned path of #642 and the #655 umbrella.
|
||||
4. **Ungated browser mutations.** No review, approval, merge, close, or comment may originate from the browser without passing an MCP capability gate.
|
||||
5. **Policy invented in the console.** The console projects policy from the capability map and canonical workflows; it never encodes a second copy.
|
||||
6. **Recreating MVP scope.** Re-implementing a #426–#436 surface without an explicit evolve-or-extend statement is out of bounds.
|
||||
|
||||
## 12. Approval checklist (readable without chat history)
|
||||
|
||||
A controller can accept or reject this ADR against these six points alone:
|
||||
|
||||
1. Layers and their owners are defined (§2) and each authority is named (§4).
|
||||
2. The redaction boundary and the two invariants are stated (§5).
|
||||
3. API versioning is decided, including what happens to the existing unversioned routes (§6).
|
||||
4. A page map exists and names an owning child for every page (§7).
|
||||
5. Every #631 child maps to at least one component and one phase (§8).
|
||||
6. Phase gates and forbidden paths are explicit (§9, §11).
|
||||
|
||||
## 13. Open questions and follow-ups
|
||||
|
||||
Unresolved choices are recorded here rather than settled by implication. Each needs its own durable issue before the phase that depends on it:
|
||||
|
||||
- **Authentication mechanism.** Whether the console authenticates via an access proxy (Cloudflare Access or equivalent) or an application-level session is deferred to #633. This ADR requires only that it fail closed.
|
||||
- **Event model substrate.** Whether the #637 timeline reads the control-plane event log directly or through a projection is deferred to #637.
|
||||
- **CI path filter coverage.** `webui/ci_paths.py` triggers the web UI suite on `webui/`, `tests/test_webui_*`, and `docs/webui*`. This ADR lives under `docs/architecture/`, so editing it alone does not trigger that gate; the accompanying `tests/test_webui_architecture_docs.py` does run in the full suite. Widening the filter is a small follow-up, deliberately not bundled into a documentation-only change.
|
||||
- **Retention.** Audit-event retention duration is owned by #633.
|
||||
|
||||
## 14. Acceptance
|
||||
|
||||
Accepting this ADR means:
|
||||
|
||||
- Phase 1 children may proceed against the layers, page map, and API rules above.
|
||||
- Phase 2 children may not open a write path until #633 lands.
|
||||
- Any deviation is recorded as an amendment to this file with its own issue reference, not as an undocumented divergence in code.
|
||||
@@ -121,6 +121,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_observability_list_projects`
|
||||
- `gitea_observability_reconcile_incident`
|
||||
- `gitea_post_heartbeat`
|
||||
- `gitea_publish_unpublished_issue_branch`
|
||||
- `gitea_quarantine_contaminated_review`
|
||||
- `gitea_reclaim_expired_workflow_lease`
|
||||
- `gitea_reconcile_already_landed_pr`
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
# 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.
|
||||
@@ -7,7 +7,10 @@ only.
|
||||
## MVP deployment model
|
||||
|
||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||
- **Authentication:** none in MVP — protection comes from network placement
|
||||
- **Authentication:** none in MVP — protection comes from network placement.
|
||||
The authorization, RBAC, redaction, and audit model that future gated writes
|
||||
must pass through is defined in
|
||||
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
|
||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||
embedded in HTML, JavaScript, or browser storage
|
||||
|
||||
+14
-2
@@ -37,6 +37,16 @@ Optional environment variables:
|
||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
|
||||
See
|
||||
[architecture/webui-control-plane-console-architecture-adr.md](architecture/webui-control-plane-console-architecture-adr.md)
|
||||
for the console architecture: layer and authority boundaries, the redaction
|
||||
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
|
||||
that govern when a write path may open (#632, epic #631).
|
||||
|
||||
See [webui-project-registry-api.md](webui-project-registry-api.md) for the
|
||||
versioned project registry contract: registry schema versions 1 and 2, project
|
||||
status, onboarding checklist state, and the fail-closed error payloads (#635).
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
| Path | Description |
|
||||
@@ -45,9 +55,11 @@ Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
||||
| `/queue` | Live PR and issue queue dashboard (#429) |
|
||||
| `/api/queue` | JSON queue export with pagination metadata |
|
||||
| `/projects` | Project registry list (#427) |
|
||||
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
|
||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/api/v1/projects` | Versioned JSON registry export (#635) |
|
||||
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
|
||||
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Project registry API (#635)
|
||||
|
||||
Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
|
||||
gives the project registry a versioned, read-only API. This document is the
|
||||
field-by-field contract for that API and for the registry file behind it.
|
||||
|
||||
Everything here is **read-only**. The console never writes the registry; an
|
||||
operator edits the JSON file, and an invalid file fails closed rather than
|
||||
rendering a partial inventory.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
|
||||
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
|
||||
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
|
||||
| `/projects` | GET | HTML list — status and onboarding progress per project |
|
||||
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |
|
||||
|
||||
Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
|
||||
and only after this document and `webui-local-dev.md` record the swap. The alias
|
||||
returns the same payload as `/api/v1/projects`, including the legacy `version`
|
||||
and `source_path` keys #427 consumers already read.
|
||||
|
||||
The HTML views render from the same DTO the JSON routes serialize
|
||||
(`project_to_dict`), so the console and the API cannot disagree about a
|
||||
project's status or onboarding progress.
|
||||
|
||||
## Registry file
|
||||
|
||||
Default location: `webui/data/projects.registry.json`. Override with the
|
||||
`WEBUI_PROJECT_REGISTRY` environment variable.
|
||||
|
||||
Schema versions: **1** and **2** are accepted; **2** is current. A version 1
|
||||
file loads unchanged and is normalized with the documented defaults, so an
|
||||
existing operator registry keeps working without edits.
|
||||
|
||||
### Root
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `version` | int | yes | `1` or `2`. Anything else fails closed |
|
||||
| `projects` | array | yes | Must be non-empty |
|
||||
|
||||
### Project
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `id` | string | yes | — | Stable registry id used in URLs |
|
||||
| `repo_name` | string | yes | — | Gitea repository name |
|
||||
| `gitea_owner` | string | yes | — | Owning org or user |
|
||||
| `remote_host` | string | yes | — | Instance base URL, no credentials |
|
||||
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
|
||||
| `default_branch` | string | yes | — | Stable branch name |
|
||||
| `local_checkout_path` | string | yes | — | Control checkout path |
|
||||
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
|
||||
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
|
||||
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
|
||||
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
|
||||
| `onboarding_checklist` | array | no | `[]` | See below |
|
||||
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |
|
||||
|
||||
### Onboarding step
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `id` | string | yes | — | Stable step id |
|
||||
| `title` | string | yes | — | Short operator-facing label |
|
||||
| `description` | string | yes | — | Self-contained; assumes no chat history |
|
||||
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
|
||||
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |
|
||||
|
||||
### Last-seen health
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
|
||||
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
|
||||
| `detail` | string | no | Short redacted note |
|
||||
|
||||
Health is recorded metadata, not a live probe: Phase 1 performs no outbound
|
||||
health checks. Endpoints, tokens, and keychain identifiers must never appear
|
||||
here.
|
||||
|
||||
## Response shape
|
||||
|
||||
`GET /api/v1/projects`:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": "v1",
|
||||
"schema_version": 2,
|
||||
"version": 2,
|
||||
"source_path": "/path/to/webui/data/projects.registry.json",
|
||||
"source": {
|
||||
"kind": "file",
|
||||
"path": "/path/to/webui/data/projects.registry.json",
|
||||
"inventory_complete": true
|
||||
},
|
||||
"project_count": 1,
|
||||
"projects": [
|
||||
{
|
||||
"id": "example",
|
||||
"repo_name": "Example",
|
||||
"gitea_owner": "Org",
|
||||
"repo_full_name": "Org/Example",
|
||||
"remote_host": "https://gitea.example.invalid",
|
||||
"remote_name": "example-remote",
|
||||
"default_branch": "main",
|
||||
"local_checkout_path": ".",
|
||||
"status": "active",
|
||||
"profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
|
||||
"workflow_paths": {"skill": "skills/..."},
|
||||
"schema_paths": {},
|
||||
"onboarding_checklist": [
|
||||
{
|
||||
"id": "profiles",
|
||||
"title": "Configure execution profiles",
|
||||
"description": "...",
|
||||
"state": "complete",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"onboarding_summary": {
|
||||
"total": 1,
|
||||
"complete": 1,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"not_applicable": 0,
|
||||
"required_outstanding": 0,
|
||||
"onboarding_complete": true
|
||||
},
|
||||
"last_seen_health": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
|
||||
`source`, and a single `project` object with the same fields.
|
||||
|
||||
The `source` block satisfies the ADR section 6 provenance rule: every payload
|
||||
states where the data came from and whether the inventory is complete. A
|
||||
file-backed registry is always complete — there is no pagination to truncate it.
|
||||
|
||||
`onboarding_summary` is derived, never stored. `required_outstanding` counts
|
||||
steps that are `required` **and** in state `pending` or `blocked`;
|
||||
`onboarding_complete` is true when that count is zero.
|
||||
|
||||
## Fail-closed errors
|
||||
|
||||
Validation failures raise `RegistryError`, which routes render instead of a
|
||||
traceback.
|
||||
|
||||
`404` — unknown project id on `/api/v1/projects/{project_id}`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "project_not_found",
|
||||
"project_id": "not-registered",
|
||||
"known_project_ids": ["example"],
|
||||
"remediation": "Request one of the known project ids, or add the project ...",
|
||||
"source": {"kind": "file", "path": "...", "inventory_complete": true}
|
||||
}
|
||||
```
|
||||
|
||||
`500` — invalid registry, on both the versioned route and the alias:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "registry_invalid",
|
||||
"detail": "unsupported registry version: 42",
|
||||
"remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
|
||||
"field_path": "version",
|
||||
"source_path": "/path/to/registry.json"
|
||||
}
|
||||
```
|
||||
|
||||
`field_path` points at the offending location (`projects[0].profiles.reconciler`,
|
||||
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
|
||||
the same detail, field, source, and remediation on a "Project registry
|
||||
unavailable" page.
|
||||
|
||||
Conditions that fail closed:
|
||||
|
||||
* file missing or unreadable;
|
||||
* invalid JSON (the remediation names line and column);
|
||||
* root not an object, or `projects` missing/empty;
|
||||
* unsupported `version`;
|
||||
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
|
||||
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
|
||||
* an unknown `status`, onboarding `state`, or health `status`.
|
||||
|
||||
## Credential rule
|
||||
|
||||
The registry stores redacted metadata only. Credential-shaped keys are
|
||||
rejected at load time, before any DTO is built, consistent with
|
||||
[safety-model.md](safety-model.md) and
|
||||
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
|
||||
and are resolved server-side by `gitea_auth`.
|
||||
|
||||
## Migrating a version 1 registry
|
||||
|
||||
1. Set `"version": 2`.
|
||||
2. Optionally add `"status"` per project (omitted means `active`).
|
||||
3. Optionally add `"remote_name"` per project.
|
||||
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
|
||||
means `pending` and `true`).
|
||||
5. Optionally add `"last_seen_health"`.
|
||||
|
||||
No step is mandatory: a version 1 file keeps loading. Bumping the version only
|
||||
declares that the file may use the v2 fields.
|
||||
Reference in New Issue
Block a user