feat(FN-2354): add structured redacted auth-sync diagnostics

- Add a shared auth-sync audit log helper that emits structured settings-sync/auth events with operation, direction, route, node IDs, and provider metadata
- Replace string-based auth sync logs in push, pull, and auth-receive routes with the new structured diagnostic event emission
- Ensure only provider names and counts are logged while filtering out credentials and other sensitive auth material
- Expand node sync route tests to capture runtime log sink events and assert structured fields plus redaction guarantees for push, pull, and receive flows
This commit is contained in:
Fusion
2026-04-24 02:03:48 -07:00
committed by gsxdsm
parent 7c1a1c36cc
commit 21699cc499
2 changed files with 172 additions and 34 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { request, get } from "../test-request.js";
import { resetRuntimeLogSink, setRuntimeLogSink, type RuntimeLogContext } from "../runtime-logger.js";
// Mock node:fs for auth.json reading
vi.mock("node:fs", () => ({
@@ -147,10 +148,18 @@ function createMockLocalNode(overrides: Record<string, unknown> = {}) {
// ── Tests ─────────────────────────────────────────────────────────────
interface RuntimeEvent {
level: "info" | "warn" | "error";
scope: string;
message: string;
context?: RuntimeLogContext;
}
describe("Node settings sync routes", () => {
let store: MockStore;
let app: ReturnType<typeof import("../server.js").createServer>;
let mockFetch: ReturnType<typeof vi.fn>;
let runtimeEvents: RuntimeEvent[];
beforeEach(async () => {
vi.clearAllMocks();
@@ -170,12 +179,18 @@ describe("Node settings sync routes", () => {
mockFetch = vi.fn();
global.fetch = mockFetch;
runtimeEvents = [];
setRuntimeLogSink((level, scope, message, context) => {
runtimeEvents.push({ level, scope, message, context });
});
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as any);
});
afterEach(() => {
resetRuntimeLogSink();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -571,16 +586,15 @@ describe("Node settings sync routes", () => {
expect(res.body.error).toContain("apiKey");
});
it("logs provider names but not credentials", async () => {
it("emits structured redacted diagnostics for push-mode auth sync", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await request(
const res = await request(
app,
"POST",
"/api/nodes/node-remote-001/auth/sync",
@@ -588,25 +602,45 @@ describe("Node settings sync routes", () => {
{ "content-type": "application/json" },
);
// Verify that some providers were logged
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("providers="),
expect(res.status).toBe(200);
const authEvent = runtimeEvents.find((event) =>
event.message === "Auth sync diagnostic event"
&& event.context?.route === "/nodes/:id/auth/sync"
&& event.context?.direction === "push"
);
// Verify that API keys are not logged
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining("sk-"),
);
consoleSpy.mockRestore();
expect(authEvent).toMatchObject({
level: "info",
message: "Auth sync diagnostic event",
context: expect.objectContaining({
operation: "sync",
direction: "push",
route: "/nodes/:id/auth/sync",
sourceNodeId: "node-local-001",
targetNodeId: "node-remote-001",
providerNames: res.body.syncedProviders,
providerCount: res.body.syncedProviders.length,
}),
});
expect(authEvent?.scope.endsWith("routes:settings-sync:auth")).toBe(true);
expect(authEvent?.context).toHaveProperty("targetNodeId", "node-remote-001");
const serialized = JSON.stringify(authEvent);
expect(serialized).not.toContain("sk-");
expect(serialized).not.toContain("Bearer ");
expect(serialized).not.toContain("\"key\"");
expect(serialized).not.toContain("\"access\"");
expect(serialized).not.toContain("\"refresh\"");
});
it("successfully pulls auth credentials from remote", async () => {
it("emits structured redacted diagnostics for pull-mode auth sync", async () => {
const remoteNode = createMockRemoteNode();
mockGetNode.mockResolvedValue(remoteNode);
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({
providers: {
google: { type: "api_key", key: "AIzaTest123" },
google: { type: "api_key", key: "sk-pull-secret-123" },
},
sourceNodeId: "node-other",
timestamp: "2026-04-14T10:00:00.000Z",
@@ -624,6 +658,36 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.syncedProviders).toContain("google");
const authEvent = runtimeEvents.find((event) =>
event.message === "Auth sync diagnostic event"
&& event.context?.route === "/nodes/:id/auth/sync"
&& event.context?.direction === "pull"
);
expect(authEvent).toMatchObject({
level: "info",
message: "Auth sync diagnostic event",
context: expect.objectContaining({
operation: "sync",
direction: "pull",
route: "/nodes/:id/auth/sync",
sourceNodeId: "node-other",
targetNodeId: "node-local-001",
providerNames: ["google"],
providerCount: 1,
}),
});
expect(authEvent?.scope.endsWith("routes:settings-sync:auth")).toBe(true);
expect(authEvent?.context).toHaveProperty("sourceNodeId", "node-other");
expect(authEvent?.context).toHaveProperty("targetNodeId", "node-local-001");
const serialized = JSON.stringify(authEvent);
expect(serialized).not.toContain("sk-pull-secret-123");
expect(serialized).not.toContain("Bearer ");
expect(serialized).not.toContain("\"key\"");
expect(serialized).not.toContain("\"access\"");
expect(serialized).not.toContain("\"refresh\"");
});
});
@@ -762,12 +826,11 @@ describe("Node settings sync routes", () => {
expect(res.status).toBe(400);
});
it("logs provider names but not credentials", async () => {
it("emits structured redacted diagnostics for auth-receive", async () => {
const localNode = createMockLocalNode();
mockListNodes.mockResolvedValue([localNode]);
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await request(
const res = await request(
app,
"POST",
"/api/settings/auth-receive",
@@ -779,13 +842,34 @@ describe("Node settings sync routes", () => {
{ "content-type": "application/json", "Authorization": `Bearer ${localNode.apiKey}` },
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("anthropic"),
expect(res.status).toBe(200);
const authEvent = runtimeEvents.find((event) =>
event.message === "Auth sync diagnostic event"
&& event.context?.route === "/settings/auth-receive"
&& event.context?.direction === "receive"
);
expect(consoleSpy).not.toHaveBeenCalledWith(
expect.stringContaining("sk-ant-secret"),
);
consoleSpy.mockRestore();
expect(authEvent).toMatchObject({
level: "info",
message: "Auth sync diagnostic event",
context: {
operation: "receive",
direction: "receive",
route: "/settings/auth-receive",
sourceNodeId: "node-remote-001",
providerNames: ["anthropic"],
providerCount: 1,
},
});
expect(authEvent?.scope.endsWith("routes:settings-sync:auth")).toBe(true);
expect(authEvent?.context).not.toHaveProperty("targetNodeId");
const serialized = JSON.stringify(authEvent);
expect(serialized).not.toContain("sk-ant-secret");
expect(serialized).not.toContain("Bearer ");
expect(serialized).not.toContain("\"key\"");
expect(serialized).not.toContain("\"access\"");
expect(serialized).not.toContain("\"refresh\"");
});
});

View File

@@ -2022,6 +2022,49 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
logger.error(input.message, context);
}
type AuthSyncDirection = "push" | "pull" | "receive";
type AuthSyncOperation = "receive" | "sync";
interface AuthSyncAuditLogInput {
level?: "info" | "warn" | "error";
operation: AuthSyncOperation;
direction: AuthSyncDirection;
route: "/settings/auth-receive" | "/nodes/:id/auth/sync";
sourceNodeId?: string;
targetNodeId?: string;
providerNames: string[];
}
const AUTH_SYNC_AUDIT_MESSAGE = "Auth sync diagnostic event";
function emitAuthSyncAuditLog(input: AuthSyncAuditLogInput): void {
const logger = runtimeLogger.child("settings-sync").child("auth");
const level = input.level ?? "info";
const providerNames = input.providerNames.filter((provider) => typeof provider === "string");
const context: Record<string, unknown> = {
operation: input.operation,
direction: input.direction,
route: input.route,
providerNames,
providerCount: providerNames.length,
...(input.sourceNodeId !== undefined ? { sourceNodeId: input.sourceNodeId } : {}),
...(input.targetNodeId !== undefined ? { targetNodeId: input.targetNodeId } : {}),
};
if (level === "warn") {
logger.warn(AUTH_SYNC_AUDIT_MESSAGE, context);
return;
}
if (level === "error") {
logger.error(AUTH_SYNC_AUDIT_MESSAGE, context);
return;
}
logger.info(AUTH_SYNC_AUDIT_MESSAGE, context);
}
/**
* Forward an HTTP request to a remote node.
* Validates the node exists, is remote, and has a URL, then proxies the request.
@@ -3316,10 +3359,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
}
// Log without actual credentials
runtimeLogger.child("settings-sync").info(
`Auth credentials received: providers=${receivedProviders.join(",")}, source=${sourceNodeId}`,
);
emitAuthSyncAuditLog({
operation: "receive",
direction: "receive",
route: "/settings/auth-receive",
sourceNodeId,
providerNames: receivedProviders,
});
await central.close();
@@ -16736,11 +16782,15 @@ async function persistImportedSkills(
await central.close();
// Log without actual credentials
const providerNames = Object.keys(apiKeyProviders);
runtimeLogger.child("settings-sync").info(
`Auth sync completed: direction=push, providers=${providerNames.join(",")}, targetNode=${node.id}`,
);
emitAuthSyncAuditLog({
operation: "sync",
direction: "push",
route: "/nodes/:id/auth/sync",
sourceNodeId: localPeerInfo.nodeId,
targetNodeId: node.id,
providerNames,
});
res.json({ success: true, syncedProviders: providerNames });
} else {
@@ -16767,10 +16817,14 @@ async function persistImportedSkills(
await central.close();
// Log without actual credentials
runtimeLogger.child("settings-sync").info(
`Auth sync completed: direction=pull, providers=${syncedProviders.join(",")}, targetNode=${node.id}`,
);
emitAuthSyncAuditLog({
operation: "sync",
direction: "pull",
route: "/nodes/:id/auth/sync",
sourceNodeId: remoteAuth.sourceNodeId,
targetNodeId: localPeerInfo.nodeId,
providerNames: syncedProviders,
});
res.json({ success: true, syncedProviders });
}