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_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:
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user