mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+10
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+10
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(¶ms)
|
||||
|
||||
Generated
+3
-1
@@ -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")
|
||||
}
|
||||
|
||||
Generated
+1
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+27
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user