Skip to content

Runbook: migrate a plaintext connection password to Infisical

Who runs this: a human. Agents cannot β€” the secret guard blocks infisical secrets set and every other secret write.

Pending as of 2026-08-10: connection 9238201b-d167-4a07-8e67-ede3ba88b507 (the CK sandbox pricing connection, per scripts/eng-419-sync-sandbox-pricing.py) still carries a plaintext password in extra_config.database_config.password.

There is already a migration script and it is the supported path:

Terminal window
bun run packages/db/scripts/migrate-database-config-to-infisical.ts

It finds every erp_connections row with a plaintext extra_config.database_config.password and no databaseSecretsPath, stores the whole database_config in Infisical through CredentialService.storeDatabaseCredentials() (path ERP_DBCRED_{UUID}), then sets password to null and writes databaseSecretsPath back β€” in that order, so the secret exists before the plaintext goes away.

It is idempotent (skips rows that already have databaseSecretsPath) and safe (skips null/missing passwords without error), so a re-run after a partial failure is fine.

One caveat: it re-checks nothing at write time. Selection filters on the password and databaseSecretsPath, but the update is keyed on the row id alone (.where(eq(erpConnections.id, conn.id))), so a change landing between select and update is overwritten rather than detected. Run it when nothing else is editing these rows, and re-verify afterwards (step 3) rather than assuming the printed count is the final state.

Ad-hoc SQL for this is a trap in three separate ways, all of which have been gotten wrong before:

  • the column is extra_config, not config;
  • the contract sets database_config.password to JSON null, it does not remove the key β€” consumers read the key;
  • the Infisical path is derived by CredentialService, not chosen by hand.

1. Confirm what will be migrated. The script processes every matching connection, not just the one you have in mind, so look before running it:

SELECT id, name, erp_type,
extra_config->'database_config'->>'username' AS username,
extra_config->>'databaseSecretsPath' AS secrets_path
FROM erp_connections
WHERE extra_config->'database_config'->'password' IS NOT NULL
AND extra_config->'database_config'->'password' != 'null'::jsonb
AND extra_config->>'databaseSecretsPath' IS NULL;

That predicate is the script’s own selection criteria. Expect 9238201b-d167-4a07-8e67-ede3ba88b507 in the result. If other rows appear, decide deliberately whether they should migrate in the same pass.

2. Run it against the intended environment. Test writes go to the Staging Neon DB, never prod β€” confirm which database DATABASE_URL points at before running.

Terminal window
bun run packages/db/scripts/migrate-database-config-to-infisical.ts

It exits non-zero if any row errored.

3. Verify. Re-run the step 1 query β€” it should return no rows. Then confirm the credential actually resolves, which the DB row alone does not prove:

SELECT id,
extra_config->'database_config'->'password' AS password_json, -- expect null
extra_config->>'databaseSecretsPath' AS secrets_path -- expect ERP_DBCRED_<uuid>
FROM erp_connections
WHERE id = '9238201b-d167-4a07-8e67-ede3ba88b507';

Then exercise the connection itself β€” a sync or health check. A row that looks right is not evidence the credential reads back.

There is no automatic rollback, and the script does not retain the old value: recovery is re-reading the password from Infisical at ERP_DBCRED_{UUID} and writing it back into extra_config.database_config.password. That is why step 3’s verification matters β€” confirm the Infisical secret resolves before you consider the migration done.

The failure mode to avoid is a connection with neither a live Infisical secret nor a legacy password: no recoverable credential anywhere. Six production connections sat in exactly that state once. The script’s ordering prevents it; hand-written SQL is what caused it.

op in this repo is a read-only service account β€” op item create fails even for the user. If a value needs storing in 1Password rather than Infisical, do it in the 1Password UI. Also: infisical secrets set echoes the value back on success, so redirect its output if you ever run it directly.