import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; 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; } interface ApprovalTokenResponse { access_token?: 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)); const inbox = parseJson<{ proposals?: Array>; oversight?: Array> }>(res.stdout, { proposals: [], oversight: [] }); const proposals = (inbox.proposals ?? []).filter((proposal) => this.canAccessProject(actor, String(proposal.project ?? actor.project))); const visibleIds = new Set(proposals.map((proposal) => String(proposal.id ?? ''))); const oversight = (inbox.oversight ?? []).filter((record) => visibleIds.has(String(record.proposal_id ?? ''))); return { ...inbox, count: proposals.length, 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) }; } async decide(input: ApprovalDecision, actor: SettingsActor) { if (!input.id || !input.decision || !input.reason) { throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required'); } try { const pending = this.findProposal(input.id, actor); this.requireRbac(actor, 'approval', 'grant', String(pending.project ?? actor.project)); await 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 async 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 { const approvalInput = stableJson({ id: proposal.id, action: proposal.action, target: proposal.target, proposer: proposal.proposer, payload: proposal.payload ?? {}, }); writeFileSync(inputPath, approvalInput); const approvalJwt = input.approvalJwt || await this.mintApprovalJwt(actor, proposal, approvalInput); runFile('bash', [ APPROVAL_VERIFY, String(proposal.action ?? 'default'), String(proposal.proposer ?? ''), inputPath, actor.actor, '-', ], approvalJwt ? { CASAN_APPROVAL_JWT: 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 async mintApprovalJwt(actor: SettingsActor, proposal: Record, approvalInput: string): Promise { const endpoint = process.env.CASAN_APPROVAL_TOKEN_URL?.trim(); if (!endpoint) return undefined; const signerToken = process.env.CASAN_APPROVAL_TOKEN_AUTH_TOKEN?.trim(); if (!signerToken) { throw new ForbiddenException('APPROVAL_DECIDE_DENY approval signer credential missing'); } let url: URL; try { url = new URL(endpoint); } catch { throw new ForbiddenException('APPROVAL_DECIDE_DENY approval signer URL invalid'); } const allowedHosts = new Set((process.env.CASAN_APPROVAL_TOKEN_ALLOWED_HOSTS ?? '') .split(',') .map((host) => host.trim().toLowerCase()) .filter(Boolean)); const httpAllowed = process.env.CASAN_APPROVAL_TOKEN_ALLOW_HTTP === '1'; if (!allowedHosts.has(url.hostname.toLowerCase()) || (url.protocol !== 'https:' && !(httpAllowed && url.protocol === 'http:'))) { throw new ForbiddenException('APPROVAL_DECIDE_DENY approval signer URL not allowed'); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CASAN-Approval-Signer-Token': signerToken, }, body: JSON.stringify({ sub: actor.actor, role: process.env.CASAN_APPROVAL_SIGNER_ROLE?.trim() || this.approvalRole(actor.role), action: String(proposal.action ?? 'default'), actor: String(proposal.proposer ?? ''), input_sha256: createHash('sha256').update(approvalInput).digest('hex'), ttl_s: 120, }), signal: controller.signal, }); if (!response.ok) { throw new ForbiddenException(`APPROVAL_DECIDE_DENY approval signer HTTP ${response.status}`); } const token = (await response.json()) as ApprovalTokenResponse; if (!token.access_token) { throw new ForbiddenException('APPROVAL_DECIDE_DENY approval signer response missing token'); } return token.access_token; } catch (err: unknown) { if (err instanceof ForbiddenException) throw err; const message = err instanceof Error ? err.message : String(err); throw new ForbiddenException(`APPROVAL_DECIDE_DENY approval signer unavailable: ${message}`); } finally { clearTimeout(timeout); } } private approvalRole(role: string): string { if (role === 'project-admin') return 'project_owner'; return 'ops'; } 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 canAccessProject(actor: SettingsActor, targetProject: string): boolean { try { this.requireRbac(actor, 'monitoring', 'read', targetProject); return true; } catch { return false; } } private requireRbac(actor: SettingsActor, resource: string, action: string, targetProject = actor.project) { try { runFile('python3', [ RBAC_CLI, 'check', '--role', actor.role, '--resource', resource, '--action', action, '--role-project', actor.project, '--target-project', targetProject, '--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 }; } } }