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
@@ -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."