mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(ai): audit setting and removing a stored provider key
Removing a provider key, like setting one, left no audit row, unlike every other credential change. Each key slot a PATCH touches now writes user.ai-provider.key.set or .remove with the slot and never the value.
This commit is contained in:
1 parent
e94257928a
commit
65c296d430
3 files changed
+73
No files matched your search
@@ -63,6 +63,10 @@ vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
hasActiveConsentForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
@@ -72,6 +76,7 @@ import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority";
|
||||
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
function patchRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/user/ai-provider", {
|
||||
@@ -243,6 +248,38 @@ describe("PATCH /api/user/ai-provider — v1.22 fields", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const res = await patch(patchRequest({ anthropicKey: null }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
|
||||
"user.ai-provider.key.remove",
|
||||
expect.objectContaining({
|
||||
userId: "u-1",
|
||||
details: { slot: "anthropic" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("audits a stored key without its value", async () => {
|
||||
await patch(patchRequest({ openaiKey: "sk-test-123", compatKey: "" }));
|
||||
const calls = vi.mocked(auditLog).mock.calls;
|
||||
expect(calls.map(([action, opts]) => [action, opts?.details])).toEqual([
|
||||
["user.ai-provider.key.set", { slot: "openai" }],
|
||||
["user.ai-provider.key.remove", { slot: "openai-compatible" }],
|
||||
]);
|
||||
expect(JSON.stringify(calls)).not.toContain("sk-test-123");
|
||||
});
|
||||
|
||||
it("writes no key audit for a change that touches no key", async () => {
|
||||
await patch(patchRequest({ responseTimeoutSeconds: 120 }));
|
||||
expect(vi.mocked(auditLog)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// v1.33.1 (#470) — the OpenAI-compatible gateway writes three columns of its
|
||||
// own. What matters at this layer: the SSRF floor applies to the gateway's
|
||||
// base URL exactly as it does to the Local one, and the column is nobody
|
||||
|
||||
@@ -4,9 +4,11 @@ import { prisma } from "@/lib/db";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
@@ -374,5 +376,28 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
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) {
|
||||
if (value === undefined) continue;
|
||||
await auditLog(
|
||||
value === null || value === ""
|
||||
? "user.ai-provider.key.remove"
|
||||
: "user.ai-provider.key.set",
|
||||
{
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { slot },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
@@ -111,6 +111,17 @@ describe("PATCH /api/user/ai-provider — removing one saved key", () => {
|
||||
expect(row.aiCompatKeyEncrypted).not.toBeNull();
|
||||
expect(row.aiProvider).toBe("OPENAI");
|
||||
expect(row.aiModel).toBe("gpt-4o-mini");
|
||||
// The removal is audited, naming the slot and no value.
|
||||
const audits = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId, action: { startsWith: "user.ai-provider.key." } },
|
||||
select: { action: true, details: true },
|
||||
});
|
||||
expect(audits).toEqual([
|
||||
{
|
||||
action: "user.ai-provider.key.remove",
|
||||
details: JSON.stringify({ slot: "openai" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("the read the form seeds from reports the key gone", async () => {
|
||||
|
||||
Reference in new issue
Block a user