Skip to content

rotateCredential leaves an orphan credential if the revoke of the previous one fails #53

Description

@zopeVaibhav

Summary

A failed revoke inside rotateCredential leaves the newly written credential live in the vault. The caller sees an error and treats the rotation as failed, so the orphan is invisible and can survive across retries.

Evidence

server/src/credentials.ts:303 writes the new credential, then calls store.revoke on the previous one without any rollback:

export async function rotateCredential(
  service: CredentialService,
  input: CredentialInput & { previousCredentialId: string },
) {
  const credential = await persistCredential(service, input);
  await service.store.revoke(input.previousCredentialId);
  ...
}

A test that reproduces it (added on the fix branch, fails on main):

expect(received).toContain(expected)
Expected to contain: "credential-new"
Received: [ "credential-old" ]

Without a rollback the new credential stays; only the old one appears in the revoke log.

Impact

  • readModelSecret orders by createdAt desc, so the orphan becomes the credential the runtime resolves to for that (provider, keyId). Every subsequent model call spends the orphan.
  • No credential.rotated audit event is written for a failed attempt, so the drift never appears in the audit trail. An operator has no signal that two live credentials exist for the same key.
  • A caller that retries a failed rotate writes another orphan each attempt. Nothing in the code deduplicates by (provider, keyId), so the vault ends up with N active copies of one logical secret.

Root Cause

The write and the revoke are two independent store calls with no compensation. persistCredential succeeds and commits its row before store.revoke runs, so any failure past that point (transient DB error, previousCredentialId that no longer exists, connection reset) leaves the two sides out of step.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions