feat: add control panel
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user