The Infrastructure Hub -- Part 6
CAB Automation: Signed Approvals and Compliance Evidence
The Problem
In article 4 we built the CAB workflow: AI summarizes the Terraform plan, generates a risk assessment, creates a Change Request, and the CAB reviews it in Backstage. The workflow works. Changes flow from PR to production with proper evidence.
Then the auditor arrives.
“Show me proof that this change was approved by the right person.” You show the Change Request in the database. “How do I know that record wasn’t modified after the fact?” You don’t have an answer.
“Show me every change that touched production in the last quarter, with the approval chain.” You can query the database. But how does the auditor verify that the data hasn’t been tampered with? A database record is just a row. Anyone with write access can change it.
This is the gap between “we have a process” and “we can prove the process was followed.” Regulated industries — finance, energy, healthcare, anything under NIS2 — need the second one. And with the EU’s digital identity regulations, “proof” increasingly means cryptographic proof.
The fix: sign every approval with a post-quantum digital signature. The approver’s identity is verified through QuantumID. The signature uses ML-DSA — quantum-safe, non-repudiable, and verifiable by any auditor without access to your internal systems.
The Solution
Three additions to the CAB workflow from article 4:
1. Signed approvals — When a CAB reviewer clicks “Approve”, the system signs the approval record with the reviewer’s ML-DSA key via QuantumAPI. The signature proves who approved, what they approved, and when — and it can’t be forged or backdated.
2. Sealed evidence packages — The full evidence (plan summary, risk assessment, PR URL, scan results, test results, approval signature) is hashed and signed as a single package. Modify any field and the signature breaks.
3. Compliance reports — A scheduled job generates audit reports from the signed records: all changes in a period, who approved each one, risk levels, rollback plans. Exportable as PDF or JSON for auditors.
Execute
1. The Signed Approval Endpoint
When a CAB reviewer approves a change, the backend signs the approval:
app.MapPost("/api/cab/approve", async (ApprovalRequest request, IConfiguration config) =>
{
var apiKey = config["QuantumApi:ApiKey"];
if (string.IsNullOrEmpty(apiKey))
return Results.Json(new { error = "QuantumAPI not configured." }, statusCode: 503);
// Build the approval payload — everything the auditor needs to verify
var payload = new ApprovalPayload
{
ChangeRequestId = request.ChangeRequestId,
ApprovedBy = request.ApprovedBy,
ApprovedAt = DateTimeOffset.UtcNow,
Module = request.Module,
Client = request.Client,
RiskLevel = request.RiskLevel,
PlanHash = request.PlanHash,
};
// Serialize to deterministic JSON (property order matches class definition, no whitespace)
var canonical = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
});
// Sign with ML-DSA via QuantumAPI
var client = new QuantumApiClient(new QuantumApiOptions { ApiKey = apiKey });
var signature = await client.Signing.SignAsync(new SignRequest
{
Data = canonical,
Algorithm = "ML-DSA-65",
});
// Store the signed approval
var connStr = config["Rag:PostgresConnection"];
if (!string.IsNullOrEmpty(connStr))
{
await using var dataSource = NpgsqlDataSource.Create(connStr);
await using var cmd = dataSource.CreateCommand();
cmd.CommandText = """
INSERT INTO cab_approvals
(change_request_id, approved_by, approved_at, module, client,
risk_level, plan_hash, payload_json, signature, key_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
""";
cmd.Parameters.AddWithValue(payload.ChangeRequestId);
cmd.Parameters.AddWithValue(payload.ApprovedBy);
cmd.Parameters.AddWithValue(payload.ApprovedAt);
cmd.Parameters.AddWithValue(payload.Module);
cmd.Parameters.AddWithValue(payload.Client);
cmd.Parameters.AddWithValue(payload.RiskLevel);
cmd.Parameters.AddWithValue(payload.PlanHash);
cmd.Parameters.AddWithValue(canonical);
cmd.Parameters.AddWithValue(signature.Signature);
cmd.Parameters.AddWithValue(signature.KeyId);
await cmd.ExecuteNonQueryAsync();
}
return Results.Ok(new
{
approved = true,
signature = signature.Signature,
keyId = signature.KeyId,
algorithm = "ML-DSA-65",
timestamp = payload.ApprovedAt,
});
});
record ApprovalRequest(
string ChangeRequestId,
string ApprovedBy,
string Module,
string Client,
string RiskLevel,
string PlanHash);
record ApprovalPayload
{
public string ChangeRequestId { get; init; } = default!;
public string ApprovedBy { get; init; } = default!;
public DateTimeOffset ApprovedAt { get; init; }
public string Module { get; init; } = default!;
public string Client { get; init; } = default!;
public string RiskLevel { get; init; } = default!;
public string PlanHash { get; init; } = default!;
}
The PlanHash is a SHA-256 hash of the Terraform plan output. It links the approval to a specific plan — you can’t approve one plan and apply a different one.
The Database Schema
CREATE TABLE cab_approvals (
id SERIAL PRIMARY KEY,
change_request_id VARCHAR(100) NOT NULL,
approved_by VARCHAR(255) NOT NULL,
approved_at TIMESTAMPTZ NOT NULL,
module VARCHAR(255) NOT NULL,
client VARCHAR(100),
risk_level VARCHAR(20) NOT NULL,
plan_hash VARCHAR(64) NOT NULL,
payload_json TEXT NOT NULL,
signature TEXT NOT NULL,
key_id VARCHAR(100) NOT NULL,
UNIQUE (change_request_id)
);
CREATE INDEX idx_approvals_module ON cab_approvals(module);
CREATE INDEX idx_approvals_client ON cab_approvals(client);
CREATE INDEX idx_approvals_date ON cab_approvals(approved_at);
2. Verification Endpoint
An auditor — or an automated compliance check — can verify any approval:
app.MapGet("/api/cab/verify/{changeRequestId}", async (string changeRequestId, IConfiguration config) =>
{
var apiKey = config["QuantumApi:ApiKey"];
var connStr = config["Rag:PostgresConnection"];
if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(connStr))
return Results.Json(new { error = "Not configured." }, statusCode: 503);
await using var dataSource = NpgsqlDataSource.Create(connStr);
await using var cmd = dataSource.CreateCommand();
cmd.CommandText = """
SELECT payload_json, signature, key_id, approved_by, approved_at
FROM cab_approvals
WHERE change_request_id = $1
""";
cmd.Parameters.AddWithValue(changeRequestId);
await using var reader = await cmd.ExecuteReaderAsync();
if (!await reader.ReadAsync())
return Results.NotFound(new { error = "Approval not found." });
var payloadJson = reader.GetString(0);
var signature = reader.GetString(1);
var keyId = reader.GetString(2);
var approvedBy = reader.GetString(3);
var approvedAt = reader.GetFieldValue<DateTimeOffset>(4);
// Verify the signature with QuantumAPI
var client = new QuantumApiClient(new QuantumApiOptions { ApiKey = apiKey });
var verification = await client.Signing.VerifyAsync(new VerifyRequest
{
Data = payloadJson,
Signature = signature,
KeyId = keyId,
});
return Results.Ok(new
{
changeRequestId,
approvedBy,
approvedAt,
signatureValid = verification.Valid,
algorithm = "ML-DSA-65",
tampered = !verification.Valid,
});
});
If signatureValid is false, the record was tampered with. The auditor doesn’t need access to your database — they can call this endpoint with the change request ID and get a cryptographic proof.
3. Sealed Evidence Packages
The evidence package from article 4 includes: plan summary, risk assessment, PR URL, approvers, scan results, test results, and pipeline URL. We seal the entire package with a signature:
app.MapPost("/api/cab/seal-evidence", async (EvidencePackage evidence, IConfiguration config) =>
{
var apiKey = config["QuantumApi:ApiKey"];
if (string.IsNullOrEmpty(apiKey))
return Results.Json(new { error = "QuantumAPI not configured." }, statusCode: 503);
// Canonical JSON of the entire evidence package
var canonical = JsonSerializer.Serialize(evidence, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
});
// Hash the package
var hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
// Sign the hash
var client = new QuantumApiClient(new QuantumApiOptions { ApiKey = apiKey });
var signature = await client.Signing.SignAsync(new SignRequest
{
Data = hash,
Algorithm = "ML-DSA-65",
});
return Results.Ok(new
{
evidenceHash = hash,
signature = signature.Signature,
keyId = signature.KeyId,
algorithm = "ML-DSA-65",
sealedAt = DateTimeOffset.UtcNow,
});
});
record EvidencePackage(
string ChangeRequestId,
string Module,
string Client,
string PlanSummary,
string RiskLevel,
string[] RiskFactors,
string RollbackPlan,
string PrUrl,
string[] PrApprovers,
string SecurityScan,
string TestResults,
string PipelineUrl,
string ApprovedBy,
DateTimeOffset ApprovedAt);
4. Compliance Report Generation
A scheduled endpoint generates audit reports:
app.MapGet("/api/cab/report", async (
string? client, int? days, string? format, IConfiguration config) =>
{
var connStr = config["Rag:PostgresConnection"];
if (string.IsNullOrEmpty(connStr))
return Results.Json(new { error = "Not configured." }, statusCode: 503);
var daysValue = days ?? 90;
await using var dataSource = NpgsqlDataSource.Create(connStr);
await using var cmd = dataSource.CreateCommand();
cmd.CommandText = """
SELECT change_request_id, approved_by, approved_at, module, client,
risk_level, plan_hash, signature, key_id
FROM cab_approvals
WHERE approved_at >= NOW() - INTERVAL '1 day' * $1
AND ($2 = '' OR client = $2)
ORDER BY approved_at DESC
""";
cmd.Parameters.AddWithValue(daysValue);
cmd.Parameters.AddWithValue(client ?? "");
var records = new List<object>();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
records.Add(new
{
ChangeRequestId = reader.GetString(0),
ApprovedBy = reader.GetString(1),
ApprovedAt = reader.GetFieldValue<DateTimeOffset>(2),
Module = reader.GetString(3),
Client = reader.IsDBNull(4) ? "internal" : reader.GetString(4),
RiskLevel = reader.GetString(5),
PlanHash = reader.GetString(6),
SignaturePresent = !string.IsNullOrEmpty(reader.GetString(7)),
KeyId = reader.GetString(8),
});
}
var report = new
{
GeneratedAt = DateTimeOffset.UtcNow,
Period = $"Last {daysValue} days",
Client = client ?? "all",
TotalChanges = records.Count,
ByRiskLevel = records.GroupBy(r => ((dynamic)r).RiskLevel)
.ToDictionary(g => (string)g.Key, g => g.Count()),
AllSignaturesPresent = records.All(r => ((dynamic)r).SignaturePresent),
Changes = records,
};
return Results.Ok(report);
});
Query examples:
# All changes for client ACME in the last 90 days
curl "http://localhost:5100/api/cab/report?client=acme&days=90"
# All changes across all clients in the last 30 days
curl "http://localhost:5100/api/cab/report?days=30"
# Verify a specific approval
curl "http://localhost:5100/api/cab/verify/CR-1712345678"
5. The Backstage Integration
Update the CAB review UI from article 4 to call the signing endpoint when approving:
// Updated handleApprove in ChangeRequestReview.tsx
const handleApprove = async (cr: ChangeRequest) => {
const { userEntityRef } = await identityApi.getBackstageIdentity();
const proxyUrl = await discoveryApi.getBaseUrl('proxy');
// 1. Hash the plan for the approval record
const planHash = await hashString(cr.summary + cr.rollbackPlan);
// 2. Sign the approval via QuantumAPI
const signRes = await fetchApi.fetch(`${proxyUrl}/ai-service/api/cab/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
changeRequestId: cr.id,
approvedBy: userEntityRef,
module: cr.module,
client: cr.client,
riskLevel: cr.riskAssessment.level,
planHash,
}),
});
if (!signRes.ok) {
alert('Approval signing failed. The change was NOT approved.');
return;
}
// 3. Now approve in the change management backend
const baseUrl = await discoveryApi.getBaseUrl('change-management');
await fetchApi.fetch(`${baseUrl}/requests/${cr.id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approvedBy: userEntityRef }),
});
// 4. Seal the full evidence package
await fetchApi.fetch(`${proxyUrl}/ai-service/api/cab/seal-evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
changeRequestId: cr.id,
module: cr.module,
client: cr.client,
planSummary: cr.summary,
riskLevel: cr.riskAssessment.level,
riskFactors: cr.riskAssessment.factors,
rollbackPlan: cr.rollbackPlan,
prUrl: cr.evidence.prUrl,
prApprovers: cr.evidence.prApprovers,
securityScan: cr.evidence.securityScan,
testResults: cr.evidence.testResults,
pipelineUrl: cr.evidence.pipelineRunUrl,
approvedBy: userEntityRef,
approvedAt: new Date().toISOString(),
}),
});
setRequests(prev => prev.filter(r => r.id !== cr.id));
};
async function hashString(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
The order matters: sign first, then approve, then seal. If signing fails, the approval doesn’t go through. An unsigned approval is not an approval.
What the Auditor Sees
The auditor requests the compliance report:
{
"generatedAt": "2026-04-05T10:00:00Z",
"period": "Last 90 days",
"client": "acme",
"totalChanges": 23,
"byRiskLevel": {
"low": 14,
"medium": 7,
"high": 1,
"critical": 1
},
"allSignaturesPresent": true,
"changes": [
{
"changeRequestId": "CR-1712345678",
"approvedBy": "user:default/victor.zaragoza",
"approvedAt": "2026-04-01T14:30:00Z",
"module": "tf-azurerm-aks",
"client": "acme",
"riskLevel": "high",
"planHash": "a3f7c2d1e4b5...",
"signaturePresent": true,
"keyId": "key-mldsa-cab-001"
}
]
}
For any change, the auditor can call /api/cab/verify/CR-1712345678 and get cryptographic proof that:
- Victor Zaragoza approved this specific change
- The approval hasn’t been tampered with
- The plan hash matches (the plan wasn’t changed after approval)
- The signature algorithm is ML-DSA-65 (quantum-safe)
No meeting minutes. No email threads. No “trust me, I approved it.” Cryptographic proof.
Checklist
-
cab_approvalstable created with indexes -
/api/cab/approveendpoint signs approvals with ML-DSA -
/api/cab/verify/{id}endpoint validates signatures -
/api/cab/seal-evidenceendpoint hashes and signs the full evidence package -
/api/cab/reportendpoint generates compliance reports - Backstage CAB UI calls signing endpoint before approving
- Failed signatures block the approval (unsigned = unapproved)
- Plan hash links approval to specific Terraform plan
- Reports filterable by client and date range
- All endpoints return 503 when QuantumAPI or PostgreSQL is not configured
Challenge
Before the next article:
- Set up the
cab_approvalstable in your PostgreSQL instance - Approve one change request through the Backstage UI — verify the signature is stored
- Call the verify endpoint — confirm
signatureValid: true - Modify the
payload_jsonin the database directly — call verify again — confirmsignatureValid: false
That last step is the real test. If you can modify a record and the verification still passes, something is wrong.
In the next article, we build Chat with Your Infrastructure — a conversational panel in Backstage where you ask questions about your modules, pipelines, and environments. Not a generic chatbot — it reads your catalog, your state files, and your documentation before answering.
The full code is on GitHub.
If this series helps you, consider buying me a coffee.
This is article 6 of the Infrastructure Hub series. Previous: Secrets and Post-Quantum Identities. Next: Chat with Your Infrastructure — conversational access to your entire platform.
Loading comments...