fix(control-panel): sign strict goal approvals via oidc

This commit is contained in:
thanhnv
2026-07-18 13:59:15 +07:00
parent c0a6dbc25f
commit 03ddeb7123
5 changed files with 109 additions and 21 deletions
@@ -18,7 +18,7 @@ export class ApprovalsController {
}
@Post('decide')
decide(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalDecision) {
return ok(this.svc.decide(body, actorFromHeaders(headers)));
async decide(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalDecision) {
return ok(await this.svc.decide(body, actorFromHeaders(headers)));
}
}
@@ -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;
@@ -10,7 +10,7 @@ const projectAdmin = { actor: 'alice', role: 'project-admin', project: 'default'
const approver = { actor: 'bob', role: 'approver', project: 'default', tenant: 'default' };
const orgAdmin = { actor: 'root', role: 'org-admin', project: 'default', tenant: 'default' };
function withTempGovernance(fn: (paths: { inbox: string; store: string }) => void) {
async function withTempGovernance(fn: (paths: { inbox: string; store: string }) => Promise<void> | void) {
const prevInbox = process.env.CASAN_APPROVAL_INBOX_FILE;
const prevStrict = process.env.CASAN_APPROVAL_STRICT;
const prevStore = process.env.CASAN_CP_STORE_FILE;
@@ -24,7 +24,7 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi
process.env.CASAN_CP_KEY_DIR = join(work, 'keys');
process.env.CASAN_CP_PUB = join(work, 'cp.pub');
try {
fn({ inbox: process.env.CASAN_APPROVAL_INBOX_FILE, store: process.env.CASAN_CP_STORE_FILE });
await fn({ inbox: process.env.CASAN_APPROVAL_INBOX_FILE, store: process.env.CASAN_CP_STORE_FILE });
} finally {
if (prevInbox === undefined) delete process.env.CASAN_APPROVAL_INBOX_FILE;
else process.env.CASAN_APPROVAL_INBOX_FILE = prevInbox;
@@ -41,8 +41,8 @@ function withTempGovernance(fn: (paths: { inbox: string; store: string }) => voi
}
}
test('approval inbox submit -> approve applies governed setting and writes oversight', () => {
withTempGovernance(({ inbox, store }) => {
test('approval inbox submit -> approve applies governed setting and writes oversight', async () => {
await withTempGovernance(async ({ inbox, store }) => {
const svc = new ApprovalsService();
const submitted = svc.submit({
action: 'settings.write',
@@ -55,7 +55,7 @@ test('approval inbox submit -> approve applies governed setting and writes overs
assert.equal(submitted.proposal.status, 'pending');
assert.equal(submitted.proposal.delegation.requires_approval, true);
const decided = svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'approved by reviewer' }, approver) as any;
const decided = await svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'approved by reviewer' }, approver) as any;
assert.equal(decided.proposal.status, 'approved');
assert.equal(decided.applied.value, true);
@@ -69,8 +69,8 @@ test('approval inbox submit -> approve applies governed setting and writes overs
});
});
test('approval inbox denies forged JWT in strict mode without deciding proposal', () => {
withTempGovernance(({ inbox }) => {
test('approval inbox denies forged JWT in strict mode without deciding proposal', async () => {
await withTempGovernance(async ({ inbox }) => {
const svc = new ApprovalsService();
const submitted = svc.submit({
action: 'settings.write',
@@ -81,8 +81,8 @@ test('approval inbox denies forged JWT in strict mode without deciding proposal'
payload: { key: 'security.strict', value: true },
}, projectAdmin) as any;
process.env.CASAN_APPROVAL_STRICT = '1';
assert.throws(
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'forged jwt', approvalJwt: 'fake.jwt.token' }, approver),
await assert.rejects(
svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'forged jwt', approvalJwt: 'fake.jwt.token' }, approver),
ForbiddenException,
);
const inboxRaw = JSON.parse(readFileSync(inbox, 'utf8'));
@@ -91,8 +91,8 @@ test('approval inbox denies forged JWT in strict mode without deciding proposal'
});
});
test('approval inbox enforces separation of duties', () => {
withTempGovernance(() => {
test('approval inbox enforces separation of duties', async () => {
await withTempGovernance(async () => {
const svc = new ApprovalsService();
const submitted = svc.submit({
action: 'settings.write',
@@ -101,8 +101,8 @@ test('approval inbox enforces separation of duties', () => {
reason: 'change by root',
payload: { key: 'compression.enabled', value: true },
}, orgAdmin) as any;
assert.throws(
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'self approve' }, orgAdmin),
await assert.rejects(
svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'self approve' }, orgAdmin),
ForbiddenException,
);
});