Files
CASAN/packages/casan-control-panel/backend/src/approvals/approvals.service.ts
T
2026-07-19 09:37:16 +07:00

335 lines
12 KiB
TypeScript

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<string, unknown>;
}
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<T>(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<string, unknown>;
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<Record<string, any>>; oversight?: Array<Record<string, any>> }>(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<Record<string, any>>(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<Record<string, any>>(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<Record<string, any>>(res.stdout, { proposals: [] });
const proposal = (store.proposals ?? []).find((p: Record<string, any>) => 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<string, any>) {
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<string, any>, approvalInput: string): Promise<string | undefined> {
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<string, any>, 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<Record<string, any>>(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 };
}
}
}