250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
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;
|
|
approvalJwt?: string;
|
|
}
|
|
|
|
const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash');
|
|
const INBOX_CLI = join(HARNESS_BIN, 'approval-inbox.py');
|
|
const APPROVAL_VERIFY = join(HARNESS_BIN, 'approval-verify.sh');
|
|
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;
|
|
}
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
if (value && typeof value === 'object') {
|
|
const obj = value as Record<string, unknown>;
|
|
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
|
|
}
|
|
if (value === undefined) return 'null';
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
@Injectable()
|
|
export class ApprovalsService {
|
|
list(actor: SettingsActor, status = 'pending') {
|
|
this.requireRbac(actor, 'monitoring', 'read');
|
|
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status], this.tenantEnv(actor));
|
|
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit(actor) };
|
|
}
|
|
|
|
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, this.tenantEnv(actor));
|
|
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
|
|
}
|
|
|
|
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 pending = this.findProposal(input.id, actor);
|
|
this.verifyApprovalIdentity(input, actor, pending);
|
|
const res = runFile('python3', [
|
|
INBOX_CLI,
|
|
'decide',
|
|
'--id',
|
|
input.id,
|
|
'--decision',
|
|
input.decision,
|
|
'--approver',
|
|
actor.actor,
|
|
'--reason',
|
|
input.reason,
|
|
], this.tenantEnv(actor));
|
|
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(actor) };
|
|
} catch (err: any) {
|
|
if (err instanceof ForbiddenException) throw err;
|
|
if (Number(err.status) === 3 || Number(err.status) === 1) {
|
|
throw new ForbiddenException(err.stderr || err.message);
|
|
}
|
|
throw new InternalServerErrorException(err.stderr || err.message);
|
|
}
|
|
}
|
|
|
|
private findProposal(id: string, actor: SettingsActor) {
|
|
const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all'], this.tenantEnv(actor));
|
|
const store = parseJson<Record<string, any>>(res.stdout, { proposals: [] });
|
|
const proposal = (store.proposals ?? []).find((p: Record<string, any>) => p.id === id);
|
|
if (!proposal) throw new ForbiddenException(`APPROVAL_DECIDE_DENY unknown_id ${id}`);
|
|
return proposal;
|
|
}
|
|
|
|
private verifyApprovalIdentity(input: ApprovalDecision, actor: SettingsActor, proposal: Record<string, any>) {
|
|
const strict = process.env.CASAN_APPROVAL_STRICT === '1' || process.env.CASAN_PROFILE === 'prod' || Boolean(input.approvalJwt);
|
|
if (!strict) return;
|
|
const work = mkdtempSync(join(tmpdir(), 'cp-approval-verify-'));
|
|
const inputPath = join(work, 'approval-input.json');
|
|
try {
|
|
writeFileSync(inputPath, stableJson({
|
|
id: proposal.id,
|
|
action: proposal.action,
|
|
target: proposal.target,
|
|
proposer: proposal.proposer,
|
|
payload: proposal.payload ?? {},
|
|
}));
|
|
runFile('bash', [
|
|
APPROVAL_VERIFY,
|
|
String(proposal.action ?? 'default'),
|
|
String(proposal.proposer ?? ''),
|
|
inputPath,
|
|
actor.actor,
|
|
'-',
|
|
], input.approvalJwt ? { CASAN_APPROVAL_JWT: input.approvalJwt } : undefined);
|
|
} catch (err: any) {
|
|
throw new ForbiddenException(err.stderr || err.message || 'APPROVAL_DECIDE_DENY approval identity failed');
|
|
} finally {
|
|
rmSync(work, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
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}`,
|
|
], this.tenantEnv(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 tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
|
|
return { CASAN_TENANT_ID: actor.tenant || 'default' };
|
|
}
|
|
|
|
private verifyAudit(actor: SettingsActor) {
|
|
try {
|
|
const res = runFile('python3', [INBOX_CLI, 'verify-audit'], this.tenantEnv(actor));
|
|
return { ok: true, output: res.stdout };
|
|
} catch (err: any) {
|
|
return { ok: false, output: err.stderr || err.stdout || err.message };
|
|
}
|
|
}
|
|
}
|