Skip to content

secrets: add an atomic create-if-absent to the vault SPI #700

Description

@ginccc

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

  1. 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.
  2. ISecretProvider.storeIfAbsent(...) exposing it, leaving store as the explicit upsert for callers that want it (rotation, RestSecretStore).
  3. Move useNamedVaultKey onto it and report a genuine conflict instead of the current best-effort read-back.
  4. 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.

Activity

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

Metadata

Metadata

Assignees

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