fix(control-panel): sign strict goal approvals via oidc
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -33,6 +34,10 @@ interface CommandResult {
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface ApprovalTokenResponse {
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
|
||||
try {
|
||||
const stdout = execFileSync(command, args, {
|
||||
@@ -113,14 +118,14 @@ export class ApprovalsService {
|
||||
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
|
||||
}
|
||||
|
||||
decide(input: ApprovalDecision, actor: SettingsActor) {
|
||||
async decide(input: ApprovalDecision, actor: SettingsActor) {
|
||||
if (!input.id || !input.decision || !input.reason) {
|
||||
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
|
||||
}
|
||||
this.requireRbac(actor, 'approval', 'grant');
|
||||
try {
|
||||
const pending = this.findProposal(input.id, actor);
|
||||
this.verifyApprovalIdentity(input, actor, pending);
|
||||
await this.verifyApprovalIdentity(input, actor, pending);
|
||||
const res = runFile('python3', [
|
||||
INBOX_CLI,
|
||||
'decide',
|
||||
@@ -153,19 +158,21 @@ export class ApprovalsService {
|
||||
return proposal;
|
||||
}
|
||||
|
||||
private verifyApprovalIdentity(input: ApprovalDecision, actor: SettingsActor, proposal: Record<string, any>) {
|
||||
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 {
|
||||
writeFileSync(inputPath, stableJson({
|
||||
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'),
|
||||
@@ -173,7 +180,7 @@ export class ApprovalsService {
|
||||
inputPath,
|
||||
actor.actor,
|
||||
'-',
|
||||
], input.approvalJwt ? { CASAN_APPROVAL_JWT: input.approvalJwt } : undefined);
|
||||
], approvalJwt ? { CASAN_APPROVAL_JWT: approvalJwt } : undefined);
|
||||
} catch (err: any) {
|
||||
throw new ForbiddenException(err.stderr || err.message || 'APPROVAL_DECIDE_DENY approval identity failed');
|
||||
} finally {
|
||||
@@ -181,6 +188,71 @@ export class ApprovalsService {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user