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