Rotate a credential in one transaction - #76
Conversation
|
The transaction approach is the right fix. Compensation was always going to lose to a post-commit throw or a SIGTERM between the two writes, and FOR UPDATE plus revoke-before-insert is the order the unique index forces. The tests look good, especially the one that a failed rotate writes no audit event. The index is the problem though.
Those currently leave orphans, which is the bug in #53. With the index they become a raw unique violation and a 500. Either those callers need to go through rotate (or revoke-then-create) in this PR, or the index should wait until they do. Two smaller things:
The audit-after-commit gap you called out is fine as a follow-up. |
9e7c0c9 to
b5e917e
Compare
|
Thanks for this, it was the right call to look at the index separately from the rotation. I checked all three callers you named and you are right about every one. Going back through the callers turned up two more things, and together they changed my mind about shipping the index here at all. There is a fourth colliding caller and it is the most used one. The bigger one is that my backfill revoked the wrong row. It kept the newest live credential per key, but the duplicates this is meant to clean up come from rotations that inserted the new row and then failed to revoke the old one, so the caller saw an error and never repointed anything. The reference still names the older row. I set this up against Postgres with a So I have taken the index, its migration and the caller conversions out, and this PR is now only the atomic rotation plus two guards. Nothing in it changes any caller's behaviour, and it stands on its own for the bug in #53. Your second option, in other words: the index waits until the callers are ready for it. The mismatch guard you suggested is in this PR rather than deferred. The audit-after-commit gap stays a follow-up, as you and the earlier review both suggested. Same for mapping vault errors onto 404 and 409 instead of 500, which needs an The follow-up PR has the index, the corrected backfill that prefers a referenced row over the newest, and all four callers converted. It also has to thread the caller's transaction into the store: |
b5e917e to
6080e13
Compare
|
Changed course on the split. I said the index would come as its own PR once this landed, and it is in this one instead. Your first option, in the end. All four callers now go through rotate-or-create: The backfill prefers a referenced row over the newest now. Proved it against Postgres rather than reasoning about it: two live duplicates with a Reason for folding it back in: the index is the half that stops the leak. Splitting meant merging twice to fix #53, and the rotation alone changes no caller's behaviour, so on its own it fixes nothing an operator would notice. Rebased onto current Audit-inside-the-transaction and the 500-vs-404/409 mapping stay follow-ups, as agreed. |
1e4581a to
43427fa
Compare
Rotation was two independent store calls: insert the new credential, then revoke the previous one. A failure on the revoke returned an error to the caller and left the new credential live in the vault, where nothing referenced it and nothing said it was there. Retrying wrote another one. Compensating for that in the caller does not work, and the first attempt at this tried. `revoke` can commit its UPDATE and still throw on the way back, from a statement timeout, a dropped connection, or the pool being torn down as the response returns; a compensating revoke then retires the new credential on top of a previous one that really was revoked, and the key is left with nothing live. The same fault that broke the first revoke is the one most likely to break the compensating one, so the recovery is least available exactly when it is needed. And nothing in the process survives the process: killed between the two writes, no compensation runs at all. So the two writes are now one. `CredentialStore.rotate` opens a single transaction, locks the previous credential `FOR UPDATE`, revokes it and inserts its replacement, following the shape already used for agent profiles in `agents/profile-store.ts`. Either both land or neither does, Postgres decides that rather than this code, and a process that dies mid-rotation leaves a database that rolls itself back. `credential.rotated` is recorded only once the transaction has returned, so a rotation that never happened leaves no row claiming it did. Two guards come with it. The lock reads the previous credential's kind, provider and keyId as well as its state, and refuses a rotation whose input names a different key: `POST /api/admin/credentials/:id/rotate` takes that id straight from the URL while the key it rotates into comes from the body, so without the check a mismatched pair would retire one key's only live credential and store the new secret under another. `revoke` now stamps only a row that is still live and says "not found or already revoked" otherwise, instead of overwriting an existing timestamp and reporting success, which is what let two callers each believe they were the one who retired a credential. Tests cover both layers: that a failed rotation writes no audit event, and against Postgres that a rotation retires the previous credential and stores the new one together, that an already-revoked or absent previous credential is refused with nothing written, that a mismatched key is refused with the previous credential left live, and that revoking twice is refused.
A key was free to accumulate live credentials. Nothing said which of them a deployment meant, `readModelSecret` picked the newest and every other reader followed a stored id, and the failed rotations in CopilotKit#53 left exactly this behind: a live row nothing referenced, invisible until somebody went looking. Two replicas rotating the same secret could also both write one, since nothing serialised them. `credentials_active_key_idx` makes it a rule the database keeps: unique on (kind, provider, key_id) where revoked_at is null. Revoked rows are excluded, so history is untouched and only what is current is constrained. Existing databases have to be reconciled before that index can be built, and which duplicate survives decides whether a deployment comes back up working. The newest is the wrong answer: in the failure this cleans up it is the new row that nothing references, while the older one is still named by the connector, MCP server or agent that was using it, so keeping the newest revokes the credential actually in use. The backfill ranks a referenced row first and falls back to the newest only where nothing points at either. Three callers stored a new credential for a key without retiring what was there. All three leaked orphans already; under the index they would have failed outright. `storeAgentAuth` rotates when the agent has a live credential and inserts when it does not, checking liveness rather than trusting the reference: an administrator can revoke a key from the Credentials page, nothing repoints the agent that names it, and rotating onto a revoked row is refused, so trusting it would leave that agent's key impossible to replace. It also takes the caller's transaction now. Agent edits are a transaction over `agents` and `agent_profiles` and the credential belongs to that same change; written on a pooled connection of its own it would commit even where the edit rolled back, and could deadlock against the locks that edit is holding. `configureGoogleDrive` retires the credential a reconfigure abandons when the impersonation subject changes. That row keeps its own key and is referenced by nothing afterwards, so a subject set, changed, and set back again would meet it again on the index. `removeServer` revokes the token before deleting the server row, so adding the same server again does not meet its own leftover. The revoke goes first deliberately: these are two tables with no transaction spanning them, and a failure between them should leave a server that removing again will finish off rather than a live token nothing can reach. The fourth caller is the one the product uses most. The Credentials page offers Add and Revoke and no rotate control, and `rotate` has no client caller at all, so replacing a model key is done by adding one for the same provider and keyId. `createCredential` therefore treats a key that already holds a live credential as a replacement and rotates, which is atomic and records `credential.rotated` naming what it replaced, rather than raising a bare unique violation on the only path the page offers. Tests cover each caller and the rule itself: an agent key created, rotated, and created again over a revoked reference; a Google Drive reconfigure under the same subject and under a changed one, including setting the original back; an MCP server removal revoking its token; the index refusing a second live row; and adding a credential for an occupied key replacing what was there. The connector tests run against the real vault rather than a stand-in, which is what would have caught the drift here in the first place.
43427fa to
324eb88
Compare
Closes #53.
Changed course since the last push. The review offered two ways to take the index: convert the colliding callers in this PR, or defer the index until they are converted. I took the second and said the index would follow separately. It is back here instead, with all four callers converted and the backfill corrected, because the index is the half that actually stops the leak and splitting it meant merging twice to fix one bug. This PR is now the reviewer's first option, finished. Two commits: the atomic rotation, then the index and the callers.
Also covers item 4 of #88, which was written against
f725fb5and so predates both commits.The problem
Rotation was two independent store calls: insert the new credential, then revoke the previous one. A failure on the revoke returned an error to the caller and left the new credential live in the vault, unreferenced and unannounced. Retrying wrote another one.
The first attempt at this, in #54, compensated in the caller. That does not work, and the review of that PR was right about why.
revokecan commit its UPDATE and still throw on the way back, from a statement timeout, a dropped connection, or the pool being torn down as the response returns; the compensating revoke then retires the new credential on top of a previous one that really was revoked, leaving the key with nothing live. The same fault that broke the first revoke is the one most likely to break the compensating one. And nothing held in the process survives the process being killed between the two writes.Underneath that, nothing said a key had one live credential. Every leaked row above was legal.
readModelSecretpicked the newest and every other reader followed a stored id, so a deployment could hold several live credentials for one key with no way to tell which one it meant.Rotation, in one transaction
CredentialStore.rotateopens a single transaction, locks the previous credentialFOR UPDATE, revokes it and inserts its replacement, following the shape already used for agent profiles inagents/profile-store.ts. Either both writes land or neither does, Postgres decides that rather than this code, and a process that dies mid-rotation leaves a database that rolls itself back.credential.rotatedis recorded only once the transaction has returned, so a rotation that never happened leaves no row claiming it did.Two guards come with it.
The lock reads the previous credential's kind, provider and keyId as well as its state, and refuses a rotation whose input names a different key.
POST /api/admin/credentials/:id/rotatetakes that id straight from the URL while the key it rotates into comes from the body, so without the check a mismatched pair would retire one key's only live credential and store the new secret under another.revokenow stamps only a row that is still live, and says "not found or already revoked" otherwise, rather than overwriting an existing timestamp and reporting success. That is what let two callers each believe they were the one who retired a credential.Revoke goes before insert inside the transaction. Both orders are invisible from outside it, and this one never holds two live rows for a key even in the middle, which is the invariant the index depends on.
One live credential per key
credentials_active_key_idxis unique on(kind, provider, key_id)whererevoked_at is null. Revoked rows are excluded, so history is untouched and only what is current is constrained.The backfill decides which duplicate survives
Existing databases have to be reconciled before the index can be built, and the newest is the wrong answer. In the failure this cleans up it is the new row that nothing references, while the older one is still named by the connector, MCP server or agent that was using it. Keeping the newest revokes the credential actually in use and the deployment comes back up authenticating with nothing.
0006_credentials_one_live_key.sqlranks a referenced row first — referenced meaning named byconnector_instances.credential_id,mcp_servers.credential_id, or an agent'sconfiguration -> 'auth' ->> 'credentialId'— and falls back to the newest only where nothing points at either. On a database with no duplicates it rewrites no rows.The four callers
Each stored a new credential for a key without retiring what was there. All four leaked orphans already; under the index they would have failed outright.
storeAgentAuthrotates when the agent has a live credential and inserts when it does not, checking liveness rather than trusting the reference. An administrator can revoke a key from the Credentials page, nothing repoints the agent that names it, and rotating onto a revoked row is refused — so trusting the reference would leave that agent's key impossible to replace, with every later edit failing on the same stale id.configureGoogleDriveretires the credential a reconfigure abandons when the impersonation subject changes. That row keeps its own key and is referenced by nothing afterwards, so a subject set, changed and set back again would meet it again on the index.removeServerrevokes the MCP token before deleting the server row, so adding the same server again does not meet its own leftover. The revoke goes first deliberately: two tables, no transaction spanning them, and a failure between them should leave a server that removing again will finish off, rather than a live token nothing can reach.createCredentialis the one the product uses most. The Credentials page offers Add and Revoke and no rotate control, androtatehas no client caller at all, so replacing a model key is done by adding one for the same provider and keyId. It now treats a key that already holds a live credential as a replacement and rotates, atomically, recordingcredential.rotatednaming what it replaced.Threading the caller's transaction
storeAgentAuthtakes an executor now. Agent edits are a transaction overagentsandagent_profilesand the credential belongs to that same change; written on a pooled connection of its own it would commit even where the edit rolled back, and could deadlock against the locks that edit is holding on a small pool.What is not covered
credential.rotatedis written after the transaction returns rather than inside it, so a rotation that commits and then loses the audit INSERT still writes a half story. The same pattern is on every audit-writing path in the app, so folding audit into the transaction is a wider change than this bug; as agreed in the review of #54, it belongs to a separate issue about the audit interface.The vault's errors still surface as 500s. There is no
app.onErrorand the credential routes have no try/catch, so "not found or already revoked" and a mismatched key both come back as 500 rather than 404 and 409. Also a follow-up, and unchanged here.Deleting a Bot still leaves its credential live. That is the remaining half of #88's item 4 and is not touched by either commit.
Verification
writes no audit event when the rotation failspins that a failed rotation records nothing.connector_instancesrow pointing at the older, the older survives and the orphan is revoked.0000through0006, onpgvector/pgvector:pg17. Resulting schema checked againstmeta/0006_snapshot.json: the index is unique with the partial predicate,accounts.issueris nullable, and thesso_providersforeign key isset null.drizzle-kit checkreports everything's fine and the journal agrees with the directory.bun run typecheckandbun run format:checkclean.bun run lintreports 27 warnings, four of themnoNonNullAssertionin the credential tests this branch adds; the rest are on files it does not touch.A note for whoever generates the next migration
meta/0005_snapshot.jsononmainis stale. It recordsaccounts.issuerasNOT NULLand thesso_providersforeign key ascascade; #87 then changed both incore.tsand did not regenerate it.drizzle-kit generatetherefore emits anALTER COLUMN issuer DROP NOT NULLand a foreign key drop-and-re-add that belong to that release, not to this one. I dropped them from0006, whose SQL is the backfill and the index only. The regenerated0006snapshot carries the corrected state, so the drift stops here, but the SQL for it was never the problem —0004already applies the foreign key change and theNOT NULLwas never applied at all, which the clean-slate migrate above confirms.