176 lines
5.4 KiB
TypeScript
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 };
|
|
}
|
|
}
|
|
}
|