Merge branch 'master' into feat/issue-634-readonly-system-health-api

This commit is contained in:
2026-07-23 00:06:06 -05:00
23 changed files with 6870 additions and 95 deletions
+152 -5
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from starlette.applications import Starlette
@@ -11,14 +12,29 @@ from starlette.routing import Route
from webui.deployment_boundary import deployment_snapshot
from webui.layout import render_page
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
@@ -114,14 +130,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(
@@ -139,10 +167,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())
@@ -248,6 +313,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)
@@ -257,6 +365,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)
@@ -270,10 +385,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":
@@ -302,6 +438,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"]),
@@ -325,6 +467,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"],
),
],
exception_handlers={405: method_not_allowed},
)