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:
committed by
gsxdsm
parent
8ef3f52cf5
commit
abb0f63aae
60
packages/core/src/__tests__/secrets-store.test.ts
Normal file
60
packages/core/src/__tests__/secrets-store.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestProject } from "./test-project.js";
|
||||
import { CentralCore } from "../central-core.js";
|
||||
import { MasterKeyManager } from "../master-key.js";
|
||||
import { SecretsStore } from "../secrets-store.js";
|
||||
|
||||
async function createSecretsStore(auditEmitter?: (event: any) => void) {
|
||||
const fixture = await createTestProject();
|
||||
const central = new CentralCore(fixture.globalDir);
|
||||
await central.init();
|
||||
const centralDb = (central as unknown as { db: import("../central-db.js").CentralDatabase | null }).db;
|
||||
if (!centralDb) throw new Error("central db unavailable");
|
||||
const masterKeyManager = new MasterKeyManager({ globalDir: fixture.globalDir });
|
||||
const store = new SecretsStore(fixture.store.getDatabase(), centralDb, () => masterKeyManager.getOrCreateKey(), { auditEmitter });
|
||||
return { fixture, store };
|
||||
}
|
||||
|
||||
describe("SecretsStore audit emitter", () => {
|
||||
const emitter = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
emitter.mockReset();
|
||||
});
|
||||
|
||||
it("emits create/update/delete/read without secret values", async () => {
|
||||
const { fixture, store } = await createSecretsStore(emitter);
|
||||
try {
|
||||
const created = await store.createSecret({ scope: "project", key: "API_KEY", plaintextValue: "secret-a" });
|
||||
await store.updateSecret(created.id, "project", { plaintextValue: "secret-b", key: "API_KEY_2" });
|
||||
await store.revealSecret(created.id, "project", { agentId: "agent-1" });
|
||||
store.deleteSecret(created.id, "project");
|
||||
|
||||
expect(emitter).toHaveBeenCalledTimes(4);
|
||||
for (const event of emitter.mock.calls.map((call) => call[0])) {
|
||||
expect(event).toHaveProperty("key");
|
||||
expect(event).toHaveProperty("scope");
|
||||
expect(event).not.toHaveProperty("plaintextValue");
|
||||
expect(event).not.toHaveProperty("value");
|
||||
expect(event).not.toHaveProperty("ciphertext");
|
||||
expect(event).not.toHaveProperty("nonce");
|
||||
}
|
||||
expect(emitter.mock.calls[2][0]).toMatchObject({ mutationType: "secret:read", actor: { agentId: "agent-1" } });
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("swallows emitter exceptions", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const { fixture, store } = await createSecretsStore(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
try {
|
||||
await expect(store.createSecret({ scope: "project", key: "API_KEY", plaintextValue: "secret-a" })).resolves.toBeTruthy();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,19 @@ interface SecretCipherRow extends SecretRow {
|
||||
|
||||
type SecretsDb = Pick<ProjectDatabase, "prepare" | "bumpLastModified"> | Pick<CentralDatabase, "prepare" | "bumpLastModified">;
|
||||
|
||||
type SecretsStoreAuditEvent = {
|
||||
mutationType: "secret:create" | "secret:update" | "secret:delete" | "secret:read";
|
||||
scope: SecretScope;
|
||||
secretId: string;
|
||||
key: string;
|
||||
actor?: { agentId?: string | null; userId?: string | null };
|
||||
};
|
||||
|
||||
export interface SecretsStoreOptions {
|
||||
/** Optional non-blocking audit emitter. Errors are swallowed/warned so CRUD paths continue. */
|
||||
auditEmitter?: (event: SecretsStoreAuditEvent) => void;
|
||||
}
|
||||
|
||||
export class SecretsStoreError extends Error {
|
||||
readonly code: "duplicate-key" | "not-found" | "invalid-policy" | "invalid-key" | "decrypt-failed";
|
||||
|
||||
@@ -72,10 +85,20 @@ export class SecretsStore {
|
||||
private readonly projectDb: Pick<ProjectDatabase, "prepare" | "bumpLastModified">,
|
||||
private readonly centralDb: Pick<CentralDatabase, "prepare" | "bumpLastModified">,
|
||||
masterKeyProvider: MasterKeyProvider,
|
||||
private readonly options: SecretsStoreOptions = {},
|
||||
) {
|
||||
this.cipher = createSecretCipher(masterKeyProvider);
|
||||
}
|
||||
|
||||
private emitAudit(event: SecretsStoreAuditEvent): void {
|
||||
if (!this.options.auditEmitter) return;
|
||||
try {
|
||||
this.options.auditEmitter(event);
|
||||
} catch (error) {
|
||||
console.warn("[secrets-store] audit emitter failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
private dbForScope(scope: SecretScope): SecretsDb {
|
||||
return scope === "project" ? this.projectDb : this.centralDb;
|
||||
}
|
||||
@@ -160,7 +183,9 @@ export class SecretsStore {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.getSecretMetadata(id, scope)!;
|
||||
const created = this.getSecretMetadata(id, scope)!;
|
||||
this.emitAudit({ mutationType: "secret:create", scope, secretId: created.id, key: created.key });
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateSecret(id: string, scope: SecretScope, patch: {
|
||||
@@ -231,17 +256,22 @@ export class SecretsStore {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return this.getSecretMetadata(id, scope)!;
|
||||
const updated = this.getSecretMetadata(id, scope)!;
|
||||
this.emitAudit({ mutationType: "secret:update", scope, secretId: updated.id, key: updated.key });
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteSecret(id: string, scope: SecretScope): void {
|
||||
const db = this.dbForScope(scope);
|
||||
const table = tableForScope(scope);
|
||||
const result = db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id) as { changes?: number };
|
||||
if ((result.changes ?? 0) === 0) {
|
||||
const existing = this.getSecretMetadata(id, scope);
|
||||
if (!existing) {
|
||||
throw new SecretsStoreError({ code: "not-found", message: "Secret not found" });
|
||||
}
|
||||
|
||||
const db = this.dbForScope(scope);
|
||||
const table = tableForScope(scope);
|
||||
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
|
||||
db.bumpLastModified();
|
||||
this.emitAudit({ mutationType: "secret:delete", scope, secretId: id, key: existing.key });
|
||||
}
|
||||
|
||||
async revealSecret(
|
||||
@@ -272,6 +302,7 @@ export class SecretsStore {
|
||||
db.prepare(`UPDATE ${table} SET last_read_at = ?, last_read_by = ?, updated_at = ? WHERE id = ?`).run(now, lastReadBy, now, id);
|
||||
db.bumpLastModified();
|
||||
|
||||
this.emitAudit({ mutationType: "secret:read", scope, secretId: id, key: row.key, actor: reader });
|
||||
return { key: row.key, plaintextValue };
|
||||
}
|
||||
}
|
||||
|
||||
131
packages/dashboard/src/__tests__/routes-approval-secrets.test.ts
Normal file
131
packages/dashboard/src/__tests__/routes-approval-secrets.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -202,6 +202,12 @@ export {
|
||||
type LLMSynthesisProviderOptions,
|
||||
} from "./research/providers/index.js";
|
||||
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
|
||||
export {
|
||||
SECRET_MUTATION_TYPES,
|
||||
SECRET_AUDIT_PLAINTEXT_FORBIDDEN_KEYS,
|
||||
assertNoSecretPlaintext,
|
||||
type FilesystemMutationType,
|
||||
} from "./run-audit.js";
|
||||
export { PrCommentHandler } from "./pr-comment-handler.js";
|
||||
export {
|
||||
NtfyNotifier,
|
||||
|
||||
Reference in New Issue
Block a user