Merge branch 'master' into feat/issue-638-webui-app-shell-phase1
Resolve conflict remediation for PR #818 (Closes #638) against master
caaae9b6. Two conflicts in webui/app.py, both resolved as unions since
the Phase 1 shell work (#638) and the merged master changes touch
disjoint concerns:
- Imports: keep the new webui.nav (NAV_GROUPS, STUB_PAGES) import from
#638 alongside master's expanded project_registry / project_views API
(ProjectRegistry, RegistryError, known_project_ids,
project_detail_to_dict, render_registry_error).
- Route table: keep master's read-only /api/console/security-model route
alongside #638's read-only Phase 1 stub routes (STUB_PAGES).
No behavior change beyond union; console stays read-only. docs/webui-local-dev.md
auto-merged. Full webui suite green (272 passed, 310 subtests).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+152
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from starlette.applications import Starlette
|
||||
@@ -12,14 +13,29 @@ from starlette.routing import Route
|
||||
from webui.deployment_boundary import deployment_snapshot
|
||||
from webui.layout import render_page
|
||||
from webui.nav import NAV_GROUPS, STUB_PAGES
|
||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
||||
from webui.project_views import render_project_detail, render_projects_list
|
||||
from webui.project_registry import (
|
||||
ProjectRegistry,
|
||||
RegistryError,
|
||||
find_project,
|
||||
known_project_ids,
|
||||
load_registry,
|
||||
project_detail_to_dict,
|
||||
registry_to_dict,
|
||||
)
|
||||
from webui.project_views import (
|
||||
render_project_detail,
|
||||
render_projects_list,
|
||||
render_registry_error,
|
||||
)
|
||||
from webui.prompt_library import find_prompt, library_to_dict
|
||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||
from final_report_validator import FINAL_REPORT_TASK_KINDS
|
||||
|
||||
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
||||
from webui.gated_action_views import render_actions_page
|
||||
from webui import console_audit
|
||||
from webui.console_authz import authorize, rbac_matrix, resolve_principal
|
||||
from webui.console_redaction import redaction_policy
|
||||
from webui.audit_validator import audit_report, audit_to_dict
|
||||
from webui.audit_views import render_audit_page
|
||||
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
||||
@@ -120,14 +136,26 @@ async def api_queue(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
|
||||
|
||||
|
||||
def _load_project_registry() -> tuple[ProjectRegistry | None, RegistryError | None]:
|
||||
"""Load the registry, converting validation failure into a fail-closed pair."""
|
||||
try:
|
||||
return load_registry(), None
|
||||
except RegistryError as exc:
|
||||
return None, exc
|
||||
|
||||
|
||||
async def projects(_request: Request) -> HTMLResponse:
|
||||
registry = load_registry()
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return HTMLResponse(render_registry_error(error), status_code=500)
|
||||
return HTMLResponse(render_projects_list(registry))
|
||||
|
||||
|
||||
async def project_detail(request: Request) -> HTMLResponse:
|
||||
project_id = request.path_params["project_id"]
|
||||
registry = load_registry()
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return HTMLResponse(render_registry_error(error), status_code=500)
|
||||
project = find_project(registry, project_id)
|
||||
if project is None:
|
||||
return HTMLResponse(
|
||||
@@ -145,10 +173,47 @@ async def project_detail(request: Request) -> HTMLResponse:
|
||||
|
||||
|
||||
async def api_projects(_request: Request) -> JSONResponse:
|
||||
registry = load_registry()
|
||||
"""Unversioned MVP alias, retained through Phase 1 (#632 section 6)."""
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return JSONResponse(error.to_dict(), status_code=500)
|
||||
return JSONResponse(registry_to_dict(registry))
|
||||
|
||||
|
||||
async def api_v1_projects(_request: Request) -> JSONResponse:
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return JSONResponse(error.to_dict(), status_code=500)
|
||||
return JSONResponse(registry_to_dict(registry))
|
||||
|
||||
|
||||
async def api_v1_project_detail(request: Request) -> JSONResponse:
|
||||
project_id = request.path_params["project_id"]
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return JSONResponse(error.to_dict(), status_code=500)
|
||||
project = find_project(registry, project_id)
|
||||
if project is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "project_not_found",
|
||||
"project_id": project_id,
|
||||
"known_project_ids": known_project_ids(registry),
|
||||
"remediation": (
|
||||
"Request one of the known project ids, or add the project to the "
|
||||
"registry file named in 'source'."
|
||||
),
|
||||
"source": {
|
||||
"kind": "file",
|
||||
"path": str(registry.source_path),
|
||||
"inventory_complete": True,
|
||||
},
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
return JSONResponse(project_detail_to_dict(registry, project))
|
||||
|
||||
|
||||
async def prompts(_request: Request) -> HTMLResponse:
|
||||
return HTMLResponse(render_prompts_page())
|
||||
|
||||
@@ -254,6 +319,49 @@ async def api_actions(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(load_action_registry().to_dict())
|
||||
|
||||
|
||||
def _request_id() -> str:
|
||||
return f"req-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _audit_target(action_id: str, params: dict[str, object]) -> dict[str, object]:
|
||||
"""Describe the action target for the audit record (never secrets)."""
|
||||
if "pr_number" in params:
|
||||
return {"kind": "pr", "ref": f"#{params['pr_number']}"}
|
||||
if "issue_number" in params:
|
||||
return {"kind": "issue", "ref": f"#{params['issue_number']}"}
|
||||
if "branch_name" in params:
|
||||
return {"kind": "branch", "ref": str(params["branch_name"])}
|
||||
return {"kind": "unspecified", "ref": action_id}
|
||||
|
||||
|
||||
def _authorize_request(
|
||||
request: Request,
|
||||
action_id: str,
|
||||
params: dict[str, object],
|
||||
*,
|
||||
for_execution: bool,
|
||||
result: str,
|
||||
) -> dict[str, object]:
|
||||
"""Resolve principal, decide, and audit. Returns the decision payload.
|
||||
|
||||
Phase 1 records the decision rather than enforcing it as the terminal
|
||||
outcome: ``webui.gated_actions`` already fails closed for every action, so
|
||||
this layer cannot loosen anything. Phase 2 enforces on this same decision.
|
||||
"""
|
||||
principal = resolve_principal(headers=dict(request.headers))
|
||||
decision = authorize(action_id, principal, for_execution=for_execution)
|
||||
console_audit.record_event(
|
||||
action_id=action_id,
|
||||
result=result,
|
||||
decision=decision,
|
||||
principal=principal,
|
||||
target=_audit_target(action_id, params),
|
||||
request_id=_request_id(),
|
||||
detail=decision.detail,
|
||||
)
|
||||
return decision.to_dict()
|
||||
|
||||
|
||||
async def api_action_preview(request: Request) -> JSONResponse:
|
||||
action_id = request.path_params["action_id"]
|
||||
params = dict(request.query_params)
|
||||
@@ -263,6 +371,13 @@ async def api_action_preview(request: Request) -> JSONResponse:
|
||||
result = preview_action(action_id, **params)
|
||||
if "error" in result:
|
||||
return JSONResponse(result, status_code=404)
|
||||
result["authorization"] = _authorize_request(
|
||||
request,
|
||||
action_id,
|
||||
params,
|
||||
for_execution=False,
|
||||
result=console_audit.RESULT_PREVIEWED,
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@@ -276,10 +391,31 @@ async def api_action_attempt(request: Request) -> JSONResponse:
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
result = attempt_action(action_id, **body)
|
||||
authorization = _authorize_request(
|
||||
request,
|
||||
action_id,
|
||||
body,
|
||||
for_execution=True,
|
||||
result=(
|
||||
console_audit.RESULT_DENIED
|
||||
if not result.get("success")
|
||||
else console_audit.RESULT_ALLOWED
|
||||
),
|
||||
)
|
||||
result["authorization"] = authorization
|
||||
status = 403 if not result.get("success") else 200
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def api_console_security_model(_request: Request) -> JSONResponse:
|
||||
"""Read-only publication of the #633 authorization/redaction/audit model."""
|
||||
return JSONResponse({
|
||||
"rbac": rbac_matrix(),
|
||||
"redaction": redaction_policy(),
|
||||
"audit": console_audit.audit_policy(),
|
||||
})
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
path = request.url.path
|
||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||
@@ -307,6 +443,12 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
Route("/projects", projects, methods=["GET"]),
|
||||
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
||||
Route("/api/projects", api_projects, methods=["GET"]),
|
||||
Route("/api/v1/projects", api_v1_projects, methods=["GET"]),
|
||||
Route(
|
||||
"/api/v1/projects/{project_id}",
|
||||
api_v1_project_detail,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route("/prompts", prompts, methods=["GET"]),
|
||||
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||
@@ -330,6 +472,11 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/api/leases", api_leases, methods=["GET"]),
|
||||
Route(
|
||||
"/api/console/security-model",
|
||||
api_console_security_model,
|
||||
methods=["GET"],
|
||||
),
|
||||
*[
|
||||
Route(path, phase_stub, methods=["GET"])
|
||||
for path in STUB_PAGES
|
||||
|
||||
Reference in New Issue
Block a user