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(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>(runPython(CP_CLI, ['list-policy']).stdout, {}); const settings = parseJson>(runPython(CP_CLI, ['get-all']).stdout, {}); const audit = parseJson(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>(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>(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>(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 }; } } }