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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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