feat: add control panel
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user