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
+5 -4
View File
@@ -278,10 +278,11 @@ func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevconta
func dbUserSecretsToProto(secrets []database.UserSecret) []*agentproto.WorkspaceSecret {
ret := make([]*agentproto.WorkspaceSecret, 0, len(secrets))
for _, s := range secrets {
// Only include secrets that have an environment variable
// name or file path set. Secrets with neither are not
// injected at runtime.
if s.EnvName == "" && s.FilePath == "" {
// Skip disabled secrets so they are not injected as env vars or
// written to secret files. The API guarantees every enabled
// secret has at least one of env_name or file_path set, so we
// don't need to filter both-empty rows separately here.
if !s.Enabled {
continue
}
ret = append(ret, &agentproto.WorkspaceSecret{
+8 -7
View File
@@ -468,19 +468,20 @@ func TestGetManifest(t *testing.T) {
mDB.EXPECT().GetWorkspaceByID(gomock.Any(), workspace.ID).Return(workspace, nil)
// Return a mix of secrets: env-only, file-only, both, and
// one with neither set. The last should be filtered out.
// one explicitly disabled. The disabled secret should be
// filtered out.
mDB.EXPECT().ListUserSecretsWithValues(gomock.Any(), workspace.OwnerID).Return([]database.UserSecret{
{EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx"},
{EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key"},
{EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val"},
{EnvName: "", FilePath: "", Value: "stored-only"},
{EnvName: "GITHUB_TOKEN", FilePath: "", Value: "ghp_xxxx", Enabled: true},
{EnvName: "", FilePath: "~/.ssh/id_rsa", Value: "private-key", Enabled: true},
{EnvName: "BOTH_ENV", FilePath: "/etc/both", Value: "both-val", Enabled: true},
{EnvName: "DISABLED_ENV", FilePath: "", Value: "disabled-val", Enabled: false},
}, nil)
got, err := api.GetManifest(context.Background(), &agentproto.GetManifestRequest{})
require.NoError(t, err)
// The secret with neither env_name nor file_path should
// be filtered out, leaving exactly 3.
// The disabled secret should be filtered out, leaving
// exactly 3.
require.Len(t, got.Secrets, 3)
require.Equal(t, "GITHUB_TOKEN", got.Secrets[0].EnvName)
require.Equal(t, "", got.Secrets[0].FilePath)
+10
View File
@@ -19269,6 +19269,9 @@ const docTemplate = `{
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"env_name": {
"type": "string"
},
@@ -26044,6 +26047,9 @@ const docTemplate = `{
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"env_name": {
"type": "string"
},
@@ -26652,6 +26658,10 @@ const docTemplate = `{
"description": {
"type": "string"
},
"enabled": {
"description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.",
"type": "boolean"
},
"env_name": {
"type": "string"
},
+10
View File
@@ -17436,6 +17436,9 @@
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"env_name": {
"type": "string"
},
@@ -23938,6 +23941,9 @@
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"env_name": {
"type": "string"
},
@@ -24519,6 +24525,10 @@
"description": {
"type": "string"
},
"enabled": {
"description": "Enabled controls whether the secret is injected into workspaces.\nDisabled secrets remain visible and editable, but are not added\nto the agent manifest, so they are not exposed as environment\nvariables or written to secret files.",
"type": "boolean"
},
"env_name": {
"type": "string"
},
+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 *;
+11 -1
View File
@@ -2103,6 +2103,10 @@ func TestUserSecretsTelemetry(t *testing.T) {
}, func(p *database.CreateUserSecretParams) {
p.EnvName = ""
p.FilePath = ""
// A target-less secret must be disabled to satisfy the
// user_secrets_enabled_requires_target constraint. Disabled
// secrets are still counted in the telemetry breakdown.
p.Enabled = false
})
_, snap := collectSnapshot(ctx, t, db, nil)
@@ -2149,9 +2153,12 @@ func TestUserSecretsTelemetry(t *testing.T) {
// Clear EnvName and FilePath so the unique
// (user_id, env_name) and (user_id, file_path)
// indexes don't collide across multiple secrets
// for the same user.
// for the same user. Target-less secrets must be
// disabled to satisfy the
// user_secrets_enabled_requires_target constraint.
p.EnvName = ""
p.FilePath = ""
p.Enabled = false
})
}
}
@@ -2261,6 +2268,9 @@ func TestUserSecretsTelemetry(t *testing.T) {
}, func(p *database.CreateUserSecretParams) {
p.EnvName = ""
p.FilePath = ""
// Target-less secrets must be disabled to satisfy the
// user_secrets_enabled_requires_target constraint.
p.Enabled = false
})
clock := quartz.NewMock(t)
+84 -1
View File
@@ -28,6 +28,13 @@ const (
userSecretsEnvBytesLimitConstraint database.CheckConstraint = "user_secrets_per_user_env_bytes_limit"
)
// errUserSecretInjectionTargetRequired signals that a PATCH would leave an
// enabled secret with both env_name and file_path empty. It is returned
// from the patchUserSecret transaction so the handler can map it to a 400.
// Creates enforce the same invariant in
// codersdk.ValidateCreateUserSecretRequest.
var errUserSecretInjectionTargetRequired = xerrors.New("enabled user secret must have at least one of env_name or file_path set")
// @Summary Create a new user secret
// @ID create-a-new-user-secret
// @Security CoderSessionToken
@@ -62,6 +69,11 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
secret, err := api.Database.CreateUserSecret(ctx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
@@ -71,12 +83,17 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) {
ValueKeyID: sql.NullString{},
EnvName: req.EnvName,
FilePath: req.FilePath,
Enabled: enabled,
})
if err != nil {
if validations := userSecretConflictValidationErrors(err); len(validations) > 0 {
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
return
}
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
return
}
if resp, ok := userSecretLimitResponse(err); ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
@@ -155,6 +172,10 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
failedIndex := -1
err = api.Database.InTx(func(tx database.Store) error {
for i, sreq := range reqs {
enabled := true
if sreq.Enabled != nil {
enabled = *sreq.Enabled
}
s, txErr := tx.CreateUserSecret(ctx, database.CreateUserSecretParams{
ID: uuid.New(),
UserID: user.ID,
@@ -164,6 +185,7 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
ValueKeyID: sql.NullString{},
EnvName: sreq.EnvName,
FilePath: sreq.FilePath,
Enabled: enabled,
})
if txErr != nil {
failedIndex = i
@@ -185,6 +207,15 @@ func (api *API) postUserSecretsBatch(rw http.ResponseWriter, r *http.Request) {
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, conflicts)
return
}
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
if index >= 0 {
for i := range validations {
validations[i].Field = fmt.Sprintf("secrets[%d].%s", index, validations[i].Field)
}
}
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
return
}
if resp, ok := userSecretLimitResponse(err); ok {
if index >= 0 {
resp.Detail = fmt.Sprintf("Entry secrets[%d] (%q): %s", index, reqs[index].Name, resp.Detail)
@@ -319,7 +350,7 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
return
}
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil {
if req.Value == nil && req.Description == nil && req.EnvName == nil && req.FilePath == nil && req.Enabled == nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "At least one field must be provided.",
})
@@ -342,6 +373,8 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
EnvName: "",
UpdateFilePath: req.FilePath != nil,
FilePath: "",
UpdateEnabled: req.Enabled != nil,
Enabled: false,
}
if req.Value != nil {
params.Value = *req.Value
@@ -355,6 +388,9 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
if req.FilePath != nil {
params.FilePath = *req.FilePath
}
if req.Enabled != nil {
params.Enabled = *req.Enabled
}
// Pre-read the secret inside a transaction so the audit diff has both an
// "old" and "new" snapshot.
@@ -375,6 +411,26 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
}
aReq.Old = old
// Reject patches that would leave an enabled secret with both
// env_name and file_path empty. Evaluated against the post-update
// state so atomic env<->file swaps still succeed, and so targets
// can be cleared when the same PATCH also disables the secret.
postEnvName := old.EnvName
if req.EnvName != nil {
postEnvName = *req.EnvName
}
postFilePath := old.FilePath
if req.FilePath != nil {
postFilePath = *req.FilePath
}
postEnabled := old.Enabled
if req.Enabled != nil {
postEnabled = *req.Enabled
}
if postEnabled && postEnvName == "" && postFilePath == "" {
return errUserSecretInjectionTargetRequired
}
updated, err := tx.UpdateUserSecretByUserIDAndName(ctx, params)
if err != nil {
return xerrors.Errorf("update user secret: %w", err)
@@ -388,6 +444,17 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) {
httpapi.ResourceNotFound(rw)
return
}
if errors.Is(err, errUserSecretInjectionTargetRequired) {
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, []codersdk.ValidationError{{
Field: codersdk.UserSecretEnvNameField,
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
}})
return
}
if validations := userSecretInjectionTargetValidationErrors(err); len(validations) > 0 {
writeUserSecretValidationErrors(ctx, rw, http.StatusBadRequest, validations)
return
}
if validations := userSecretConflictValidationErrors(err); len(validations) > 0 {
writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations)
return
@@ -518,6 +585,22 @@ func userSecretLimitResponse(err error) (codersdk.Response, bool) {
return codersdk.Response{}, false
}
// userSecretInjectionTargetValidationErrors maps the
// user_secrets_enabled_requires_target CHECK violation to a field-level
// validation error. The database constraint is the race-safe source of
// truth for the injection-target invariant: concurrent PATCHes can each
// clear a different target and pass the handler's own post-state check,
// so the constraint is what ultimately rejects an enabled target-less row.
func userSecretInjectionTargetValidationErrors(err error) []codersdk.ValidationError {
if database.IsCheckViolation(err, database.CheckUserSecretsEnabledRequiresTarget) {
return []codersdk.ValidationError{{
Field: codersdk.UserSecretEnvNameField,
Detail: codersdk.UserSecretInjectionTargetRequiredDetail,
}}
}
return nil
}
func userSecretConflictValidationErrors(err error) []codersdk.ValidationError {
switch {
case database.IsUniqueViolation(err, database.UniqueUserSecretsUserNameIndex):
+25 -10
View File
@@ -29,13 +29,24 @@ func TestUserSecretAudit(t *testing.T) {
// collide in the shared user's secret namespace.
return strings.ReplaceAll(t.Name(), "/", "-")
}
genEnvName := func(t *testing.T) string {
// Same derivation as genSecretName, but in the
// SCREAMING_SNAKE_CASE shape env names require. Every
// secret needs at least one of env_name or file_path,
// and the per-user UNIQUE index makes empty-injection
// not an option.
name := strings.ReplaceAll(t.Name(), "/", "_")
name = strings.ReplaceAll(name, "-", "_")
return strings.ToUpper(name)
}
t.Run("CreateEmitsLog", func(t *testing.T) {
auditor.ResetLogs()
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: genSecretName(t),
Value: "ghp_xxxxxxxxxxxx",
Name: genSecretName(t),
Value: "ghp_xxxxxxxxxxxx",
EnvName: genEnvName(t),
})
require.NoError(t, err)
@@ -51,8 +62,9 @@ func TestUserSecretAudit(t *testing.T) {
auditor.ResetLogs()
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: genSecretName(t),
Value: "old",
Name: genSecretName(t),
Value: "old",
EnvName: genEnvName(t),
})
require.NoError(t, err)
@@ -77,8 +89,9 @@ func TestUserSecretAudit(t *testing.T) {
auditor.ResetLogs()
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: genSecretName(t),
Value: "value",
Name: genSecretName(t),
Value: "value",
EnvName: genEnvName(t),
})
require.NoError(t, err)
@@ -138,8 +151,9 @@ func TestUserSecretAudit(t *testing.T) {
name := genSecretName(t)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: name,
Value: "value",
Name: name,
Value: "value",
EnvName: genEnvName(t),
})
require.NoError(t, err)
// Reset to ignore the created log. We are only testing that the
@@ -159,8 +173,9 @@ func TestUserSecretAudit(t *testing.T) {
secretName := genSecretName(t)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: secretName,
Value: "value",
Name: secretName,
Value: "value",
EnvName: genEnvName(t),
})
require.NoError(t, err)
// Discard the create log so the assertion below only sees audit entries
+217 -30
View File
@@ -87,14 +87,16 @@ func TestPostUserSecret(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "dup-secret",
Value: "value1",
Name: "dup-secret",
Value: "value1",
EnvName: "DUP_SECRET_ENV_1",
})
require.NoError(t, err)
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "dup-secret",
Value: "value2",
Name: "dup-secret",
Value: "value2",
EnvName: "DUP_SECRET_ENV_2",
})
requireSecretValidationEqualsError(t, err, http.StatusConflict, "name", "name already in use")
})
@@ -206,6 +208,63 @@ func TestPostUserSecret(t *testing.T) {
})
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "must not exceed")
})
t.Run("MissingInjectionTarget", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "missing-target-secret",
Value: "value",
})
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
})
t.Run("DisabledByDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
disabled := false
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "create-disabled",
Value: "value",
EnvName: "CREATE_DISABLED",
Enabled: &disabled,
})
require.NoError(t, err)
assert.False(t, secret.Enabled)
})
t.Run("DisabledWithoutTarget", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
// A disabled secret may omit both injection targets. Bulk
// imports rely on this for keys that cannot be env-injected.
disabled := false
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "create-disabled-no-target",
Value: "value",
Enabled: &disabled,
})
require.NoError(t, err)
assert.False(t, secret.Enabled)
assert.Empty(t, secret.EnvName)
assert.Empty(t, secret.FilePath)
})
t.Run("EnabledByDefault", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "create-default-enabled",
Value: "value",
EnvName: "CREATE_DEFAULT_ENABLED",
})
require.NoError(t, err)
assert.True(t, secret.Enabled)
})
}
func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) {
@@ -216,8 +275,9 @@ func TestPostUserSecretForbiddenForAnotherUser(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := memberClient.CreateUserSecret(ctx, owner.UserID.String(), codersdk.CreateUserSecretRequest{
Name: "forbidden",
Value: "value",
Name: "forbidden",
Value: "value",
EnvName: "FORBIDDEN",
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
@@ -240,14 +300,16 @@ func TestGetUserSecrets(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "list-secret-a",
Value: "value-a",
Name: "list-secret-a",
Value: "value-a",
EnvName: "LIST_SECRET_A",
})
require.NoError(t, err)
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "list-secret-b",
Value: "value-b",
Name: "list-secret-b",
Value: "value-b",
EnvName: "LIST_SECRET_B",
})
require.NoError(t, err)
@@ -327,8 +389,9 @@ func TestPatchUserSecret(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "patch-nofields-secret",
Value: "my-value",
Name: "patch-nofields-secret",
Value: "my-value",
EnvName: "PATCH_NOFIELDS_ENV",
})
require.NoError(t, err)
@@ -365,8 +428,9 @@ func TestPatchUserSecret(t *testing.T) {
require.NoError(t, err)
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "conflict-env-2",
Value: "value2",
Name: "conflict-env-2",
Value: "value2",
FilePath: "/tmp/conflict-env-2",
})
require.NoError(t, err)
@@ -389,8 +453,9 @@ func TestPatchUserSecret(t *testing.T) {
require.NoError(t, err)
_, err = client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "conflict-fp-2",
Value: "value2",
Name: "conflict-fp-2",
Value: "value2",
EnvName: "CONFLICT_FP_2",
})
require.NoError(t, err)
@@ -406,8 +471,9 @@ func TestPatchUserSecret(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "patch-invalid-env",
Value: "good-value",
Name: "patch-invalid-env",
Value: "good-value",
FilePath: "/tmp/patch-invalid-env",
})
require.NoError(t, err)
@@ -423,8 +489,9 @@ func TestPatchUserSecret(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "patch-invalid-file-path",
Value: "good-value",
Name: "patch-invalid-file-path",
Value: "good-value",
EnvName: "PATCH_INVALID_FILE_PATH",
})
require.NoError(t, err)
@@ -440,8 +507,9 @@ func TestPatchUserSecret(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "patch-invalid-val",
Value: "good-value",
Name: "patch-invalid-val",
Value: "good-value",
EnvName: "PATCH_INVALID_VAL",
})
require.NoError(t, err)
@@ -451,6 +519,121 @@ func TestPatchUserSecret(t *testing.T) {
})
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "value", "null bytes")
})
t.Run("ToggleEnabled", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
secret, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "toggle-enabled",
Value: "value",
EnvName: "TOGGLE_ENABLED",
})
require.NoError(t, err)
require.True(t, secret.Enabled)
disable := false
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{
Enabled: &disable,
})
require.NoError(t, err)
assert.False(t, updated.Enabled)
// Other fields should be unchanged.
assert.Equal(t, "TOGGLE_ENABLED", updated.EnvName)
enable := true
updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "toggle-enabled", codersdk.UpdateUserSecretRequest{
Enabled: &enable,
})
require.NoError(t, err)
assert.True(t, updated.Enabled)
})
t.Run("ClearingBothTargetsRejected", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "clear-both",
Value: "value",
EnvName: "CLEAR_BOTH_ENV",
})
require.NoError(t, err)
// PATCH that clears env_name while file_path is also empty
// should be rejected: the row stays enabled but would have no
// injection target.
empty := ""
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-both", codersdk.UpdateUserSecretRequest{
EnvName: &empty,
})
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
})
t.Run("ClearingTargetsWhileDisablingAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "clear-and-disable",
Value: "value",
EnvName: "CLEAR_AND_DISABLE",
})
require.NoError(t, err)
// Clearing the last target is allowed when the same PATCH also
// disables the secret: only enabled secrets need a target.
empty := ""
disabled := false
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
EnvName: &empty,
Enabled: &disabled,
})
require.NoError(t, err)
assert.False(t, updated.Enabled)
assert.Empty(t, updated.EnvName)
// Re-enabling without restoring a target is rejected.
enable := true
_, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
Enabled: &enable,
})
requireSecretValidationContainsError(t, err, http.StatusBadRequest, "env_name", "at least one of env_name or file_path")
// Re-enabling and restoring a target in one PATCH succeeds.
envName := "CLEAR_AND_DISABLE"
updated, err = client.UpdateUserSecret(ctx, codersdk.Me, "clear-and-disable", codersdk.UpdateUserSecretRequest{
EnvName: &envName,
Enabled: &enable,
})
require.NoError(t, err)
assert.True(t, updated.Enabled)
assert.Equal(t, "CLEAR_AND_DISABLE", updated.EnvName)
})
t.Run("AtomicEnvFileSwap", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "atomic-swap",
Value: "value",
EnvName: "ATOMIC_SWAP_ENV",
})
require.NoError(t, err)
// Clearing env_name and setting file_path in the same PATCH must
// succeed: the post-update row still has an injection target.
empty := ""
newPath := "/tmp/atomic-swap"
updated, err := client.UpdateUserSecret(ctx, codersdk.Me, "atomic-swap", codersdk.UpdateUserSecretRequest{
EnvName: &empty,
FilePath: &newPath,
})
require.NoError(t, err)
assert.Equal(t, "", updated.EnvName)
assert.Equal(t, "/tmp/atomic-swap", updated.FilePath)
})
}
func requireSecretValidationContainsError(t *testing.T, err error, status int, field string, detailContains string) {
@@ -510,8 +693,9 @@ func TestUserSecretLimits(t *testing.T) {
var firstSecret codersdk.UserSecret
for i := 0; i < codersdk.MaxUserSecretsPerUserCount; i++ {
s, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("count-limit-%03d", i),
Value: "x",
Name: fmt.Sprintf("count-limit-%03d", i),
Value: "x",
FilePath: fmt.Sprintf("/tmp/count-limit-%03d", i),
})
require.NoError(t, err)
if i == 0 {
@@ -521,8 +705,9 @@ func TestUserSecretLimits(t *testing.T) {
// POST: the 51st secret is rejected.
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "one-too-many",
Value: "x",
Name: "one-too-many",
Value: "x",
FilePath: "/tmp/one-too-many",
})
requireSecretAPIError(t, err, http.StatusBadRequest, "at most")
@@ -537,8 +722,9 @@ func TestUserSecretLimits(t *testing.T) {
// Other-user isolation: the second user's budget is independent.
_, err = otherClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "other-user-secret",
Value: "x",
Name: "other-user-secret",
Value: "x",
FilePath: "/tmp/other-user-secret",
})
require.NoError(t, err)
})
@@ -702,8 +888,9 @@ func TestDeleteUserSecret(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "delete-me-secret",
Value: "my-value",
Name: "delete-me-secret",
Value: "my-value",
EnvName: "DELETE_ME_SECRET",
})
require.NoError(t, err)
+6 -4
View File
@@ -179,8 +179,9 @@ func TestImportUserSecretsConflict(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitMedium)
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: "EXISTING",
Value: "original",
Name: "EXISTING",
Value: "original",
EnvName: "EXISTING",
})
require.NoError(t, err)
auditor.ResetLogs()
@@ -217,8 +218,9 @@ func TestImportUserSecretsLimits(t *testing.T) {
for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ {
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
Name: fmt.Sprintf("prefill-%03d", i),
Value: "original",
Name: fmt.Sprintf("prefill-%03d", i),
Value: "original",
FilePath: fmt.Sprintf("/tmp/prefill-%03d", i),
})
require.NoError(t, err)
}