feat: add enable/disable support for user secrets (#27537)

Users can now disable a secret to stop it from being injected into
workspaces without deleting it, and re-enable it later. Disabled secrets
stay visible and editable everywhere they already appear.

An enabled secret must have at least one injection target; a secret with
no target can be stored only while disabled. Existing target-less secrets
are migrated to disabled to preserve current behavior.

Support spans the REST API, SDK, CLI, dashboard, and audit log.
This commit is contained in:
Zach
2026-07-28 09:58:33 -06:00
committed by GitHub
parent 3c61a9a939
commit 85984ff142
56 changed files with 1391 additions and 186 deletions
+1
View File
@@ -56,6 +56,7 @@ const (
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills
CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills
CheckUserSkillsNameFormat CheckConstraint = "user_skills_name_format" // user_skills
+2
View File
@@ -2098,6 +2098,7 @@ func UserSecret(secret database.ListUserSecretsRow) codersdk.UserSecret {
Description: secret.Description,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
Enabled: secret.Enabled,
CreatedAt: secret.CreatedAt,
UpdatedAt: secret.UpdatedAt,
}
@@ -2112,6 +2113,7 @@ func UserSecretFromFull(secret database.UserSecret) codersdk.UserSecret {
Description: secret.Description,
EnvName: secret.EnvName,
FilePath: secret.FilePath,
Enabled: secret.Enabled,
CreatedAt: secret.CreatedAt,
UpdatedAt: secret.UpdatedAt,
}
+1
View File
@@ -1962,6 +1962,7 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret, mutat
ValueKeyID: seed.ValueKeyID,
EnvName: takeFirst(seed.EnvName, "SECRET_ENV_NAME"),
FilePath: takeFirst(seed.FilePath, "~/secret/file/path"),
Enabled: takeFirst(seed.Enabled, true),
}
for _, mut := range mutators {
mut(&params)
+3 -1
View File
@@ -3624,7 +3624,9 @@ CREATE TABLE user_secrets (
file_path text DEFAULT ''::text NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
value_key_id text
value_key_id text,
enabled boolean DEFAULT true NOT NULL,
CONSTRAINT user_secrets_enabled_requires_target CHECK (((NOT enabled) OR (env_name <> ''::text) OR (file_path <> ''::text)))
);
CREATE TABLE user_skills (
@@ -0,0 +1,2 @@
ALTER TABLE user_secrets
DROP COLUMN enabled;
@@ -0,0 +1,30 @@
-- Add an explicit enabled flag to user_secrets.
--
-- A disabled secret stays visible and editable in the management UI, CLI,
-- and API, but is not injected into workspaces and does not satisfy any
-- "secret present" predicate. This is the single source of truth for
-- "not injected"; the agent manifest layer no longer skips rows based
-- on having both env_name and file_path empty.
--
-- Existing rows whose env_name and file_path are both empty are flipped
-- to enabled = false. Today those rows are silently skipped during agent
-- manifest assembly, so flipping them preserves observable behavior
-- while letting the manifest stop encoding the both-empty special case.
ALTER TABLE user_secrets
ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true;
UPDATE user_secrets
SET enabled = false
WHERE env_name = '' AND file_path = '';
-- Enforce the injection-target invariant in the database: an enabled
-- secret must have at least one of env_name / file_path non-empty.
-- Disabled secrets may have no targets (bulk imports use that state for
-- keys that cannot be env-injected). The API also checks this on write,
-- but the constraint is the source of truth: it closes a read-modify-write
-- race where two concurrent PATCHes each clear a different target, both
-- pass the API's post-state check, and serialize to an enabled row with
-- no targets.
ALTER TABLE user_secrets
ADD CONSTRAINT user_secrets_enabled_requires_target
CHECK (NOT enabled OR env_name <> '' OR file_path <> '');
@@ -2251,3 +2251,100 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) {
"search must exclude deleted, model-only, and tool-role rows (%d %d %d)",
toolMsg.ID, modelOnly.ID, deletedMsg.ID)
}
func TestMigration000556UserSecretsEnabled(t *testing.T) {
t.Parallel()
const migrationVersion = 556
sqlDB := testSQLDB(t)
// Migrate up to the migration before the one that adds the enabled
// column.
next, err := migrations.Stepper(sqlDB)
require.NoError(t, err)
for {
version, more, err := next()
require.NoError(t, err)
if !more {
t.Fatalf("migration %d not found", migrationVersion)
}
if version == migrationVersion-1 {
break
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
userID := uuid.New()
envSecretID := uuid.New()
fileSecretID := uuid.New()
bothEmptySecretID := uuid.New()
now := time.Now().UTC().Truncate(time.Microsecond)
tx, err := sqlDB.BeginTx(ctx, nil)
require.NoError(t, err)
defer tx.Rollback()
fixtures := []struct {
query string
args []any
}{
{
`INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, status, rbac_roles, login_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{userID, "user-secrets-enabled", "user-secrets-enabled@test.com", []byte{}, now, now, "active", pq.StringArray{}, "password"},
},
// env-only secret: should remain enabled after migration.
{
`INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{envSecretID, userID, "env-secret", "", "v1", "ENV_SECRET", "", now, now},
},
// file-only secret: should remain enabled after migration.
{
`INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{fileSecretID, userID, "file-secret", "", "v2", "", "/tmp/file-secret", now, now},
},
// Both env_name and file_path empty: silently skipped today by
// the agent manifest layer. Should be flipped to enabled=false
// by the migration so the behavior is preserved exactly under
// the new "always inject when enabled" rule.
{
`INSERT INTO user_secrets (id, user_id, name, description, value, env_name, file_path, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[]any{bothEmptySecretID, userID, "both-empty", "", "v3", "", "", now, now},
},
}
for i, f := range fixtures {
_, err := tx.ExecContext(ctx, f.query, f.args...)
require.NoError(t, err, "fixture %d", i)
}
require.NoError(t, tx.Commit())
// Run the migration.
version, _, err := next()
require.NoError(t, err)
require.EqualValues(t, migrationVersion, version)
getEnabled := func(t *testing.T, id uuid.UUID) bool {
t.Helper()
var enabled bool
err := sqlDB.QueryRowContext(ctx,
"SELECT enabled FROM user_secrets WHERE id = $1", id,
).Scan(&enabled)
require.NoError(t, err)
return enabled
}
require.True(t, getEnabled(t, envSecretID),
"env-only secret should remain enabled")
require.True(t, getEnabled(t, fileSecretID),
"file-only secret should remain enabled")
require.False(t, getEnabled(t, bothEmptySecretID),
"secret with both targets empty should be flipped to disabled "+
"to preserve the previous implicit-skip behavior")
}
+1
View File
@@ -6244,6 +6244,7 @@ type UserSecret struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
Enabled bool `db:"enabled" json:"enabled"`
}
type UserSkill struct {
+82 -1
View File
@@ -8483,7 +8483,9 @@ func TestUserSecretsCRUDOperations(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate key value")
// Create secret with empty env_name and file_path (should succeed)
// Create secret with empty env_name and file_path. A target-less
// secret must be disabled to satisfy the
// user_secrets_enabled_requires_target constraint.
secret2 := dbgen.UserSecret(t, db, database.UserSecret{
UserID: testUser.ID,
Name: "unique-test-4",
@@ -8491,6 +8493,8 @@ func TestUserSecretsCRUDOperations(t *testing.T) {
Value: "value2",
EnvName: "", // Empty env_name
FilePath: "", // Empty file_path
}, func(params *database.CreateUserSecretParams) {
params.Enabled = false
})
// Verify both secrets exist
@@ -8505,6 +8509,83 @@ func TestUserSecretsCRUDOperations(t *testing.T) {
})
}
// TestUserSecretsEnabledRequiresTargetConstraint verifies the
// user_secrets_enabled_requires_target CHECK constraint. It is the
// race-safe backstop for the injection-target invariant: the API's
// post-state check can be defeated by two concurrent PATCHes that each
// clear a different target, so the database must reject an enabled row
// with no target.
func TestUserSecretsEnabledRequiresTargetConstraint(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
user := dbgen.User(t, db, database.User{})
// A disabled secret may have no target.
disabled, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
Name: "disabled-no-target",
Value: "v",
EnvName: "",
FilePath: "",
Enabled: false,
})
require.NoError(t, err)
// Enabling a target-less secret must be rejected by the constraint.
_, err = db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: disabled.Name,
UpdateEnabled: true,
Enabled: true,
})
require.True(t, database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget),
"enabling a target-less secret should violate the constraint, got: %v", err)
// An enabled secret with both targets set.
enabled, err := db.CreateUserSecret(ctx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
Name: "enabled-both",
Value: "v",
EnvName: "ENABLED_BOTH",
FilePath: "~/enabled-both",
Enabled: true,
})
require.NoError(t, err)
// Clearing both targets while the secret stays enabled (the race
// outcome) must be rejected.
_, err = db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: enabled.Name,
UpdateEnvName: true,
EnvName: "",
UpdateFilePath: true,
FilePath: "",
})
require.True(t, database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget),
"clearing both targets of an enabled secret should violate the constraint, got: %v", err)
// Clearing both targets and disabling in the same update is allowed.
updated, err := db.UpdateUserSecretByUserIDAndName(ctx, database.UpdateUserSecretByUserIDAndNameParams{
UserID: user.ID,
Name: enabled.Name,
UpdateEnvName: true,
EnvName: "",
UpdateFilePath: true,
FilePath: "",
UpdateEnabled: true,
Enabled: false,
})
require.NoError(t, err)
require.False(t, updated.Enabled)
require.Empty(t, updated.EnvName)
require.Empty(t, updated.FilePath)
}
// TestUserSecretsSoftDeleteTrigger verifies that a user's secrets
// are deleted when the user is soft-deleted.
func TestUserSecretsSoftDeleteTrigger(t *testing.T) {
+27 -10
View File
@@ -29754,7 +29754,8 @@ INSERT INTO user_secrets (
value,
value_key_id,
env_name,
file_path
file_path,
enabled
) VALUES (
$1,
$2,
@@ -29763,8 +29764,9 @@ INSERT INTO user_secrets (
$5,
$6,
$7,
$8
) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
$8,
$9
) RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
`
type CreateUserSecretParams struct {
@@ -29776,6 +29778,7 @@ type CreateUserSecretParams struct {
ValueKeyID sql.NullString `db:"value_key_id" json:"value_key_id"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
Enabled bool `db:"enabled" json:"enabled"`
}
func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretParams) (UserSecret, error) {
@@ -29788,6 +29791,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP
arg.ValueKeyID,
arg.EnvName,
arg.FilePath,
arg.Enabled,
)
var i UserSecret
err := row.Scan(
@@ -29801,6 +29805,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP
&i.CreatedAt,
&i.UpdatedAt,
&i.ValueKeyID,
&i.Enabled,
)
return i, err
}
@@ -29808,7 +29813,7 @@ func (q *sqlQuerier) CreateUserSecret(ctx context.Context, arg CreateUserSecretP
const deleteUserSecretByUserIDAndName = `-- name: DeleteUserSecretByUserIDAndName :one
DELETE FROM user_secrets
WHERE user_id = $1 AND name = $2
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
`
type DeleteUserSecretByUserIDAndNameParams struct {
@@ -29830,12 +29835,13 @@ func (q *sqlQuerier) DeleteUserSecretByUserIDAndName(ctx context.Context, arg De
&i.CreatedAt,
&i.UpdatedAt,
&i.ValueKeyID,
&i.Enabled,
)
return i, err
}
const getUserSecretByID = `-- name: GetUserSecretByID :one
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
FROM user_secrets
WHERE id = $1
`
@@ -29854,12 +29860,13 @@ func (q *sqlQuerier) GetUserSecretByID(ctx context.Context, id uuid.UUID) (UserS
&i.CreatedAt,
&i.UpdatedAt,
&i.ValueKeyID,
&i.Enabled,
)
return i, err
}
const getUserSecretByUserIDAndName = `-- name: GetUserSecretByUserIDAndName :one
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
FROM user_secrets
WHERE user_id = $1 AND name = $2
`
@@ -29883,6 +29890,7 @@ func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUs
&i.CreatedAt,
&i.UpdatedAt,
&i.ValueKeyID,
&i.Enabled,
)
return i, err
}
@@ -29983,7 +29991,7 @@ func (q *sqlQuerier) GetUserSecretsTelemetrySummary(ctx context.Context) (GetUse
const listUserSecrets = `-- name: ListUserSecrets :many
SELECT
id, user_id, name, description,
env_name, file_path,
env_name, file_path, enabled,
created_at, updated_at
FROM user_secrets
WHERE user_id = $1
@@ -29997,6 +30005,7 @@ type ListUserSecretsRow struct {
Description string `db:"description" json:"description"`
EnvName string `db:"env_name" json:"env_name"`
FilePath string `db:"file_path" json:"file_path"`
Enabled bool `db:"enabled" json:"enabled"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
@@ -30019,6 +30028,7 @@ func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]L
&i.Description,
&i.EnvName,
&i.FilePath,
&i.Enabled,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -30036,7 +30046,7 @@ func (q *sqlQuerier) ListUserSecrets(ctx context.Context, userID uuid.UUID) ([]L
}
const listUserSecretsWithValues = `-- name: ListUserSecretsWithValues :many
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
SELECT id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
FROM user_secrets
WHERE user_id = $1
ORDER BY name ASC
@@ -30065,6 +30075,7 @@ func (q *sqlQuerier) ListUserSecretsWithValues(ctx context.Context, userID uuid.
&i.CreatedAt,
&i.UpdatedAt,
&i.ValueKeyID,
&i.Enabled,
); err != nil {
return nil, err
}
@@ -30087,9 +30098,10 @@ SET
description = CASE WHEN $4::bool THEN $5 ELSE description END,
env_name = CASE WHEN $6::bool THEN $7 ELSE env_name END,
file_path = CASE WHEN $8::bool THEN $9 ELSE file_path END,
enabled = CASE WHEN $10::bool THEN $11 ELSE enabled END,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = $10 AND name = $11
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id
WHERE user_id = $12 AND name = $13
RETURNING id, user_id, name, description, value, env_name, file_path, created_at, updated_at, value_key_id, enabled
`
type UpdateUserSecretByUserIDAndNameParams struct {
@@ -30102,6 +30114,8 @@ type UpdateUserSecretByUserIDAndNameParams struct {
EnvName string `db:"env_name" json:"env_name"`
UpdateFilePath bool `db:"update_file_path" json:"update_file_path"`
FilePath string `db:"file_path" json:"file_path"`
UpdateEnabled bool `db:"update_enabled" json:"update_enabled"`
Enabled bool `db:"enabled" json:"enabled"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
}
@@ -30117,6 +30131,8 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
arg.EnvName,
arg.UpdateFilePath,
arg.FilePath,
arg.UpdateEnabled,
arg.Enabled,
arg.UserID,
arg.Name,
)
@@ -30132,6 +30148,7 @@ func (q *sqlQuerier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg Up
&i.CreatedAt,
&i.UpdatedAt,
&i.ValueKeyID,
&i.Enabled,
)
return i, err
}
+6 -3
View File
@@ -13,7 +13,7 @@ WHERE id = @id;
-- REST API list and get endpoints.
SELECT
id, user_id, name, description,
env_name, file_path,
env_name, file_path, enabled,
created_at, updated_at
FROM user_secrets
WHERE user_id = @user_id
@@ -37,7 +37,8 @@ INSERT INTO user_secrets (
value,
value_key_id,
env_name,
file_path
file_path,
enabled
) VALUES (
@id,
@user_id,
@@ -46,7 +47,8 @@ INSERT INTO user_secrets (
@value,
@value_key_id,
@env_name,
@file_path
@file_path,
@enabled
) RETURNING *;
-- name: UpdateUserSecretByUserIDAndName :one
@@ -57,6 +59,7 @@ SET
description = CASE WHEN @update_description::bool THEN @description ELSE description END,
env_name = CASE WHEN @update_env_name::bool THEN @env_name ELSE env_name END,
file_path = CASE WHEN @update_file_path::bool THEN @file_path ELSE file_path END,
enabled = CASE WHEN @update_enabled::bool THEN @enabled ELSE enabled END,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = @user_id AND name = @name
RETURNING *;