Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -14,14 +14,17 @@ 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

demo-calls:
$(ACTIVATE) && bash scripts/demo_calls.sh

demo-seed:
$(ACTIVATE) && python scripts/demo_seed.py

lint:
$(ACTIVATE) && ruff check .

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
20 changes: 20 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/postman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 41 additions & 1 deletion example_app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions example_app/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ <h1>AI Security Command Center</h1>
<div class="status-title">System Health</div>
<div class="status-pills">
<span id="opaStatus" class="pill">OPA: unknown</span>
<span id="approvalStatus" class="pill neutral">Approval: unknown</span>
<span id="auditStatus" class="pill neutral">Audit: unknown</span>
<span id="envStatus" class="pill neutral">Env: unknown</span>
<span id="authWarning" class="pill warn hidden">Auth warning</span>
</div>
Expand Down Expand Up @@ -674,7 +676,9 @@ <h2>Evidence & ABOM</h2>
<p class="note">Generate evidence pack (policies, baselines, SBOM, audit excerpts).</p>
<div class="controls">
<button id="downloadEvidence" class="primary">Download Evidence Pack</button>
<button id="downloadOpenApi">Download OpenAPI spec</button>
</div>
<div class="note">Static spec: `docs/openapi.json` (YAML: `docs/openapi.yaml`).</div>
</section>
</main>
</div>
Expand All @@ -687,6 +691,8 @@ <h2>Evidence & ABOM</h2>
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");
Expand Down Expand Up @@ -719,6 +725,46 @@ <h2>Evidence & ABOM</h2>
}
}

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;
Expand Down Expand Up @@ -1157,6 +1203,8 @@ <h2>Evidence & ABOM</h2>
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);
Expand Down Expand Up @@ -1303,12 +1351,38 @@ <h2>Evidence & ABOM</h2>
}
}

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();
Expand Down
5 changes: 5 additions & 0 deletions scripts/approval_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ def approve(req: ApprovalRequest):
return {"approved": True}


@app.get("/health")
def health():
return {"status": "ok"}


if __name__ == "__main__":
import uvicorn

Expand Down
Loading