feat: add control panel

This commit is contained in:
thanhnv
2026-07-08 19:07:35 +09:00
parent a07b15e489
commit 3be9970c15
104 changed files with 3639 additions and 461 deletions
+99 -14
View File
@@ -1,13 +1,13 @@
# CASAN Ops Console (Plan-13 Track 1) — read-only Control Panel
# CASAN Ops Console (Plan-13 Track 1/2/3/4 + Command Center) — Control Panel
Real **NestJS API + React UI** that surfaces CASAN harness telemetry. This is the Level-3
`casan-platform` **Control Panel** component. **Read-only** ("Đọc ≠ Ghi"): it never writes
settings or bypasses a gate — management (settings/RBAC/approval) is Plan-13 Track 2/3
(future, soft-blocked by Plan-14).
Real **NestJS API + React UI** that surfaces CASAN harness telemetry and governed settings
management. This is the Level-3 `casan-platform` **Control Panel** component.
Monitoring remains read-only ("Đọc ≠ Ghi"). Settings writes go through RBAC and the
harness-owned governance CLI; the UI never writes harness files directly or bypasses a gate.
```
backend/ NestJS read-only API (/api/v1 + /healthz) over .specify telemetry
frontend/ React + Vite + Tailwind + TanStack Query Ops Console
backend/ NestJS API (/api/v1 + /healthz) over .specify telemetry + governed settings
frontend/ React + Vite + Tailwind + TanStack Query Ops Console + Settings/Approvals/Kill-switch/FinOps/Command pages
```
## Run (local)
@@ -18,26 +18,111 @@ npm run console:ui # Vite UI → http://127.0.0.1:5174 (proxie
```
Open http://127.0.0.1:5174 — panels show REAL metrics from `.specify/logs/**`.
## API (all read-only, `ok()`-enveloped except `/healthz`)
## API (`ok()`-enveloped except `/healthz`)
`GET /api/v1/overview` · `runs` (+ `runs/:traceId`) · `governance` · `security` ·
`incidents` · `tools` · `traceability` · `drift` · `cost` · `GET /healthz` (200 fresh /
503 stale — fail-loud, mirrors `dashboard-server.py`).
Command Center:
- `GET /api/v1/command` — Plan-13 §8.6 read-only executive surface. Returns eight
evidence-backed widgets with provenance envelopes
`{source, artifact_path, commit, run_at, verified, status}`, plus executive briefing
rows and a live governance ticker. The `/command` UI exposes the same data with a
VI/EN briefing toggle and evidence drawer.
Settings management:
- `GET /api/v1/settings` — list policy/current values/audit tail; viewer-readable.
- `POST /api/v1/settings` — governed setting write. Requires `x-casan-role` with write
permission; calls `rbac-check.py` before `control-plane-settings.py set`.
- `POST /api/v1/settings/rollback` — governed rollback through the same core CLI.
Local management headers: `x-casan-actor`, `x-casan-role`, `x-casan-project`,
`x-casan-tenant`. Missing role defaults to `viewer`, so writes fail closed.
Kill-switch management:
- `GET /api/v1/kill-switch` — list active kill-switches from `kill-switch.sh status`.
- `POST /api/v1/kill-switch/engage` — RBAC-gated engage (`operator` or stronger).
- `POST /api/v1/kill-switch/clear` — RBAC-gated clear (`org-admin`; strict approval remains
enforced by the harness CLI in production mode).
Approval inbox / HITL:
- `GET /api/v1/approvals?status=pending` — list proposals and oversight tail.
- `POST /api/v1/approvals/submit` — submit a governed proposal; delegation is resolved
by harness `approval-inbox.py` + `delegation-policy.yaml`.
- `POST /api/v1/approvals/decide` — approve/reject with SoD and reason; approved
settings proposals apply through `control-plane-settings.py`.
FinOps/SLO:
- `/finops` UI reads `GET /api/v1/cost` plus `GET /api/v1/settings`.
- Provider cost/tokens come from provider usage telemetry.
- KPI/SLO tiles come from `14-business-kpi-report.json`.
- Budget status uses `cost.absolute_cap_usd` when configured; otherwise it shows
`not configured`.
Data sources + aggregation mirror `packages/casan-harness/tests/generate-agentops-dashboard.py`.
App root + telemetry paths resolve via the same marker walk-up as `casan-paths.sh`
(`.specify` or `packages/casan-harness`) and honor `CASAN_DASHBOARD_*` env overrides.
## Security posture (MVP)
Binds `127.0.0.1`, no auth (read-only local ops). Refuses a non-loopback bind under
`CASAN_PROFILE=prod` / `CASAN_CP_STRICT=1` — off-loopback exposure needs TLS/OIDC (Plan-13
Track 4). Auth/login (reuse OKR JWT) is a follow-up.
Binds `127.0.0.1` by default. Refuses a non-loopback bind under `CASAN_PROFILE=prod` /
`CASAN_CP_STRICT=1` unless `CASAN_CP_TRUST_AUTH_PROXY=1` is set for an authenticated reverse
proxy that overwrites identity headers. Management endpoints are RBAC-gated via the harness
`rbac-check.py`; IdP group claims such as `casan-approver` are mapped to RBAC roles through
the same harness engine.
## Test
```bash
npm run console:test # backend telemetry reader/service + healthz logic
npm run console:test # backend telemetry/settings/approvals/kill-switch/auth mapping/command contract
npm run console:build # backend tsc + frontend typecheck/vite build
```
## Production-Like Smoke
```bash
docker compose -f docker-compose.control-panel.yml config
bash packages/casan-control-panel/scripts/local-prod-smoke.sh
```
The scaffold includes `Dockerfile.control-panel-api`, `Dockerfile.control-panel-ui`, and
`nginx/control-panel.conf`. Nginx protects UI/API through oauth2-proxy `auth_request`,
overwrites browser-supplied `X-CASAN-*` headers, and passes IdP group claims to the API for
RBAC mapping. The local smoke starts a self-signed HTTPS stack with a mock OIDC IdP and
expects `CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin`; it also
asserts the Command Center returns all eight widgets with provenance envelopes and invokes
`managed-prod-smoke.sh` with the authenticated mock-IdP cookie jar. A passing local run
therefore emits both `CP_LOCAL_SMOKE_PASS ...` and
`CP_MANAGED_SMOKE_PASS actor=oidc-ops role=org-admin widgets=8`.
Managed production readiness, once the host has real TLS files and an enterprise OIDC
env file:
```bash
CASAN_CP_TLS_DIR=/opt/casan-control-panel/tls \
CASAN_CP_OAUTH_ENV=/opt/casan-control-panel/oauth2-proxy.env \
bash packages/casan-control-panel/scripts/prod-readiness-check.sh
```
Managed production endpoint smoke, once DNS/TLS/OIDC are deployed:
```bash
CASAN_CP_BASE_URL=https://control-panel.example.com \
bash packages/casan-control-panel/scripts/managed-prod-smoke.sh
```
That unauthenticated smoke must report auth protection and spoofed-header blocking. To also
verify authenticated identity mapping and Command Center behind the enterprise IdP, pass an
exported browser cookie jar for a real logged-in session:
```bash
CASAN_CP_BASE_URL=https://control-panel.example.com \
CASAN_CP_COOKIE_JAR=/secure/path/control-panel-cookies.txt \
bash packages/casan-control-panel/scripts/managed-prod-smoke.sh
```
## Not in this pass
Track 2 settings writes (wrap `control-plane-settings.py`), Track 3 RBAC + approval inbox
(Plan-14), Track 4 docker/deploy + TLS/OIDC + FinOps/SLO. See
Live managed host/cert/enterprise IdP traffic cutover. See
`docs/plans/CASAN_PLAN_13_CONTROL_PLANE.md`.
@@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"type": "module",
"description": "CASAN Ops Console — read-only NestJS API over harness telemetry (Plan-13 Track 1).",
"description": "CASAN Ops Console — NestJS API over harness telemetry, governed settings, approval inbox, kill-switch, and FinOps/SLO (Plan-13 Track 1/2/3/4 partial).",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsx watch src/main.ts",
@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { TelemetryModule } from './telemetry/telemetry.module.js';
import { HealthController } from './health/health.controller.js';
import { SettingsModule } from './settings/settings.module.js';
import { KillSwitchModule } from './kill-switch/kill-switch.module.js';
import { ApprovalsModule } from './approvals/approvals.module.js';
@Module({
imports: [TelemetryModule],
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Headers, Inject, Post, Query } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { ApprovalDecision, ApprovalSubmit, ApprovalsService } from './approvals.service.js';
@Controller('api/v1/approvals')
export class ApprovalsController {
constructor(@Inject(ApprovalsService) private readonly svc: ApprovalsService) {}
@Get()
list(@Headers() headers: Record<string, string | string[] | undefined>, @Query('status') status?: string) {
return ok(this.svc.list(actorFromHeaders(headers), status || 'pending'));
}
@Post('submit')
submit(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalSubmit) {
return ok(this.svc.submit(body, actorFromHeaders(headers)));
}
@Post('decide')
decide(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalDecision) {
return ok(this.svc.decide(body, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ApprovalsController } from './approvals.controller.js';
import { ApprovalsService } from './approvals.service.js';
@Module({
controllers: [ApprovalsController],
providers: [ApprovalsService],
})
export class ApprovalsModule {}
@@ -0,0 +1,192 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
export interface ApprovalSubmit {
action: string;
target: string;
risk?: string;
sensitive?: boolean;
reason: string;
payload?: Record<string, unknown>;
}
export interface ApprovalDecision {
id: string;
decision: 'approve' | 'reject';
reason: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const INBOX_CLI = join(HARNESS_BIN, 'approval-inbox.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const CP_CLI = join(HARNESS_BIN, 'control-plane-settings.py');
interface CommandResult {
stdout: string;
stderr: string;
}
function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try {
const stdout = execFileSync(command, args, {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
const stderr = String(err?.stderr ?? '').trim();
const stdout = String(err?.stdout ?? '').trim();
const status = Number(err?.status ?? 1);
const e = new Error(stderr || stdout || `command failed: ${command}`);
(e as any).status = status;
(e as any).stderr = stderr;
(e as any).stdout = stdout;
throw e;
}
}
function parseJson<T>(raw: string, fallback: T): T {
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
@Injectable()
export class ApprovalsService {
list(actor: SettingsActor, status = 'pending') {
this.requireRbac(actor, 'monitoring', 'read');
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status]);
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit() };
}
submit(input: ApprovalSubmit, actor: SettingsActor) {
if (!input.action || !input.target || !input.reason) {
throw new ForbiddenException('APPROVAL_SUBMIT_DENY action/target/reason required');
}
if (input.action.startsWith('settings.')) {
this.requireRbac(actor, 'settings', 'write');
} else {
this.requireRbac(actor, 'monitoring', 'read');
}
const args = [
INBOX_CLI,
'submit',
'--project',
actor.project,
'--action',
input.action,
'--target',
input.target,
'--risk',
input.risk ?? 'standard',
'--proposer',
actor.actor,
'--reason',
input.reason,
'--payload',
JSON.stringify(input.payload ?? {}),
];
if (input.sensitive) args.push('--sensitive');
const res = runFile('python3', args);
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
}
decide(input: ApprovalDecision, actor: SettingsActor) {
if (!input.id || !input.decision || !input.reason) {
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
}
this.requireRbac(actor, 'approval', 'grant');
try {
const res = runFile('python3', [
INBOX_CLI,
'decide',
'--id',
input.id,
'--decision',
input.decision,
'--approver',
actor.actor,
'--reason',
input.reason,
]);
const proposal = parseJson<Record<string, any>>(res.stdout, {});
const applied = input.decision === 'approve' ? this.applyApprovedProposal(proposal, actor) : null;
return { proposal, applied, audit_verify: this.verifyAudit() };
} catch (err: any) {
if (Number(err.status) === 3 || Number(err.status) === 1) {
throw new ForbiddenException(err.stderr || err.message);
}
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private applyApprovedProposal(proposal: Record<string, any>, actor: SettingsActor) {
if (proposal.action !== 'settings.write') return null;
const key = proposal.payload?.key;
if (!key || proposal.payload?.value === undefined) {
throw new ForbiddenException('APPROVAL_APPLY_DENY settings proposal missing key/value');
}
try {
const res = runFile('python3', [
CP_CLI,
'set',
String(key),
JSON.stringify(proposal.payload.value),
'--actor',
String(proposal.proposer ?? actor.actor),
'--reason',
`approved:${proposal.id}:${proposal.decision_reason ?? ''}`,
'--approval',
`inbox:${proposal.id}:${actor.actor}`,
]);
return parseJson<Record<string, any>>(res.stdout, {});
} catch (err: any) {
if (Number(err.status) === 2 || Number(err.status) === 3) {
throw new ForbiddenException(err.stderr || err.message);
}
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private requireRbac(actor: SettingsActor, resource: string, action: string) {
try {
runFile('python3', [
RBAC_CLI,
'check',
'--role',
actor.role,
'--resource',
resource,
'--action',
action,
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
]);
} catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'RBAC_DENY');
}
}
private verifyAudit() {
try {
const res = runFile('python3', [INBOX_CLI, 'verify-audit']);
return { ok: true, output: res.stdout };
} catch (err: any) {
return { ok: false, output: err.stderr || err.stdout || err.message };
}
}
}
@@ -46,6 +46,10 @@ export const PATHS = {
drift: env('CASAN_CP_DRIFT', 'docs/output/casan/level5-evidence/09-drift-report.json'),
businessKpi: env('CASAN_CP_KPI', 'docs/output/casan/level5-evidence/14-business-kpi-report.json'),
benignFp: env('CASAN_CP_BENIGN_FP', 'docs/output/casan/benign-fp-report.json'),
scoringReport: env('CASAN_CP_SCORING_REPORT', 'docs/output/casan/phase3-real-run-scoring.md'),
approvalInbox: env('CASAN_CP_APPROVAL_INBOX', '.specify/level5/approval-inbox.json'),
delegationPolicy: env('CASAN_CP_DELEGATION_POLICY', 'packages/casan-harness/config/delegation-policy.yaml'),
selfImprove: env('CASAN_CP_SELF_IMPROVE', 'packages/casan-harness/scripts/bash/self-improve.py'),
};
export const STALE_AFTER_S = Number(process.env.CASAN_DASHBOARD_STALE_S ?? 3600);
@@ -0,0 +1,51 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from './app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
const RBAC_CLI = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash', 'rbac-check.py');
const LOCAL_ROLES = new Set(['org-admin', 'project-admin', 'approver', 'operator', 'viewer', 'auditor']);
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function mapClaim(claim: string): string | null {
if (LOCAL_ROLES.has(claim)) return claim;
try {
return execFileSync('python3', [RBAC_CLI, 'map-claim', '--claim', claim], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return null;
}
}
function roleFromClaim(raw: string | undefined): string {
if (!raw) return 'viewer';
const claims = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
for (const claim of claims) {
const mapped = mapClaim(claim);
if (mapped) return mapped;
}
return 'viewer';
}
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
const actor = firstHeader(headers['x-casan-actor'])
|| firstHeader(headers['x-auth-request-user'])
|| firstHeader(headers['x-forwarded-user'])
|| 'local-operator';
const roleClaim = firstHeader(headers['x-casan-role'])
|| firstHeader(headers['x-casan-groups'])
|| firstHeader(headers['x-auth-request-groups'])
|| firstHeader(headers['x-forwarded-groups']);
return {
actor,
role: roleFromClaim(roleClaim),
project: firstHeader(headers['x-casan-project']) || 'default',
tenant: firstHeader(headers['x-casan-tenant']) || 'default',
};
}
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Headers, Inject, Post } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { KillSwitchMutation, KillSwitchService } from './kill-switch.service.js';
@Controller('api/v1/kill-switch')
export class KillSwitchController {
constructor(@Inject(KillSwitchService) private readonly svc: KillSwitchService) {}
@Get()
status(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.status(actorFromHeaders(headers)));
}
@Post('engage')
engage(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: KillSwitchMutation) {
return ok(this.svc.engage(body, actorFromHeaders(headers)));
}
@Post('clear')
clear(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: KillSwitchMutation) {
return ok(this.svc.clear(body, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { KillSwitchController } from './kill-switch.controller.js';
import { KillSwitchService } from './kill-switch.service.js';
@Module({
controllers: [KillSwitchController],
providers: [KillSwitchService],
})
export class KillSwitchModule {}
@@ -0,0 +1,122 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
import type { SettingsActor } from '../settings/settings.service.js';
export interface KillSwitchMutation {
scope: string;
id: string;
reason: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const KS_CLI = join(HARNESS_BIN, 'kill-switch.sh');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const SCOPES = new Set(['project', 'model', 'provider', 'tenant', 'global']);
interface CommandResult {
stdout: string;
stderr: string;
}
function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try {
const stdout = execFileSync(command, args, {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
const stderr = String(err?.stderr ?? '').trim();
const stdout = String(err?.stdout ?? '').trim();
const status = Number(err?.status ?? 1);
const e = new Error(stderr || stdout || `command failed: ${command}`);
(e as any).status = status;
(e as any).stderr = stderr;
(e as any).stdout = stdout;
throw e;
}
}
function parseStatus(raw: string) {
const engaged: any[] = [];
let count = 0;
for (const line of raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)) {
if (line.startsWith('{')) {
try {
engaged.push(JSON.parse(line));
} catch {
engaged.push({ raw: line });
}
continue;
}
const m = line.match(/^KILL_SWITCH_STATUS engaged=(\d+)/);
if (m) count = Number(m[1]);
}
return { count, engaged };
}
@Injectable()
export class KillSwitchService {
status(actor: SettingsActor) {
this.requireRbac(actor, 'monitoring', 'read');
const res = runFile('bash', [KS_CLI, 'status'], { CASAN_ACTOR: actor.actor });
return parseStatus(res.stdout);
}
engage(input: KillSwitchMutation, actor: SettingsActor) {
this.validate(input);
this.requireRbac(actor, 'kill_switch', 'engage');
const res = runFile('bash', [KS_CLI, 'engage', input.scope, input.id, input.reason], { CASAN_ACTOR: actor.actor });
return { output: res.stdout, status: this.status(actor) };
}
clear(input: KillSwitchMutation, actor: SettingsActor) {
this.validate(input);
this.requireRbac(actor, 'kill_switch', 'clear');
try {
const res = runFile('bash', [KS_CLI, 'clear', input.scope, input.id, input.reason], { CASAN_ACTOR: actor.actor });
return { output: res.stdout, status: this.status(actor) };
} catch (err: any) {
if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'KILL_SWITCH_CLEAR_APPROVAL_REQUIRED');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private validate(input: KillSwitchMutation) {
if (!input.scope || !input.id || !input.reason) {
throw new ForbiddenException('KILL_SWITCH_DENY scope/id/reason required');
}
if (!SCOPES.has(input.scope)) {
throw new ForbiddenException(`KILL_SWITCH_DENY invalid scope ${input.scope}`);
}
}
private requireRbac(actor: SettingsActor, resource: string, action: string) {
try {
runFile('python3', [
RBAC_CLI,
'check',
'--role',
actor.role,
'--resource',
resource,
'--action',
action,
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
]);
} catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'RBAC_DENY');
}
}
}
@@ -4,9 +4,9 @@ import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module.js';
import { APP_ROOT } from './common/app-root.js';
// Read-only Ops Console API (Plan-13 Track 1). Binds loopback by default and refuses a
// non-loopback bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 — same posture as
// dashboard-server.py. Management/auth are out of scope (future tracks).
// Ops Console API (Plan-13). Binds loopback by default and refuses a non-loopback
// bind under CASAN_PROFILE=prod / CASAN_CP_STRICT=1 unless an authenticated reverse
// proxy is explicitly configured to overwrite identity headers.
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
@@ -14,9 +14,10 @@ async function bootstrap() {
const port = Number(process.env.CP_PORT ?? 3010);
let host = process.env.CP_BIND ?? '127.0.0.1';
const strict = process.env.CASAN_PROFILE === 'prod' || process.env.CASAN_CP_STRICT === '1';
if (strict && host !== '127.0.0.1' && host !== 'localhost') {
// read-only console must not expose telemetry off-loopback without the prod hardening
// (TLS/OIDC) that is Track 4 — fail closed.
const authProxy = process.env.CASAN_CP_TRUST_AUTH_PROXY === '1';
if (strict && host !== '127.0.0.1' && host !== 'localhost' && !authProxy) {
// The console must not expose telemetry/management off-loopback without TLS/OIDC
// at the reverse proxy, which must overwrite X-CASAN-* identity headers.
// eslint-disable-next-line no-console
console.error(`CP_REFUSE_NONLOOPBACK host=${host} (set up TLS/OIDC per Plan-13 Track 4 first)`);
process.exit(2);
@@ -24,6 +25,6 @@ async function bootstrap() {
await app.listen(port, host);
// eslint-disable-next-line no-console
console.log(`CASAN Ops Console API (read-only) http://${host}:${port}/api/v1 app_root=${APP_ROOT}`);
console.log(`CASAN Ops Console API http://${host}:${port}/api/v1 app_root=${APP_ROOT}`);
}
bootstrap();
@@ -0,0 +1,24 @@
import { Body, Controller, Get, Headers, Inject, Post } from '@nestjs/common';
import { ok } from '../common/api-response.js';
import { actorFromHeaders } from '../common/auth-context.js';
import { SettingMutation, SettingsService } from './settings.service.js';
@Controller('api/v1/settings')
export class SettingsController {
constructor(@Inject(SettingsService) private readonly svc: SettingsService) {}
@Get()
list(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.svc.list(actorFromHeaders(headers)));
}
@Post()
set(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: SettingMutation) {
return ok(this.svc.set(body, actorFromHeaders(headers)));
}
@Post('rollback')
rollback(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: SettingMutation) {
return ok(this.svc.rollback(body, actorFromHeaders(headers)));
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SettingsController } from './settings.controller.js';
import { SettingsService } from './settings.service.js';
@Module({
controllers: [SettingsController],
providers: [SettingsService],
})
export class SettingsModule {}
@@ -0,0 +1,175 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
export interface SettingsActor {
actor: string;
role: string;
project: string;
tenant: string;
}
export interface SettingMutation {
key: string;
value?: unknown;
reason: string;
approval?: string;
}
interface CommandResult {
stdout: string;
stderr: string;
}
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
const CP_CLI = join(HARNESS_BIN, 'control-plane-settings.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
function runPython(script: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try {
const stdout = execFileSync('python3', [script, ...args], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
const stderr = String(err?.stderr ?? '').trim();
const stdout = String(err?.stdout ?? '').trim();
const status = Number(err?.status ?? 1);
const e = new Error(stderr || stdout || `command failed: ${script}`);
(e as any).status = status;
(e as any).stderr = stderr;
(e as any).stdout = stdout;
throw e;
}
}
function parseJson<T>(raw: string, fallback: T): T {
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
@Injectable()
export class SettingsService {
list(actor: SettingsActor) {
this.requireRbac(actor, 'read', false);
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
const settings = parseJson<Record<string, any>>(runPython(CP_CLI, ['get-all']).stdout, {});
const audit = parseJson<any[]>(runPython(CP_CLI, ['get-audit']).stdout, []);
const auditVerify = this.verifyAudit();
return {
actor,
capabilities: {
can_write_standard: this.canRbac(actor, 'write', false),
can_write_sensitive: this.canRbac(actor, 'write', true),
can_rollback: this.canRbac(actor, 'write', false),
},
policy,
settings,
audit: audit.slice(-20).reverse(),
audit_verify: auditVerify,
};
}
set(input: SettingMutation, actor: SettingsActor) {
if (!input.key || input.value === undefined || !input.reason) {
throw new ForbiddenException('SETTINGS_DENY key/value/reason required');
}
const sensitive = this.isSensitive(input.key);
this.requireRbac(actor, 'write', sensitive);
try {
const res = runPython(CP_CLI, [
'set',
input.key,
JSON.stringify(input.value),
'--actor',
actor.actor,
'--reason',
input.reason,
'--approval',
input.approval ?? '',
]);
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
} catch (err: any) {
if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'APPROVAL_REQUIRED');
if (Number(err.status) === 2) throw new ForbiddenException(err.stderr || 'SETTING_NOT_ALLOWED');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
rollback(input: SettingMutation, actor: SettingsActor) {
if (!input.key || !input.reason) {
throw new ForbiddenException('SETTINGS_DENY key/reason required');
}
const sensitive = this.isSensitive(input.key);
this.requireRbac(actor, 'write', sensitive);
try {
const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason]);
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
} catch (err: any) {
if (Number(err.status) === 4) throw new ForbiddenException(err.stderr || 'NO_PRIOR_VERSION');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private isSensitive(key: string): boolean {
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
return Boolean(policy[key]?.securitySensitive);
}
private canRbac(actor: SettingsActor, action: 'read' | 'write', sensitive: boolean): boolean {
try {
this.checkRbac(actor, action, sensitive);
return true;
} catch {
return false;
}
}
private requireRbac(actor: SettingsActor, action: 'read' | 'write', sensitive: boolean) {
try {
this.checkRbac(actor, action, sensitive);
} catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'RBAC_DENY');
}
}
private checkRbac(actor: SettingsActor, action: 'read' | 'write', sensitive: boolean) {
const args = [
'check',
'--role',
actor.role,
'--resource',
'settings',
'--action',
action,
'--role-project',
actor.project,
'--target-project',
actor.project,
'--role-tenant',
actor.tenant,
'--target-tenant',
actor.tenant,
];
if (sensitive) args.push('--sensitive');
return runPython(RBAC_CLI, args);
}
private verifyAudit() {
try {
const res = runPython(CP_CLI, ['verify-audit']);
return { ok: true, output: res.stdout };
} catch (err: any) {
return { ok: false, output: err.stderr || err.stdout || err.message };
}
}
}
@@ -61,4 +61,9 @@ export class TelemetryController {
cost() {
return ok(this.svc.cost());
}
@Get('command')
command() {
return ok(this.svc.commandCenter());
}
}
@@ -1,8 +1,10 @@
// Read-only aggregations over CASAN harness telemetry. Formulas mirror
// packages/casan-harness/tests/generate-agentops-dashboard.py; all numbers come from real
// on-disk feeds. Missing feeds degrade to zero/empty + a `stale` flag — never fabricated.
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { Injectable } from '@nestjs/common';
import { PATHS, isStale, metricsAgeSeconds, STALE_AFTER_S } from '../common/app-root.js';
import { APP_ROOT, PATHS, isStale, metricsAgeSeconds, STALE_AFTER_S } from '../common/app-root.js';
import { readJsonl, readJson, readHead, readTrace } from './telemetry.reader.js';
type Row = Record<string, any>;
@@ -10,6 +12,46 @@ const num = (v: any) => (typeof v === 'number' && isFinite(v) ? v : 0);
const sum = (rows: Row[], k: string) => rows.reduce((a, r) => a + num(r[k]), 0);
const count = (rows: Row[], pred: (r: Row) => boolean) => rows.reduce((a, r) => a + (pred(r) ? 1 : 0), 0);
const recent = (rows: Row[], n: number) => rows.slice(-n).reverse();
const arr = (v: any): any[] => (Array.isArray(v) ? v : []);
const pct = (part: number, total: number) => (total > 0 ? Math.round((part / total) * 1000) / 10 : 0);
function artifact(path: string, source: string, verifiedWhenPresent = true) {
const present = existsSync(path);
const runAt = present ? statSync(path).mtime.toISOString() : null;
return {
source,
artifact_path: relative(APP_ROOT, path) || '.',
commit: gitCommit(),
run_at: runAt,
verified: present && verifiedWhenPresent,
status: present ? (verifiedWhenPresent ? 'verified' : 'present_unverified') : 'missing',
};
}
function gitCommit(): string | null {
try {
const head = readFileSync(join(APP_ROOT, '.git', 'HEAD'), 'utf8').trim();
if (/^[0-9a-f]{40}$/i.test(head)) return head;
const ref = head.match(/^ref:\s+(.+)$/)?.[1];
if (!ref) return null;
const value = readFileSync(join(APP_ROOT, '.git', ref), 'utf8').trim();
return /^[0-9a-f]{40}$/i.test(value) ? value : null;
} catch {
return null;
}
}
function widget(
id: string,
title: string,
status: string,
summary: string,
metrics: Record<string, any>,
envelope: ReturnType<typeof artifact>,
evidence: Record<string, any> = {},
) {
return { id, title, status, summary, metrics, envelope, evidence };
}
@Injectable()
export class TelemetryService {
@@ -134,4 +176,163 @@ export class TelemetryService {
business_kpi: readJson(PATHS.businessKpi),
};
}
commandCenter() {
const metrics = readJsonl(PATHS.metrics);
const provider = readJsonl(PATHS.providerUsage);
const audit = readJsonl(PATHS.audit);
const security = readJsonl(PATHS.security);
const incidents = readJsonl(PATHS.incidents);
const actionGate = readJsonl(PATHS.actionGate);
const traceability = readJson(PATHS.traceability) as any;
const kpi = readJson(PATHS.businessKpi) as any;
const drift = readJson(PATHS.drift) as any;
const approvalStore = (readJson(PATHS.approvalInbox) as any) ?? { proposals: [], oversight: [] };
const proposals = arr(approvalStore.proposals);
const oversight = arr(approvalStore.oversight);
const head = readHead(PATHS.auditHead);
const passedReq = num(traceability?.summary?.passed);
const failedReq = num(traceability?.summary?.failed);
const totalReq = num(traceability?.summary?.requirements) || passedReq + failedReq;
const blockedSecurity = count(security, (s) => ['blocked', 'BLOCK', 'DENY'].includes(String(s.status ?? s.decision)));
const pendingApprovals = count(proposals, (p) => p.status === 'pending');
const approvedApprovals = count(proposals, (p) => p.status === 'approved' || p.status === 'auto_allowed');
const killSwitchScopes = [...new Set(incidents.filter((i) => i.action === 'kill_switch_engaged').map((i) => String(i.scope ?? 'unknown')))];
const kpis = arr(kpi?.kpis);
const kpisMet = count(kpis, (k) => k.target_met === true);
const auditVerified = audit.length > 0 && Boolean(head);
const selfImproveRelated = proposals.filter((p) => String(p.action ?? p.type ?? '').includes('improve'));
const widgets = {
maturity: widget(
'maturity',
'Maturity gauge + H1-H7 radar',
metrics.length || audit.length || security.length ? 'ok' : 'no_data',
`${metrics.length} runs, ${audit.length} governance records, ${security.length} security verdicts`,
{
runs: metrics.length,
governance_records: audit.length,
security_verdicts: security.length,
action_blocks: count(actionGate, (a) => a.outcome === 'BLOCK'),
drift_report: drift ? 'present' : 'missing',
},
artifact(PATHS.scoringReport, 'phase3-real-run-scoring.md'),
{ harness_signals: this.overview().harness_signals },
),
hitl: widget(
'hitl',
'Human-in-the-loop panel',
pendingApprovals > 0 ? 'warn' : (proposals.length > 0 ? 'ok' : 'no_data'),
`${pendingApprovals} pending, ${approvedApprovals} approved/auto-allowed`,
{
pending: pendingApprovals,
approved_or_auto: approvedApprovals,
rejected: count(proposals, (p) => p.status === 'rejected'),
oversight_events: oversight.length,
},
artifact(PATHS.approvalInbox, 'approval-inbox.json', oversight.length === 0 || Boolean(oversight[oversight.length - 1]?.hash)),
{ recent_oversight: recent(oversight, 5) },
),
kill_switch: widget(
'kill_switch',
'Kill-switch and guardrails',
killSwitchScopes.length > 0 ? 'fail' : 'ok',
killSwitchScopes.length ? `${killSwitchScopes.length} engaged scope(s)` : 'No active kill-switch incident in telemetry',
{
engaged_scopes: killSwitchScopes,
incidents: incidents.length,
critical: count(incidents, (i) => i.severity === 'CRIT'),
},
artifact(PATHS.incidents, 'incidents.jsonl'),
{ recent_incidents: recent(incidents, 5) },
),
traceability: widget(
'traceability',
'Traceability Sankey',
failedReq > 0 ? 'fail' : (totalReq > 0 ? 'ok' : 'no_data'),
totalReq > 0 ? `${passedReq}/${totalReq} requirements pass` : 'No traceability artifact found',
{
requirements: totalReq,
passed: passedReq,
failed: failedReq,
coverage_pct: pct(passedReq, totalReq),
},
artifact(PATHS.traceability, 'traceability-matrix.json'),
{ summary: traceability?.summary ?? null },
),
security: widget(
'security',
'Security posture',
blockedSecurity > 0 ? 'ok' : (security.length > 0 ? 'warn' : 'no_data'),
`${blockedSecurity}/${security.length} security verdicts blocked`,
{
verdicts: security.length,
blocked: blockedSecurity,
block_rate_pct: pct(blockedSecurity, security.length),
},
artifact(PATHS.security, 'security.jsonl'),
{ recent_security: recent(security, 5) },
),
finops: widget(
'finops',
'Token economy / FinOps',
provider.length > 0 || kpis.length > 0 ? 'ok' : 'no_data',
`$${sum(provider, 'cost_usd').toFixed(4)} provider cost, ${sum(provider, 'total_tokens')} tokens`,
{
provider_cost: sum(provider, 'cost_usd'),
provider_tokens: sum(provider, 'total_tokens'),
kpis: kpis.length,
kpis_met: kpisMet,
},
artifact(PATHS.providerUsage, 'provider-usage.jsonl'),
{ business_kpi: { status: kpi?.status ?? null, kpis_met: kpisMet, kpis: kpis.length } },
),
certification: widget(
'certification',
'Certified-run seal',
auditVerified ? 'ok' : 'warn',
auditVerified ? `Audit head present with ${audit.length} record(s)` : 'Audit chain head missing or empty',
{
audit_records: audit.length,
audit_head_present: Boolean(head),
last_decision: audit.length ? audit[audit.length - 1].decision ?? null : null,
},
artifact(PATHS.auditHead, 'audit-head.txt', auditVerified),
{ audit_head: head, recent_audit: recent(audit, 5) },
),
self_improve: widget(
'self_improve',
'Self-improve pipeline',
selfImproveRelated.length > 0 ? 'warn' : (existsSync(PATHS.selfImprove) ? 'ok' : 'no_data'),
selfImproveRelated.length ? `${selfImproveRelated.length} self-improve proposal(s) in inbox` : 'Core self-improve primitive is present; no proposal artifact queued',
{
inbox_related: selfImproveRelated.length,
primitive_present: existsSync(PATHS.selfImprove),
},
artifact(PATHS.selfImprove, 'self-improve.py'),
{ proposals: recent(selfImproveRelated, 5) },
),
};
const ticker = [
...recent(oversight, 10).map((e) => ({ at: e.at ?? e.created_at ?? null, kind: 'oversight', text: `${e.event ?? 'oversight'} ${e.proposal_id ?? ''}`.trim(), status: e.status ?? 'event' })),
...recent(incidents, 10).map((e) => ({ at: e.timestamp ?? null, kind: 'incident', text: `${e.event ?? 'incident'} ${e.scope ?? ''}`.trim(), status: e.severity ?? 'event' })),
...recent(audit, 10).map((e) => ({ at: e.timestamp ?? null, kind: 'audit', text: `${e.action ?? 'action'} ${e.decision ?? ''}`.trim(), status: e.decision ?? 'event' })),
].sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, 20);
return {
...this.freshness(),
generated_at: new Date().toISOString(),
widgets,
briefing: [
{ label: 'Maturity', value: widgets.maturity.summary, status: widgets.maturity.status, widget: 'maturity' },
{ label: 'Control', value: widgets.hitl.summary, status: widgets.hitl.status, widget: 'hitl' },
{ label: 'Safety', value: widgets.security.summary, status: widgets.security.status, widget: 'security' },
{ label: 'Economy', value: widgets.finops.summary, status: widgets.finops.status, widget: 'finops' },
{ label: 'Certification', value: widgets.certification.summary, status: widgets.certification.status, widget: 'certification' },
],
ticker,
};
}
}
@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ForbiddenException } from '@nestjs/common';
import { ApprovalsService } from '../src/approvals/approvals.service.js';
const projectAdmin = { actor: 'alice', role: 'project-admin', project: 'default', tenant: 'default' };
const approver = { actor: 'bob', role: 'approver', project: 'default', tenant: 'default' };
const orgAdmin = { actor: 'root', role: 'org-admin', project: 'default', tenant: 'default' };
function withTempGovernance(fn: (paths: { inbox: string; store: string }) => void) {
const prevInbox = process.env.CASAN_APPROVAL_INBOX_FILE;
const prevStore = process.env.CASAN_CP_STORE_FILE;
const prevKeyDir = process.env.CASAN_CP_KEY_DIR;
const prevPub = process.env.CASAN_CP_PUB;
const work = mkdtempSync(join(tmpdir(), 'cp-approval-'));
process.env.CASAN_APPROVAL_INBOX_FILE = join(work, 'approval-inbox.json');
process.env.CASAN_CP_STORE_FILE = join(work, 'settings.json');
process.env.CASAN_CP_KEY_DIR = join(work, 'keys');
process.env.CASAN_CP_PUB = join(work, 'cp.pub');
try {
fn({ inbox: process.env.CASAN_APPROVAL_INBOX_FILE, store: process.env.CASAN_CP_STORE_FILE });
} finally {
if (prevInbox === undefined) delete process.env.CASAN_APPROVAL_INBOX_FILE;
else process.env.CASAN_APPROVAL_INBOX_FILE = prevInbox;
if (prevStore === undefined) delete process.env.CASAN_CP_STORE_FILE;
else process.env.CASAN_CP_STORE_FILE = prevStore;
if (prevKeyDir === undefined) delete process.env.CASAN_CP_KEY_DIR;
else process.env.CASAN_CP_KEY_DIR = prevKeyDir;
if (prevPub === undefined) delete process.env.CASAN_CP_PUB;
else process.env.CASAN_CP_PUB = prevPub;
}
}
test('approval inbox submit -> approve applies governed setting and writes oversight', () => {
withTempGovernance(({ inbox, store }) => {
const svc = new ApprovalsService();
const submitted = svc.submit({
action: 'settings.write',
target: 'security.strict',
risk: 'high',
sensitive: true,
reason: 'tighten strict mode',
payload: { key: 'security.strict', value: true },
}, projectAdmin) as any;
assert.equal(submitted.proposal.status, 'pending');
assert.equal(submitted.proposal.delegation.requires_approval, true);
const decided = svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'approved by reviewer' }, approver) as any;
assert.equal(decided.proposal.status, 'approved');
assert.equal(decided.applied.value, true);
const inboxRaw = JSON.parse(readFileSync(inbox, 'utf8'));
assert.equal(inboxRaw.oversight.length, 2);
assert.equal(inboxRaw.oversight[1].event, 'proposal_approved');
const storeRaw = JSON.parse(readFileSync(store, 'utf8'));
assert.equal(storeRaw.settings['security.strict'].value, true);
assert.equal(storeRaw.audit.length, 1);
});
});
test('approval inbox enforces separation of duties', () => {
withTempGovernance(() => {
const svc = new ApprovalsService();
const submitted = svc.submit({
action: 'settings.write',
target: 'compression.enabled',
risk: 'standard',
reason: 'change by root',
payload: { key: 'compression.enabled', value: true },
}, orgAdmin) as any;
assert.throws(
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'self approve' }, orgAdmin),
ForbiddenException,
);
});
});
@@ -0,0 +1,30 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { actorFromHeaders } from '../src/common/auth-context.js';
test('auth context keeps local explicit roles for dev', () => {
const actor = actorFromHeaders({
'x-casan-actor': 'alice',
'x-casan-role': 'project-admin',
'x-casan-project': 'okr',
'x-casan-tenant': 'tenant-a',
});
assert.deepEqual(actor, { actor: 'alice', role: 'project-admin', project: 'okr', tenant: 'tenant-a' });
});
test('auth context maps IdP group claim to RBAC role', () => {
const actor = actorFromHeaders({
'x-auth-request-user': 'bob@example.com',
'x-auth-request-groups': 'engineering,casan-approver',
});
assert.equal(actor.actor, 'bob@example.com');
assert.equal(actor.role, 'approver');
});
test('auth context fails closed to viewer for unknown role claim', () => {
const actor = actorFromHeaders({
'x-auth-request-user': 'eve@example.com',
'x-auth-request-groups': 'unknown-admin',
});
assert.equal(actor.role, 'viewer');
});
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ForbiddenException } from '@nestjs/common';
import { KillSwitchService } from '../src/kill-switch/kill-switch.service.js';
const viewer = { actor: 'viewer-1', role: 'viewer', project: 'default', tenant: 'default' };
const operator = { actor: 'operator-1', role: 'operator', project: 'default', tenant: 'default' };
const admin = { actor: 'admin-1', role: 'org-admin', project: 'default', tenant: 'default' };
function withTempKillSwitch(fn: () => void) {
const prev = process.env.CASAN_KILLSWITCH_DIR;
process.env.CASAN_KILLSWITCH_DIR = mkdtempSync(join(tmpdir(), 'cp-ks-'));
try {
fn();
} finally {
if (prev === undefined) delete process.env.CASAN_KILLSWITCH_DIR;
else process.env.CASAN_KILLSWITCH_DIR = prev;
}
}
test('kill-switch status is readable by viewer', () => {
withTempKillSwitch(() => {
const svc = new KillSwitchService();
const status = svc.status(viewer);
assert.equal(status.count, 0);
assert.deepEqual(status.engaged, []);
});
});
test('viewer cannot engage kill-switch', () => {
withTempKillSwitch(() => {
const svc = new KillSwitchService();
assert.throws(
() => svc.engage({ scope: 'project', id: 'p1', reason: 'viewer should fail' }, viewer),
ForbiddenException,
);
});
});
test('operator can engage and org-admin can clear kill-switch', () => {
withTempKillSwitch(() => {
const svc = new KillSwitchService();
const engaged = svc.engage({ scope: 'project', id: 'p1', reason: 'incident drill' }, operator) as any;
assert.equal(engaged.status.count, 1);
assert.equal(engaged.status.engaged[0].scope, 'project');
assert.equal(engaged.status.engaged[0].actor, 'operator-1');
assert.throws(
() => svc.clear({ scope: 'project', id: 'p1', reason: 'viewer clear should fail' }, viewer),
ForbiddenException,
);
const cleared = svc.clear({ scope: 'project', id: 'p1', reason: 'resolved' }, admin) as any;
assert.equal(cleared.status.count, 0);
});
});
@@ -0,0 +1,80 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ForbiddenException } from '@nestjs/common';
import { SettingsService } from '../src/settings/settings.service.js';
const viewer = { actor: 'viewer-1', role: 'viewer', project: 'default', tenant: 'default' };
const admin = { actor: 'admin-1', role: 'org-admin', project: 'default', tenant: 'default' };
function withTempStore(fn: (storeFile: string) => void) {
const prev = process.env.CASAN_CP_STORE_FILE;
const storeFile = join(mkdtempSync(join(tmpdir(), 'cp-settings-')), 'settings.json');
process.env.CASAN_CP_STORE_FILE = storeFile;
try {
fn(storeFile);
} finally {
if (prev === undefined) delete process.env.CASAN_CP_STORE_FILE;
else process.env.CASAN_CP_STORE_FILE = prev;
}
}
test('settings list is readable by viewer and exposes policy/capabilities', () => {
withTempStore(() => {
const svc = new SettingsService();
const res = svc.list(viewer) as any;
assert.ok(res.policy['compression.enabled']);
assert.equal(res.capabilities.can_write_standard, false);
assert.equal(res.capabilities.can_write_sensitive, false);
});
});
test('viewer cannot write settings', () => {
withTempStore(() => {
const svc = new SettingsService();
assert.throws(
() => svc.set({ key: 'compression.enabled', value: true, reason: 'test viewer deny' }, viewer),
ForbiddenException,
);
});
});
test('org-admin writes and rolls back through governed store with audit', () => {
withTempStore((storeFile) => {
const svc = new SettingsService();
const first = svc.set({ key: 'compression.enabled', value: true, reason: 'enable compression' }, admin) as any;
const second = svc.set({ key: 'compression.enabled', value: false, reason: 'disable compression' }, admin) as any;
const rolled = svc.rollback({ key: 'compression.enabled', reason: 'rollback compression' }, admin) as any;
assert.equal(first.setting.version, 1);
assert.equal(second.setting.version, 2);
assert.equal(rolled.setting.version, 3);
assert.equal(rolled.setting.value, true);
assert.equal(rolled.audit_verify.ok, true);
assert.ok(existsSync(storeFile));
const raw = JSON.parse(readFileSync(storeFile, 'utf8'));
assert.equal(raw.audit.length, 3);
assert.equal(raw.audit[0].action, 'set');
assert.equal(raw.audit[2].action, 'rollback');
});
});
test('sensitive setting requires approval even for org-admin in dev gate', () => {
withTempStore(() => {
const svc = new SettingsService();
assert.throws(
() => svc.set({ key: 'security.strict', value: false, reason: 'loosen strict mode' }, admin),
ForbiddenException,
);
const res = svc.set({
key: 'security.strict',
value: true,
reason: 'tighten strict mode with approval token',
approval: 'approved-in-dev',
}, admin) as any;
assert.equal(res.setting.value, true);
});
});
@@ -47,6 +47,30 @@ test('security()/governance()/cost() return objects with expected keys', () => {
assert.ok('provider_tokens' in (svc.cost() as any));
});
test('commandCenter() returns evidence-backed widget contract', () => {
const svc = new TelemetryService();
const command = svc.commandCenter() as any;
const ids = Object.keys(command.widgets);
assert.deepEqual(ids.sort(), [
'certification',
'finops',
'hitl',
'kill_switch',
'maturity',
'security',
'self_improve',
'traceability',
]);
for (const w of Object.values(command.widgets) as any[]) {
assert.ok(w.id && w.title && w.status);
assert.ok(w.envelope && typeof w.envelope.artifact_path === 'string');
assert.ok(['verified', 'present_unverified', 'missing'].includes(w.envelope.status));
assert.equal(typeof w.envelope.verified, 'boolean');
}
assert.ok(Array.isArray(command.briefing));
assert.ok(Array.isArray(command.ticker));
});
test('freshness helpers behave (age is number|null, isStale is boolean)', () => {
const age = metricsAgeSeconds();
assert.ok(age === null || typeof age === 'number');
@@ -6,6 +6,10 @@ import { Governance } from './pages/Governance';
import { Security } from './pages/Security';
import { Incidents } from './pages/Incidents';
import { Traceability } from './pages/Traceability';
import { Settings } from './pages/Settings';
import { FinOps } from './pages/FinOps';
import { Approvals } from './pages/Approvals';
import { CommandCenter } from './pages/CommandCenter';
export default function App() {
return (
@@ -17,6 +21,10 @@ export default function App() {
<Route path="/security" element={<Security />} />
<Route path="/incidents" element={<Incidents />} />
<Route path="/traceability" element={<Traceability />} />
<Route path="/finops" element={<FinOps />} />
<Route path="/approvals" element={<Approvals />} />
<Route path="/settings" element={<Settings />} />
<Route path="/command" element={<CommandCenter />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
@@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom';
const NAV = [
['/', 'Overview'], ['/runs', 'Runs'], ['/governance', 'Governance'],
['/security', 'Security'], ['/incidents', 'Incidents'], ['/traceability', 'Traceability'],
['/finops', 'FinOps'], ['/approvals', 'Approvals'], ['/settings', 'Settings'], ['/command', 'Command'],
];
export function Sidebar() {
return (
@@ -1,5 +1,5 @@
// Single axios client for the read-only Ops Console API. Mirrors the OKR app's api.ts:
// relative baseURL, unwrap response.data.data. All GET (read-only).
// Single axios client for the Ops Console API. Mirrors the OKR app's api.ts:
// relative baseURL, unwrap response.data.data.
import axios from 'axios';
const client = axios.create({
@@ -11,6 +11,16 @@ async function get<T>(path: string): Promise<T> {
return res.data.data as T;
}
async function getWithHeaders<T>(path: string, headers: Record<string, string>): Promise<T> {
const res = await client.get(`/${path}`, { headers });
return res.data.data as T;
}
async function post<T>(path: string, body: unknown, headers: Record<string, string>): Promise<T> {
const res = await client.post(`/${path}`, body, { headers });
return res.data.data as T;
}
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
export interface Overview extends Freshness {
@@ -23,6 +33,73 @@ export interface Overview extends Freshness {
audit_chain: { records: number; head: string | null; last_decision: string | null };
}
export interface SettingsActor {
actor: string;
role: string;
project: string;
tenant: string;
}
export interface KillSwitchState {
count: number;
engaged: Array<{ scope: string; id: string; reason?: string; engaged_at?: string; actor?: string; raw?: string }>;
}
export interface ApprovalsState {
count: number;
proposals: any[];
oversight: any[];
audit_verify: { ok: boolean; output: string };
}
export interface CommandEnvelope {
source: string;
artifact_path: string;
commit: string | null;
run_at: string | null;
verified: boolean;
status: 'verified' | 'present_unverified' | 'missing';
}
export interface CommandWidget {
id: string;
title: string;
status: string;
summary: string;
metrics: Record<string, unknown>;
envelope: CommandEnvelope;
evidence: Record<string, unknown>;
}
export interface CommandCenterState extends Freshness {
generated_at: string;
widgets: Record<string, CommandWidget>;
briefing: Array<{ label: string; value: string; status: string; widget: string }>;
ticker: Array<{ at: string | null; kind: string; text: string; status: string }>;
}
export interface SettingsState {
actor: SettingsActor;
capabilities: {
can_write_standard: boolean;
can_write_sensitive: boolean;
can_rollback: boolean;
};
policy: Record<string, { securitySensitive: boolean; description: string }>;
settings: Record<string, { value: unknown; version: number; updatedAt: string; actor: string; reason: string }>;
audit: any[];
audit_verify: { ok: boolean; output: string };
}
function actorHeaders(actor: SettingsActor): Record<string, string> {
return {
'x-casan-actor': actor.actor,
'x-casan-role': actor.role,
'x-casan-project': actor.project,
'x-casan-tenant': actor.tenant,
};
}
export const api = {
overview: () => get<Overview>('overview'),
runs: (limit = 50) => get<Freshness & { count: number; runs: any[] }>(`runs?limit=${limit}`),
@@ -31,6 +108,22 @@ export const api = {
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
traceability: () => get<Freshness & { matrix: any }>('traceability'),
cost: () => get<Freshness & { provider_tokens: number; provider_cost: number; by_provider: any[]; business_kpi: any }>('cost'),
command: () => get<CommandCenterState>('command'),
settings: (actor: SettingsActor) => getWithHeaders<SettingsState>('settings', actorHeaders(actor)),
setSetting: (actor: SettingsActor, body: { key: string; value: unknown; reason: string; approval?: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings', body, actorHeaders(actor)),
rollbackSetting: (actor: SettingsActor, body: { key: string; reason: string }) =>
post<{ key: string; setting: any; audit_verify: { ok: boolean; output: string } }>('settings/rollback', body, actorHeaders(actor)),
killSwitch: (actor: SettingsActor) => getWithHeaders<KillSwitchState>('kill-switch', actorHeaders(actor)),
engageKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
post<{ output: string; status: KillSwitchState }>('kill-switch/engage', body, actorHeaders(actor)),
clearKillSwitch: (actor: SettingsActor, body: { scope: string; id: string; reason: string }) =>
post<{ output: string; status: KillSwitchState }>('kill-switch/clear', body, actorHeaders(actor)),
approvals: (actor: SettingsActor, status = 'pending') => getWithHeaders<ApprovalsState>(`approvals?status=${status}`, actorHeaders(actor)),
submitApproval: (actor: SettingsActor, body: { action: string; target: string; risk?: string; sensitive?: boolean; reason: string; payload?: Record<string, unknown> }) =>
post<{ proposal: any; audit_verify: { ok: boolean; output: string } }>('approvals/submit', body, actorHeaders(actor)),
decideApproval: (actor: SettingsActor, body: { id: string; decision: 'approve' | 'reject'; reason: string }) =>
post<{ proposal: any; applied: any; audit_verify: { ok: boolean; output: string } }>('approvals/decide', body, actorHeaders(actor)),
};
// Health is raw (not enveloped) + carries HTTP status.
@@ -0,0 +1,174 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'project-admin', 'approver', 'org-admin', 'auditor'];
const STATUS = ['pending', 'approved', 'rejected', 'auto_allowed', 'all'];
function parseValue(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
export function Approvals() {
const queryClient = useQueryClient();
const [actor, setActor] = useState<SettingsActor>({ actor: 'alice', role: 'project-admin', project: 'default', tenant: 'default' });
const [status, setStatus] = useState('pending');
const [key, setKey] = useState('security.strict');
const [value, setValue] = useState('true');
const [sensitive, setSensitive] = useState(true);
const [reason, setReason] = useState('review requested from Control Panel');
const [decisionReason, setDecisionReason] = useState('reviewed in approval inbox');
const [message, setMessage] = useState<string | null>(null);
const inbox = useQuery({
queryKey: ['approvals', actor, status],
queryFn: () => api.approvals(actor, status),
retry: false,
});
const submit = useMutation({
mutationFn: () => api.submitApproval(actor, {
action: 'settings.write',
target: key,
risk: sensitive ? 'high' : 'standard',
sensitive,
reason,
payload: { key, value: parseValue(value) },
}),
onSuccess: (res) => {
setMessage(`submitted ${res.proposal.id} status=${res.proposal.status}`);
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'submit failed'),
});
const decide = useMutation({
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) =>
api.decideApproval(actor, { id, decision, reason: decisionReason }),
onSuccess: (res) => {
setMessage(`${res.proposal.status} ${res.proposal.id}${res.applied ? ` applied v${res.applied.version}` : ''}`);
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'decision failed'),
});
if (inbox.isLoading || !inbox.data) return <div className="text-gray-500">Loading…</div>;
return (
<>
<Card title="Approval identity" right={<StatusBadge value={inbox.data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 text-sm">
<label className="space-y-1">
<span className="text-gray-500">Actor</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Role</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Project</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Tenant</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Status</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={status} onChange={(e) => setStatus(e.target.value)}>
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
</div>
</Card>
<Card title="Submit settings proposal">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<label className="space-y-1">
<span className="text-gray-500">Setting key</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={key} onChange={(e) => setKey(e.target.value)} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Value (JSON or string)</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={value} onChange={(e) => setValue(e.target.value)} />
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-gray-500">Reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={sensitive} onChange={(e) => setSensitive(e.target.checked)} />
<span className="text-gray-700">Security-sensitive / high risk</span>
</label>
<div>
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
disabled={submit.isPending} onClick={() => submit.mutate()}>
Submit proposal
</button>
</div>
</div>
{message && <div className="mt-3 rounded border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">{message}</div>}
</Card>
<Card title="Inbox">
<div className="space-y-3">
<label className="block space-y-1 text-sm">
<span className="text-gray-500">Decision reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={decisionReason} onChange={(e) => setDecisionReason(e.target.value)} />
</label>
{inbox.data.proposals.map((p) => (
<div key={p.id} className="rounded border border-gray-200 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-medium text-gray-800">{p.id} · {p.action} · {p.target}</div>
<div className="text-xs text-gray-500">{p.proposer} · {p.created_at} · {p.delegation?.level} · {p.delegation?.reason}</div>
</div>
<StatusBadge value={p.status} />
</div>
<div className="mt-2 text-sm text-gray-600">{p.reason}</div>
<pre className="mt-2 overflow-auto rounded bg-gray-50 p-2 text-xs text-gray-700">{JSON.stringify(p.payload, null, 2)}</pre>
{p.status === 'pending' && (
<div className="mt-3 flex gap-2">
<button className="rounded bg-green-600 px-3 py-2 text-sm text-white disabled:bg-gray-300"
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'approve' })}>
Approve
</button>
<button className="rounded border border-gray-300 px-3 py-2 text-sm text-gray-700 disabled:text-gray-300"
disabled={decide.isPending} onClick={() => decide.mutate({ id: p.id, decision: 'reject' })}>
Reject
</button>
</div>
)}
</div>
))}
{inbox.data.proposals.length === 0 && <div className="text-sm text-gray-500">No proposals for this status.</div>}
</div>
</Card>
<Card title="Oversight log">
<div className="space-y-2 max-h-96 overflow-auto">
{inbox.data.oversight.slice().reverse().map((o: any) => (
<div key={`${o.seq}-${o.hash}`} className="rounded border border-gray-200 p-3 text-sm">
<div className="font-medium text-gray-800">{o.event} · {o.proposal_id}</div>
<div className="text-xs text-gray-500">{o.actor} · {o.at} · hash {String(o.hash).slice(0, 12)}</div>
<div className="text-xs text-gray-600 mt-1">{o.reason}</div>
</div>
))}
</div>
</Card>
</>
);
}
@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api, CommandWidget } from '../lib/api';
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
const ORDER = ['maturity', 'hitl', 'kill_switch', 'traceability', 'security', 'finops', 'certification', 'self_improve'];
const COPY = {
en: {
title: 'Executive briefing',
maturity: 'Maturity',
control: 'Control',
safety: 'Safety',
economy: 'Economy',
certification: 'Certification',
ticker: 'Live governance ticker',
evidence: 'Evidence',
},
vi: {
title: 'Tóm tắt điều hành',
maturity: 'Trưởng thành',
control: 'Kiểm soát',
safety: 'An toàn',
economy: 'Chi phí',
certification: 'Chứng thực',
ticker: 'Dòng sự kiện governance',
evidence: 'Bằng chứng',
},
};
function fmtTime(v: string | null | undefined) {
return v ? v.replace('T', ' ').replace('Z', '') : 'n/a';
}
function metricValue(v: unknown): string {
if (Array.isArray(v)) return v.length ? v.join(', ') : 'none';
if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(4);
if (typeof v === 'boolean') return v ? 'true' : 'false';
return v == null || v === '' ? 'n/a' : String(v);
}
function WidgetPanel({ widget, onOpen }: { widget: CommandWidget; onOpen: (w: CommandWidget) => void }) {
const metrics = Object.entries(widget.metrics).slice(0, 4);
return (
<button
type="button"
onClick={() => onOpen(widget)}
className="text-left bg-white rounded-lg shadow-sm border border-gray-200 p-4 hover:border-blue-300 hover:shadow-md transition min-h-[168px]"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-semibold text-gray-800 leading-snug">{widget.title}</div>
<div className="text-sm text-gray-500 mt-2 leading-relaxed">{widget.summary}</div>
</div>
<StatusBadge value={widget.status} />
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs">
{metrics.map(([k, v]) => (
<div key={k} className="rounded border border-gray-100 bg-gray-50 p-2 min-w-0">
<div className="text-gray-400 truncate">{k.replaceAll('_', ' ')}</div>
<div className="font-medium text-gray-700 truncate">{metricValue(v)}</div>
</div>
))}
</div>
<div className="mt-3 text-xs text-gray-400 truncate">{widget.envelope.artifact_path}</div>
</button>
);
}
export function CommandCenter() {
const { data, isLoading, isError } = useQuery({ queryKey: ['command'], queryFn: api.command });
const [lang, setLang] = useState<'en' | 'vi'>('en');
const [selected, setSelected] = useState<CommandWidget | null>(null);
const labels = COPY[lang];
const widgets = useMemo(() => {
if (!data) return [];
return ORDER.map((id) => data.widgets[id]).filter(Boolean);
}, [data]);
if (isLoading) return <div className="text-gray-500">Loading…</div>;
if (isError || !data) return <div className="text-red-600">Cannot reach Command Center API.</div>;
const verified = widgets.filter((w) => w.envelope.verified).length;
const failing = widgets.filter((w) => ['fail', 'warn'].includes(w.status)).length;
return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatTile label="Widgets" value={widgets.length} sub="Plan-13 §8.6" />
<StatTile label="Verified sources" value={`${verified}/${widgets.length}`} />
<StatTile label="Attention" value={failing} sub="warn/fail widgets" />
<StatTile label="Generated" value={fmtTime(data.generated_at).slice(11, 19)} sub={fmtTime(data.generated_at).slice(0, 10)} />
</div>
<Card
title={labels.title}
right={
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-xs">
{(['en', 'vi'] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => setLang(l)}
className={`px-3 py-1.5 ${lang === l ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
>
{l.toUpperCase()}
</button>
))}
</div>
}
>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
{data.briefing.map((b) => (
<button
type="button"
key={b.widget}
onClick={() => setSelected(data.widgets[b.widget])}
className="text-left rounded-lg border border-gray-200 p-3 hover:border-blue-300 hover:bg-blue-50 min-h-[112px]"
>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-800">{(labels as Record<string, string>)[b.label.toLowerCase()] ?? b.label}</div>
<StatusBadge value={b.status} />
</div>
<div className="text-xs text-gray-500 mt-3 leading-relaxed">{b.value}</div>
</button>
))}
</div>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-4 gap-4">
<div className="xl:col-span-3 grid grid-cols-1 md:grid-cols-2 gap-4">
{widgets.map((w) => <WidgetPanel key={w.id} widget={w} onOpen={setSelected} />)}
</div>
<Card title={labels.ticker}>
<div className="space-y-3">
{data.ticker.map((e, i) => (
<div key={`${e.kind}-${e.at}-${i}`} className="border-b border-gray-100 pb-3 last:border-b-0 last:pb-0">
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-gray-400">{fmtTime(e.at)}</span>
<StatusBadge value={e.status} />
</div>
<div className="text-sm text-gray-700 mt-1">{e.text}</div>
<div className="text-xs text-gray-400 mt-1">{e.kind}</div>
</div>
))}
{data.ticker.length === 0 && <div className="text-sm text-gray-500">No governance events.</div>}
</div>
</Card>
</div>
{selected && (
<div className="fixed inset-0 z-50 bg-gray-900/20 flex justify-end" onClick={() => setSelected(null)}>
<aside className="h-full w-full max-w-2xl bg-white border-l border-gray-200 shadow-xl p-6 overflow-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">{selected.title}</h2>
<p className="text-sm text-gray-500 mt-1">{selected.summary}</p>
</div>
<button type="button" onClick={() => setSelected(null)} className="px-3 py-1.5 rounded border border-gray-200 text-sm text-gray-600 hover:bg-gray-50">Close</button>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 text-sm">
<div className="rounded border border-gray-200 p-3">
<div className="text-xs text-gray-400">source</div>
<div className="font-medium text-gray-700 break-all">{selected.envelope.source}</div>
</div>
<div className="rounded border border-gray-200 p-3">
<div className="text-xs text-gray-400">status</div>
<div className="font-medium text-gray-700">{selected.envelope.status}</div>
</div>
<div className="rounded border border-gray-200 p-3 col-span-2">
<div className="text-xs text-gray-400">commit</div>
<div className="font-medium text-gray-700 break-all">{selected.envelope.commit ?? 'n/a'}</div>
</div>
<div className="rounded border border-gray-200 p-3 col-span-2">
<div className="text-xs text-gray-400">artifact</div>
<div className="font-medium text-gray-700 break-all">{selected.envelope.artifact_path}</div>
</div>
</div>
<pre className="mt-5 text-xs bg-gray-950 text-gray-100 rounded-lg p-4 overflow-auto">{JSON.stringify(selected, null, 2)}</pre>
</aside>
</div>
)}
</>
);
}
@@ -0,0 +1,85 @@
import { useQuery } from '@tanstack/react-query';
import { api, SettingsActor } from '../lib/api';
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
const viewer: SettingsActor = {
actor: 'finops-viewer',
role: 'viewer',
project: 'default',
tenant: 'default',
};
function money(v: number | null | undefined): string {
return typeof v === 'number' && Number.isFinite(v) ? `$${v.toFixed(4)}` : 'n/a';
}
export function FinOps() {
const cost = useQuery({ queryKey: ['cost'], queryFn: api.cost });
const settings = useQuery({ queryKey: ['settings', 'finops-viewer'], queryFn: () => api.settings(viewer), retry: false });
if (cost.isLoading || !cost.data) return <div className="text-gray-500">Loading…</div>;
const capRaw = settings.data?.settings['cost.absolute_cap_usd']?.value;
const cap = typeof capRaw === 'number' ? capRaw : null;
const actual = cost.data.provider_cost;
const budgetStatus = cap === null ? 'not configured' : actual <= cap ? 'ok' : 'breach';
const kpis = Array.isArray(cost.data.business_kpi?.kpis) ? cost.data.business_kpi.kpis : [];
return (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatTile label="Provider cost" value={money(actual)} sub="from provider usage artifact" />
<StatTile label="Provider tokens" value={cost.data.provider_tokens} />
<StatTile label="Budget cap" value={money(cap)} sub="cost.absolute_cap_usd setting" />
<StatTile label="Budget status" value={<StatusBadge value={budgetStatus} />} />
</div>
<Card title="Provider usage">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-200">
<th className="py-2">time</th><th>provider</th><th>model</th><th>step</th><th>tokens</th><th>cost</th><th>status</th>
</tr>
</thead>
<tbody>
{cost.data.by_provider.map((r: any, i: number) => (
<tr key={`${r.timestamp}-${i}`} className="border-b border-gray-100">
<td className="py-2 text-gray-500">{r.timestamp?.replace('T', ' ').replace('Z', '')}</td>
<td>{r.provider}</td>
<td>{r.model}</td>
<td>{r.step}</td>
<td>{r.total_tokens}</td>
<td>{money(r.cost_usd)}</td>
<td><StatusBadge value={r.status ?? 'unknown'} /></td>
</tr>
))}
{cost.data.by_provider.length === 0 && (
<tr><td className="py-4 text-gray-500" colSpan={7}>No provider usage records.</td></tr>
)}
</tbody>
</table>
</div>
</Card>
<Card title="SLO / KPI board">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{kpis.map((k: any) => (
<div key={k.id} className="rounded border border-gray-200 p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-gray-800">{String(k.id).replaceAll('_', ' ')}</div>
<StatusBadge value={k.target_met ? 'ok' : 'breach'} />
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs text-gray-600">
<div><span className="block text-gray-400">baseline</span>{k.baseline}</div>
<div><span className="block text-gray-400">current</span>{k.current}</div>
<div><span className="block text-gray-400">target</span>{k.target}</div>
</div>
</div>
))}
{kpis.length === 0 && <div className="text-gray-500">No KPI artifact found.</div>}
</div>
</Card>
</>
);
}
@@ -1,16 +1,101 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, SettingsActor } from '../lib/api';
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'operator', 'org-admin'];
const SCOPES = ['project', 'model', 'provider', 'tenant', 'global'];
export function Incidents() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['incidents'], queryFn: api.incidents });
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'operator', project: 'default', tenant: 'default' });
const [scope, setScope] = useState('project');
const [id, setId] = useState('default');
const [reason, setReason] = useState('incident containment drill');
const [message, setMessage] = useState<string | null>(null);
const killSwitch = useQuery({ queryKey: ['kill-switch', actor], queryFn: () => api.killSwitch(actor), retry: false });
const engage = useMutation({
mutationFn: () => api.engageKillSwitch(actor, { scope, id: scope === 'global' ? 'all' : id, reason }),
onSuccess: (res) => {
setMessage(res.output);
void queryClient.invalidateQueries({ queryKey: ['kill-switch'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'engage failed'),
});
const clear = useMutation({
mutationFn: () => api.clearKillSwitch(actor, { scope, id: scope === 'global' ? 'all' : id, reason }),
onSuccess: (res) => {
setMessage(res.output);
void queryClient.invalidateQueries({ queryKey: ['kill-switch'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'clear failed'),
});
if (isLoading || !data) return <div className="text-gray-500">Loading…</div>;
return (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatTile label="Incidents" value={data.total} />
<StatTile label="Kill-switch scopes" value={data.kill_switch_scopes.length} sub={data.kill_switch_scopes.join(', ') || 'none'} />
<StatTile label="Kill-switch scopes" value={killSwitch.data?.count ?? data.kill_switch_scopes.length}
sub={killSwitch.data?.engaged.map((k) => `${k.scope}/${k.id}`).join(', ') || data.kill_switch_scopes.join(', ') || 'none'} />
</div>
<Card title="Kill-switch control" right={<StatusBadge value={(killSwitch.data?.count ?? 0) > 0 ? 'blocked' : 'ok'} />}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="space-y-1">
<span className="text-gray-500">Actor</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Role</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Scope</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={scope} onChange={(e) => setScope(e.target.value)}>
{SCOPES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">ID</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={scope === 'global' ? 'all' : id}
disabled={scope === 'global'} onChange={(e) => setId(e.target.value)} />
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-gray-500">Reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<div className="flex gap-2 md:col-span-2">
<button className="rounded bg-red-600 px-4 py-2 text-white disabled:bg-gray-300"
disabled={engage.isPending} onClick={() => engage.mutate()}>
Engage
</button>
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
disabled={clear.isPending} onClick={() => clear.mutate()}>
Clear
</button>
</div>
{message && <div className="md:col-span-2 rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
</div>
<div className="space-y-2 max-h-72 overflow-auto">
{(killSwitch.data?.engaged ?? []).map((k) => (
<div key={`${k.scope}-${k.id}`} className="rounded border border-red-200 bg-red-50 p-3">
<div className="font-medium text-red-800">{k.scope}/{k.id}</div>
<div className="text-xs text-red-700">{k.actor ?? 'system'} · {k.engaged_at ?? 'unknown'}</div>
<div className="text-xs text-red-700 mt-1">{k.reason}</div>
</div>
))}
{(killSwitch.data?.engaged.length ?? 0) === 0 && <div className="text-gray-500">No active kill-switches.</div>}
</div>
</div>
</Card>
<Card title="Incident log">
<div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="text-left text-gray-500 border-b border-gray-200"><th className="py-2">time</th><th>event</th><th>severity</th><th>scope</th><th>action</th></tr></thead>
@@ -0,0 +1,181 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, SettingsActor } from '../lib/api';
import { Card, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'operator', 'project-admin', 'org-admin', 'auditor'];
function parseValue(raw: string): unknown {
if (raw.trim() === '') return '';
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function valuePreview(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value);
}
export function Settings() {
const queryClient = useQueryClient();
const [actor, setActor] = useState<SettingsActor>({
actor: 'local-operator',
role: 'viewer',
project: 'default',
tenant: 'default',
});
const [selectedKey, setSelectedKey] = useState('');
const [rawValue, setRawValue] = useState('true');
const [reason, setReason] = useState('operator change from Ops Console');
const [approval, setApproval] = useState('');
const [message, setMessage] = useState<string | null>(null);
const settingsQuery = useQuery({
queryKey: ['settings', actor],
queryFn: () => api.settings(actor),
retry: false,
});
const keys = useMemo(() => Object.keys(settingsQuery.data?.policy ?? {}).sort(), [settingsQuery.data]);
const effectiveKey = selectedKey || keys[0] || '';
const policy = effectiveKey ? settingsQuery.data?.policy[effectiveKey] : undefined;
const setMutation = useMutation({
mutationFn: () => api.setSetting(actor, {
key: effectiveKey,
value: parseValue(rawValue),
reason,
approval: approval || undefined,
}),
onSuccess: (res) => {
setMessage(`SET ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'SET failed'),
});
const rollbackMutation = useMutation({
mutationFn: () => api.rollbackSetting(actor, { key: effectiveKey, reason }),
onSuccess: (res) => {
setMessage(`ROLLBACK ${res.key} v${res.setting.version} audit=${res.audit_verify.ok ? 'ok' : 'failed'}`);
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'ROLLBACK failed'),
});
if (settingsQuery.isLoading) return <div className="text-gray-500">Loading…</div>;
if (settingsQuery.isError || !settingsQuery.data) return <div className="text-red-600">Cannot reach settings API.</div>;
const data = settingsQuery.data;
const current = effectiveKey ? data.settings[effectiveKey] : undefined;
const canWrite = policy?.securitySensitive ? data.capabilities.can_write_sensitive : data.capabilities.can_write_standard;
return (
<>
<Card title="Management identity" right={<StatusBadge value={data.audit_verify.ok ? 'audit ok' : 'audit fail'} />}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 text-sm">
<label className="space-y-1">
<span className="text-gray-500">Actor</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Role</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Project</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.project}
onChange={(e) => setActor({ ...actor, project: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Tenant</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.tenant}
onChange={(e) => setActor({ ...actor, tenant: e.target.value })} />
</label>
</div>
</Card>
<Card title="Governed settings">
<div className="overflow-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
<tr><th className="py-2 pr-4">Key</th><th className="py-2 pr-4">Current</th><th className="py-2 pr-4">Version</th><th className="py-2">Policy</th></tr>
</thead>
<tbody>
{keys.map((key) => {
const row = data.settings[key];
const p = data.policy[key];
return (
<tr key={key} className={`border-b border-gray-100 cursor-pointer ${effectiveKey === key ? 'bg-blue-50' : ''}`}
onClick={() => { setSelectedKey(key); setRawValue(valuePreview(row?.value ?? '')); }}>
<td className="py-3 pr-4 font-medium text-gray-800">{key}</td>
<td className="py-3 pr-4 text-gray-600"><code>{row ? valuePreview(row.value) : 'unset'}</code></td>
<td className="py-3 pr-4 text-gray-600">{row?.version ?? '—'}</td>
<td className="py-3">{p.securitySensitive ? <StatusBadge value="sensitive" /> : <StatusBadge value="standard" />}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card title="Apply or rollback">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
<div className="space-y-3">
<div>
<div className="text-gray-500">Selected key</div>
<div className="font-medium text-gray-800">{effectiveKey || 'none'}</div>
{policy && <div className="text-gray-500 mt-1">{policy.description}</div>}
{current && <div className="text-gray-500 mt-1">current v{current.version} by {current.actor}</div>}
</div>
<label className="block space-y-1">
<span className="text-gray-500">Value (JSON or string)</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={rawValue} onChange={(e) => setRawValue(e.target.value)} />
</label>
<label className="block space-y-1">
<span className="text-gray-500">Reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<label className="block space-y-1">
<span className="text-gray-500">Approval token</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={approval} onChange={(e) => setApproval(e.target.value)} />
</label>
<div className="flex flex-wrap gap-2">
<button className="rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-300"
disabled={!effectiveKey || !canWrite || setMutation.isPending}
onClick={() => setMutation.mutate()}>
Apply
</button>
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
disabled={!effectiveKey || !data.capabilities.can_rollback || rollbackMutation.isPending}
onClick={() => rollbackMutation.mutate()}>
Rollback
</button>
</div>
{message && <div className="rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
</div>
<div>
<div className="mb-2 text-xs font-semibold uppercase text-gray-500">Recent settings audit</div>
<div className="space-y-2 max-h-80 overflow-auto">
{data.audit.map((a) => (
<div key={`${a.seq}-${a.hash}`} className="rounded border border-gray-200 p-3">
<div className="font-medium text-gray-800">{a.action} {a.key}</div>
<div className="text-xs text-gray-500">{a.actor} · {a.at} · vhash {String(a.hash).slice(0, 12)}</div>
<div className="text-xs text-gray-600 mt-1">{a.reason}</div>
</div>
))}
{data.audit.length === 0 && <div className="text-gray-500">No settings audit records yet.</div>}
</div>
</div>
</div>
</Card>
</>
);
}
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -uo pipefail
# Plan-13 local production-like smoke:
# TLS self-signed + mock OIDC IdP + oauth2-proxy + nginx auth_request + CP API.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
COMPOSE="$ROOT/docker-compose.control-panel.local.yml"
TLS_DIR="$ROOT/tmp/control-panel-local/tls"
BASE="https://localhost:18443"
mkdir -p "$TLS_DIR"
if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$TLS_DIR/tls.key" \
-out "$TLS_DIR/tls.crt" \
-subj "/CN=localhost" \
-days 1 >/dev/null 2>&1
fi
cleanup() {
docker compose -f "$COMPOSE" down --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
fail() {
echo "$1"
docker compose -f "$COMPOSE" logs --tail=120 control-panel-api control-panel-ui oauth2-proxy idp || true
exit 1
}
docker compose -f "$COMPOSE" up -d --build >/tmp/casan-cp-local-smoke-up.log 2>&1 || {
cat /tmp/casan-cp-local-smoke-up.log
exit 1
}
for _ in $(seq 1 60); do
if curl -k -s -I "$BASE/" >/dev/null 2>&1; then
break
fi
sleep 1
done
JAR="$(mktemp)"
trap 'rm -f "$JAR"; cleanup' EXIT
curl -k -s -L -c "$JAR" -b "$JAR" "$BASE/" -o /tmp/casan-cp-local-index.html -w "%{http_code}" > /tmp/casan-cp-local-index.code
INDEX_CODE="$(cat /tmp/casan-cp-local-index.code)"
if [[ "$INDEX_CODE" != "200" ]] || ! grep -q "CASAN Ops Console" /tmp/casan-cp-local-index.html; then
echo "CP_LOCAL_SMOKE_FAIL ui_http=$INDEX_CODE"
tail -100 /tmp/casan-cp-local-index.html
fail "CP_LOCAL_SMOKE_FAIL ui_unexpected"
fi
for _ in $(seq 1 30); do
curl -k -s -L -c "$JAR" -b "$JAR" "$BASE/api/v1/settings" -o /tmp/casan-cp-local-settings.json -w "%{http_code}" > /tmp/casan-cp-local-settings.code
SETTINGS_CODE="$(cat /tmp/casan-cp-local-settings.code)"
[[ "$SETTINGS_CODE" == "200" ]] && break
sleep 1
done
SETTINGS_CODE="$(cat /tmp/casan-cp-local-settings.code)"
if [[ "$SETTINGS_CODE" != "200" ]]; then
echo "CP_LOCAL_SMOKE_FAIL settings_http=$SETTINGS_CODE"
cat /tmp/casan-cp-local-settings.json
fail "CP_LOCAL_SMOKE_FAIL settings_unexpected"
fi
for _ in $(seq 1 30); do
curl -k -s -L -c "$JAR" -b "$JAR" "$BASE/api/v1/command" -o /tmp/casan-cp-local-command.json -w "%{http_code}" > /tmp/casan-cp-local-command.code
COMMAND_CODE="$(cat /tmp/casan-cp-local-command.code)"
[[ "$COMMAND_CODE" == "200" ]] && break
sleep 1
done
COMMAND_CODE="$(cat /tmp/casan-cp-local-command.code)"
if [[ "$COMMAND_CODE" != "200" ]]; then
echo "CP_LOCAL_SMOKE_FAIL command_http=$COMMAND_CODE"
cat /tmp/casan-cp-local-command.json
fail "CP_LOCAL_SMOKE_FAIL command_unexpected"
fi
python3 - <<'PY'
import json
data = json.load(open('/tmp/casan-cp-local-settings.json'))
actor = data.get('data', {}).get('actor', {})
assert actor.get('actor') == 'oidc-ops', actor
assert actor.get('role') == 'org-admin', actor
assert data.get('success') is True
command = json.load(open('/tmp/casan-cp-local-command.json'))
widgets = command.get('data', {}).get('widgets', {})
assert command.get('success') is True
assert len(widgets) == 8, widgets.keys()
for widget in widgets.values():
envelope = widget.get('envelope', {})
assert {'source', 'artifact_path', 'commit', 'run_at', 'verified', 'status'} <= set(envelope), envelope
print('CP_LOCAL_SMOKE_PASS https_oidc=true actor=oidc-ops role=org-admin')
PY
CASAN_CP_BASE_URL="$BASE" \
CASAN_CP_ALLOW_INSECURE=1 \
CASAN_CP_COOKIE_JAR="$JAR" \
bash "$ROOT/packages/casan-control-panel/scripts/managed-prod-smoke.sh" >/tmp/casan-cp-local-managed-smoke.out
cat /tmp/casan-cp-local-managed-smoke.out
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
# Smoke a deployed managed Control Panel endpoint.
#
# Required:
# CASAN_CP_BASE_URL=https://control-panel.example.com
#
# Optional:
# CASAN_CP_COOKIE_JAR=/path/to/cookies.txt # authenticated browser/session cookie jar
# CASAN_CP_ALLOW_INSECURE=1 # only for local labs/self-signed certs
#
# Without a cookie jar this proves auth is enforced. With a cookie jar it also proves
# the API identity mapping and Command Center contract behind the auth proxy.
BASE="${CASAN_CP_BASE_URL:-}"
COOKIE_JAR="${CASAN_CP_COOKIE_JAR:-}"
fail() {
echo "CP_MANAGED_SMOKE_FAIL $1"
exit 1
}
[[ -n "$BASE" ]] || fail "missing_base_url env=CASAN_CP_BASE_URL"
BASE="${BASE%/}"
if [[ "$BASE" != https://* && "${CASAN_CP_ALLOW_INSECURE:-0}" != "1" ]]; then
fail "base_url_must_be_https"
fi
CURL=(curl -sS)
if [[ "${CASAN_CP_ALLOW_INSECURE:-0}" == "1" ]]; then
CURL+=(-k)
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
root_code="$("${CURL[@]}" -o "$tmp/root.html" -w "%{http_code}" "$BASE/" || true)"
if [[ "$root_code" == "200" ]]; then
fail "unauth_root_returned_200"
fi
spoof_code="$("${CURL[@]}" \
-H 'X-CASAN-Actor: spoofed-admin' \
-H 'X-CASAN-Role: org-admin' \
-H 'X-CASAN-Groups: casan-org-admin' \
-o "$tmp/spoof.json" \
-w "%{http_code}" \
"$BASE/api/v1/settings" || true)"
if [[ "$spoof_code" == "200" ]]; then
fail "spoofed_identity_bypass"
fi
if [[ -z "$COOKIE_JAR" ]]; then
echo "CP_MANAGED_SMOKE_PARTIAL unauth_protected=true spoof_blocked=true authenticated=false"
exit 0
fi
[[ -f "$COOKIE_JAR" ]] || fail "missing_cookie_jar path=$COOKIE_JAR"
settings_code="$("${CURL[@]}" -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-o "$tmp/settings.json" -w "%{http_code}" "$BASE/api/v1/settings" || true)"
if [[ "$settings_code" != "200" ]]; then
cat "$tmp/settings.json" || true
fail "settings_http=$settings_code"
fi
command_code="$("${CURL[@]}" -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-o "$tmp/command.json" -w "%{http_code}" "$BASE/api/v1/command" || true)"
if [[ "$command_code" != "200" ]]; then
cat "$tmp/command.json" || true
fail "command_http=$command_code"
fi
python3 - "$tmp/settings.json" "$tmp/command.json" <<'PY'
import json
import sys
settings = json.load(open(sys.argv[1]))
assert settings.get("success") is True, settings
actor = settings.get("data", {}).get("actor", {})
assert actor.get("actor") and actor.get("actor") != "anonymous", actor
assert actor.get("role") and actor.get("role") != "unknown", actor
command = json.load(open(sys.argv[2]))
assert command.get("success") is True, command
widgets = command.get("data", {}).get("widgets", {})
assert len(widgets) == 8, widgets.keys()
for widget in widgets.values():
envelope = widget.get("envelope", {})
required = {"source", "artifact_path", "commit", "run_at", "verified", "status"}
assert required <= set(envelope), envelope
print(f"CP_MANAGED_SMOKE_PASS actor={actor.get('actor')} role={actor.get('role')} widgets={len(widgets)}")
PY
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
# Validate the managed-production Control Panel handoff without printing secrets.
# This does not contact the enterprise IdP; it proves the host has the required
# TLS/OIDC files and that values are not still local/mock placeholders.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
COMPOSE="${CASAN_CP_COMPOSE:-$ROOT/docker-compose.control-panel.yml}"
TLS_DIR="${CASAN_CP_TLS_DIR:-/opt/casan-control-panel/tls}"
OAUTH_ENV="${CASAN_CP_OAUTH_ENV:-/opt/casan-control-panel/oauth2-proxy.env}"
fail() {
echo "CP_PROD_READINESS_FAIL $1"
exit 1
}
pass() {
echo "PASS: $1"
}
value_of() {
local key="$1"
sed -n -E "s/^${key}=//p" "$OAUTH_ENV" | tail -1
}
require_file() {
local path="$1"
[[ -f "$path" ]] || fail "missing_file path=$path"
[[ -s "$path" ]] || fail "empty_file path=$path"
}
require_env() {
local key="$1"
local value
value="$(value_of "$key")"
[[ -n "$value" ]] || fail "missing_env key=$key file=$OAUTH_ENV"
case "$value" in
*replace-with*|*example.com*|*localhost*|*127.0.0.1*|*idp:8080*)
fail "placeholder_env key=$key"
;;
esac
}
docker compose -f "$COMPOSE" config >/tmp/casan-cp-prod-compose-config.txt
pass "docker compose config"
require_file "$TLS_DIR/tls.crt"
require_file "$TLS_DIR/tls.key"
openssl x509 -in "$TLS_DIR/tls.crt" -noout >/dev/null
pass "tls certificate/key present"
require_file "$OAUTH_ENV"
for key in \
OAUTH2_PROXY_PROVIDER \
OAUTH2_PROXY_OIDC_ISSUER_URL \
OAUTH2_PROXY_CLIENT_ID \
OAUTH2_PROXY_CLIENT_SECRET \
OAUTH2_PROXY_COOKIE_SECRET \
OAUTH2_PROXY_REDIRECT_URL \
OAUTH2_PROXY_OIDC_GROUPS_CLAIM
do
require_env "$key"
done
[[ "$(value_of OAUTH2_PROXY_PROVIDER)" == "oidc" ]] || fail "provider_must_be_oidc"
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == "true" ]] || fail "cookie_secure_must_be_true"
[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == "true" ]] || fail "xauthrequest_must_be_true"
[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == "false" ]] || fail "pass_access_token_must_be_false"
[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == "false" ]] || fail "pass_authorization_header_must_be_false"
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "issuer_must_be_https"
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == https://*"/oauth2/callback" ]] || fail "redirect_url_must_be_https_callback"
[[ "$(value_of OAUTH2_PROXY_OIDC_GROUPS_CLAIM)" == "groups" ]] || fail "groups_claim_must_be_groups"
pass "oauth2-proxy env"
tmp="$(mktemp -d)"
cp "$TLS_DIR/tls.crt" "$tmp/tls.crt"
cp "$TLS_DIR/tls.key" "$tmp/tls.key"
docker run --rm \
--add-host oauth2-proxy:127.0.0.1 \
--add-host control-panel-api:127.0.0.1 \
-v "$ROOT/nginx/control-panel.conf:/etc/nginx/conf.d/default.conf:ro" \
-v "$tmp:/etc/nginx/tls:ro" \
nginx:1.27-alpine nginx -t >/tmp/casan-cp-prod-nginx-test.log 2>&1 || {
cat /tmp/casan-cp-prod-nginx-test.log
rm -rf "$tmp"
fail "nginx_config"
}
rm -rf "$tmp"
pass "nginx config"
echo "CP_PROD_READINESS_PASS compose=true tls=true oidc=true nginx=true"
@@ -0,0 +1,47 @@
{
"default_level": "L1",
"projects": {
"default": {
"default_level": "L1",
"actions": {
"settings.write.standard": "L2",
"settings.write.sensitive": "L1",
"kill_switch.engage": "L2",
"kill_switch.clear": "L1",
"self_improve.apply": "L1"
}
}
},
"levels": {
"L0": {
"label": "manual only",
"standard_requires_approval": true,
"high_requires_approval": true
},
"L1": {
"label": "human approves changes",
"standard_requires_approval": true,
"high_requires_approval": true
},
"L2": {
"label": "operator delegates low-risk changes",
"standard_requires_approval": false,
"high_requires_approval": true
},
"L3": {
"label": "bounded autonomy",
"standard_requires_approval": false,
"high_requires_approval": true
},
"L4": {
"label": "broad autonomy with high-risk human gate",
"standard_requires_approval": false,
"high_requires_approval": true
},
"L5": {
"label": "full autonomy except hard security gates",
"standard_requires_approval": false,
"high_requires_approval": true
}
}
}
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""CASAN Approval Inbox + Delegation resolver (Plan-13 HITL surface).
Harness-owned primitive for the Control Panel:
* delegation-policy.yaml resolves whether an action needs human approval;
* pending proposals are stored in a versioned JSON file;
* approve/reject decisions are append-only oversight events with a hash chain;
* Separation of Duties is enforced in the primitive (proposer != approver).
The web app may present this data, but the governance state lives in the harness.
"""
import argparse
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
GENESIS_HASH = "0" * 64
def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
ROOT = project_root()
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def inbox_path() -> str:
return os.environ.get("CASAN_APPROVAL_INBOX_FILE") or os.path.join(
ROOT, ".specify", "level5", "approval-inbox.json"
)
def policy_path() -> str:
return os.environ.get("CASAN_DELEGATION_POLICY") or os.path.join(
HARNESS_ROOT, "config", "delegation-policy.yaml"
)
def empty_store():
return {"proposals": [], "oversight": []}
def load_store():
path = inbox_path()
if not os.path.isfile(path):
return empty_store()
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
return {
"proposals": data.get("proposals", []),
"oversight": data.get("oversight", []),
}
def save_store(store):
path = inbox_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(store, fh, indent=2, ensure_ascii=False)
fh.write("\n")
os.replace(tmp, path)
def hash_entry(entry) -> str:
return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
def append_oversight(store, base):
prev = store["oversight"][-1] if store["oversight"] else None
prev_hash = prev["hash"] if prev else GENESIS_HASH
seq = len(store["oversight"]) + 1
core = {"seq": seq, **base, "prevHash": prev_hash}
store["oversight"].append({**core, "hash": hash_entry(core)})
def load_policy():
path = policy_path()
try:
with open(path, encoding="utf-8") as fh:
# File is JSON-in-YAML syntax: valid YAML, dependency-free JSON parse.
return json.load(fh)
except Exception as exc:
raise SystemExit(f"DELEGATION_POLICY_DENY unreadable policy: {exc}")
def resolve(policy, project, action, risk, sensitive):
projects = policy.get("projects", {})
project_cfg = projects.get(project, {})
level = project_cfg.get("actions", {}).get(action) or project_cfg.get("default_level") or policy.get("default_level", "L1")
level_cfg = policy.get("levels", {}).get(level)
if not level_cfg:
return {"project": project, "action": action, "level": level, "requires_approval": True, "reason": "unknown_level_fail_closed"}
high = sensitive or risk in {"high", "critical", "security-sensitive"}
requires = bool(level_cfg.get("high_requires_approval" if high else "standard_requires_approval", True))
return {
"project": project,
"action": action,
"level": level,
"risk": risk,
"sensitive": bool(sensitive),
"requires_approval": requires,
"reason": "high_risk_gate" if high and requires else ("level_requires_approval" if requires else "delegated_by_policy"),
}
def cmd_resolve(args):
verdict = resolve(load_policy(), args.project, args.action, args.risk, args.sensitive)
print(json.dumps(verdict, ensure_ascii=False))
return 0 if not verdict["requires_approval"] else 2
def cmd_submit(args):
policy_verdict = resolve(load_policy(), args.project, args.action, args.risk, args.sensitive)
store = load_store()
raw = f"{args.action}|{args.target}|{args.proposer}|{args.reason}|{now_iso()}"
pid = "AP-" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
try:
payload = json.loads(args.payload) if args.payload else {}
except ValueError:
print("APPROVAL_SUBMIT_DENY payload_not_json", file=sys.stderr)
return 64
status = "pending" if policy_verdict["requires_approval"] else "auto_allowed"
proposal = {
"id": pid,
"status": status,
"project": args.project,
"action": args.action,
"target": args.target,
"risk": args.risk,
"sensitive": bool(args.sensitive),
"proposer": args.proposer,
"reason": args.reason,
"payload": payload,
"delegation": policy_verdict,
"created_at": now_iso(),
"decided_at": None,
"approver": None,
"decision_reason": None,
}
store["proposals"].append(proposal)
append_oversight(store, {
"event": "proposal_submitted",
"proposal_id": pid,
"actor": args.proposer,
"action": args.action,
"target": args.target,
"status": status,
"reason": args.reason,
"at": proposal["created_at"],
})
save_store(store)
print(json.dumps(proposal, ensure_ascii=False))
return 0
def cmd_list(args):
store = load_store()
proposals = store["proposals"]
if args.status != "all":
proposals = [p for p in proposals if p.get("status") == args.status]
print(json.dumps({"count": len(proposals), "proposals": proposals, "oversight": store["oversight"][-50:]}, ensure_ascii=False))
return 0
def cmd_decide(args):
if args.decision not in {"approve", "reject"}:
print("APPROVAL_DECIDE_DENY decision_must_be_approve_or_reject", file=sys.stderr)
return 64
store = load_store()
proposal = next((p for p in store["proposals"] if p.get("id") == args.id), None)
if proposal is None:
print(f"APPROVAL_DECIDE_DENY unknown_id {args.id}", file=sys.stderr)
return 1
if proposal.get("status") != "pending":
print(f"APPROVAL_DECIDE_DENY not_pending status={proposal.get('status')}", file=sys.stderr)
return 3
if proposal.get("proposer") == args.approver:
print(f"APPROVAL_DECIDE_DENY sod_self_approval actor={args.approver}", file=sys.stderr)
return 3
proposal["status"] = "approved" if args.decision == "approve" else "rejected"
proposal["approver"] = args.approver
proposal["decision_reason"] = args.reason
proposal["decided_at"] = now_iso()
append_oversight(store, {
"event": f"proposal_{proposal['status']}",
"proposal_id": proposal["id"],
"actor": args.approver,
"action": proposal.get("action"),
"target": proposal.get("target"),
"status": proposal["status"],
"reason": args.reason,
"at": proposal["decided_at"],
})
save_store(store)
print(json.dumps(proposal, ensure_ascii=False))
return 0
def cmd_verify(args):
store = load_store()
prev = GENESIS_HASH
for entry in store["oversight"]:
rest = {k: v for k, v in entry.items() if k != "hash"}
if rest.get("prevHash") != prev or hash_entry(rest) != entry.get("hash"):
print(f"APPROVAL_INBOX_AUDIT ok=false brokenAt={entry.get('seq')}")
return 1
prev = entry["hash"]
print(f"APPROVAL_INBOX_AUDIT ok=true records={len(store['oversight'])} head={prev}")
return 0
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("resolve")
r.add_argument("--project", default="default")
r.add_argument("--action", required=True)
r.add_argument("--risk", default="standard")
r.add_argument("--sensitive", action="store_true")
s = sub.add_parser("submit")
s.add_argument("--project", default="default")
s.add_argument("--action", required=True)
s.add_argument("--target", required=True)
s.add_argument("--risk", default="standard")
s.add_argument("--sensitive", action="store_true")
s.add_argument("--proposer", required=True)
s.add_argument("--reason", required=True)
s.add_argument("--payload", default="{}")
l = sub.add_parser("list")
l.add_argument("--status", default="pending", choices=["pending", "approved", "rejected", "auto_allowed", "all"])
d = sub.add_parser("decide")
d.add_argument("--id", required=True)
d.add_argument("--decision", required=True)
d.add_argument("--approver", required=True)
d.add_argument("--reason", required=True)
sub.add_parser("verify-audit")
args = ap.parse_args()
if args.cmd == "resolve":
return cmd_resolve(args)
if args.cmd == "submit":
return cmd_submit(args)
if args.cmd == "list":
return cmd_list(args)
if args.cmd == "decide":
return cmd_decide(args)
if args.cmd == "verify-audit":
return cmd_verify(args)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -31,7 +31,7 @@ def _casan_app_root():
_d = os.path.abspath(os.path.dirname(__file__))
_p = _d
while _p != os.path.dirname(_p):
if os.path.isdir(os.path.join(_p, ".specify")):
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
return _p
_p = os.path.dirname(_p)
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
@@ -24,11 +24,15 @@ fi
_casan_paths_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Walk up from a starting dir until a dir containing `.specify/` is found.
# Walk up from a starting dir to the app/bundle root. Primary marker is the `.specify`
# state dir (present in an adopted repo). Secondary marker is `packages/casan-harness`
# (present in a freshly-extracted release bundle that has no `.specify` yet) — so a
# just-unpacked core/devkit/demo bundle resolves correctly and creates `.specify` on
# first write. In a real repo `.specify` matches first, so behavior is unchanged.
_casan_find_app_root() {
local d="$1"
while [[ -n "$d" && "$d" != "/" ]]; do
if [[ -d "$d/.specify" ]]; then
if [[ -d "$d/.specify" || -d "$d/packages/casan-harness" ]]; then
printf '%s\n' "$d"
return 0
fi
@@ -89,6 +89,7 @@ run "phase-h4-split-inject" bash "$TESTS/phase-h4-split-inject-tests.sh"
run "phase10-traceability" bash "$TESTS/phase10-traceability-tests.sh"
run "phase08-compression" bash "$TESTS/phase08-compression-tests.sh"
run "phase-control-plane" bash "$TESTS/phase-control-plane-tests.sh"
run "phase-control-plane-hitl" bash "$TESTS/phase-control-plane-hitl-tests.sh"
run "phase-rbac" bash "$TESTS/phase-rbac-tests.sh"
run "phase-rbac-audit" bash "$TESTS/phase-rbac-audit-tests.sh"
run "phase-rai" bash "$TESTS/phase-rai-tests.sh"
@@ -27,7 +27,7 @@ def _casan_app_root():
_d = os.path.abspath(os.path.dirname(__file__))
_p = _d
while _p != os.path.dirname(_p):
if os.path.isdir(os.path.join(_p, ".specify")):
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
return _p
_p = os.path.dirname(_p)
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
@@ -54,7 +54,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -25,7 +25,7 @@ def _app_root(start):
# dashboards + runtime logs live at the app's `.specify`, so walk UP for it.
d = pathlib.Path(start).resolve()
for p in (d, *d.parents):
if (p / ".specify").is_dir():
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
return p
return d.parents[3]
@@ -61,7 +61,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -31,7 +31,7 @@ def _casan_app_root():
_d = os.path.abspath(os.path.dirname(__file__))
_p = _d
while _p != os.path.dirname(_p):
if os.path.isdir(os.path.join(_p, ".specify")):
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
return _p
_p = os.path.dirname(_p)
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
@@ -39,7 +39,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -22,7 +22,7 @@ def project_root() -> str:
d = os.path.abspath(os.path.dirname(__file__))
p = d
while p != os.path.dirname(p):
if os.path.isdir(os.path.join(p, ".specify")):
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
return p
p = os.path.dirname(p)
return os.path.abspath(os.path.join(d, "..", "..", ".."))
@@ -10,7 +10,7 @@ def _app_root(start):
# runtime state lives at the app's `.specify`, so walk UP for that marker.
d = pathlib.Path(start).resolve()
for p in (d, *d.parents):
if (p / ".specify").is_dir():
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
return p
return d.parents[2]
@@ -13,7 +13,7 @@ def _app_root(start):
# a fixed parent depth.
d = pathlib.Path(start).resolve()
for p in (d, *d.parents):
if (p / ".specify").is_dir():
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
return p
return d.parents[2]
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-13 — Control Plane HITL approval inbox + delegation.
# Deterministic; no app/model required. Proves delegation resolution, pending
# inbox, approve/reject oversight hash-chain, SoD, and governed setting apply.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
INBOX="$CASAN_HARNESS_ROOT/scripts/bash/approval-inbox.py"
CP="$CASAN_HARNESS_ROOT/scripts/bash/control-plane-settings.py"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
export CASAN_APPROVAL_INBOX_FILE="$WORK/approval-inbox.json"
export CASAN_CP_STORE_FILE="$WORK/settings.json"
export CASAN_CP_KEY_DIR="$WORK/keys"
export CASAN_CP_PUB="$WORK/cp.pub"
PASS=0; FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
echo "===== Plan-13 HITL approval inbox + delegation ====="
set +e
python3 "$INBOX" resolve --project default --action settings.write.sensitive --risk high --sensitive > "$WORK/resolve-sensitive.json"
RC=$?
set -e 2>/dev/null || true
[[ "$RC" -eq 2 ]] && grep -q '"requires_approval": true' "$WORK/resolve-sensitive.json" \
&& pass "delegation resolver requires approval for high-risk sensitive setting" \
|| fail "sensitive delegation did not require approval (rc=$RC)"
python3 "$INBOX" submit \
--project default \
--action settings.write \
--target security.strict \
--risk high \
--sensitive \
--proposer alice \
--reason "tighten strict mode" \
--payload '{"key":"security.strict","value":true}' > "$WORK/proposal.json" 2>"$WORK/submit.err"
PID="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["id"])' "$WORK/proposal.json")"
[[ -n "$PID" ]] && pass "submit creates a pending approval proposal ($PID)" || fail "proposal id missing"
python3 "$INBOX" list --status pending > "$WORK/pending.json"
grep -q "$PID" "$WORK/pending.json" && pass "pending inbox lists submitted proposal" || fail "pending inbox missing proposal"
set +e
python3 "$INBOX" decide --id "$PID" --decision approve --approver alice --reason self >/dev/null 2>"$WORK/self.err"
RC=$?
set -e 2>/dev/null || true
[[ "$RC" -eq 3 ]] && grep -q "sod_self_approval" "$WORK/self.err" \
&& pass "SoD blocks self-approval" || fail "self-approval was not blocked (rc=$RC)"
python3 "$INBOX" decide --id "$PID" --decision approve --approver bob --reason reviewed > "$WORK/approved.json" 2>"$WORK/approve.err"
grep -q '"status": "approved"' "$WORK/approved.json" \
&& pass "distinct approver approves proposal" || fail "approval decision failed"
python3 "$INBOX" verify-audit >/dev/null 2>&1 \
&& pass "oversight hash-chain verifies intact" || fail "oversight hash-chain failed"
# Approved settings proposals are applied by the Control Panel API. The harness
# primitive still provides the approval token shape used by that API; prove the
# governed store accepts and audits it.
python3 "$CP" set security.strict true --actor alice --reason "approved:$PID:reviewed" --approval "inbox:$PID:bob" >/dev/null 2>&1 \
&& pass "approved proposal can apply via governed settings store" || fail "governed set failed after approval"
python3 "$CP" verify-audit >/dev/null 2>&1 \
&& pass "settings audit verifies after approved apply" || fail "settings audit failed after approved apply"
python3 - "$CASAN_APPROVAL_INBOX_FILE" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
d["oversight"][0]["status"] = "tampered"
json.dump(d, open(sys.argv[1], "w"))
PY
set +e
python3 "$INBOX" verify-audit >/dev/null 2>"$WORK/tamper.err"
RC=$?
set -e 2>/dev/null || true
[[ "$RC" -eq 1 ]] && pass "oversight hash-chain detects tampering" || fail "oversight tamper not detected (rc=$RC)"
echo ""
echo "===== CONTROL-PLANE HITL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
+11 -9
View File
@@ -1,8 +1,9 @@
# CASAN Platform (Level 3 — Productization UI) · **PREVIEW / structure-only**
# CASAN Platform (Level 3 — Productization UI) · **PREVIEW**
> Status: **PREVIEW.** Only the AgentOps **dashboard** exists today (shipped inside the core
> harness). The rest of the platform UI is scaffolded here as structure + intent — NOT
> implemented in this task. `package-release.sh platform` builds a clearly-stamped
> Status: **PREVIEW.** The AgentOps dashboard and Plan-13 Control Panel, including
> Command Center baseline, exist today.
> Evidence/attack viewers, Gitea integration, Ask CASAN, and managed production rollout
> remain planned. `package-release.sh platform` builds a clearly-stamped
> `casan-platform-preview-*` bundle containing only what exists.
Optional layer for teams that want UI / dashboard / visibility. Packages: `casan-platform`,
@@ -12,9 +13,9 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan
| Component | Status | Where |
|---|---|---|
| AgentOps Dashboard | ✅ exists | `packages/casan-harness/scripts/bash/dashboard-server.py` + `dashboard-serve.sh` (`casan dashboard`) |
| **Ops Console (Control Panel)** | ✅ **read-only (Plan-13 Track 1)** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability views. |
| Management / settings writes | 📋 planned | Plan-13 Track 2 (wrap `control-plane-settings.py`) |
| RBAC + approval inbox | 📋 planned | Plan-13 Track 3 / Plan-14 |
| **Ops Console (Control Panel)** | ✅ **monitoring + governed settings + HITL + kill-switch + FinOps/SLO + Command Center + local-prod TLS/OIDC smoke** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry, settings management, approval inbox, kill-switch, FinOps/SLO, and Command Center (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability + FinOps + Approvals + role-aware Settings page + `/command` evidence-backed executive view. |
| Management / settings writes | ✅ done+test | Plan-13 Track 2: wraps `control-plane-settings.py`, calls `rbac-check.py`, supports set/rollback/audit verify. |
| RBAC + approval inbox | ✅ local-prod done | Settings + kill-switch API RBAC enforcement, approval inbox/delegation/oversight, SoD, governed setting proposal apply, and local OIDC claim→role mapping smoke are done. Enterprise IdP rollout remains production follow-up. |
| Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` |
| Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results |
| Read-only Ask CASAN | 📋 planned | Plan-18 MVP-0 (read-only) |
@@ -27,5 +28,6 @@ scripts/package-release.sh platform # → dist/casan-platform-preview-vX.Y.Z
The bundle includes a `PREVIEW-INCOMPLETE.txt` marker. Do not treat it as a finished product.
## To implement later
Start from Plan-13 (Control Plane) + Plan-18 MVP-0 (Ask CASAN read-only). Keep the UI
**read-only over harness artifacts** first; write/governed actions belong to Level 4.
Start from Plan-15 RAI view or Plan-18 MVP-0 (Ask CASAN read-only). Keep new numbers
evidence-backed with provenance; any write/governed action must continue to route through
harness RBAC, approval, and audit primitives.