diff --git a/Makefile b/Makefile
index d8090af..5c86122 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ PYTHON ?= python3.12
VENV ?= .venv
ACTIVATE = source $(VENV)/bin/activate
-.PHONY: venv deps deps-dev demo demo-stop demo-calls lint test scenarios openapi
+.PHONY: venv deps deps-dev demo demo-stop demo-calls demo-seed lint test scenarios openapi
venv:
$(PYTHON) -m venv $(VENV)
@@ -14,7 +14,7 @@ deps-dev: deps
$(ACTIVATE) && pip install -r requirements-dev.txt
demo:
- $(ACTIVATE) && bash scripts/demo.sh
+ $(ACTIVATE) && OCPA_DEMO_BACKGROUND=true OCPA_DEMO_SEED=true bash scripts/demo.sh
demo-stop:
$(ACTIVATE) && bash scripts/demo_stop.sh
@@ -22,6 +22,9 @@ demo-stop:
demo-calls:
$(ACTIVATE) && bash scripts/demo_calls.sh
+demo-seed:
+ $(ACTIVATE) && python scripts/demo_seed.py
+
lint:
$(ACTIVATE) && ruff check .
diff --git a/README.md b/README.md
index 7506494..42d4363 100644
--- a/README.md
+++ b/README.md
@@ -78,10 +78,12 @@ make test
```
## Demo (CISO-friendly)
-- `make demo` runs OPA + approval stub + API.
+- `make demo` runs OPA + approval stub + API in the background and waits for `/health`.
+- Demo startup seeds deterministic audit events into `/tmp/ocpa-audit.log` (disable with `OCPA_DEMO_SEED=false`).
- If OPA isn't installed, the demo will download it automatically (requires curl or wget).
- `make demo-calls` runs the curated curl flow.
- `make demo-stop` stops the demo background processes (OPA + approval stub).
+- `make demo-seed` re-seeds the demo audit log on demand.
- The demo shows: read-only allow (dev), high side-effect deny in prod without ticket,
high side-effect allow in non-prod with ticket (approval stub enabled), registry create
with admin JWT/role, MCP usage deny in prod without ticket.
@@ -94,7 +96,7 @@ make test
- MCP inventory: `GET/POST /mcp/servers`, `POST /mcp/usage`, `GET /mcp/usage`
- Web console: `GET /ui` (ledger/approvals/evidence via `/ui/ledger`, `/ui/approvals`, `/ui/evidence-pack`)
- Risk summary: `GET /risk/summary` (counts derived from audit events)
-- OpenAPI spec: `docs/openapi.json` (YAML: `docs/openapi.yaml`, regenerate with `make openapi`)
+- OpenAPI spec: `docs/openapi.json` (YAML: `docs/openapi.yaml`, download via `/ui/openapi`, regenerate with `make openapi`)
- See `docs/postman/README.md` for setup and `docs/postman/ocpa.postman_collection.json` for ready-to-run requests.
## Configuration
diff --git a/docs/configuration.md b/docs/configuration.md
index 73ed478..371404d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -40,6 +40,13 @@ In `OCPA_ENV=prod` with `OCPA_JWT_CLAIMS_MODE=enforce`, `OCPA_JWT_AUDIENCE` and
## Approvals
- `OCPA_REQUIRE_APPROVAL` (default `false`): require approvals for tool calls.
- `OCPA_APPROVAL_WEBHOOK`: webhook URL returning `{ "approved": true|false }`.
+- `OCPA_APPROVAL_HEALTH_URL`: optional health endpoint for approval service (exposed via `/health`).
+
+## Demo
+- `OCPA_DEMO_MODE` (default `false`): enables UI demo fallback behavior.
+- `OCPA_DEMO_SEED` (default `true` in `make demo`): seed deterministic demo audit events.
+- `OCPA_DEMO_BACKGROUND` (default `true` in `make demo`): run the API in the background.
+- `OCPA_DEMO_OPEN` (default `false`): open the UI after startup when `scripts/demo.sh` runs.
## Rate limiting
- `OCPA_RATE_LIMIT_MAX`: max calls per window.
diff --git a/docs/openapi.json b/docs/openapi.json
index 280303c..9b2c5e1 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -1277,6 +1277,38 @@
},
"summary": "Ui Ledger"
}
+ },
+ "/ui/openapi": {
+ "get": {
+ "operationId": "ui_openapi_ui_openapi_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "Ui Openapi"
+ }
+ },
+ "/ui/openapi.yaml": {
+ "get": {
+ "operationId": "ui_openapi_yaml_ui_openapi_yaml_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "Ui Openapi Yaml"
+ }
}
}
}
\ No newline at end of file
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index d369b8d..6beabd7 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -279,6 +279,26 @@ paths:
text/html:
schema:
type: string
+ /ui/openapi:
+ get:
+ summary: Ui Openapi
+ operationId: ui_openapi_ui_openapi_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ /ui/openapi.yaml:
+ get:
+ summary: Ui Openapi Yaml
+ operationId: ui_openapi_yaml_ui_openapi_yaml_get
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
/ui/inventory:
get:
summary: Ui Inventory
diff --git a/docs/postman/README.md b/docs/postman/README.md
index 9f29b99..a928959 100644
--- a/docs/postman/README.md
+++ b/docs/postman/README.md
@@ -10,7 +10,7 @@ Use this collection to exercise the OCPA API (health, tool calls, registry, MCP)
- `adminRoles`: admin (used for `X-OCPA-Roles`)
- `env`: dev
- `toolId`, `agentId`, `serverId`, `ticketId`, `dryRun`, `mcpScope`
-3. Optional: import the OpenAPI spec `docs/openapi.json` into Postman to generate additional requests.
+3. Optional: import the OpenAPI spec `docs/openapi.json` (or fetch `http://localhost:8000/ui/openapi`) into Postman.
Regenerate it with `make openapi` after API changes.
## Generate an admin token (HS256)
diff --git a/example_app/api.py b/example_app/api.py
index 15d8c13..de1f9b8 100644
--- a/example_app/api.py
+++ b/example_app/api.py
@@ -11,7 +11,7 @@
from typing import Any, Dict, Optional
from fastapi import FastAPI, Header, HTTPException, Response
-from fastapi.responses import HTMLResponse, StreamingResponse
+from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
import requests
@@ -119,11 +119,22 @@ def health() -> Dict[str, Any]:
if env == "prod" and claims_mode == "enforce" and (not audience or not issuer):
warnings.append("audience/issuer not configured")
opa_status = _check_opa_health()
+ approval_status = _check_approval_health()
+ audit_path = _audit_path()
+ audit_exists = audit_path.exists()
+ audit_events = _read_audit_events(_clamp_limit(200)) if audit_exists else []
return {
"status": "ok",
"env": env,
"auth_warnings": warnings,
"opa_status": opa_status,
+ "approval_status": approval_status,
+ "audit": {
+ "path": str(audit_path),
+ "exists": audit_exists,
+ "events": len(audit_events),
+ "size_bytes": audit_path.stat().st_size if audit_exists else 0,
+ },
"demo_mode": demo_mode,
}
@@ -222,6 +233,22 @@ def ui_home() -> HTMLResponse:
return HTMLResponse(ui_path.read_text(encoding="utf-8"))
+@app.get("/ui/openapi")
+def ui_openapi() -> JSONResponse:
+ spec_path = _repo_root() / "docs" / "openapi.json"
+ if spec_path.exists():
+ return JSONResponse(json.loads(spec_path.read_text(encoding="utf-8")))
+ return JSONResponse(app.openapi())
+
+
+@app.get("/ui/openapi.yaml")
+def ui_openapi_yaml() -> Response:
+ spec_path = _repo_root() / "docs" / "openapi.yaml"
+ if spec_path.exists():
+ return Response(spec_path.read_text(encoding="utf-8"), media_type="text/yaml")
+ return Response("", media_type="text/yaml")
+
+
@app.get("/ui/inventory")
def ui_inventory() -> Dict[str, Any]:
return {
@@ -535,6 +562,19 @@ def _check_opa_health() -> str:
return "unavailable"
+def _check_approval_health() -> str:
+ health_url = os.environ.get("OCPA_APPROVAL_HEALTH_URL")
+ if not health_url:
+ return "unconfigured"
+ try:
+ response = requests.get(health_url, timeout=0.5)
+ if response.status_code == 200:
+ return "ok"
+ return "error"
+ except Exception: # noqa: BLE001
+ return "unavailable"
+
+
def _clamp_limit(limit: int) -> int:
if limit < 1:
return 1
diff --git a/example_app/ui/index.html b/example_app/ui/index.html
index d1cd635..7a2d595 100644
--- a/example_app/ui/index.html
+++ b/example_app/ui/index.html
@@ -529,6 +529,8 @@
AI Security Command Center
System Health
OPA: unknown
+ Approval: unknown
+ Audit: unknown
Env: unknown
Auth warning
@@ -674,7 +676,9 @@ Evidence & ABOM
Generate evidence pack (policies, baselines, SBOM, audit excerpts).
+
+ Static spec: `docs/openapi.json` (YAML: `docs/openapi.yaml`).
@@ -687,6 +691,8 @@ Evidence & ABOM
const lastUpdatedEl = document.getElementById("lastUpdated");
const summaryMetaEl = document.getElementById("summaryMeta");
const envStatusEl = document.getElementById("envStatus");
+ const approvalStatusEl = document.getElementById("approvalStatus");
+ const auditStatusEl = document.getElementById("auditStatus");
const postureBadgeEl = document.getElementById("postureBadge");
const postureSignalsEl = document.getElementById("postureSignals");
const driversEl = document.getElementById("drivers");
@@ -719,6 +725,46 @@ Evidence & ABOM
}
}
+ function renderApprovalStatus(value) {
+ if (!approvalStatusEl) {
+ return;
+ }
+ const status = value || "unknown";
+ approvalStatusEl.textContent = `Approval: ${status}`;
+ approvalStatusEl.classList.remove("allow", "deny", "warn", "neutral");
+ if (status === "ok") {
+ approvalStatusEl.classList.add("allow");
+ } else if (status === "unconfigured" || status === "unknown") {
+ approvalStatusEl.classList.add("neutral");
+ } else {
+ approvalStatusEl.classList.add("warn");
+ }
+ }
+
+ function renderAuditStatus(audit) {
+ if (!auditStatusEl) {
+ return;
+ }
+ if (!audit) {
+ auditStatusEl.textContent = "Audit: unknown";
+ auditStatusEl.classList.remove("allow", "deny", "warn");
+ auditStatusEl.classList.add("neutral");
+ return;
+ }
+ const events = audit.events || 0;
+ auditStatusEl.classList.remove("allow", "deny", "warn", "neutral");
+ if (!audit.exists) {
+ auditStatusEl.textContent = "Audit: missing";
+ auditStatusEl.classList.add("warn");
+ } else if (events === 0) {
+ auditStatusEl.textContent = "Audit: empty";
+ auditStatusEl.classList.add("warn");
+ } else {
+ auditStatusEl.textContent = `Audit: ${events} events`;
+ auditStatusEl.classList.add("allow");
+ }
+ }
+
function renderAuthWarnings(warnings) {
if (!authWarningEl) {
return;
@@ -1157,6 +1203,8 @@ Evidence & ABOM
try {
const health = await fetchJson("/health");
renderOpaStatus(health.opa_status);
+ renderApprovalStatus(health.approval_status);
+ renderAuditStatus(health.audit);
renderAuthWarnings(health.auth_warnings);
renderEnvStatus(health.env);
demoMode = Boolean(health.demo_mode);
@@ -1303,12 +1351,38 @@ Evidence & ABOM
}
}
+ async function downloadOpenApi(format = "json") {
+ setStatus("Downloading OpenAPI spec...");
+ const path = format === "yaml" ? "/ui/openapi.yaml" : "/ui/openapi";
+ const filename = format === "yaml" ? "openapi.yaml" : "openapi.json";
+ try {
+ const resp = await fetch(path);
+ if (!resp.ok) {
+ const text = await resp.text();
+ throw new Error(`${resp.status} ${resp.statusText}: ${text}`);
+ }
+ const blob = await resp.blob();
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+ setStatus("OpenAPI spec downloaded.");
+ } catch (err) {
+ setStatus(`OpenAPI download failed: ${err.message}`);
+ }
+ }
+
document.getElementById("saveToken").addEventListener("click", () => {
localStorage.setItem(tokenKey, tokenEl.value.trim());
setStatus("Token saved.");
});
document.getElementById("refreshAll").addEventListener("click", refreshAll);
document.getElementById("downloadEvidence").addEventListener("click", downloadEvidence);
+ document.getElementById("downloadOpenApi").addEventListener("click", () => downloadOpenApi("json"));
tokenEl.value = getToken();
refreshAll();
diff --git a/scripts/approval_stub.py b/scripts/approval_stub.py
index 7472464..0f7fc38 100644
--- a/scripts/approval_stub.py
+++ b/scripts/approval_stub.py
@@ -21,6 +21,11 @@ def approve(req: ApprovalRequest):
return {"approved": True}
+@app.get("/health")
+def health():
+ return {"status": "ok"}
+
+
if __name__ == "__main__":
import uvicorn
diff --git a/scripts/demo.sh b/scripts/demo.sh
index 6e5c073..23cdcd2 100755
--- a/scripts/demo.sh
+++ b/scripts/demo.sh
@@ -7,6 +7,9 @@ set -euo pipefail
OPA_URL=${OPA_URL:-http://localhost:8181}
API_URL=${API_URL:-http://localhost:8000}
ENV=${OCPA_ENV:-dev}
+DEMO_BACKGROUND=${OCPA_DEMO_BACKGROUND:-true}
+DEMO_SEED=${OCPA_DEMO_SEED:-true}
+DEMO_OPEN=${OCPA_DEMO_OPEN:-false}
OPA_VERSION=${OPA_VERSION:-0.64.0}
OPA_BIN=${OPA_BIN:-opa}
OPA_FLAGS=${OPA_FLAGS:-}
@@ -111,6 +114,17 @@ wait_for_opa() {
return 1
}
+wait_for_api() {
+ local health_url="${API_URL}/health"
+ for _ in {1..20}; do
+ if http_ping "${health_url}"; then
+ return 0
+ fi
+ sleep 1
+ done
+ return 1
+}
+
start_opa() {
ensure_opa
detect_opa_flags
@@ -163,18 +177,61 @@ start_approval_stub() {
sleep 2
}
+seed_demo_data() {
+ if [ "${DEMO_SEED}" != "true" ]; then
+ return
+ fi
+ echo "Seeding demo audit log..."
+ source .venv/bin/activate
+ python scripts/demo_seed.py
+}
+
start_api() {
echo "Starting API on :8000..."
export OPA_URL OCPA_ENV=$ENV OCPA_REQUIRE_APPROVAL=true OCPA_APPROVAL_WEBHOOK=http://localhost:8085/approve
+ export OCPA_APPROVAL_HEALTH_URL=${OCPA_APPROVAL_HEALTH_URL:-http://localhost:8085/health}
export OCPA_DEMO_MODE=true
export OCPA_AUDIT_ENABLED=true OCPA_AUDIT_SINKS=stdout,file OCPA_AUDIT_FILE_PATH=/tmp/ocpa-audit.log
source .venv/bin/activate
- if [ "${OCPA_DEMO_BACKGROUND:-false}" = "true" ]; then
+ if [ -f "${API_PID_FILE}" ] && kill -0 "$(cat "${API_PID_FILE}")" 2>/dev/null; then
+ if wait_for_api; then
+ echo "API already running (pid $(cat "${API_PID_FILE}"))."
+ return
+ fi
+ echo "API process found but /health is not responding."
+ tail -n 50 /tmp/ocpa-api.log || true
+ exit 1
+ fi
+ if pgrep -f "uvicorn example_app.api:app" >/dev/null; then
+ if wait_for_api; then
+ echo "API already running."
+ return
+ fi
+ echo "API process found but /health is not responding."
+ tail -n 50 /tmp/ocpa-api.log || true
+ exit 1
+ fi
+ if [ "${DEMO_BACKGROUND}" = "true" ]; then
uvicorn example_app.api:app --host 0.0.0.0 --port 8000 >/tmp/ocpa-api.log 2>&1 &
echo $! > "${API_PID_FILE}"
echo "API running in background (pid $(cat "${API_PID_FILE}"))."
+ if ! wait_for_api; then
+ echo "API failed to become healthy. Check /tmp/ocpa-api.log."
+ tail -n 50 /tmp/ocpa-api.log || true
+ exit 1
+ fi
+ seed_demo_data
+ echo "Demo ready: ${API_URL}/ui"
+ if [ "${DEMO_OPEN}" = "true" ]; then
+ if command -v open >/dev/null 2>&1; then
+ open "${API_URL}/ui"
+ elif command -v xdg-open >/dev/null 2>&1; then
+ xdg-open "${API_URL}/ui" >/dev/null 2>&1 || true
+ fi
+ fi
return
fi
+ seed_demo_data
uvicorn example_app.api:app --host 0.0.0.0 --port 8000
}
diff --git a/scripts/demo_calls.sh b/scripts/demo_calls.sh
index 8928ed2..75dffd8 100755
--- a/scripts/demo_calls.sh
+++ b/scripts/demo_calls.sh
@@ -9,6 +9,19 @@ API_URL=${API_URL:-http://localhost:8000}
CONTENT_TYPE_HEADER='Content-Type: application/json'
AUDIT_LOG=${OCPA_AUDIT_FILE_PATH:-/tmp/ocpa-audit.log}
+wait_for_api() {
+ local health_url="${API_URL}/health"
+ for _ in {1..10}; do
+ if command -v curl >/dev/null 2>&1; then
+ if curl -fsS "${health_url}" >/dev/null 2>&1; then
+ return 0
+ fi
+ fi
+ sleep 1
+ done
+ return 1
+}
+
pretty() {
if command -v jq >/dev/null 2>&1; then
jq .
@@ -18,6 +31,10 @@ pretty() {
}
echo "=== Read-only allowed (dev) ==="
+if ! wait_for_api; then
+ echo "API is not responding at ${API_URL}. Run make demo first."
+ exit 1
+fi
curl -sX POST "${API_URL}/invoke" -H "${CONTENT_TYPE_HEADER}" \
-d '{"tool_id":"get_customer_profile","input":{"customer_id":"123"},"context":{"env":"dev","agent_id":"agentA","roles":["support"]}}' | pretty
diff --git a/scripts/demo_seed.py b/scripts/demo_seed.py
new file mode 100644
index 0000000..0661f12
--- /dev/null
+++ b/scripts/demo_seed.py
@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from pathlib import Path
+from typing import Any, Dict, List
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from ocpa.audit import AuditEvent # noqa: E402
+
+
+def _repo_root() -> Path:
+ return Path(__file__).resolve().parents[1]
+
+
+def _resolve_path(raw: str) -> Path:
+ path = Path(raw)
+ if path.is_absolute():
+ return path
+ return _repo_root() / path
+
+
+def build_events(base_ts: int) -> List[Dict[str, Any]]:
+ return [
+ AuditEvent(
+ event_type="ocpa_tool_call",
+ action="invoke",
+ resource_type="tool",
+ resource_id="rotate_api_key",
+ decision="deny",
+ reason="default_deny",
+ side_effect_level="high",
+ env="prod",
+ approval_required=True,
+ ts=base_ts - 120,
+ ).to_dict(),
+ AuditEvent(
+ event_type="ocpa_tool_call",
+ action="invoke",
+ resource_type="tool",
+ resource_id="create_ticket",
+ decision="allow",
+ reason="approval_granted",
+ side_effect_level="low",
+ env="dev",
+ approval_status="approved",
+ ts=base_ts - 300,
+ ).to_dict(),
+ AuditEvent(
+ event_type="ocpa_mcp_call",
+ action="call",
+ resource_type="mcp",
+ resource_id="payments-mcp",
+ decision="deny",
+ reason="mcp_server_not_allowed",
+ env="prod",
+ error="mcp_call_failed",
+ ts=base_ts - 420,
+ ).to_dict(),
+ AuditEvent(
+ event_type="ocpa_tool_call",
+ action="invoke",
+ resource_type="tool",
+ resource_id="rotate_api_key",
+ decision="deny",
+ reason="rate_limited",
+ side_effect_level="high",
+ env="dev",
+ error="rate_limited",
+ ts=base_ts - 600,
+ ).to_dict(),
+ AuditEvent(
+ event_type="ocpa_registry_change",
+ action="create",
+ resource_type="tool",
+ resource_id="demo_tool",
+ decision="allow",
+ env="dev",
+ ts=base_ts - 760,
+ ).to_dict(),
+ AuditEvent(
+ event_type="ocpa_tool_call",
+ action="invoke",
+ resource_type="tool",
+ resource_id="get_customer_profile",
+ decision="allow",
+ reason="read_only_allow",
+ side_effect_level="none",
+ env="dev",
+ ts=base_ts - 900,
+ ).to_dict(),
+ ]
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Seed deterministic demo audit events.")
+ parser.add_argument(
+ "--path",
+ default=os.environ.get("OCPA_AUDIT_FILE_PATH", "/tmp/ocpa-audit.log"),
+ help="Audit log path (default: OCPA_AUDIT_FILE_PATH or /tmp/ocpa-audit.log).",
+ )
+ parser.add_argument(
+ "--append",
+ action="store_true",
+ help="Append to existing audit log instead of overwriting.",
+ )
+ parser.add_argument(
+ "--base-ts",
+ type=int,
+ default=int(time.time()),
+ help="Base unix timestamp for seeded events.",
+ )
+ args = parser.parse_args()
+
+ path = _resolve_path(args.path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ mode = "a" if args.append else "w"
+ events = build_events(args.base_ts)
+ with path.open(mode, encoding="utf-8") as handle:
+ for event in events:
+ handle.write(json.dumps(event) + "\n")
+ print(f"Seeded {len(events)} demo events into {path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/demo_stop.sh b/scripts/demo_stop.sh
index 884f4d2..5b16171 100644
--- a/scripts/demo_stop.sh
+++ b/scripts/demo_stop.sh
@@ -25,8 +25,24 @@ stop_pid() {
rm -f "${pid_file}"
}
+stop_by_pattern() {
+ local name=$1
+ local pattern=$2
+ local pids
+ pids=$(pgrep -f "${pattern}" || true)
+ if [ -z "${pids}" ]; then
+ return
+ fi
+ echo "Stopping ${name} (${pids})..."
+ kill ${pids} >/dev/null 2>&1 || true
+}
+
stop_pid "API" "${API_PID_FILE}"
stop_pid "Approval stub" "${APPROVAL_PID_FILE}"
stop_pid "OPA" "${OPA_PID_FILE}"
+stop_by_pattern "API" "uvicorn example_app.api:app"
+stop_by_pattern "Approval stub" "scripts.approval_stub:app"
+stop_by_pattern "OPA" "opa run --server"
+
echo "Done."