From ce2a909ed5d86de6037cecbae0afddbed6e3bfe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 16:50:14 +0200 Subject: [PATCH] fix(ai): write no key-removal audit for a slot that held no key --- .../user/ai-provider/__tests__/route.test.ts | 15 +++++++ src/app/api/user/ai-provider/route.ts | 39 +++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index e7ea24c49..d6de4a654 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -252,6 +252,9 @@ describe("PATCH /api/user/ai-provider — key changes are audited", () => { const patch = PATCH as (req: Request) => Promise; it("audits a removed key, naming its slot and never a value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiAnthropicKeyEncrypted: "enc:old", + } as never); const res = await patch(patchRequest({ anthropicKey: null })); expect(res.status).toBe(200); expect(vi.mocked(auditLog)).toHaveBeenCalledTimes(1); @@ -265,6 +268,9 @@ describe("PATCH /api/user/ai-provider — key changes are audited", () => { }); it("audits a stored key without its value", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiCompatKeyEncrypted: "enc:old", + } as never); await patch(patchRequest({ openaiKey: "sk-test-123", compatKey: "" })); const calls = vi.mocked(auditLog).mock.calls; expect(calls.map(([action, opts]) => [action, opts?.details])).toEqual([ @@ -274,6 +280,15 @@ describe("PATCH /api/user/ai-provider — key changes are audited", () => { expect(JSON.stringify(calls)).not.toContain("sk-test-123"); }); + it("writes no remove audit for a slot that held no key", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + aiAnthropicKeyEncrypted: null, + } as never); + const res = await patch(patchRequest({ anthropicKey: null })); + expect(res.status).toBe(200); + expect(vi.mocked(auditLog)).not.toHaveBeenCalled(); + }); + it("writes no key audit for a change that touches no key", async () => { await patch(patchRequest({ responseTimeoutSeconds: 120 })); expect(vi.mocked(auditLog)).not.toHaveBeenCalled(); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index ecac527a3..842e8e41b 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -372,23 +372,40 @@ export const PATCH = apiHandler(async (request: NextRequest) => { updates.aiBaseUrl = null; } + // A stored provider key is a credential: setting or removing one is + // audited like every other credential change, with the slot it went into + // and never the value. A removal used to leave no trace at all. Clearing a + // slot that held no key removes nothing and is not audited, so the slots a + // removal names are read before the write. + const keySlots = [ + ["anthropic", body.anthropicKey, "aiAnthropicKeyEncrypted"], + ["openai", body.openaiKey, "aiOpenaiKeyEncrypted"], + ["local", body.localKey, "aiLocalKeyEncrypted"], + ["openai-compatible", body.compatKey, "aiCompatKeyEncrypted"], + ] as const; + const isRemoval = (value: string | null | undefined) => + value === null || value === ""; + const storedKeys = keySlots.some(([, value]) => isRemoval(value)) + ? await prisma.user.findUnique({ + where: { id: user.id }, + select: { + aiAnthropicKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiCompatKeyEncrypted: true, + }, + }) + : null; + if (Object.keys(updates).length > 0) { await prisma.user.update({ where: { id: user.id }, data: updates }); } - // A stored provider key is a credential: setting or removing one is - // audited like every other credential change, with the slot it went into - // and never the value. A removal used to leave no trace at all. - const keySlots = [ - ["anthropic", body.anthropicKey], - ["openai", body.openaiKey], - ["local", body.localKey], - ["openai-compatible", body.compatKey], - ] as const; - for (const [slot, value] of keySlots) { + for (const [slot, value, column] of keySlots) { if (value === undefined) continue; + if (isRemoval(value) && storedKeys?.[column] == null) continue; await auditLog( - value === null || value === "" + isRemoval(value) ? "user.ai-provider.key.remove" : "user.ai-provider.key.set", {