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
+9
View File
@@ -38,6 +38,14 @@ services:
CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130 CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130
CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-} CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-}
CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED: "1" CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED: "1"
# Goal approval decisions are signed by the local OIDC test IdP and then
# verified by H5. The UI never receives or stores this short-lived JWT.
CASAN_APPROVAL_TOKEN_URL: http://idp:8080/token
CASAN_APPROVAL_TOKEN_AUTH_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret}
CASAN_APPROVAL_TOKEN_ALLOWED_HOSTS: idp
CASAN_APPROVAL_TOKEN_ALLOW_HTTP: "1"
CASAN_APPROVAL_SIGNER_ROLE: ops
CASAN_IDP_JWKS_URL: http://idp:8080/.well-known/jwks.json
volumes: volumes:
- ./.specify:/app/.specify - ./.specify:/app/.specify
- ./docs/output:/app/docs/output:ro - ./docs/output:/app/docs/output:ro
@@ -107,6 +115,7 @@ services:
CASAN_IDP_SUB: oidc-ops CASAN_IDP_SUB: oidc-ops
CASAN_IDP_EMAIL: oidc-ops@example.com CASAN_IDP_EMAIL: oidc-ops@example.com
CASAN_IDP_GROUPS: casan-org-admin,casan-approver CASAN_IDP_GROUPS: casan-org-admin,casan-approver
CASAN_APPROVAL_SIGNER_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret}
ports: ports:
- "18082:8080" - "18082:8080"
networks: networks:
+7
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import hmac
import os import os
import time import time
import uuid import uuid
@@ -18,6 +19,7 @@ DEFAULT_GROUPS = [g for g in os.environ.get("CASAN_IDP_GROUPS", "casan-org-admin
KID = "casan-local-prod-idp" KID = "casan-local-prod-idp"
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
CODES = {} CODES = {}
APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "")
def b64u_int(value: int) -> str: def b64u_int(value: int) -> str:
@@ -122,6 +124,10 @@ class Handler(BaseHTTPRequestHandler):
raw = self.rfile.read(size) or b"{}" raw = self.rfile.read(size) or b"{}"
ctype = self.headers.get("Content-Type", "") ctype = self.headers.get("Content-Type", "")
if "application/json" in ctype: if "application/json" in ctype:
supplied_token = self.headers.get("X-CASAN-Approval-Signer-Token", "")
if not APPROVAL_SIGNER_TOKEN or not hmac.compare_digest(supplied_token, APPROVAL_SIGNER_TOKEN):
self.send_json(401, {"error": "approval_signer_unauthorized"})
return
try: try:
payload = json.loads(raw) payload = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -136,6 +142,7 @@ class Handler(BaseHTTPRequestHandler):
"action": payload.get("action", "deploy"), "action": payload.get("action", "deploy"),
"actor": payload.get("actor", "alice"), "actor": payload.get("actor", "alice"),
"input_sha256": payload.get("input_sha256", ""), "input_sha256": payload.get("input_sha256", ""),
"jti": uuid.uuid4().hex,
"iat": now, "iat": now,
"exp": now + int(payload.get("ttl_s", 300)), "exp": now + int(payload.get("ttl_s", 300)),
} }
@@ -18,7 +18,7 @@ export class ApprovalsController {
} }
@Post('decide') @Post('decide')
decide(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalDecision) { async decide(@Headers() headers: Record<string, string | string[] | undefined>, @Body() body: ApprovalDecision) {
return ok(this.svc.decide(body, actorFromHeaders(headers))); return ok(await this.svc.decide(body, actorFromHeaders(headers)));
} }
} }
@@ -1,5 +1,6 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -33,6 +34,10 @@ interface CommandResult {
stderr: string; stderr: string;
} }
interface ApprovalTokenResponse {
access_token?: string;
}
function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult { function runFile(command: string, args: string[], env?: NodeJS.ProcessEnv): CommandResult {
try { try {
const stdout = execFileSync(command, args, { 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) }; 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) { if (!input.id || !input.decision || !input.reason) {
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required'); throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
} }
this.requireRbac(actor, 'approval', 'grant'); this.requireRbac(actor, 'approval', 'grant');
try { try {
const pending = this.findProposal(input.id, actor); const pending = this.findProposal(input.id, actor);
this.verifyApprovalIdentity(input, actor, pending); await this.verifyApprovalIdentity(input, actor, pending);
const res = runFile('python3', [ const res = runFile('python3', [
INBOX_CLI, INBOX_CLI,
'decide', 'decide',
@@ -153,19 +158,21 @@ export class ApprovalsService {
return proposal; 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); const strict = process.env.CASAN_APPROVAL_STRICT === '1' || process.env.CASAN_PROFILE === 'prod' || Boolean(input.approvalJwt);
if (!strict) return; if (!strict) return;
const work = mkdtempSync(join(tmpdir(), 'cp-approval-verify-')); const work = mkdtempSync(join(tmpdir(), 'cp-approval-verify-'));
const inputPath = join(work, 'approval-input.json'); const inputPath = join(work, 'approval-input.json');
try { try {
writeFileSync(inputPath, stableJson({ const approvalInput = stableJson({
id: proposal.id, id: proposal.id,
action: proposal.action, action: proposal.action,
target: proposal.target, target: proposal.target,
proposer: proposal.proposer, proposer: proposal.proposer,
payload: proposal.payload ?? {}, payload: proposal.payload ?? {},
})); });
writeFileSync(inputPath, approvalInput);
const approvalJwt = input.approvalJwt || await this.mintApprovalJwt(actor, proposal, approvalInput);
runFile('bash', [ runFile('bash', [
APPROVAL_VERIFY, APPROVAL_VERIFY,
String(proposal.action ?? 'default'), String(proposal.action ?? 'default'),
@@ -173,7 +180,7 @@ export class ApprovalsService {
inputPath, inputPath,
actor.actor, actor.actor,
'-', '-',
], input.approvalJwt ? { CASAN_APPROVAL_JWT: input.approvalJwt } : undefined); ], approvalJwt ? { CASAN_APPROVAL_JWT: approvalJwt } : undefined);
} catch (err: any) { } catch (err: any) {
throw new ForbiddenException(err.stderr || err.message || 'APPROVAL_DECIDE_DENY approval identity failed'); throw new ForbiddenException(err.stderr || err.message || 'APPROVAL_DECIDE_DENY approval identity failed');
} finally { } 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) { private applyApprovedProposal(proposal: Record<string, any>, actor: SettingsActor) {
if (proposal.action !== 'settings.write') return null; if (proposal.action !== 'settings.write') return null;
const key = proposal.payload?.key; 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 approver = { actor: 'bob', role: 'approver', project: 'default', tenant: 'default' };
const orgAdmin = { actor: 'root', role: 'org-admin', 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 prevInbox = process.env.CASAN_APPROVAL_INBOX_FILE;
const prevStrict = process.env.CASAN_APPROVAL_STRICT; const prevStrict = process.env.CASAN_APPROVAL_STRICT;
const prevStore = process.env.CASAN_CP_STORE_FILE; 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_KEY_DIR = join(work, 'keys');
process.env.CASAN_CP_PUB = join(work, 'cp.pub'); process.env.CASAN_CP_PUB = join(work, 'cp.pub');
try { 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 { } finally {
if (prevInbox === undefined) delete process.env.CASAN_APPROVAL_INBOX_FILE; if (prevInbox === undefined) delete process.env.CASAN_APPROVAL_INBOX_FILE;
else process.env.CASAN_APPROVAL_INBOX_FILE = prevInbox; 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', () => { test('approval inbox submit -> approve applies governed setting and writes oversight', async () => {
withTempGovernance(({ inbox, store }) => { await withTempGovernance(async ({ inbox, store }) => {
const svc = new ApprovalsService(); const svc = new ApprovalsService();
const submitted = svc.submit({ const submitted = svc.submit({
action: 'settings.write', 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.status, 'pending');
assert.equal(submitted.proposal.delegation.requires_approval, true); 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.proposal.status, 'approved');
assert.equal(decided.applied.value, true); 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', () => { test('approval inbox denies forged JWT in strict mode without deciding proposal', async () => {
withTempGovernance(({ inbox }) => { await withTempGovernance(async ({ inbox }) => {
const svc = new ApprovalsService(); const svc = new ApprovalsService();
const submitted = svc.submit({ const submitted = svc.submit({
action: 'settings.write', 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 }, payload: { key: 'security.strict', value: true },
}, projectAdmin) as any; }, projectAdmin) as any;
process.env.CASAN_APPROVAL_STRICT = '1'; process.env.CASAN_APPROVAL_STRICT = '1';
assert.throws( await assert.rejects(
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'forged jwt', approvalJwt: 'fake.jwt.token' }, approver), svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'forged jwt', approvalJwt: 'fake.jwt.token' }, approver),
ForbiddenException, ForbiddenException,
); );
const inboxRaw = JSON.parse(readFileSync(inbox, 'utf8')); 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', () => { test('approval inbox enforces separation of duties', async () => {
withTempGovernance(() => { await withTempGovernance(async () => {
const svc = new ApprovalsService(); const svc = new ApprovalsService();
const submitted = svc.submit({ const submitted = svc.submit({
action: 'settings.write', action: 'settings.write',
@@ -101,8 +101,8 @@ test('approval inbox enforces separation of duties', () => {
reason: 'change by root', reason: 'change by root',
payload: { key: 'compression.enabled', value: true }, payload: { key: 'compression.enabled', value: true },
}, orgAdmin) as any; }, orgAdmin) as any;
assert.throws( await assert.rejects(
() => svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'self approve' }, orgAdmin), svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'self approve' }, orgAdmin),
ForbiddenException, ForbiddenException,
); );
}); });