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"