fix(ai): write no key-removal audit for a slot that held no key

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 16:50:14 +02:00
1 parent a3f5f998cf
commit ce2a909ed5
2 files changed
+43 -11

No files matched your search

@@ -252,6 +252,9 @@ describe("PATCH /api/user/ai-provider — key changes are audited", () => {
const patch = PATCH as (req: Request) => Promise<Response>;
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();
+28 -11
View File
@@ -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",
{