From 90861ffa75ddb6036476af3f084ecc270a22801d Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 7 Jul 2026 07:42:12 -0700 Subject: [PATCH] test(enterprise/dbcrypt): add Rotate/Decrypt/Delete regression tests (#26979) Related to #25389. `enterprise/dbcrypt/cliutil.go`'s `Rotate`, `Decrypt`, and `Delete` are the only interface for migrating database encryption keys, but had zero test coverage. Adds `enterprise/dbcrypt/cliutil_test.go` with per-table regression tests for all three functions. ## Covered The 7 tables currently handled by `Rotate`/`Decrypt`/`Delete`: - `user_links`, `external_auth_links` - `user_secrets` - `gitsshkeys` - `ai_providers`, `ai_provider_keys`, `user_ai_provider_keys` Each table has a happy-path test verifying live rows (and, where the schema allows it, soft-deleted rows) round-trip correctly. `Rotate` and `Decrypt` additionally have a failure-path test per table confirming the operation aborts cleanly with a `DecryptFailedError`, leaving data untouched, when an active `dbcrypt_keys` entry isn't included in the operation. `Delete` takes no cipher argument, so instead each test confirms an encrypted row is wiped while a never-encrypted row is left untouched by the `WHERE ... IS NOT NULL` clauses in its SQL. ## Gaps (follow-up) `crypto_keys`, `mcp_server_configs`, and `mcp_server_user_tokens` are encrypted at rest but are not yet handled by `Rotate`/`Decrypt`/`Delete` at all. This is the bug reported in #25381: an incomplete `--old-keys`/`--keys` list against these tables fails with a foreign key violation on revoke. A follow-up PR will add both the `cliutil.go` fix and matching regression tests for these three tables. --- enterprise/dbcrypt/cliutil_test.go | 1796 ++++++++++++++++++++++++++++ 1 file changed, 1796 insertions(+) create mode 100644 enterprise/dbcrypt/cliutil_test.go diff --git a/enterprise/dbcrypt/cliutil_test.go b/enterprise/dbcrypt/cliutil_test.go new file mode 100644 index 0000000000..fef0375319 --- /dev/null +++ b/enterprise/dbcrypt/cliutil_test.go @@ -0,0 +1,1796 @@ +package dbcrypt_test + +import ( + "context" + "database/sql" + "encoding/base64" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/enterprise/dbcrypt" + "github.com/coder/coder/v2/testutil" +) + +// rotateFixture provisions an isolated Postgres database plus two +// independent ciphers ("A" and "B") used to exercise a single rotation. +// Fixtures are seeded through cryptDBA so seeded rows start out genuinely +// encrypted under cipher A, mirroring a deployment that already has +// encryption enabled and is rotating to a new key. +type rotateFixture struct { + ctx context.Context + rawDB database.Store + sqlDB *sql.DB + cipherA dbcrypt.Cipher + cipherB dbcrypt.Cipher + cryptDBA database.Store +} + +func newRotateFixture(t *testing.T) *rotateFixture { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + + ciphersA, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + ciphersB, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + + cryptDBA, err := dbcrypt.New(ctx, rawDB, ciphersA...) + require.NoError(t, err) + + return &rotateFixture{ + ctx: ctx, + rawDB: rawDB, + sqlDB: sqlDB, + cipherA: ciphersA[0], + cipherB: ciphersB[0], + cryptDBA: cryptDBA, + } +} + +// rotateErr moves every dbcrypt-managed row from cipherA to cipherB and +// returns whatever error Rotate produces, including from the trailing +// RevokeDBCryptKey(cipherA) call that Rotate issues internally. That revoke +// is where the #25381 bug surfaces: it fails with a foreign key violation +// if any row anywhere still references cipherA's digest. +func (f *rotateFixture) rotateErr(t *testing.T) error { + t.Helper() + return dbcrypt.Rotate(f.ctx, testutil.Logger(t), f.sqlDB, []dbcrypt.Cipher{f.cipherB, f.cipherA}) +} + +// rotate is rotateErr for tests that expect the rotation to succeed. +func (f *rotateFixture) rotate(t *testing.T) { + t.Helper() + err := f.rotateErr(t) + require.NoError(t, err, "rotate should succeed and cleanly revoke the old key") +} + +// newCipher returns a fresh, independent cipher unrelated to a fixture's +// cipherA/cipherB, for tests that need to seed data under a key Rotate is +// never told about. +func newCipher(t *testing.T) dbcrypt.Cipher { + t.Helper() + ciphers, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + return ciphers[0] +} + +// registerCipher builds a dbcrypt-wrapped store with c as its primary +// cipher, seeded rows are encrypted under c, but also loads cipherA so +// construction can decrypt cipherA's existing dbcrypt_keys canary (every +// dbcrypt-managed column has a foreign key to dbcrypt_keys, so a row can +// only reference c's digest once c itself has a dbcrypt_keys row; and +// building any dbcrypt-wrapped store requires decrypting the canary of +// every currently active key, not just the one it intends to write with). +// Used to register and seed data under a cipher that Rotate is +// deliberately never told about. +func (f *rotateFixture) registerCipher(t *testing.T, c dbcrypt.Cipher) database.Store { + t.Helper() + wrapped, err := dbcrypt.New(f.ctx, f.rawDB, c, f.cipherA) + require.NoError(t, err) + return wrapped +} + +// upsertUserAIProviderKey inserts a user_ai_provider_keys row through the +// given store. There is no dbgen helper for this table, so this wraps the +// raw UpsertUserAIProviderKey call for reuse across tests. +func upsertUserAIProviderKey(ctx context.Context, t *testing.T, store database.Store, userID, providerID uuid.UUID, apiKey string) database.UserAIProviderKey { + t.Helper() + now := time.Now() + key, err := store.UpsertUserAIProviderKey(ctx, database.UpsertUserAIProviderKeyParams{ + ID: uuid.New(), + UserID: userID, + AIProviderID: providerID, + APIKey: apiKey, + CreatedAt: now, + UpdatedAt: now, + }) + require.NoError(t, err) + return key +} + +// decryptRawString decodes and decrypts a raw (base64) ciphertext value read +// directly from the database, for comparison against the original plaintext. +func decryptRawString(t *testing.T, c dbcrypt.Cipher, raw string) string { + t.Helper() + data, err := base64.StdEncoding.DecodeString(raw) + require.NoError(t, err, "raw value must be valid base64 ciphertext") + plain, err := c.Decrypt(data) + require.NoError(t, err, "must decrypt with the expected cipher") + return string(plain) +} + +// TestRotateUserLinks covers the user_links table (OAuth login access and +// refresh tokens). Simulates an operator routinely rotating from an +// existing key to a new one on a deployment that already has encryption +// enabled: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateUserLinks(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // user_links cannot exist for a deleted user, so only a live row is + // exercised here. + user := dbgen.User(t, f.rawDB, database.User{}) + const wantAccess = "access-token" + const wantRefresh = "refresh-token" + seeded := dbgen.UserLink(t, f.cryptDBA, database.UserLink{ + UserID: user.ID, + LoginType: user.LoginType, + OAuthAccessToken: wantAccess, + OAuthRefreshToken: wantRefresh, + }) + require.Equal(t, f.cipherA.HexDigest(), seeded.OAuthAccessTokenKeyID.String, "sanity check: seed must be encrypted under cipher A") + + f.rotate(t) + + links, err := f.rawDB.GetUserLinksByUserID(f.ctx, user.ID) + require.NoError(t, err) + require.Len(t, links, 1) + link := links[0] + require.Equal(t, f.cipherB.HexDigest(), link.OAuthAccessTokenKeyID.String) + require.Equal(t, f.cipherB.HexDigest(), link.OAuthRefreshTokenKeyID.String) + require.Equal(t, wantAccess, decryptRawString(t, f.cipherB, link.OAuthAccessToken)) + require.Equal(t, wantRefresh, decryptRawString(t, f.cipherB, link.OAuthRefreshToken)) + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // A user_links row is encrypted under cipher C, which is registered in + // dbcrypt_keys (required for the row to exist at all, thanks to its + // foreign key) but never passed to the rotation below. Building any + // dbcrypt-wrapped store, including the one Rotate builds internally, + // requires decrypting the dbcrypt_keys canary of every currently active + // key, not just the ones the caller intends to use. So this fails + // immediately with a DecryptFailedError before Rotate reads a single + // row, and leaves every table, including this one, untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.UserLink(t, cryptDBC, database.UserLink{ + UserID: user.ID, + LoginType: user.LoginType, + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + require.Equal(t, cipherC.HexDigest(), seeded.OAuthAccessTokenKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + links, getErr := f.rawDB.GetUserLinksByUserID(f.ctx, user.ID) + require.NoError(t, getErr) + require.Len(t, links, 1) + require.Equal(t, cipherC.HexDigest(), links[0].OAuthAccessTokenKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// TestRotateExternalAuthLinks covers the external_auth_links table (external +// Git provider OAuth tokens). Simulates rotating keys on a deployment that +// includes users who have since been soft-deleted but whose tokens are +// still sitting in the table, exactly the case that must be swept before +// the old key can be revoked: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateExternalAuthLinks(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // Unlike user_links, external_auth_links survive user soft-deletion, + // so both a live and a deleted user's row are exercised. + live := dbgen.User(t, f.rawDB, database.User{}) + deletedUser := dbgen.User(t, f.rawDB, database.User{Deleted: true}) + + seedLink := func(u database.User) { + dbgen.ExternalAuthLink(t, f.cryptDBA, database.ExternalAuthLink{ + UserID: u.ID, + ProviderID: "fake", + OAuthAccessToken: "access-" + u.ID.String(), + OAuthRefreshToken: "refresh-" + u.ID.String(), + }) + } + seedLink(live) + seedLink(deletedUser) + + f.rotate(t) + + for _, u := range []database.User{live, deletedUser} { + links, err := f.rawDB.GetExternalAuthLinksByUserID(f.ctx, u.ID) + require.NoError(t, err, "user %s", u.ID) + require.Len(t, links, 1, "user %s", u.ID) + link := links[0] + require.Equal(t, f.cipherB.HexDigest(), link.OAuthAccessTokenKeyID.String) + require.Equal(t, f.cipherB.HexDigest(), link.OAuthRefreshTokenKeyID.String) + require.Equal(t, "access-"+u.ID.String(), decryptRawString(t, f.cipherB, link.OAuthAccessToken)) + require.Equal(t, "refresh-"+u.ID.String(), decryptRawString(t, f.cipherB, link.OAuthRefreshToken)) + } + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // An external_auth_links row is encrypted under cipher C, registered in + // dbcrypt_keys but never passed to the rotation below, so Rotate fails + // immediately (see TestRotateUserLinks/DecryptErr for why) and leaves + // the row untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.ExternalAuthLink(t, cryptDBC, database.ExternalAuthLink{ + UserID: user.ID, + ProviderID: "fake", + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + require.Equal(t, cipherC.HexDigest(), seeded.OAuthAccessTokenKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + links, getErr := f.rawDB.GetExternalAuthLinksByUserID(f.ctx, user.ID) + require.NoError(t, getErr) + require.Len(t, links, 1) + require.Equal(t, cipherC.HexDigest(), links[0].OAuthAccessTokenKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// TestRotateUserSecrets covers the user_secrets table (arbitrary +// user-defined secret values). Simulates a routine key rotation on a +// deployment with existing encrypted user secrets: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateUserSecrets(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // user_secrets cannot exist for a deleted user, so only a live row + // is exercised here. + user := dbgen.User(t, f.rawDB, database.User{}) + const wantValue = "super-secret-value" + dbgen.UserSecret(t, f.cryptDBA, database.UserSecret{ + UserID: user.ID, + Name: "my-secret", + Value: wantValue, + }) + + f.rotate(t) + + secrets, err := f.rawDB.ListUserSecretsWithValues(f.ctx, user.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.Equal(t, f.cipherB.HexDigest(), secrets[0].ValueKeyID.String) + require.Equal(t, wantValue, decryptRawString(t, f.cipherB, secrets[0].Value)) + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // A user_secrets row is encrypted under cipher C, registered in + // dbcrypt_keys but never passed to the rotation below, so Rotate fails + // immediately (see TestRotateUserLinks/DecryptErr for why) and leaves + // the row untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.UserSecret(t, cryptDBC, database.UserSecret{ + UserID: user.ID, + Name: "my-secret", + Value: "super-secret-value", + }) + require.Equal(t, cipherC.HexDigest(), seeded.ValueKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + secrets, getErr := f.rawDB.ListUserSecretsWithValues(f.ctx, user.ID) + require.NoError(t, getErr) + require.Len(t, secrets, 1) + require.Equal(t, cipherC.HexDigest(), secrets[0].ValueKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// TestRotateGitSSHKey covers the gitsshkeys table (per-user Git SSH private +// keys). Simulates rotating keys on a deployment where a soft-deleted +// user's SSH key row is still present, since gitsshkeys rows are preserved +// (not removed) by the user soft-delete trigger so the row can be +// regenerated if the user is restored: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateGitSSHKey(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // gitsshkeys are preserved by the user soft-delete trigger, so both + // a live and a deleted user's row are exercised. + live := dbgen.User(t, f.rawDB, database.User{}) + deletedUser := dbgen.User(t, f.rawDB, database.User{Deleted: true}) + + seedKey := func(u database.User) { + dbgen.GitSSHKey(t, f.cryptDBA, database.GitSSHKey{ + UserID: u.ID, + PrivateKey: "private-" + u.ID.String(), + PublicKey: "public-" + u.ID.String(), + }) + } + seedKey(live) + seedKey(deletedUser) + + f.rotate(t) + + for _, u := range []database.User{live, deletedUser} { + key, err := f.rawDB.GetGitSSHKey(f.ctx, u.ID) + require.NoError(t, err, "user %s", u.ID) + require.Equal(t, f.cipherB.HexDigest(), key.PrivateKeyKeyID.String) + require.Equal(t, "private-"+u.ID.String(), decryptRawString(t, f.cipherB, key.PrivateKey)) + // The public key is never encrypted. + require.Equal(t, "public-"+u.ID.String(), key.PublicKey) + } + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // A gitsshkeys row is encrypted under cipher C, registered in + // dbcrypt_keys but never passed to the rotation below, so Rotate fails + // immediately (see TestRotateUserLinks/DecryptErr for why) and leaves + // the row untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.GitSSHKey(t, cryptDBC, database.GitSSHKey{ + UserID: user.ID, + PrivateKey: "private-key", + PublicKey: "public-key", + }) + require.Equal(t, cipherC.HexDigest(), seeded.PrivateKeyKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + key, getErr := f.rawDB.GetGitSSHKey(f.ctx, user.ID) + require.NoError(t, getErr) + require.Equal(t, cipherC.HexDigest(), key.PrivateKeyKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// TestRotateAIProviders covers the ai_providers table (provider-level +// settings, e.g. AWS Bedrock region/model config). Simulates rotating keys +// on a deployment that has decommissioned (soft-deleted) an AI provider but +// kept the row for audit/FK history, exactly the kind of row that's easy to +// forget when a new dbcrypt-managed table is added: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateAIProviders(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // ai_providers carries its own soft-delete flag (independent of any + // user), so both a live and a soft-deleted provider are exercised. + live := dbgen.AIProvider(t, f.cryptDBA, database.AIProvider{ + Settings: sql.NullString{String: "settings-live", Valid: true}, + }) + deleted := dbgen.AIProvider(t, f.cryptDBA, database.AIProvider{ + Settings: sql.NullString{String: "settings-deleted", Valid: true}, + }) + require.NoError(t, f.rawDB.DeleteAIProviderByID(f.ctx, deleted.ID)) + + f.rotate(t) + + providers, err := f.rawDB.GetAIProviders(f.ctx, database.GetAIProvidersParams{ + IncludeDeleted: true, + IncludeDisabled: true, + }) + require.NoError(t, err) + byID := make(map[uuid.UUID]database.AIProvider, len(providers)) + for _, p := range providers { + byID[p.ID] = p + } + + gotLive, ok := byID[live.ID] + require.True(t, ok) + require.Equal(t, f.cipherB.HexDigest(), gotLive.SettingsKeyID.String) + require.Equal(t, "settings-live", decryptRawString(t, f.cipherB, gotLive.Settings.String)) + + gotDeleted, ok := byID[deleted.ID] + require.True(t, ok) + require.True(t, gotDeleted.Deleted, "provider should remain marked deleted after rotation") + require.Equal(t, f.cipherB.HexDigest(), gotDeleted.SettingsKeyID.String) + require.Equal(t, "settings-deleted", decryptRawString(t, f.cipherB, gotDeleted.Settings.String)) + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // An ai_providers row's settings are encrypted under cipher C, + // registered in dbcrypt_keys but never passed to the rotation below, so + // Rotate fails immediately (see TestRotateUserLinks/DecryptErr for why) + // and leaves the row untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + seeded := dbgen.AIProvider(t, cryptDBC, database.AIProvider{ + Settings: sql.NullString{String: "settings-value", Valid: true}, + }) + require.Equal(t, cipherC.HexDigest(), seeded.SettingsKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + got, getErr := f.rawDB.GetAIProviderByID(f.ctx, seeded.ID) + require.NoError(t, getErr) + require.Equal(t, cipherC.HexDigest(), got.SettingsKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// TestRotateAIProviderKeys covers the ai_provider_keys table (provider API +// keys, e.g. OpenAI/Anthropic credentials). Simulates rotating keys where +// one key belongs to a live provider and another belongs to a provider that +// has since been soft-deleted; the key row has no deleted flag of its own, +// it inherits "deleted" from its parent provider via a JOIN: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateAIProviderKeys(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // GetAIProviderKeys(includeDeleted=true), which Rotate calls, joins + // against ai_providers.deleted, so a key belonging to a soft-deleted + // provider is exercised alongside a key on a live provider. + liveProvider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + deletedProvider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + require.NoError(t, f.rawDB.DeleteAIProviderByID(f.ctx, deletedProvider.ID)) + + liveKey := dbgen.AIProviderKey(t, f.cryptDBA, database.AIProviderKey{ + ProviderID: liveProvider.ID, + APIKey: "api-key-live", + }) + deletedKey := dbgen.AIProviderKey(t, f.cryptDBA, database.AIProviderKey{ + ProviderID: deletedProvider.ID, + APIKey: "api-key-deleted-provider", + }) + + f.rotate(t) + + gotLive, err := f.rawDB.GetAIProviderKeyByID(f.ctx, liveKey.ID) + require.NoError(t, err) + require.Equal(t, f.cipherB.HexDigest(), gotLive.ApiKeyKeyID.String) + require.Equal(t, "api-key-live", decryptRawString(t, f.cipherB, gotLive.APIKey)) + + gotDeleted, err := f.rawDB.GetAIProviderKeyByID(f.ctx, deletedKey.ID) + require.NoError(t, err) + require.Equal(t, f.cipherB.HexDigest(), gotDeleted.ApiKeyKeyID.String) + require.Equal(t, "api-key-deleted-provider", decryptRawString(t, f.cipherB, gotDeleted.APIKey)) + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // An ai_provider_keys row is encrypted under cipher C, registered in + // dbcrypt_keys but never passed to the rotation below, so Rotate fails + // immediately (see TestRotateUserLinks/DecryptErr for why) and leaves + // the row untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + seeded := dbgen.AIProviderKey(t, cryptDBC, database.AIProviderKey{ + ProviderID: provider.ID, + APIKey: "api-key-value", + }) + require.Equal(t, cipherC.HexDigest(), seeded.ApiKeyKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + got, getErr := f.rawDB.GetAIProviderKeyByID(f.ctx, seeded.ID) + require.NoError(t, getErr) + require.Equal(t, cipherC.HexDigest(), got.ApiKeyKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// TestRotateUserAIProviderKeys covers the user_ai_provider_keys table +// (per-user, user-owned AI provider API keys). Simulates rotating keys +// where one row belongs to a live user and another to a soft-deleted user; +// unlike the other tables, GetUserAIProviderKeys has no deleted/live filter +// at all, so this test exists to confirm Rotate doesn't silently need one: +// +// coder server dbcrypt rotate \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --new-key \ +// --old-keys +func TestRotateUserAIProviderKeys(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + // GetUserAIProviderKeys, which Rotate calls, has no deleted/live + // filter at all, so a row belonging to a deleted user is exercised + // alongside a live user's row purely to confirm Rotate doesn't need + // one. + liveUser := dbgen.User(t, f.rawDB, database.User{}) + deletedUser := dbgen.User(t, f.rawDB, database.User{Deleted: true}) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + + liveKey := upsertUserAIProviderKey(f.ctx, t, f.cryptDBA, liveUser.ID, provider.ID, "user-key-live") + deletedKey := upsertUserAIProviderKey(f.ctx, t, f.cryptDBA, deletedUser.ID, provider.ID, "user-key-deleted-user") + + f.rotate(t) + + keys, err := f.rawDB.GetUserAIProviderKeys(f.ctx) + require.NoError(t, err) + byID := make(map[uuid.UUID]database.UserAIProviderKey, len(keys)) + for _, k := range keys { + byID[k.ID] = k + } + + for _, tc := range []struct { + id uuid.UUID + want string + }{ + {liveKey.ID, "user-key-live"}, + {deletedKey.ID, "user-key-deleted-user"}, + } { + got, ok := byID[tc.id] + require.True(t, ok, "key %s", tc.id) + require.Equal(t, f.cipherB.HexDigest(), got.ApiKeyKeyID.String) + require.Equal(t, tc.want, decryptRawString(t, f.cipherB, got.APIKey)) + } + }) + + // DecryptErr simulates an operator omitting an old key from --old-keys. + // A user_ai_provider_keys row is encrypted under cipher C, registered + // in dbcrypt_keys but never passed to the rotation below, so Rotate + // fails immediately (see TestRotateUserLinks/DecryptErr for why) and + // leaves the row untouched. + // + // # cipher C's key is missing from --old-keys here on purpose: + // coder server dbcrypt rotate \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --new-key \ + // --old-keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newRotateFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + seeded := upsertUserAIProviderKey(f.ctx, t, cryptDBC, user.ID, provider.ID, "user-key-value") + require.Equal(t, cipherC.HexDigest(), seeded.ApiKeyKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.rotateErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Rotate") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + keys, getErr := f.rawDB.GetUserAIProviderKeys(f.ctx) + require.NoError(t, getErr) + var got *database.UserAIProviderKey + for i := range keys { + if keys[i].ID == seeded.ID { + got = &keys[i] + } + } + require.NotNil(t, got) + require.Equal(t, cipherC.HexDigest(), got.ApiKeyKeyID.String, "row must remain encrypted under cipher C after a failed rotation") + }) +} + +// decryptFixture provisions an isolated Postgres database plus a single +// cipher ("A") used to exercise a single Decrypt operation. Unlike Rotate, +// Decrypt has no destination cipher, it writes plaintext back and clears +// every key ID column, then revokes every cipher it was given. +type decryptFixture struct { + ctx context.Context + rawDB database.Store + sqlDB *sql.DB + cipherA dbcrypt.Cipher + cryptDBA database.Store +} + +func newDecryptFixture(t *testing.T) *decryptFixture { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + + ciphersA, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + + cryptDBA, err := dbcrypt.New(ctx, rawDB, ciphersA...) + require.NoError(t, err) + + return &decryptFixture{ + ctx: ctx, + rawDB: rawDB, + sqlDB: sqlDB, + cipherA: ciphersA[0], + cryptDBA: cryptDBA, + } +} + +// decryptErr decrypts every dbcrypt-managed row and revokes cipherA, +// returning whatever error Decrypt produces. +func (f *decryptFixture) decryptErr(t *testing.T) error { + t.Helper() + return dbcrypt.Decrypt(f.ctx, testutil.Logger(t), f.sqlDB, []dbcrypt.Cipher{f.cipherA}) +} + +// decrypt is decryptErr for tests that expect the decrypt to succeed. +func (f *decryptFixture) decrypt(t *testing.T) { + t.Helper() + err := f.decryptErr(t) + require.NoError(t, err, "decrypt should succeed and cleanly revoke cipher A") +} + +// registerCipher is the decryptFixture counterpart of +// rotateFixture.registerCipher: it builds a dbcrypt-wrapped store with c as +// primary but also loads cipherA, so construction can decrypt cipherA's +// existing dbcrypt_keys canary. Used to seed data under a cipher that +// Decrypt is deliberately never told about. +func (f *decryptFixture) registerCipher(t *testing.T, c dbcrypt.Cipher) database.Store { + t.Helper() + wrapped, err := dbcrypt.New(f.ctx, f.rawDB, c, f.cipherA) + require.NoError(t, err) + return wrapped +} + +// TestDecryptUserLinks covers the user_links table (OAuth login access and +// refresh tokens). Simulates an operator turning off database encryption +// entirely on a deployment that has it enabled: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptUserLinks(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + // user_links cannot exist for a deleted user, so only a live row is + // exercised here. + user := dbgen.User(t, f.rawDB, database.User{}) + const wantAccess = "access-token" + const wantRefresh = "refresh-token" + seeded := dbgen.UserLink(t, f.cryptDBA, database.UserLink{ + UserID: user.ID, + LoginType: user.LoginType, + OAuthAccessToken: wantAccess, + OAuthRefreshToken: wantRefresh, + }) + require.Equal(t, f.cipherA.HexDigest(), seeded.OAuthAccessTokenKeyID.String, "sanity check: seed must be encrypted under cipher A") + + f.decrypt(t) + + links, err := f.rawDB.GetUserLinksByUserID(f.ctx, user.ID) + require.NoError(t, err) + require.Len(t, links, 1) + link := links[0] + require.False(t, link.OAuthAccessTokenKeyID.Valid, "key ID should be cleared after decrypt") + require.False(t, link.OAuthRefreshTokenKeyID.Valid, "key ID should be cleared after decrypt") + require.Equal(t, wantAccess, link.OAuthAccessToken, "value should be stored as plaintext after decrypt") + require.Equal(t, wantRefresh, link.OAuthRefreshToken, "value should be stored as plaintext after decrypt") + }) + + // DecryptErr simulates an operator omitting a key from --keys. A + // user_links row is encrypted under cipher C, registered in + // dbcrypt_keys but never passed to the decrypt below, so it fails + // immediately (see TestRotateUserLinks/DecryptErr for why building any + // dbcrypt-wrapped store requires every active key to be known) and + // leaves the row untouched. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.UserLink(t, cryptDBC, database.UserLink{ + UserID: user.ID, + LoginType: user.LoginType, + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + require.Equal(t, cipherC.HexDigest(), seeded.OAuthAccessTokenKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + links, getErr := f.rawDB.GetUserLinksByUserID(f.ctx, user.ID) + require.NoError(t, getErr) + require.Len(t, links, 1) + require.Equal(t, cipherC.HexDigest(), links[0].OAuthAccessTokenKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// TestDecryptExternalAuthLinks covers the external_auth_links table +// (external Git provider OAuth tokens). Simulates disabling encryption on a +// deployment that includes users who have since been soft-deleted but whose +// tokens are still sitting in the table: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptExternalAuthLinks(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + // Unlike user_links, external_auth_links survive user soft-deletion, + // so both a live and a deleted user's row are exercised. + live := dbgen.User(t, f.rawDB, database.User{}) + deletedUser := dbgen.User(t, f.rawDB, database.User{Deleted: true}) + + seedLink := func(u database.User) { + dbgen.ExternalAuthLink(t, f.cryptDBA, database.ExternalAuthLink{ + UserID: u.ID, + ProviderID: "fake", + OAuthAccessToken: "access-" + u.ID.String(), + OAuthRefreshToken: "refresh-" + u.ID.String(), + }) + } + seedLink(live) + seedLink(deletedUser) + + f.decrypt(t) + + for _, u := range []database.User{live, deletedUser} { + links, err := f.rawDB.GetExternalAuthLinksByUserID(f.ctx, u.ID) + require.NoError(t, err, "user %s", u.ID) + require.Len(t, links, 1, "user %s", u.ID) + link := links[0] + require.False(t, link.OAuthAccessTokenKeyID.Valid) + require.False(t, link.OAuthRefreshTokenKeyID.Valid) + require.Equal(t, "access-"+u.ID.String(), link.OAuthAccessToken) + require.Equal(t, "refresh-"+u.ID.String(), link.OAuthRefreshToken) + } + }) + + // DecryptErr simulates an operator omitting a key from --keys. See + // TestDecryptUserLinks/DecryptErr for the underlying mechanism. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.ExternalAuthLink(t, cryptDBC, database.ExternalAuthLink{ + UserID: user.ID, + ProviderID: "fake", + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + require.Equal(t, cipherC.HexDigest(), seeded.OAuthAccessTokenKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + links, getErr := f.rawDB.GetExternalAuthLinksByUserID(f.ctx, user.ID) + require.NoError(t, getErr) + require.Len(t, links, 1) + require.Equal(t, cipherC.HexDigest(), links[0].OAuthAccessTokenKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// TestDecryptUserSecrets covers the user_secrets table (arbitrary +// user-defined secret values). Simulates disabling encryption on a +// deployment with existing encrypted user secrets: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptUserSecrets(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + // user_secrets cannot exist for a deleted user, so only a live row + // is exercised here. + user := dbgen.User(t, f.rawDB, database.User{}) + const wantValue = "super-secret-value" + dbgen.UserSecret(t, f.cryptDBA, database.UserSecret{ + UserID: user.ID, + Name: "my-secret", + Value: wantValue, + }) + + f.decrypt(t) + + secrets, err := f.rawDB.ListUserSecretsWithValues(f.ctx, user.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.False(t, secrets[0].ValueKeyID.Valid) + require.Equal(t, wantValue, secrets[0].Value) + }) + + // DecryptErr simulates an operator omitting a key from --keys. See + // TestDecryptUserLinks/DecryptErr for the underlying mechanism. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.UserSecret(t, cryptDBC, database.UserSecret{ + UserID: user.ID, + Name: "my-secret", + Value: "super-secret-value", + }) + require.Equal(t, cipherC.HexDigest(), seeded.ValueKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + secrets, getErr := f.rawDB.ListUserSecretsWithValues(f.ctx, user.ID) + require.NoError(t, getErr) + require.Len(t, secrets, 1) + require.Equal(t, cipherC.HexDigest(), secrets[0].ValueKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// TestDecryptGitSSHKey covers the gitsshkeys table (per-user Git SSH +// private keys). Simulates disabling encryption on a deployment where a +// soft-deleted user's SSH key row is still present: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptGitSSHKey(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + // gitsshkeys are preserved by the user soft-delete trigger, so both + // a live and a deleted user's row are exercised. + live := dbgen.User(t, f.rawDB, database.User{}) + deletedUser := dbgen.User(t, f.rawDB, database.User{Deleted: true}) + + seedKey := func(u database.User) { + dbgen.GitSSHKey(t, f.cryptDBA, database.GitSSHKey{ + UserID: u.ID, + PrivateKey: "private-" + u.ID.String(), + PublicKey: "public-" + u.ID.String(), + }) + } + seedKey(live) + seedKey(deletedUser) + + f.decrypt(t) + + for _, u := range []database.User{live, deletedUser} { + key, err := f.rawDB.GetGitSSHKey(f.ctx, u.ID) + require.NoError(t, err, "user %s", u.ID) + require.False(t, key.PrivateKeyKeyID.Valid) + require.Equal(t, "private-"+u.ID.String(), key.PrivateKey) + require.Equal(t, "public-"+u.ID.String(), key.PublicKey) + } + }) + + // DecryptErr simulates an operator omitting a key from --keys. See + // TestDecryptUserLinks/DecryptErr for the underlying mechanism. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + seeded := dbgen.GitSSHKey(t, cryptDBC, database.GitSSHKey{ + UserID: user.ID, + PrivateKey: "private-key", + PublicKey: "public-key", + }) + require.Equal(t, cipherC.HexDigest(), seeded.PrivateKeyKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + key, getErr := f.rawDB.GetGitSSHKey(f.ctx, user.ID) + require.NoError(t, getErr) + require.Equal(t, cipherC.HexDigest(), key.PrivateKeyKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// TestDecryptAIProviders covers the ai_providers table (provider-level +// settings, e.g. AWS Bedrock region/model config). Simulates disabling +// encryption on a deployment that has decommissioned (soft-deleted) an AI +// provider but kept the row for audit/FK history: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptAIProviders(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + // ai_providers carries its own soft-delete flag (independent of any + // user), so both a live and a soft-deleted provider are exercised. + live := dbgen.AIProvider(t, f.cryptDBA, database.AIProvider{ + Settings: sql.NullString{String: "settings-live", Valid: true}, + }) + deleted := dbgen.AIProvider(t, f.cryptDBA, database.AIProvider{ + Settings: sql.NullString{String: "settings-deleted", Valid: true}, + }) + require.NoError(t, f.rawDB.DeleteAIProviderByID(f.ctx, deleted.ID)) + + f.decrypt(t) + + providers, err := f.rawDB.GetAIProviders(f.ctx, database.GetAIProvidersParams{ + IncludeDeleted: true, + IncludeDisabled: true, + }) + require.NoError(t, err) + byID := make(map[uuid.UUID]database.AIProvider, len(providers)) + for _, p := range providers { + byID[p.ID] = p + } + + gotLive, ok := byID[live.ID] + require.True(t, ok) + require.False(t, gotLive.SettingsKeyID.Valid) + require.Equal(t, "settings-live", gotLive.Settings.String) + + gotDeleted, ok := byID[deleted.ID] + require.True(t, ok) + require.True(t, gotDeleted.Deleted, "provider should remain marked deleted after decrypt") + require.False(t, gotDeleted.SettingsKeyID.Valid) + require.Equal(t, "settings-deleted", gotDeleted.Settings.String) + }) + + // DecryptErr simulates an operator omitting a key from --keys. See + // TestDecryptUserLinks/DecryptErr for the underlying mechanism. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + seeded := dbgen.AIProvider(t, cryptDBC, database.AIProvider{ + Settings: sql.NullString{String: "settings-value", Valid: true}, + }) + require.Equal(t, cipherC.HexDigest(), seeded.SettingsKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + got, getErr := f.rawDB.GetAIProviderByID(f.ctx, seeded.ID) + require.NoError(t, getErr) + require.Equal(t, cipherC.HexDigest(), got.SettingsKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// TestDecryptAIProviderKeys covers the ai_provider_keys table (provider API +// keys, e.g. OpenAI/Anthropic credentials). Simulates disabling encryption +// where one key belongs to a live provider and another belongs to a +// provider that has since been soft-deleted: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptAIProviderKeys(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + // GetAIProviderKeys(includeDeleted=true), which Decrypt calls, + // joins against ai_providers.deleted, so a key belonging to a + // soft-deleted provider is exercised alongside a key on a live + // provider. + liveProvider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + deletedProvider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + require.NoError(t, f.rawDB.DeleteAIProviderByID(f.ctx, deletedProvider.ID)) + + liveKey := dbgen.AIProviderKey(t, f.cryptDBA, database.AIProviderKey{ + ProviderID: liveProvider.ID, + APIKey: "api-key-live", + }) + deletedKey := dbgen.AIProviderKey(t, f.cryptDBA, database.AIProviderKey{ + ProviderID: deletedProvider.ID, + APIKey: "api-key-deleted-provider", + }) + + f.decrypt(t) + + gotLive, err := f.rawDB.GetAIProviderKeyByID(f.ctx, liveKey.ID) + require.NoError(t, err) + require.False(t, gotLive.ApiKeyKeyID.Valid) + require.Equal(t, "api-key-live", gotLive.APIKey) + + gotDeleted, err := f.rawDB.GetAIProviderKeyByID(f.ctx, deletedKey.ID) + require.NoError(t, err) + require.False(t, gotDeleted.ApiKeyKeyID.Valid) + require.Equal(t, "api-key-deleted-provider", gotDeleted.APIKey) + }) + + // DecryptErr simulates an operator omitting a key from --keys. See + // TestDecryptUserLinks/DecryptErr for the underlying mechanism. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + seeded := dbgen.AIProviderKey(t, cryptDBC, database.AIProviderKey{ + ProviderID: provider.ID, + APIKey: "api-key-value", + }) + require.Equal(t, cipherC.HexDigest(), seeded.ApiKeyKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + got, getErr := f.rawDB.GetAIProviderKeyByID(f.ctx, seeded.ID) + require.NoError(t, getErr) + require.Equal(t, cipherC.HexDigest(), got.ApiKeyKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// TestDecryptUserAIProviderKeys covers the user_ai_provider_keys table +// (per-user, user-owned AI provider API keys). GetUserAIProviderKeys, which +// Decrypt calls, has no deleted/live filter at all, so a row belonging to a +// deleted user is exercised alongside a live user's row: +// +// coder server dbcrypt decrypt \ +// --postgres-url "$CODER_PG_CONNECTION_URL" \ +// --keys +func TestDecryptUserAIProviderKeys(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + liveUser := dbgen.User(t, f.rawDB, database.User{}) + deletedUser := dbgen.User(t, f.rawDB, database.User{Deleted: true}) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + + liveKey := upsertUserAIProviderKey(f.ctx, t, f.cryptDBA, liveUser.ID, provider.ID, "user-key-live") + deletedKey := upsertUserAIProviderKey(f.ctx, t, f.cryptDBA, deletedUser.ID, provider.ID, "user-key-deleted-user") + + f.decrypt(t) + + keys, err := f.rawDB.GetUserAIProviderKeys(f.ctx) + require.NoError(t, err) + byID := make(map[uuid.UUID]database.UserAIProviderKey, len(keys)) + for _, k := range keys { + byID[k.ID] = k + } + + for _, tc := range []struct { + id uuid.UUID + want string + }{ + {liveKey.ID, "user-key-live"}, + {deletedKey.ID, "user-key-deleted-user"}, + } { + got, ok := byID[tc.id] + require.True(t, ok, "key %s", tc.id) + require.False(t, got.ApiKeyKeyID.Valid) + require.Equal(t, tc.want, got.APIKey) + } + }) + + // DecryptErr simulates an operator omitting a key from --keys. See + // TestDecryptUserLinks/DecryptErr for the underlying mechanism. + // + // # cipher C's key is missing from --keys here on purpose: + // coder server dbcrypt decrypt \ + // --postgres-url "$CODER_PG_CONNECTION_URL" \ + // --keys + t.Run("DecryptErr", func(t *testing.T) { + t.Parallel() + f := newDecryptFixture(t) + + cipherC := newCipher(t) + cryptDBC := f.registerCipher(t, cipherC) + user := dbgen.User(t, f.rawDB, database.User{}) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + seeded := upsertUserAIProviderKey(f.ctx, t, cryptDBC, user.ID, provider.ID, "user-key-value") + require.Equal(t, cipherC.HexDigest(), seeded.ApiKeyKeyID.String, "sanity check: seed must be encrypted under cipher C") + + err := f.decryptErr(t) + require.Error(t, err, "expected an error: cipher C is active in dbcrypt_keys but was never passed to Decrypt") + var derr *dbcrypt.DecryptFailedError + require.ErrorAs(t, err, &derr, "expected a decrypt error") + + keys, getErr := f.rawDB.GetUserAIProviderKeys(f.ctx) + require.NoError(t, getErr) + var got *database.UserAIProviderKey + for i := range keys { + if keys[i].ID == seeded.ID { + got = &keys[i] + } + } + require.NotNil(t, got) + require.Equal(t, cipherC.HexDigest(), got.ApiKeyKeyID.String, "row must remain encrypted under cipher C after a failed decrypt") + }) +} + +// deleteFixture provisions an isolated Postgres database plus a cipher used +// to seed encrypted rows before exercising Delete. Delete itself takes no +// cipher argument at all: it wipes rows via a fixed SQL statement and +// revokes every key it finds active in dbcrypt_keys, so there is no +// "missing cipher" failure mode analogous to Rotate/Decrypt's DecryptErr. +type deleteFixture struct { + ctx context.Context + rawDB database.Store + sqlDB *sql.DB + cipherA dbcrypt.Cipher + cryptDBA database.Store +} + +func newDeleteFixture(t *testing.T) *deleteFixture { + t.Helper() + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + + ciphersA, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + + cryptDBA, err := dbcrypt.New(ctx, rawDB, ciphersA...) + require.NoError(t, err) + + return &deleteFixture{ + ctx: ctx, + rawDB: rawDB, + sqlDB: sqlDB, + cipherA: ciphersA[0], + cryptDBA: cryptDBA, + } +} + +// delete wipes every dbcrypt-managed row that's currently encrypted and +// revokes every active key, asserting the operation succeeds. +func (f *deleteFixture) delete(t *testing.T) { + t.Helper() + err := dbcrypt.Delete(f.ctx, testutil.Logger(t), f.sqlDB) + require.NoError(t, err, "delete should succeed and revoke every active key") +} + +// requireAllKeysRevoked asserts every row in dbcrypt_keys has been revoked, +// which Delete guarantees unconditionally regardless of which tables held +// data under that key. +func requireAllKeysRevoked(ctx context.Context, t *testing.T, rawDB database.Store) { + t.Helper() + keys, err := rawDB.GetDBCryptKeys(ctx) + require.NoError(t, err) + require.NotEmpty(t, keys, "expected at least one dbcrypt key to exist") + for _, k := range keys { + require.False(t, k.ActiveKeyDigest.Valid, "key %d should no longer be active", k.Number) + require.True(t, k.RevokedKeyDigest.Valid, "key %d should be marked revoked", k.Number) + } +} + +// requireDBCryptKeyActive asserts cipher's dbcrypt_keys row is currently the +// active (non-revoked) key. +func requireDBCryptKeyActive(ctx context.Context, t *testing.T, rawDB database.Store, cipher dbcrypt.Cipher) { + t.Helper() + keys, err := rawDB.GetDBCryptKeys(ctx) + require.NoError(t, err) + for _, k := range keys { + if k.ActiveKeyDigest.Valid && k.ActiveKeyDigest.String == cipher.HexDigest() { + return + } + } + t.Fatalf("no active dbcrypt_keys row found for cipher %s", cipher.HexDigest()) +} + +// requireDBCryptKeyRevoked asserts cipher's dbcrypt_keys row has been +// revoked, i.e. its digest moved from active_key_digest to +// revoked_key_digest. +func requireDBCryptKeyRevoked(ctx context.Context, t *testing.T, rawDB database.Store, cipher dbcrypt.Cipher) { + t.Helper() + keys, err := rawDB.GetDBCryptKeys(ctx) + require.NoError(t, err) + for _, k := range keys { + if k.RevokedKeyDigest.Valid && k.RevokedKeyDigest.String == cipher.HexDigest() { + require.False(t, k.ActiveKeyDigest.Valid, "revoked cipher must not also be active") + return + } + } + t.Fatalf("no revoked dbcrypt_keys row found for cipher %s", cipher.HexDigest()) +} + +// TestDeleteUserLinks covers the user_links table (OAuth login access and +// refresh tokens). Simulates an operator recovering from a lost encryption +// key, the last-resort case where decrypt/rotate are both impossible: +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteUserLinks(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + // user_links cannot exist for a deleted user, so only live users are + // exercised here: one with an encrypted row (must be deleted), one + // with a never-encrypted row (must survive, per the WHERE ... IS NOT + // NULL clauses in Delete's SQL). + encUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.UserLink(t, f.cryptDBA, database.UserLink{ + UserID: encUser.ID, + LoginType: encUser.LoginType, + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + + plainUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.UserLink(t, f.rawDB, database.UserLink{ + UserID: plainUser.ID, + LoginType: plainUser.LoginType, + OAuthAccessToken: "plain-access-token", + OAuthRefreshToken: "plain-refresh-token", + }) + + f.delete(t) + + encLinks, err := f.rawDB.GetUserLinksByUserID(f.ctx, encUser.ID) + require.NoError(t, err) + require.Empty(t, encLinks, "encrypted user_links row should have been deleted") + + plainLinks, err := f.rawDB.GetUserLinksByUserID(f.ctx, plainUser.ID) + require.NoError(t, err) + require.Len(t, plainLinks, 1, "never-encrypted user_links row should survive") + require.Equal(t, "plain-access-token", plainLinks[0].OAuthAccessToken) + require.Equal(t, "plain-refresh-token", plainLinks[0].OAuthRefreshToken) + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestDeleteExternalAuthLinks covers the external_auth_links table +// (external Git provider OAuth tokens): +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteExternalAuthLinks(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + encUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.ExternalAuthLink(t, f.cryptDBA, database.ExternalAuthLink{ + UserID: encUser.ID, + ProviderID: "fake", + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + + plainUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.ExternalAuthLink(t, f.rawDB, database.ExternalAuthLink{ + UserID: plainUser.ID, + ProviderID: "fake", + OAuthAccessToken: "plain-access-token", + OAuthRefreshToken: "plain-refresh-token", + }) + + f.delete(t) + + encLinks, err := f.rawDB.GetExternalAuthLinksByUserID(f.ctx, encUser.ID) + require.NoError(t, err) + require.Empty(t, encLinks, "encrypted external_auth_links row should have been deleted") + + plainLinks, err := f.rawDB.GetExternalAuthLinksByUserID(f.ctx, plainUser.ID) + require.NoError(t, err) + require.Len(t, plainLinks, 1, "never-encrypted external_auth_links row should survive") + require.Equal(t, "plain-access-token", plainLinks[0].OAuthAccessToken) + require.Equal(t, "plain-refresh-token", plainLinks[0].OAuthRefreshToken) + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestDeleteUserSecrets covers the user_secrets table (arbitrary +// user-defined secret values): +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteUserSecrets(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + encUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.UserSecret(t, f.cryptDBA, database.UserSecret{ + UserID: encUser.ID, + Name: "my-secret", + Value: "super-secret-value", + }) + + plainUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.UserSecret(t, f.rawDB, database.UserSecret{ + UserID: plainUser.ID, + Name: "my-secret", + Value: "plain-secret-value", + }) + + f.delete(t) + + encSecrets, err := f.rawDB.ListUserSecretsWithValues(f.ctx, encUser.ID) + require.NoError(t, err) + require.Empty(t, encSecrets, "encrypted user_secrets row should have been deleted") + + plainSecrets, err := f.rawDB.ListUserSecretsWithValues(f.ctx, plainUser.ID) + require.NoError(t, err) + require.Len(t, plainSecrets, 1, "never-encrypted user_secrets row should survive") + require.Equal(t, "plain-secret-value", plainSecrets[0].Value) + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestDeleteGitSSHKey covers the gitsshkeys table (per-user Git SSH private +// keys). Unlike the tables above, Delete's SQL clears this row in place +// with an UPDATE rather than deleting it, so the user can regenerate a key +// via the UI afterward: +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteGitSSHKey(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + encUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.GitSSHKey(t, f.cryptDBA, database.GitSSHKey{ + UserID: encUser.ID, + PrivateKey: "private-key", + PublicKey: "public-key", + }) + + plainUser := dbgen.User(t, f.rawDB, database.User{}) + dbgen.GitSSHKey(t, f.rawDB, database.GitSSHKey{ + UserID: plainUser.ID, + PrivateKey: "plain-private-key", + PublicKey: "plain-public-key", + }) + + f.delete(t) + + encKey, err := f.rawDB.GetGitSSHKey(f.ctx, encUser.ID) + require.NoError(t, err, "row should still exist, only cleared") + require.Empty(t, encKey.PrivateKey, "encrypted private_key should be wiped") + require.False(t, encKey.PrivateKeyKeyID.Valid, "private_key_key_id should be cleared") + require.Equal(t, "public-key", encKey.PublicKey, "public key is never touched") + + plainKey, err := f.rawDB.GetGitSSHKey(f.ctx, plainUser.ID) + require.NoError(t, err) + require.Equal(t, "plain-private-key", plainKey.PrivateKey, "never-encrypted private key should survive untouched") + require.Equal(t, "plain-public-key", plainKey.PublicKey) + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestDeleteAIProviders covers the ai_providers table (provider-level +// settings, e.g. AWS Bedrock region/model config). Like gitsshkeys, this +// row is cleared in place with an UPDATE, not deleted: +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteAIProviders(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + encProvider := dbgen.AIProvider(t, f.cryptDBA, database.AIProvider{ + Settings: sql.NullString{String: "settings-value", Valid: true}, + }) + plainProvider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{ + Settings: sql.NullString{String: "plain-settings-value", Valid: true}, + }) + + f.delete(t) + + got, err := f.rawDB.GetAIProviderByID(f.ctx, encProvider.ID) + require.NoError(t, err, "row should still exist, only cleared") + require.False(t, got.Settings.Valid, "encrypted settings should be wiped") + require.False(t, got.SettingsKeyID.Valid, "settings_key_id should be cleared") + + gotPlain, err := f.rawDB.GetAIProviderByID(f.ctx, plainProvider.ID) + require.NoError(t, err) + require.True(t, gotPlain.Settings.Valid) + require.Equal(t, "plain-settings-value", gotPlain.Settings.String, "never-encrypted settings should survive untouched") + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestDeleteAIProviderKeys covers the ai_provider_keys table (provider API +// keys, e.g. OpenAI/Anthropic credentials): +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteAIProviderKeys(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + encKey := dbgen.AIProviderKey(t, f.cryptDBA, database.AIProviderKey{ + ProviderID: provider.ID, + APIKey: "api-key-value", + }) + plainKey := dbgen.AIProviderKey(t, f.rawDB, database.AIProviderKey{ + ProviderID: provider.ID, + APIKey: "plain-api-key-value", + }) + + f.delete(t) + + _, err := f.rawDB.GetAIProviderKeyByID(f.ctx, encKey.ID) + require.ErrorIs(t, err, sql.ErrNoRows, "encrypted ai_provider_keys row should have been deleted") + + gotPlain, err := f.rawDB.GetAIProviderKeyByID(f.ctx, plainKey.ID) + require.NoError(t, err, "never-encrypted ai_provider_keys row should survive") + require.Equal(t, "plain-api-key-value", gotPlain.APIKey) + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestDeleteUserAIProviderKeys covers the user_ai_provider_keys table +// (per-user, user-owned AI provider API keys): +// +// coder server dbcrypt delete \ +// --postgres-url "$CODER_PG_CONNECTION_URL" +func TestDeleteUserAIProviderKeys(t *testing.T) { + t.Parallel() + f := newDeleteFixture(t) + + // Upsert is keyed by (user_id, ai_provider_id), so encKey and plainKey + // must belong to different users to land as two independent rows. + encUser := dbgen.User(t, f.rawDB, database.User{}) + plainUser := dbgen.User(t, f.rawDB, database.User{}) + provider := dbgen.AIProvider(t, f.rawDB, database.AIProvider{}) + encKey := upsertUserAIProviderKey(f.ctx, t, f.cryptDBA, encUser.ID, provider.ID, "user-key-value") + plainKey := upsertUserAIProviderKey(f.ctx, t, f.rawDB, plainUser.ID, provider.ID, "plain-user-key-value") + + f.delete(t) + + keys, err := f.rawDB.GetUserAIProviderKeys(f.ctx) + require.NoError(t, err) + byID := make(map[uuid.UUID]database.UserAIProviderKey, len(keys)) + for _, k := range keys { + byID[k.ID] = k + } + + _, stillExists := byID[encKey.ID] + require.False(t, stillExists, "encrypted user_ai_provider_keys row should have been deleted") + + gotPlain, ok := byID[plainKey.ID] + require.True(t, ok, "never-encrypted user_ai_provider_keys row should survive") + require.Equal(t, "plain-user-key-value", gotPlain.APIKey) + + requireAllKeysRevoked(f.ctx, t, f.rawDB) +} + +// TestFullLifecycleAllHandledTables seeds one row in every table +// Rotate/Decrypt/Delete actually loop over, then drives the operator +// lifecycle end-to-end in a single database: seed data encrypted under +// cipher A (Encrypt), rotate to cipher B, decrypt back to plaintext, then +// delete a freshly re-encrypted row. One database with every handled table +// populated together, closer to how an operator actually runs these +// commands back to back, instead of one table at a time. +// +// This intentionally excludes crypto_keys, mcp_server_configs, and +// mcp_server_user_tokens. Rotate/Decrypt don't clear those tables at all +// (see issue #25381), so seeding them here would fail this test for a +// reason unrelated to what it's meant to cover: whether the 7 tables +// cliutil.go already handles stay consistent with each other across a full +// rotate-decrypt-delete sequence. +func TestFullLifecycleAllHandledTables(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, _, sqlDB := dbtestutil.NewDBWithSQLDB(t) + log := testutil.Logger(t) + + ciphersA, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + ciphersB, err := dbcrypt.NewCiphers([]byte(testutil.MustRandString(t, 32))) + require.NoError(t, err) + cipherA, cipherB := ciphersA[0], ciphersB[0] + + cryptDBA, err := dbcrypt.New(ctx, rawDB, cipherA) + require.NoError(t, err) + + // --- Encrypt: seed one row per handled table under cipher A --- + liveUser := dbgen.User(t, rawDB, database.User{}) + provider := dbgen.AIProvider(t, cryptDBA, database.AIProvider{ + Settings: sql.NullString{String: "settings-value", Valid: true}, + }) + + seededLink := dbgen.UserLink(t, cryptDBA, database.UserLink{ + UserID: liveUser.ID, + LoginType: liveUser.LoginType, + OAuthAccessToken: "access-token", + OAuthRefreshToken: "refresh-token", + }) + seededExtLink := dbgen.ExternalAuthLink(t, cryptDBA, database.ExternalAuthLink{ + UserID: liveUser.ID, + ProviderID: "fake", + OAuthAccessToken: "ext-access-token", + OAuthRefreshToken: "ext-refresh-token", + }) + seededSecret := dbgen.UserSecret(t, cryptDBA, database.UserSecret{ + UserID: liveUser.ID, + Name: "my-secret", + Value: "super-secret-value", + EnvName: "MY_SECRET_ENV", + FilePath: "~/my-secret-path", + }) + seededSSHKey := dbgen.GitSSHKey(t, cryptDBA, database.GitSSHKey{ + UserID: liveUser.ID, + PrivateKey: "private-key", + PublicKey: "public-key", + }) + seededProviderKey := dbgen.AIProviderKey(t, cryptDBA, database.AIProviderKey{ + ProviderID: provider.ID, + APIKey: "provider-api-key", + }) + seededUserProviderKey := upsertUserAIProviderKey(ctx, t, cryptDBA, liveUser.ID, provider.ID, "user-api-key") + + require.Equal(t, cipherA.HexDigest(), provider.SettingsKeyID.String, "sanity check: ai_providers seeded under cipher A") + require.Equal(t, cipherA.HexDigest(), seededLink.OAuthAccessTokenKeyID.String, "sanity check: user_links seeded under cipher A") + require.Equal(t, cipherA.HexDigest(), seededExtLink.OAuthAccessTokenKeyID.String, "sanity check: external_auth_links seeded under cipher A") + require.Equal(t, cipherA.HexDigest(), seededSecret.ValueKeyID.String, "sanity check: user_secrets seeded under cipher A") + require.Equal(t, cipherA.HexDigest(), seededSSHKey.PrivateKeyKeyID.String, "sanity check: gitsshkeys seeded under cipher A") + require.Equal(t, cipherA.HexDigest(), seededProviderKey.ApiKeyKeyID.String, "sanity check: ai_provider_keys seeded under cipher A") + require.Equal(t, cipherA.HexDigest(), seededUserProviderKey.ApiKeyKeyID.String, "sanity check: user_ai_provider_keys seeded under cipher A") + + // --- Rotate: cipher A -> cipher B --- + err = dbcrypt.Rotate(ctx, log, sqlDB, []dbcrypt.Cipher{cipherB, cipherA}) + require.NoError(t, err, "rotate should succeed and revoke cipher A even with every handled table populated at once") + requireDBCryptKeyRevoked(ctx, t, rawDB, cipherA) + requireDBCryptKeyActive(ctx, t, rawDB, cipherB) + + links, err := rawDB.GetUserLinksByUserID(ctx, liveUser.ID) + require.NoError(t, err) + require.Len(t, links, 1) + require.Equal(t, cipherB.HexDigest(), links[0].OAuthAccessTokenKeyID.String) + require.Equal(t, "access-token", decryptRawString(t, cipherB, links[0].OAuthAccessToken)) + + extLinks, err := rawDB.GetExternalAuthLinksByUserID(ctx, liveUser.ID) + require.NoError(t, err) + require.Len(t, extLinks, 1) + require.Equal(t, cipherB.HexDigest(), extLinks[0].OAuthAccessTokenKeyID.String) + + secrets, err := rawDB.ListUserSecretsWithValues(ctx, liveUser.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.Equal(t, cipherB.HexDigest(), secrets[0].ValueKeyID.String) + require.Equal(t, "super-secret-value", decryptRawString(t, cipherB, secrets[0].Value)) + + sshKey, err := rawDB.GetGitSSHKey(ctx, liveUser.ID) + require.NoError(t, err) + require.Equal(t, cipherB.HexDigest(), sshKey.PrivateKeyKeyID.String) + + gotProvider, err := rawDB.GetAIProviderByID(ctx, provider.ID) + require.NoError(t, err) + require.Equal(t, cipherB.HexDigest(), gotProvider.SettingsKeyID.String) + + gotProviderKey, err := rawDB.GetAIProviderKeyByID(ctx, seededProviderKey.ID) + require.NoError(t, err) + require.Equal(t, cipherB.HexDigest(), gotProviderKey.ApiKeyKeyID.String) + + userProviderKeys, err := rawDB.GetUserAIProviderKeys(ctx) + require.NoError(t, err) + var foundUserProviderKey bool + for _, k := range userProviderKeys { + if k.ID == seededUserProviderKey.ID { + require.Equal(t, cipherB.HexDigest(), k.ApiKeyKeyID.String) + foundUserProviderKey = true + } + } + require.True(t, foundUserProviderKey, "seeded user_ai_provider_keys row must still exist after rotate") + + // --- Decrypt: cipher B -> plaintext --- + err = dbcrypt.Decrypt(ctx, log, sqlDB, []dbcrypt.Cipher{cipherB}) + require.NoError(t, err, "decrypt should succeed and revoke cipher B even with every handled table populated at once") + requireDBCryptKeyRevoked(ctx, t, rawDB, cipherB) + + links, err = rawDB.GetUserLinksByUserID(ctx, liveUser.ID) + require.NoError(t, err) + require.False(t, links[0].OAuthAccessTokenKeyID.Valid) + require.Equal(t, "access-token", links[0].OAuthAccessToken) + + secrets, err = rawDB.ListUserSecretsWithValues(ctx, liveUser.ID) + require.NoError(t, err) + require.Len(t, secrets, 1) + require.False(t, secrets[0].ValueKeyID.Valid) + require.Equal(t, "super-secret-value", secrets[0].Value) + + // --- Delete: seed one more row under a fresh cipher, then wipe it --- + cipherC := newCipher(t) + cryptDBC, err := dbcrypt.New(ctx, rawDB, cipherC) + require.NoError(t, err) + seededPostDecryptSecret := dbgen.UserSecret(t, cryptDBC, database.UserSecret{ + UserID: liveUser.ID, + Name: "post-decrypt-secret", + Value: "another-secret-value", + EnvName: "POST_DECRYPT_ENV", + FilePath: "~/post-decrypt-path", + }) + require.Equal(t, cipherC.HexDigest(), seededPostDecryptSecret.ValueKeyID.String, "sanity check: post-decrypt secret seeded under cipher C") + + err = dbcrypt.Delete(ctx, log, sqlDB) + require.NoError(t, err, "delete should succeed and revoke every remaining active key") + + secrets, err = rawDB.ListUserSecretsWithValues(ctx, liveUser.ID) + require.NoError(t, err) + require.Len(t, secrets, 1, "only the never-re-encrypted secret should remain") + require.Equal(t, "my-secret", secrets[0].Name) + require.Equal(t, "super-secret-value", secrets[0].Value) + + requireAllKeysRevoked(ctx, t, rawDB) +}