feat: add control panel
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Smoke a deployed managed Control Panel endpoint.
|
||||
#
|
||||
# Required:
|
||||
# CASAN_CP_BASE_URL=https://control-panel.example.com
|
||||
#
|
||||
# Optional:
|
||||
# CASAN_CP_COOKIE_JAR=/path/to/cookies.txt # authenticated browser/session cookie jar
|
||||
# CASAN_CP_ALLOW_INSECURE=1 # only for local labs/self-signed certs
|
||||
#
|
||||
# Without a cookie jar this proves auth is enforced. With a cookie jar it also proves
|
||||
# the API identity mapping and Command Center contract behind the auth proxy.
|
||||
|
||||
BASE="${CASAN_CP_BASE_URL:-}"
|
||||
COOKIE_JAR="${CASAN_CP_COOKIE_JAR:-}"
|
||||
|
||||
fail() {
|
||||
echo "CP_MANAGED_SMOKE_FAIL $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -n "$BASE" ]] || fail "missing_base_url env=CASAN_CP_BASE_URL"
|
||||
BASE="${BASE%/}"
|
||||
if [[ "$BASE" != https://* && "${CASAN_CP_ALLOW_INSECURE:-0}" != "1" ]]; then
|
||||
fail "base_url_must_be_https"
|
||||
fi
|
||||
|
||||
CURL=(curl -sS)
|
||||
if [[ "${CASAN_CP_ALLOW_INSECURE:-0}" == "1" ]]; then
|
||||
CURL+=(-k)
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
root_code="$("${CURL[@]}" -o "$tmp/root.html" -w "%{http_code}" "$BASE/" || true)"
|
||||
if [[ "$root_code" == "200" ]]; then
|
||||
fail "unauth_root_returned_200"
|
||||
fi
|
||||
|
||||
spoof_code="$("${CURL[@]}" \
|
||||
-H 'X-CASAN-Actor: spoofed-admin' \
|
||||
-H 'X-CASAN-Role: org-admin' \
|
||||
-H 'X-CASAN-Groups: casan-org-admin' \
|
||||
-o "$tmp/spoof.json" \
|
||||
-w "%{http_code}" \
|
||||
"$BASE/api/v1/settings" || true)"
|
||||
if [[ "$spoof_code" == "200" ]]; then
|
||||
fail "spoofed_identity_bypass"
|
||||
fi
|
||||
|
||||
if [[ -z "$COOKIE_JAR" ]]; then
|
||||
echo "CP_MANAGED_SMOKE_PARTIAL unauth_protected=true spoof_blocked=true authenticated=false"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -f "$COOKIE_JAR" ]] || fail "missing_cookie_jar path=$COOKIE_JAR"
|
||||
|
||||
settings_code="$("${CURL[@]}" -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-o "$tmp/settings.json" -w "%{http_code}" "$BASE/api/v1/settings" || true)"
|
||||
if [[ "$settings_code" != "200" ]]; then
|
||||
cat "$tmp/settings.json" || true
|
||||
fail "settings_http=$settings_code"
|
||||
fi
|
||||
|
||||
command_code="$("${CURL[@]}" -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
|
||||
-o "$tmp/command.json" -w "%{http_code}" "$BASE/api/v1/command" || true)"
|
||||
if [[ "$command_code" != "200" ]]; then
|
||||
cat "$tmp/command.json" || true
|
||||
fail "command_http=$command_code"
|
||||
fi
|
||||
|
||||
python3 - "$tmp/settings.json" "$tmp/command.json" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
settings = json.load(open(sys.argv[1]))
|
||||
assert settings.get("success") is True, settings
|
||||
actor = settings.get("data", {}).get("actor", {})
|
||||
assert actor.get("actor") and actor.get("actor") != "anonymous", actor
|
||||
assert actor.get("role") and actor.get("role") != "unknown", actor
|
||||
|
||||
command = json.load(open(sys.argv[2]))
|
||||
assert command.get("success") is True, command
|
||||
widgets = command.get("data", {}).get("widgets", {})
|
||||
assert len(widgets) == 8, widgets.keys()
|
||||
for widget in widgets.values():
|
||||
envelope = widget.get("envelope", {})
|
||||
required = {"source", "artifact_path", "commit", "run_at", "verified", "status"}
|
||||
assert required <= set(envelope), envelope
|
||||
print(f"CP_MANAGED_SMOKE_PASS actor={actor.get('actor')} role={actor.get('role')} widgets={len(widgets)}")
|
||||
PY
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Validate the managed-production Control Panel handoff without printing secrets.
|
||||
# This does not contact the enterprise IdP; it proves the host has the required
|
||||
# TLS/OIDC files and that values are not still local/mock placeholders.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
COMPOSE="${CASAN_CP_COMPOSE:-$ROOT/docker-compose.control-panel.yml}"
|
||||
TLS_DIR="${CASAN_CP_TLS_DIR:-/opt/casan-control-panel/tls}"
|
||||
OAUTH_ENV="${CASAN_CP_OAUTH_ENV:-/opt/casan-control-panel/oauth2-proxy.env}"
|
||||
|
||||
fail() {
|
||||
echo "CP_PROD_READINESS_FAIL $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo "PASS: $1"
|
||||
}
|
||||
|
||||
value_of() {
|
||||
local key="$1"
|
||||
sed -n -E "s/^${key}=//p" "$OAUTH_ENV" | tail -1
|
||||
}
|
||||
|
||||
require_file() {
|
||||
local path="$1"
|
||||
[[ -f "$path" ]] || fail "missing_file path=$path"
|
||||
[[ -s "$path" ]] || fail "empty_file path=$path"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local key="$1"
|
||||
local value
|
||||
value="$(value_of "$key")"
|
||||
[[ -n "$value" ]] || fail "missing_env key=$key file=$OAUTH_ENV"
|
||||
case "$value" in
|
||||
*replace-with*|*example.com*|*localhost*|*127.0.0.1*|*idp:8080*)
|
||||
fail "placeholder_env key=$key"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
docker compose -f "$COMPOSE" config >/tmp/casan-cp-prod-compose-config.txt
|
||||
pass "docker compose config"
|
||||
|
||||
require_file "$TLS_DIR/tls.crt"
|
||||
require_file "$TLS_DIR/tls.key"
|
||||
openssl x509 -in "$TLS_DIR/tls.crt" -noout >/dev/null
|
||||
pass "tls certificate/key present"
|
||||
|
||||
require_file "$OAUTH_ENV"
|
||||
for key in \
|
||||
OAUTH2_PROXY_PROVIDER \
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL \
|
||||
OAUTH2_PROXY_CLIENT_ID \
|
||||
OAUTH2_PROXY_CLIENT_SECRET \
|
||||
OAUTH2_PROXY_COOKIE_SECRET \
|
||||
OAUTH2_PROXY_REDIRECT_URL \
|
||||
OAUTH2_PROXY_OIDC_GROUPS_CLAIM
|
||||
do
|
||||
require_env "$key"
|
||||
done
|
||||
|
||||
[[ "$(value_of OAUTH2_PROXY_PROVIDER)" == "oidc" ]] || fail "provider_must_be_oidc"
|
||||
[[ "$(value_of OAUTH2_PROXY_COOKIE_SECURE)" == "true" ]] || fail "cookie_secure_must_be_true"
|
||||
[[ "$(value_of OAUTH2_PROXY_SET_XAUTHREQUEST)" == "true" ]] || fail "xauthrequest_must_be_true"
|
||||
[[ "$(value_of OAUTH2_PROXY_PASS_ACCESS_TOKEN)" == "false" ]] || fail "pass_access_token_must_be_false"
|
||||
[[ "$(value_of OAUTH2_PROXY_PASS_AUTHORIZATION_HEADER)" == "false" ]] || fail "pass_authorization_header_must_be_false"
|
||||
[[ "$(value_of OAUTH2_PROXY_OIDC_ISSUER_URL)" == https://* ]] || fail "issuer_must_be_https"
|
||||
[[ "$(value_of OAUTH2_PROXY_REDIRECT_URL)" == https://*"/oauth2/callback" ]] || fail "redirect_url_must_be_https_callback"
|
||||
[[ "$(value_of OAUTH2_PROXY_OIDC_GROUPS_CLAIM)" == "groups" ]] || fail "groups_claim_must_be_groups"
|
||||
pass "oauth2-proxy env"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
cp "$TLS_DIR/tls.crt" "$tmp/tls.crt"
|
||||
cp "$TLS_DIR/tls.key" "$tmp/tls.key"
|
||||
docker run --rm \
|
||||
--add-host oauth2-proxy:127.0.0.1 \
|
||||
--add-host control-panel-api:127.0.0.1 \
|
||||
-v "$ROOT/nginx/control-panel.conf:/etc/nginx/conf.d/default.conf:ro" \
|
||||
-v "$tmp:/etc/nginx/tls:ro" \
|
||||
nginx:1.27-alpine nginx -t >/tmp/casan-cp-prod-nginx-test.log 2>&1 || {
|
||||
cat /tmp/casan-cp-prod-nginx-test.log
|
||||
rm -rf "$tmp"
|
||||
fail "nginx_config"
|
||||
}
|
||||
rm -rf "$tmp"
|
||||
pass "nginx config"
|
||||
|
||||
echo "CP_PROD_READINESS_PASS compose=true tls=true oidc=true nginx=true"
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"default_level": "L1",
|
||||
"projects": {
|
||||
"default": {
|
||||
"default_level": "L1",
|
||||
"actions": {
|
||||
"settings.write.standard": "L2",
|
||||
"settings.write.sensitive": "L1",
|
||||
"kill_switch.engage": "L2",
|
||||
"kill_switch.clear": "L1",
|
||||
"self_improve.apply": "L1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"levels": {
|
||||
"L0": {
|
||||
"label": "manual only",
|
||||
"standard_requires_approval": true,
|
||||
"high_requires_approval": true
|
||||
},
|
||||
"L1": {
|
||||
"label": "human approves changes",
|
||||
"standard_requires_approval": true,
|
||||
"high_requires_approval": true
|
||||
},
|
||||
"L2": {
|
||||
"label": "operator delegates low-risk changes",
|
||||
"standard_requires_approval": false,
|
||||
"high_requires_approval": true
|
||||
},
|
||||
"L3": {
|
||||
"label": "bounded autonomy",
|
||||
"standard_requires_approval": false,
|
||||
"high_requires_approval": true
|
||||
},
|
||||
"L4": {
|
||||
"label": "broad autonomy with high-risk human gate",
|
||||
"standard_requires_approval": false,
|
||||
"high_requires_approval": true
|
||||
},
|
||||
"L5": {
|
||||
"label": "full autonomy except hard security gates",
|
||||
"standard_requires_approval": false,
|
||||
"high_requires_approval": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN Approval Inbox + Delegation resolver (Plan-13 HITL surface).
|
||||
|
||||
Harness-owned primitive for the Control Panel:
|
||||
* delegation-policy.yaml resolves whether an action needs human approval;
|
||||
* pending proposals are stored in a versioned JSON file;
|
||||
* approve/reject decisions are append-only oversight events with a hash chain;
|
||||
* Separation of Duties is enforced in the primitive (proposer != approver).
|
||||
|
||||
The web app may present this data, but the governance state lives in the harness.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
def project_root() -> str:
|
||||
d = os.path.abspath(os.path.dirname(__file__))
|
||||
p = d
|
||||
while p != os.path.dirname(p):
|
||||
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
|
||||
return p
|
||||
p = os.path.dirname(p)
|
||||
return os.path.abspath(os.path.join(d, "..", "..", ".."))
|
||||
|
||||
|
||||
ROOT = project_root()
|
||||
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def inbox_path() -> str:
|
||||
return os.environ.get("CASAN_APPROVAL_INBOX_FILE") or os.path.join(
|
||||
ROOT, ".specify", "level5", "approval-inbox.json"
|
||||
)
|
||||
|
||||
|
||||
def policy_path() -> str:
|
||||
return os.environ.get("CASAN_DELEGATION_POLICY") or os.path.join(
|
||||
HARNESS_ROOT, "config", "delegation-policy.yaml"
|
||||
)
|
||||
|
||||
|
||||
def empty_store():
|
||||
return {"proposals": [], "oversight": []}
|
||||
|
||||
|
||||
def load_store():
|
||||
path = inbox_path()
|
||||
if not os.path.isfile(path):
|
||||
return empty_store()
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return {
|
||||
"proposals": data.get("proposals", []),
|
||||
"oversight": data.get("oversight", []),
|
||||
}
|
||||
|
||||
|
||||
def save_store(store):
|
||||
path = inbox_path()
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(store, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def hash_entry(entry) -> str:
|
||||
return hashlib.sha256(json.dumps(entry, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def append_oversight(store, base):
|
||||
prev = store["oversight"][-1] if store["oversight"] else None
|
||||
prev_hash = prev["hash"] if prev else GENESIS_HASH
|
||||
seq = len(store["oversight"]) + 1
|
||||
core = {"seq": seq, **base, "prevHash": prev_hash}
|
||||
store["oversight"].append({**core, "hash": hash_entry(core)})
|
||||
|
||||
|
||||
def load_policy():
|
||||
path = policy_path()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
# File is JSON-in-YAML syntax: valid YAML, dependency-free JSON parse.
|
||||
return json.load(fh)
|
||||
except Exception as exc:
|
||||
raise SystemExit(f"DELEGATION_POLICY_DENY unreadable policy: {exc}")
|
||||
|
||||
|
||||
def resolve(policy, project, action, risk, sensitive):
|
||||
projects = policy.get("projects", {})
|
||||
project_cfg = projects.get(project, {})
|
||||
level = project_cfg.get("actions", {}).get(action) or project_cfg.get("default_level") or policy.get("default_level", "L1")
|
||||
level_cfg = policy.get("levels", {}).get(level)
|
||||
if not level_cfg:
|
||||
return {"project": project, "action": action, "level": level, "requires_approval": True, "reason": "unknown_level_fail_closed"}
|
||||
high = sensitive or risk in {"high", "critical", "security-sensitive"}
|
||||
requires = bool(level_cfg.get("high_requires_approval" if high else "standard_requires_approval", True))
|
||||
return {
|
||||
"project": project,
|
||||
"action": action,
|
||||
"level": level,
|
||||
"risk": risk,
|
||||
"sensitive": bool(sensitive),
|
||||
"requires_approval": requires,
|
||||
"reason": "high_risk_gate" if high and requires else ("level_requires_approval" if requires else "delegated_by_policy"),
|
||||
}
|
||||
|
||||
|
||||
def cmd_resolve(args):
|
||||
verdict = resolve(load_policy(), args.project, args.action, args.risk, args.sensitive)
|
||||
print(json.dumps(verdict, ensure_ascii=False))
|
||||
return 0 if not verdict["requires_approval"] else 2
|
||||
|
||||
|
||||
def cmd_submit(args):
|
||||
policy_verdict = resolve(load_policy(), args.project, args.action, args.risk, args.sensitive)
|
||||
store = load_store()
|
||||
raw = f"{args.action}|{args.target}|{args.proposer}|{args.reason}|{now_iso()}"
|
||||
pid = "AP-" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
|
||||
try:
|
||||
payload = json.loads(args.payload) if args.payload else {}
|
||||
except ValueError:
|
||||
print("APPROVAL_SUBMIT_DENY payload_not_json", file=sys.stderr)
|
||||
return 64
|
||||
status = "pending" if policy_verdict["requires_approval"] else "auto_allowed"
|
||||
proposal = {
|
||||
"id": pid,
|
||||
"status": status,
|
||||
"project": args.project,
|
||||
"action": args.action,
|
||||
"target": args.target,
|
||||
"risk": args.risk,
|
||||
"sensitive": bool(args.sensitive),
|
||||
"proposer": args.proposer,
|
||||
"reason": args.reason,
|
||||
"payload": payload,
|
||||
"delegation": policy_verdict,
|
||||
"created_at": now_iso(),
|
||||
"decided_at": None,
|
||||
"approver": None,
|
||||
"decision_reason": None,
|
||||
}
|
||||
store["proposals"].append(proposal)
|
||||
append_oversight(store, {
|
||||
"event": "proposal_submitted",
|
||||
"proposal_id": pid,
|
||||
"actor": args.proposer,
|
||||
"action": args.action,
|
||||
"target": args.target,
|
||||
"status": status,
|
||||
"reason": args.reason,
|
||||
"at": proposal["created_at"],
|
||||
})
|
||||
save_store(store)
|
||||
print(json.dumps(proposal, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
store = load_store()
|
||||
proposals = store["proposals"]
|
||||
if args.status != "all":
|
||||
proposals = [p for p in proposals if p.get("status") == args.status]
|
||||
print(json.dumps({"count": len(proposals), "proposals": proposals, "oversight": store["oversight"][-50:]}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_decide(args):
|
||||
if args.decision not in {"approve", "reject"}:
|
||||
print("APPROVAL_DECIDE_DENY decision_must_be_approve_or_reject", file=sys.stderr)
|
||||
return 64
|
||||
store = load_store()
|
||||
proposal = next((p for p in store["proposals"] if p.get("id") == args.id), None)
|
||||
if proposal is None:
|
||||
print(f"APPROVAL_DECIDE_DENY unknown_id {args.id}", file=sys.stderr)
|
||||
return 1
|
||||
if proposal.get("status") != "pending":
|
||||
print(f"APPROVAL_DECIDE_DENY not_pending status={proposal.get('status')}", file=sys.stderr)
|
||||
return 3
|
||||
if proposal.get("proposer") == args.approver:
|
||||
print(f"APPROVAL_DECIDE_DENY sod_self_approval actor={args.approver}", file=sys.stderr)
|
||||
return 3
|
||||
proposal["status"] = "approved" if args.decision == "approve" else "rejected"
|
||||
proposal["approver"] = args.approver
|
||||
proposal["decision_reason"] = args.reason
|
||||
proposal["decided_at"] = now_iso()
|
||||
append_oversight(store, {
|
||||
"event": f"proposal_{proposal['status']}",
|
||||
"proposal_id": proposal["id"],
|
||||
"actor": args.approver,
|
||||
"action": proposal.get("action"),
|
||||
"target": proposal.get("target"),
|
||||
"status": proposal["status"],
|
||||
"reason": args.reason,
|
||||
"at": proposal["decided_at"],
|
||||
})
|
||||
save_store(store)
|
||||
print(json.dumps(proposal, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
store = load_store()
|
||||
prev = GENESIS_HASH
|
||||
for entry in store["oversight"]:
|
||||
rest = {k: v for k, v in entry.items() if k != "hash"}
|
||||
if rest.get("prevHash") != prev or hash_entry(rest) != entry.get("hash"):
|
||||
print(f"APPROVAL_INBOX_AUDIT ok=false brokenAt={entry.get('seq')}")
|
||||
return 1
|
||||
prev = entry["hash"]
|
||||
print(f"APPROVAL_INBOX_AUDIT ok=true records={len(store['oversight'])} head={prev}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
r = sub.add_parser("resolve")
|
||||
r.add_argument("--project", default="default")
|
||||
r.add_argument("--action", required=True)
|
||||
r.add_argument("--risk", default="standard")
|
||||
r.add_argument("--sensitive", action="store_true")
|
||||
s = sub.add_parser("submit")
|
||||
s.add_argument("--project", default="default")
|
||||
s.add_argument("--action", required=True)
|
||||
s.add_argument("--target", required=True)
|
||||
s.add_argument("--risk", default="standard")
|
||||
s.add_argument("--sensitive", action="store_true")
|
||||
s.add_argument("--proposer", required=True)
|
||||
s.add_argument("--reason", required=True)
|
||||
s.add_argument("--payload", default="{}")
|
||||
l = sub.add_parser("list")
|
||||
l.add_argument("--status", default="pending", choices=["pending", "approved", "rejected", "auto_allowed", "all"])
|
||||
d = sub.add_parser("decide")
|
||||
d.add_argument("--id", required=True)
|
||||
d.add_argument("--decision", required=True)
|
||||
d.add_argument("--approver", required=True)
|
||||
d.add_argument("--reason", required=True)
|
||||
sub.add_parser("verify-audit")
|
||||
args = ap.parse_args()
|
||||
if args.cmd == "resolve":
|
||||
return cmd_resolve(args)
|
||||
if args.cmd == "submit":
|
||||
return cmd_submit(args)
|
||||
if args.cmd == "list":
|
||||
return cmd_list(args)
|
||||
if args.cmd == "decide":
|
||||
return cmd_decide(args)
|
||||
if args.cmd == "verify-audit":
|
||||
return cmd_verify(args)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -31,7 +31,7 @@ def _casan_app_root():
|
||||
_d = os.path.abspath(os.path.dirname(__file__))
|
||||
_p = _d
|
||||
while _p != os.path.dirname(_p):
|
||||
if os.path.isdir(os.path.join(_p, ".specify")):
|
||||
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
|
||||
return _p
|
||||
_p = os.path.dirname(_p)
|
||||
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
|
||||
|
||||
@@ -24,11 +24,15 @@ fi
|
||||
|
||||
_casan_paths_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Walk up from a starting dir until a dir containing `.specify/` is found.
|
||||
# Walk up from a starting dir to the app/bundle root. Primary marker is the `.specify`
|
||||
# state dir (present in an adopted repo). Secondary marker is `packages/casan-harness`
|
||||
# (present in a freshly-extracted release bundle that has no `.specify` yet) — so a
|
||||
# just-unpacked core/devkit/demo bundle resolves correctly and creates `.specify` on
|
||||
# first write. In a real repo `.specify` matches first, so behavior is unchanged.
|
||||
_casan_find_app_root() {
|
||||
local d="$1"
|
||||
while [[ -n "$d" && "$d" != "/" ]]; do
|
||||
if [[ -d "$d/.specify" ]]; then
|
||||
if [[ -d "$d/.specify" || -d "$d/packages/casan-harness" ]]; then
|
||||
printf '%s\n' "$d"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -89,6 +89,7 @@ run "phase-h4-split-inject" bash "$TESTS/phase-h4-split-inject-tests.sh"
|
||||
run "phase10-traceability" bash "$TESTS/phase10-traceability-tests.sh"
|
||||
run "phase08-compression" bash "$TESTS/phase08-compression-tests.sh"
|
||||
run "phase-control-plane" bash "$TESTS/phase-control-plane-tests.sh"
|
||||
run "phase-control-plane-hitl" bash "$TESTS/phase-control-plane-hitl-tests.sh"
|
||||
run "phase-rbac" bash "$TESTS/phase-rbac-tests.sh"
|
||||
run "phase-rbac-audit" bash "$TESTS/phase-rbac-audit-tests.sh"
|
||||
run "phase-rai" bash "$TESTS/phase-rai-tests.sh"
|
||||
|
||||
@@ -27,7 +27,7 @@ def _casan_app_root():
|
||||
_d = os.path.abspath(os.path.dirname(__file__))
|
||||
_p = _d
|
||||
while _p != os.path.dirname(_p):
|
||||
if os.path.isdir(os.path.join(_p, ".specify")):
|
||||
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
|
||||
return _p
|
||||
_p = os.path.dirname(_p)
|
||||
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
|
||||
|
||||
@@ -54,7 +54,7 @@ def project_root() -> str:
|
||||
d = os.path.abspath(os.path.dirname(__file__))
|
||||
p = d
|
||||
while p != os.path.dirname(p):
|
||||
if os.path.isdir(os.path.join(p, ".specify")):
|
||||
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
|
||||
return p
|
||||
p = os.path.dirname(p)
|
||||
return os.path.abspath(os.path.join(d, "..", "..", ".."))
|
||||
|
||||
@@ -25,7 +25,7 @@ def _app_root(start):
|
||||
# dashboards + runtime logs live at the app's `.specify`, so walk UP for it.
|
||||
d = pathlib.Path(start).resolve()
|
||||
for p in (d, *d.parents):
|
||||
if (p / ".specify").is_dir():
|
||||
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
|
||||
return p
|
||||
return d.parents[3]
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ def project_root() -> str:
|
||||
d = os.path.abspath(os.path.dirname(__file__))
|
||||
p = d
|
||||
while p != os.path.dirname(p):
|
||||
if os.path.isdir(os.path.join(p, ".specify")):
|
||||
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
|
||||
return p
|
||||
p = os.path.dirname(p)
|
||||
return os.path.abspath(os.path.join(d, "..", "..", ".."))
|
||||
|
||||
@@ -31,7 +31,7 @@ def _casan_app_root():
|
||||
_d = os.path.abspath(os.path.dirname(__file__))
|
||||
_p = _d
|
||||
while _p != os.path.dirname(_p):
|
||||
if os.path.isdir(os.path.join(_p, ".specify")):
|
||||
if os.path.isdir(os.path.join(_p, ".specify")) or os.path.isdir(os.path.join(_p, "packages/casan-harness")):
|
||||
return _p
|
||||
_p = os.path.dirname(_p)
|
||||
return os.path.abspath(os.path.join(_d, "..", "..", ".."))
|
||||
|
||||
@@ -39,7 +39,7 @@ def project_root() -> str:
|
||||
d = os.path.abspath(os.path.dirname(__file__))
|
||||
p = d
|
||||
while p != os.path.dirname(p):
|
||||
if os.path.isdir(os.path.join(p, ".specify")):
|
||||
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
|
||||
return p
|
||||
p = os.path.dirname(p)
|
||||
return os.path.abspath(os.path.join(d, "..", "..", ".."))
|
||||
|
||||
@@ -22,7 +22,7 @@ def project_root() -> str:
|
||||
d = os.path.abspath(os.path.dirname(__file__))
|
||||
p = d
|
||||
while p != os.path.dirname(p):
|
||||
if os.path.isdir(os.path.join(p, ".specify")):
|
||||
if os.path.isdir(os.path.join(p, ".specify")) or os.path.isdir(os.path.join(p, "packages/casan-harness")):
|
||||
return p
|
||||
p = os.path.dirname(p)
|
||||
return os.path.abspath(os.path.join(d, "..", "..", ".."))
|
||||
|
||||
@@ -10,7 +10,7 @@ def _app_root(start):
|
||||
# runtime state lives at the app's `.specify`, so walk UP for that marker.
|
||||
d = pathlib.Path(start).resolve()
|
||||
for p in (d, *d.parents):
|
||||
if (p / ".specify").is_dir():
|
||||
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
|
||||
return p
|
||||
return d.parents[2]
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ def _app_root(start):
|
||||
# a fixed parent depth.
|
||||
d = pathlib.Path(start).resolve()
|
||||
for p in (d, *d.parents):
|
||||
if (p / ".specify").is_dir():
|
||||
if (p / ".specify").is_dir() or (p / "packages" / "casan-harness").is_dir():
|
||||
return p
|
||||
return d.parents[2]
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN Plan-13 — Control Plane HITL approval inbox + delegation.
|
||||
# Deterministic; no app/model required. Proves delegation resolution, pending
|
||||
# inbox, approve/reject oversight hash-chain, SoD, and governed setting apply.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
||||
INBOX="$CASAN_HARNESS_ROOT/scripts/bash/approval-inbox.py"
|
||||
CP="$CASAN_HARNESS_ROOT/scripts/bash/control-plane-settings.py"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
export CASAN_APPROVAL_INBOX_FILE="$WORK/approval-inbox.json"
|
||||
export CASAN_CP_STORE_FILE="$WORK/settings.json"
|
||||
export CASAN_CP_KEY_DIR="$WORK/keys"
|
||||
export CASAN_CP_PUB="$WORK/cp.pub"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
echo "===== Plan-13 HITL approval inbox + delegation ====="
|
||||
|
||||
set +e
|
||||
python3 "$INBOX" resolve --project default --action settings.write.sensitive --risk high --sensitive > "$WORK/resolve-sensitive.json"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
[[ "$RC" -eq 2 ]] && grep -q '"requires_approval": true' "$WORK/resolve-sensitive.json" \
|
||||
&& pass "delegation resolver requires approval for high-risk sensitive setting" \
|
||||
|| fail "sensitive delegation did not require approval (rc=$RC)"
|
||||
|
||||
python3 "$INBOX" submit \
|
||||
--project default \
|
||||
--action settings.write \
|
||||
--target security.strict \
|
||||
--risk high \
|
||||
--sensitive \
|
||||
--proposer alice \
|
||||
--reason "tighten strict mode" \
|
||||
--payload '{"key":"security.strict","value":true}' > "$WORK/proposal.json" 2>"$WORK/submit.err"
|
||||
PID="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["id"])' "$WORK/proposal.json")"
|
||||
[[ -n "$PID" ]] && pass "submit creates a pending approval proposal ($PID)" || fail "proposal id missing"
|
||||
|
||||
python3 "$INBOX" list --status pending > "$WORK/pending.json"
|
||||
grep -q "$PID" "$WORK/pending.json" && pass "pending inbox lists submitted proposal" || fail "pending inbox missing proposal"
|
||||
|
||||
set +e
|
||||
python3 "$INBOX" decide --id "$PID" --decision approve --approver alice --reason self >/dev/null 2>"$WORK/self.err"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
[[ "$RC" -eq 3 ]] && grep -q "sod_self_approval" "$WORK/self.err" \
|
||||
&& pass "SoD blocks self-approval" || fail "self-approval was not blocked (rc=$RC)"
|
||||
|
||||
python3 "$INBOX" decide --id "$PID" --decision approve --approver bob --reason reviewed > "$WORK/approved.json" 2>"$WORK/approve.err"
|
||||
grep -q '"status": "approved"' "$WORK/approved.json" \
|
||||
&& pass "distinct approver approves proposal" || fail "approval decision failed"
|
||||
|
||||
python3 "$INBOX" verify-audit >/dev/null 2>&1 \
|
||||
&& pass "oversight hash-chain verifies intact" || fail "oversight hash-chain failed"
|
||||
|
||||
# Approved settings proposals are applied by the Control Panel API. The harness
|
||||
# primitive still provides the approval token shape used by that API; prove the
|
||||
# governed store accepts and audits it.
|
||||
python3 "$CP" set security.strict true --actor alice --reason "approved:$PID:reviewed" --approval "inbox:$PID:bob" >/dev/null 2>&1 \
|
||||
&& pass "approved proposal can apply via governed settings store" || fail "governed set failed after approval"
|
||||
python3 "$CP" verify-audit >/dev/null 2>&1 \
|
||||
&& pass "settings audit verifies after approved apply" || fail "settings audit failed after approved apply"
|
||||
|
||||
python3 - "$CASAN_APPROVAL_INBOX_FILE" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
d["oversight"][0]["status"] = "tampered"
|
||||
json.dump(d, open(sys.argv[1], "w"))
|
||||
PY
|
||||
set +e
|
||||
python3 "$INBOX" verify-audit >/dev/null 2>"$WORK/tamper.err"
|
||||
RC=$?
|
||||
set -e 2>/dev/null || true
|
||||
[[ "$RC" -eq 1 ]] && pass "oversight hash-chain detects tampering" || fail "oversight tamper not detected (rc=$RC)"
|
||||
|
||||
echo ""
|
||||
echo "===== CONTROL-PLANE HITL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
@@ -1,8 +1,9 @@
|
||||
# CASAN Platform (Level 3 — Productization UI) · **PREVIEW / structure-only**
|
||||
# CASAN Platform (Level 3 — Productization UI) · **PREVIEW**
|
||||
|
||||
> Status: **PREVIEW.** Only the AgentOps **dashboard** exists today (shipped inside the core
|
||||
> harness). The rest of the platform UI is scaffolded here as structure + intent — NOT
|
||||
> implemented in this task. `package-release.sh platform` builds a clearly-stamped
|
||||
> Status: **PREVIEW.** The AgentOps dashboard and Plan-13 Control Panel, including
|
||||
> Command Center baseline, exist today.
|
||||
> Evidence/attack viewers, Gitea integration, Ask CASAN, and managed production rollout
|
||||
> remain planned. `package-release.sh platform` builds a clearly-stamped
|
||||
> `casan-platform-preview-*` bundle containing only what exists.
|
||||
|
||||
Optional layer for teams that want UI / dashboard / visibility. Packages: `casan-platform`,
|
||||
@@ -12,9 +13,9 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan
|
||||
| Component | Status | Where |
|
||||
|---|---|---|
|
||||
| AgentOps Dashboard | ✅ exists | `packages/casan-harness/scripts/bash/dashboard-server.py` + `dashboard-serve.sh` (`casan dashboard`) |
|
||||
| **Ops Console (Control Panel)** | ✅ **read-only (Plan-13 Track 1)** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability views. |
|
||||
| Management / settings writes | 📋 planned | Plan-13 Track 2 (wrap `control-plane-settings.py`) |
|
||||
| RBAC + approval inbox | 📋 planned | Plan-13 Track 3 / Plan-14 |
|
||||
| **Ops Console (Control Panel)** | ✅ **monitoring + governed settings + HITL + kill-switch + FinOps/SLO + Command Center + local-prod TLS/OIDC smoke** | `packages/casan-control-panel/` — NestJS API + React UI over harness telemetry, settings management, approval inbox, kill-switch, FinOps/SLO, and Command Center (`npm run console:api` + `console:ui`). Includes Run History + verdicts + governance + security + incidents + traceability + FinOps + Approvals + role-aware Settings page + `/command` evidence-backed executive view. |
|
||||
| Management / settings writes | ✅ done+test | Plan-13 Track 2: wraps `control-plane-settings.py`, calls `rbac-check.py`, supports set/rollback/audit verify. |
|
||||
| RBAC + approval inbox | ✅ local-prod done | Settings + kill-switch API RBAC enforcement, approval inbox/delegation/oversight, SoD, governed setting proposal apply, and local OIDC claim→role mapping smoke are done. Enterprise IdP rollout remains production follow-up. |
|
||||
| Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` |
|
||||
| Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results |
|
||||
| Read-only Ask CASAN | 📋 planned | Plan-18 MVP-0 (read-only) |
|
||||
@@ -27,5 +28,6 @@ scripts/package-release.sh platform # → dist/casan-platform-preview-vX.Y.Z
|
||||
The bundle includes a `PREVIEW-INCOMPLETE.txt` marker. Do not treat it as a finished product.
|
||||
|
||||
## To implement later
|
||||
Start from Plan-13 (Control Plane) + Plan-18 MVP-0 (Ask CASAN read-only). Keep the UI
|
||||
**read-only over harness artifacts** first; write/governed actions belong to Level 4.
|
||||
Start from Plan-15 RAI view or Plan-18 MVP-0 (Ask CASAN read-only). Keep new numbers
|
||||
evidence-backed with provenance; any write/governed action must continue to route through
|
||||
harness RBAC, approval, and audit primitives.
|
||||
|
||||
Reference in New Issue
Block a user