Files
coder/enterprise/dbcrypt/cliutil_test.go
T
Bobby Ho 90861ffa75 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.
2026-07-07 07:42:12 -07:00

1797 lines
67 KiB
Go

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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key B> \
// --old-keys <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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 <base64 key A>
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)
}