feat(FN-4915): complete Step 2-3 — add secret audit emitters and approval audits

Fusion-Task-Id: FN-4915
Fusion-Task-Lineage: d5c49dae-6069-4998-b632-aca52cc312dc
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 13:40:09 -07:00
committed by gsxdsm
parent 8ef3f52cf5
commit abb0f63aae
5 changed files with 273 additions and 7 deletions

View File

@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { request } from "../test-request.js";
const state = {
requests: new Map<string, any>(),
audits: new Map<string, any[]>(),
runAuditEvents: [] as any[],
};
class MockApprovalRequestStore {
constructor(_: unknown) {}
get(id: string) {
return state.requests.get(id) ?? null;
}
decide(id: string, status: "approved" | "denied", input?: { actor?: any; note?: string }) {
const req = state.requests.get(id);
if (!req) throw new Error("Approval request not found");
req.status = status;
req.decidedAt = new Date().toISOString();
req.updatedAt = req.decidedAt;
state.audits.set(id, [...(state.audits.get(id) ?? []), {
id: `evt-${status}`,
eventType: status,
actor: input?.actor ?? { actorId: "user", actorType: "user", actorName: "User" },
createdAt: req.decidedAt,
}]);
return req;
}
getAuditHistory(id: string) {
return state.audits.get(id) ?? [];
}
list() {
return [...state.requests.values()];
}
}
vi.mock("@fusion/core", () => ({ ApprovalRequestStore: MockApprovalRequestStore, AgentStore: class { async init() {} async getAgent() { return null; } } }));
vi.mock("@fusion/engine", () => ({
executeApprovedAgentProvisioning: vi.fn(),
executeApprovedWorktrunkInstall: vi.fn(),
assertNoSecretPlaintext: (metadata?: Record<string, unknown>) => {
if (!metadata) return;
for (const key of ["plaintextValue", "value", "ciphertext", "nonce", "decrypted"]) {
if (Object.prototype.hasOwnProperty.call(metadata, key)) {
throw new Error("secret audit metadata may not include plaintext fields");
}
}
},
}));
describe("approval routes secrets audit", async () => {
const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js");
function createApp() {
const router = express.Router();
router.use(express.json());
registerApprovalRoutes({
router,
runtimeLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() } as any,
getProjectContext: async () => ({
store: {
getDatabase: () => ({}),
getFusionDir: () => "/tmp/fusion",
getTask: async () => null,
pauseTask: async () => {},
recordRunAuditEvent: (event: any) => state.runAuditEvents.push(event),
},
engine: undefined,
projectId: "p1",
}),
rethrowAsApiError: (e: unknown) => { throw e; },
} as any);
const app = express();
app.use("/api", router);
app.use((err: any, _req: any, res: any, _next: any) => res.status(err?.statusCode ?? 500).json({ error: err?.message ?? String(err) }));
return app;
}
beforeEach(() => {
state.runAuditEvents = [];
const now = new Date().toISOString();
state.requests = new Map([
["apr-secret", {
id: "apr-secret",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "secrets_access",
summary: "Read secret",
action: "read",
resourceType: "secret",
resourceId: "project:API_KEY",
context: { key: "API_KEY", scope: "project", policySource: "secret" },
},
taskId: "FN-1",
runId: "run-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
]);
state.audits = new Map([["apr-secret", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]]]);
});
it("emits secret:approval-granted for approved secrets_access", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approvals/apr-secret/decision", JSON.stringify({ decision: "approve" }), { "content-type": "application/json" });
expect(res.status).toBe(200);
const event = state.runAuditEvents.at(-1);
expect(event).toMatchObject({ mutationType: "secret:approval-granted", domain: "filesystem", target: "project:API_KEY" });
});
it("emits secret:approval-denied for denied secrets_access", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approvals/apr-secret/decision", JSON.stringify({ decision: "deny" }), { "content-type": "application/json" });
expect(res.status).toBe(200);
const event = state.runAuditEvents.at(-1);
expect(event).toMatchObject({ mutationType: "secret:approval-denied", domain: "filesystem", target: "project:API_KEY" });
});
it("does not include plaintext-like metadata fields", async () => {
const app = createApp();
await request(app, "POST", "/api/approvals/apr-secret/decision", JSON.stringify({ decision: "approve" }), { "content-type": "application/json" });
const metadata = state.runAuditEvents.at(-1)?.metadata;
expect(metadata).toMatchObject({ approvalRequestId: "apr-secret", key: "API_KEY", scope: "project", policySource: "secret" });
for (const key of ["plaintextValue", "value", "ciphertext", "nonce", "decrypted"]) {
expect(metadata).not.toHaveProperty(key);
}
});
});

View File

@@ -6,7 +6,7 @@ import {
type ApprovalRequestActorSnapshot,
type ApprovalRequestStatus,
} from "@fusion/core";
import { executeApprovedAgentProvisioning, executeApprovedWorktrunkInstall } from "@fusion/engine";
import { assertNoSecretPlaintext, executeApprovedAgentProvisioning, executeApprovedWorktrunkInstall } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
import { emitApprovalSseEvent } from "../sse.js";
@@ -139,6 +139,42 @@ function emitProvisioningDecisionAudit(params: {
scopedStore.recordRunAuditEvent(event);
}
function emitSecretsAccessDecisionAudit(params: {
scopedStore: import("@fusion/core").TaskStore;
request: ApprovalRequest;
decision: "approve" | "deny";
}): void {
const { scopedStore, request, decision } = params;
if (request.targetAction.category !== "secrets_access") return;
const context = request.targetAction.context ?? {};
const scope = typeof context.scope === "string" ? context.scope : undefined;
const key = typeof context.key === "string" ? context.key : undefined;
const policySource = typeof context.policySource === "string" ? context.policySource : undefined;
const target = scope && key ? `${scope}:${key}` : request.targetAction.resourceId;
const metadata = {
approvalRequestId: request.id,
key,
scope,
policySource,
requesterAgentId: request.requester.actorId,
};
assertNoSecretPlaintext(metadata);
const event: Parameters<typeof scopedStore.recordRunAuditEvent>[0] = {
agentId: request.requester.actorId,
domain: "filesystem",
mutationType: decision === "approve" ? "secret:approval-granted" : "secret:approval-denied",
target,
metadata,
runId: request.id,
};
if (request.taskId) event.taskId = request.taskId;
if (request.runId) event.runId = request.runId;
scopedStore.recordRunAuditEvent(event);
}
function emitSandboxProvisioningDecisionAudit(params: {
scopedStore: import("@fusion/core").TaskStore;
request: ApprovalRequest;
@@ -330,6 +366,8 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
}
}
emitSecretsAccessDecisionAudit({ scopedStore, request: updated, decision: body.decision });
if (updated.targetAction.category === "sandbox_provisioning") {
if (body.decision === "approve") {
if (sandboxProvisioningExecutor) {