fix(control-panel): sign strict goal approvals via oidc
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user