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; } 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(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; 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>(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>(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>(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>(res.stdout, { proposals: [] }); const proposal = (store.proposals ?? []).find((p: Record) => p.id === id); if (!proposal) throw new ForbiddenException(`APPROVAL_DECIDE_DENY unknown_id ${id}`); return proposal; } private verifyApprovalIdentity(input: ApprovalDecision, actor: SettingsActor, proposal: Record) { 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, 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>(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 }; } } }