From 03ddeb7123fff39f1d270a83c0f7bbf272ab7e21 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Sat, 18 Jul 2026 13:59:15 +0700 Subject: [PATCH] fix(control-panel): sign strict goal approvals via oidc --- docker-compose.control-panel.local.yml | 9 ++ infra/local-prod/idp/mock_idp.py | 7 ++ .../src/approvals/approvals.controller.ts | 4 +- .../src/approvals/approvals.service.ts | 84 +++++++++++++++++-- .../backend/test/approvals.test.ts | 26 +++--- 5 files changed, 109 insertions(+), 21 deletions(-) diff --git a/docker-compose.control-panel.local.yml b/docker-compose.control-panel.local.yml index ddacac6..5d73b83 100644 --- a/docker-compose.control-panel.local.yml +++ b/docker-compose.control-panel.local.yml @@ -38,6 +38,14 @@ services: CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130 CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-} 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: - ./.specify:/app/.specify - ./docs/output:/app/docs/output:ro @@ -107,6 +115,7 @@ services: CASAN_IDP_SUB: oidc-ops CASAN_IDP_EMAIL: oidc-ops@example.com CASAN_IDP_GROUPS: casan-org-admin,casan-approver + CASAN_APPROVAL_SIGNER_TOKEN: ${CASAN_APPROVAL_SIGNER_TOKEN:-local-approval-signer-secret} ports: - "18082:8080" networks: diff --git a/infra/local-prod/idp/mock_idp.py b/infra/local-prod/idp/mock_idp.py index f2d51ff..403076e 100644 --- a/infra/local-prod/idp/mock_idp.py +++ b/infra/local-prod/idp/mock_idp.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import json +import hmac import os import time 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" KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) CODES = {} +APPROVAL_SIGNER_TOKEN = os.environ.get("CASAN_APPROVAL_SIGNER_TOKEN", "") def b64u_int(value: int) -> str: @@ -122,6 +124,10 @@ class Handler(BaseHTTPRequestHandler): raw = self.rfile.read(size) or b"{}" ctype = self.headers.get("Content-Type", "") 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: payload = json.loads(raw) except json.JSONDecodeError: @@ -136,6 +142,7 @@ class Handler(BaseHTTPRequestHandler): "action": payload.get("action", "deploy"), "actor": payload.get("actor", "alice"), "input_sha256": payload.get("input_sha256", ""), + "jti": uuid.uuid4().hex, "iat": now, "exp": now + int(payload.get("ttl_s", 300)), } diff --git a/packages/casan-control-panel/backend/src/approvals/approvals.controller.ts b/packages/casan-control-panel/backend/src/approvals/approvals.controller.ts index 1b81779..14cfdfd 100644 --- a/packages/casan-control-panel/backend/src/approvals/approvals.controller.ts +++ b/packages/casan-control-panel/backend/src/approvals/approvals.controller.ts @@ -18,7 +18,7 @@ export class ApprovalsController { } @Post('decide') - decide(@Headers() headers: Record, @Body() body: ApprovalDecision) { - return ok(this.svc.decide(body, actorFromHeaders(headers))); + async decide(@Headers() headers: Record, @Body() body: ApprovalDecision) { + return ok(await this.svc.decide(body, actorFromHeaders(headers))); } } diff --git a/packages/casan-control-panel/backend/src/approvals/approvals.service.ts b/packages/casan-control-panel/backend/src/approvals/approvals.service.ts index 55df3e8..16dcb96 100644 --- a/packages/casan-control-panel/backend/src/approvals/approvals.service.ts +++ b/packages/casan-control-panel/backend/src/approvals/approvals.service.ts @@ -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>(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) { + private async verifyApprovalIdentity(input: ApprovalDecision, actor: SettingsActor, proposal: Record) { 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, approvalInput: string): Promise { + 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, actor: SettingsActor) { if (proposal.action !== 'settings.write') return null; const key = proposal.payload?.key; diff --git a/packages/casan-control-panel/backend/test/approvals.test.ts b/packages/casan-control-panel/backend/test/approvals.test.ts index f9bcf94..bf1a52e 100644 --- a/packages/casan-control-panel/backend/test/approvals.test.ts +++ b/packages/casan-control-panel/backend/test/approvals.test.ts @@ -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) { 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, ); });