ISecretProvider.store is upsert-only, for every caller. There is no way to say "create this secret if it does not already exist", so any read-then-write sequence over a secret is a TOCTOU: two callers can both observe a key as missing and both write, and the later write silently replaces the earlier value.
Raised by CodeRabbit and Copilot independently while reviewing #699 (thread). Filed here rather than fixed there because it is a shared-SPI concern, not a property of one caller.
Callers affected today
| Caller |
Pattern |
AgentSetupService.useNamedVaultKey |
Reads metadata, creates when absent. Mitigated in #699 by reading back after the write and failing the setup on mismatch — this narrows the window and does not close it. |
AgentSetupService.findReusableSecret |
Scans checksums, then writes when nothing matched. Concurrent first-time setups with one key each store a copy; later setups converge, so it self-corrects rather than growing without bound. |
AgentSigningService.generateKeyPair / generateKeyPairVersioned |
Writes a private key straight over whatever is there. |
PropertySetterTask.autoVaultSecret |
Same, per conversation. |
RestSecretStore.storeSecret |
Reads to decide 200-vs-201, then writes. The status code can be wrong under a race; the write itself is intended as an upsert here. |
Proposal
ISecretPersistence.insertSecretIfAbsent(EncryptedSecret) -> boolean — false when a row already existed.
- Postgres:
INSERT … ON CONFLICT (tenant_id, key_name) DO NOTHING, using the affected-row count.
- Mongo:
insertOne under a unique index on (tenantId, keyName), catching the duplicate-key error. Needs an index migration; worth checking whether one already exists.
ISecretProvider.storeIfAbsent(...) exposing it, leaving store as the explicit upsert for callers that want it (rotation, RestSecretStore).
- Move
useNamedVaultKey onto it and report a genuine conflict instead of the current best-effort read-back.
- Consider a checksum reservation for
findReusableSecret, so concurrent first-time setups converge immediately rather than eventually. Lower value — the current behaviour self-corrects.
Testing
Both persistence backends need Testcontainers coverage of the concurrent case; a unit test with mocks cannot demonstrate atomicity.
Not urgent
Every path needs two concurrent writers targeting the same key name. The setup paths additionally need eddi-admin and a deliberately shared vaultKeyName. Nothing here is reachable by an unauthenticated caller.
ISecretProvider.storeis upsert-only, for every caller. There is no way to say "create this secret if it does not already exist", so any read-then-write sequence over a secret is a TOCTOU: two callers can both observe a key as missing and both write, and the later write silently replaces the earlier value.Raised by CodeRabbit and Copilot independently while reviewing #699 (thread). Filed here rather than fixed there because it is a shared-SPI concern, not a property of one caller.
Callers affected today
AgentSetupService.useNamedVaultKeyAgentSetupService.findReusableSecretAgentSigningService.generateKeyPair/generateKeyPairVersionedPropertySetterTask.autoVaultSecretRestSecretStore.storeSecretProposal
ISecretPersistence.insertSecretIfAbsent(EncryptedSecret) -> boolean—falsewhen a row already existed.INSERT … ON CONFLICT (tenant_id, key_name) DO NOTHING, using the affected-row count.insertOneunder a unique index on(tenantId, keyName), catching the duplicate-key error. Needs an index migration; worth checking whether one already exists.ISecretProvider.storeIfAbsent(...)exposing it, leavingstoreas the explicit upsert for callers that want it (rotation,RestSecretStore).useNamedVaultKeyonto it and report a genuine conflict instead of the current best-effort read-back.findReusableSecret, so concurrent first-time setups converge immediately rather than eventually. Lower value — the current behaviour self-corrects.Testing
Both persistence backends need Testcontainers coverage of the concurrent case; a unit test with mocks cannot demonstrate atomicity.
Not urgent
Every path needs two concurrent writers targeting the same key name. The setup paths additionally need
eddi-adminand a deliberately sharedvaultKeyName. Nothing here is reachable by an unauthenticated caller.