From b760aa9af94c069208909741ad7101b14d323fea Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Thu, 20 Aug 2026 23:31:27 +0530 Subject: [PATCH] Roll back a credential rotation when revoking the old one fails --- server/src/credentials.ts | 10 ++++++- server/tests/credentials.test.ts | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/server/src/credentials.ts b/server/src/credentials.ts index ddd6c599..4ba9e523 100644 --- a/server/src/credentials.ts +++ b/server/src/credentials.ts @@ -305,7 +305,15 @@ export async function rotateCredential( input: CredentialInput & { previousCredentialId: string }, ) { const credential = await persistCredential(service, input); - await service.store.revoke(input.previousCredentialId); + try { + await service.store.revoke(input.previousCredentialId); + } catch (error) { + // Roll back the new secret so a failed rotation does not leave an unlinked + // active credential in the vault. The audit event is skipped too, so the + // trail reflects only rotations that actually happened. + await service.store.revoke(credential.id).catch(() => {}); + throw error; + } await recordAuditEvent(service.auditStore, { eventType: "credential.rotated", diff --git a/server/tests/credentials.test.ts b/server/tests/credentials.test.ts index de7acd1a..be35a585 100644 --- a/server/tests/credentials.test.ts +++ b/server/tests/credentials.test.ts @@ -138,6 +138,54 @@ describe("credential encryption", () => { ).toEqual(["credential.rotated", "credential.revoked"]); }); + test("rolls back the new credential when revoke of the previous one fails", async () => { + const created: string[] = []; + const revoked: string[] = []; + const audited: unknown[] = []; + const service = { + encryptionKey: key, + store: { + create: async () => { + const id = "credential-new"; + created.push(id); + return { id, revokedAt: null }; + }, + revoke: async (id: string) => { + revoked.push(id); + if (id === "credential-old") { + throw new Error("Previous credential not found"); + } + return new Date("2026-08-13T12:00:00.000Z"); + }, + }, + auditStore: { + insert: async (event: unknown) => { + audited.push(event); + }, + }, + }; + + await expect( + rotateCredential(service, { + previousCredentialId: "credential-old", + kind: "model", + provider: "openai", + keyId: "primary", + metadata: {}, + plaintext: "new-openai-secret", + actorUserId: "admin", + }), + ).rejects.toThrow("Previous credential not found"); + + // The rotation failed, so the vault must not hold an unlinked new secret and the + // audit trail must not claim a rotation happened. + expect(created).toEqual(["credential-new"]); + expect(revoked).toContain("credential-new"); + expect( + audited.map((event) => (event as { eventType: string }).eventType), + ).not.toContain("credential.rotated"); + }); + test("decrypts only an active credential for server-side use", async () => { const encryptedValue = await encryptSecret(key, "connector-secret");