Files
CASAN/packages/casan-control-panel/backend/src/settings/settings.service.ts
T
2026-07-08 19:07:35 +09:00

176 lines
5.4 KiB
TypeScript

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