From bd1866ed7c617b778fdb58db580a8470ee91ea09 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Tue, 21 Jul 2026 16:05:14 +0000 Subject: [PATCH 1/3] feat(coderd): add enable and disable support for user secrets Adds an enabled flag to user secrets so users can temporarily stop a secret from being injected into workspaces without deleting it. - Add enabled BOOLEAN NOT NULL DEFAULT true to user_secrets. Existing rows with both env_name and file_path empty are migrated to enabled = false, preserving their current not-injected behavior. - Require at least one of env_name or file_path on every write. Explicit enabled = false is now the only way to express "stored but not injected"; the agent manifest layer no longer special-cases both-empty rows and instead skips disabled secrets. - Expose enabled through the REST API (POST default true, PATCH toggle), codersdk, and generated site types. - Track enabled in the audit log field map. - Add coder secret enable and coder secret disable . Existing sessions keep injected values until the agent manifest is refetched (workspace restart); live revocation is tracked separately. Deprecation note: PATCHes that clear both env_name and file_path used to silently disable injection and now return 400 with guidance to use enabled = false instead. --- cli/secret.go | 74 ++++++ cli/secret_test.go | 154 ++++++++++- cli/testdata/coder_secret_--help.golden | 10 +- .../coder_secret_disable_--help.golden | 9 + .../coder_secret_enable_--help.golden | 9 + coderd/agentapi/manifest.go | 9 +- coderd/agentapi/manifest_test.go | 15 +- coderd/apidoc/docs.go | 10 + coderd/apidoc/swagger.json | 10 + coderd/database/db2sdk/db2sdk.go | 2 + coderd/database/dbgen/dbgen.go | 1 + coderd/database/dump.sql | 3 +- .../000553_user_secrets_enabled.down.sql | 2 + .../000553_user_secrets_enabled.up.sql | 22 ++ coderd/database/migrations/migrate_test.go | 97 +++++++ coderd/database/models.go | 1 + coderd/database/queries.sql.go | 37 ++- coderd/database/queries/user_secrets.sql | 9 +- coderd/usersecrets.go | 52 +++- coderd/usersecrets_audit_test.go | 35 ++- coderd/usersecrets_test.go | 247 +++++++++++++++--- coderd/usersecretsimport_test.go | 10 +- codersdk/usersecrets.go | 22 +- codersdk/usersecretsimport.go | 8 + codersdk/usersecretsimport_test.go | 8 +- codersdk/usersecretvalidation.go | 18 ++ codersdk/usersecretvalidation_test.go | 14 +- docs/admin/security/audit-logs.md | 2 +- docs/reference/api/schemas.md | 50 ++-- docs/reference/api/secrets.md | 49 ++-- docs/reference/cli/secret.md | 14 +- docs/reference/cli/secret_disable.md | 10 + docs/reference/cli/secret_enable.md | 10 + enterprise/audit/table.go | 1 + enterprise/coderd/usersecrets_audit_test.go | 1 + enterprise/dbcrypt/cliutil.go | 4 + site/src/api/api.test.ts | 1 + site/src/api/typesGenerated.ts | 30 ++- .../SecretsPage/SecretsPageView.stories.tsx | 1 + .../SecretsPage/secretForm.test.ts | 2 + site/src/testHelpers/entities.ts | 8 + site/src/testHelpers/handlers.ts | 1 + 42 files changed, 924 insertions(+), 148 deletions(-) create mode 100644 cli/testdata/coder_secret_disable_--help.golden create mode 100644 cli/testdata/coder_secret_enable_--help.golden create mode 100644 coderd/database/migrations/000553_user_secrets_enabled.down.sql create mode 100644 coderd/database/migrations/000553_user_secrets_enabled.up.sql create mode 100644 docs/reference/cli/secret_disable.md create mode 100644 docs/reference/cli/secret_enable.md diff --git a/cli/secret.go b/cli/secret.go index 2fb6d75c4fc..beb8daf27b2 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -10,6 +10,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/pretty" "github.com/coder/serpent" @@ -48,6 +49,8 @@ func (r *RootCmd) secrets() *serpent.Command { Children: []*serpent.Command{ r.secretCreate(), r.secretUpdate(), + r.secretEnable(), + r.secretDisable(), r.secretList(), r.secretDelete(), }, @@ -310,6 +313,77 @@ func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow { } } +func (r *RootCmd) secretEnable() *serpent.Command { + return r.secretEnabledSetter(secretEnabledStateEnabled) +} + +func (r *RootCmd) secretDisable() *serpent.Command { + return r.secretEnabledSetter(secretEnabledStateDisabled) +} + +// secretEnabledState distinguishes the two `coder secret enable` and +// `coder secret disable` subcommands without using a bare bool, which +// revive's flag-parameter rule treats as a control coupling. +type secretEnabledState int + +const ( + secretEnabledStateEnabled secretEnabledState = iota + secretEnabledStateDisabled +) + +// secretEnabledSetter builds the `coder secret enable` and `coder secret +// disable` subcommands. Both are a one-field PATCH that flips the enabled +// state. Disabling stops injection for new sessions but leaves the secret +// in place so it can be re-enabled later; existing sessions keep injected +// values until the workspace's agent manifest is refetched. +func (r *RootCmd) secretEnabledSetter(state secretEnabledState) *serpent.Command { + var ( + verb string + participle string + short string + enabled bool + ) + switch state { + case secretEnabledStateEnabled: + verb = "enable" + participle = "Enabled" + short = "Enable a secret so it is injected into workspaces" + enabled = true + case secretEnabledStateDisabled: + verb = "disable" + participle = "Disabled" + short = "Disable a secret without removing it" + enabled = false + } + + cmd := &serpent.Command{ + Use: fmt.Sprintf("%s ", verb), + Short: short, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + name := inv.Args[0] + secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, name, codersdk.UpdateUserSecretRequest{ + Enabled: ptr.Ref(enabled), + }) + if err != nil { + return xerrors.Errorf("%s secret %q: %w", verb, name, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "%s secret %s.\n", participle, cliui.Keyword(secret.Name)) + return nil + }, + } + + return cmd +} + func (r *RootCmd) secretList() *serpent.Command { formatter := cliui.NewOutputFormatter( cliui.ChangeFormatterData( diff --git a/cli/secret_test.go b/cli/secret_test.go index be3d993db5f..455405a4924 100644 --- a/cli/secret_test.go +++ b/cli/secret_test.go @@ -195,8 +195,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -224,6 +225,10 @@ func TestSecretUpdate(t *testing.T) { }) require.NoError(t, err) + // Clearing env_name and description while leaving file_path + // keeps the secret well-formed (still has an injection + // target). Trying to clear both env_name and file_path is + // covered by the server-side test below. inv, root := clitest.New( t, "secret", @@ -232,7 +237,6 @@ func TestSecretUpdate(t *testing.T) { "--value", "rotated-secret", "--description", "", "--env", "", - "--file", "", ) output := clitest.Capture(inv) clitest.SetupConfig(t, client, root) @@ -246,7 +250,41 @@ func TestSecretUpdate(t *testing.T) { require.NoError(t, err) require.Equal(t, "", secret.Description) require.Equal(t, "", secret.EnvName) - require.Equal(t, "", secret.FilePath) + require.Equal(t, "~/.my-secret", secret.FilePath) + }) + + t.Run("ClearingBothTargetsRejected", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--env", "", + ) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Len(t, sdkErr.Validations, 1) + require.Equal(t, "env_name", sdkErr.Validations[0].Field) + require.Contains(t, sdkErr.Validations[0].Detail, "at least one of env_name or file_path") }) t.Run("UpdatesValueFromEmptyFlag", func(t *testing.T) { @@ -257,8 +295,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -286,8 +325,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -310,8 +350,9 @@ func TestSecretUpdate(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "my-secret", - Value: "original-value", + Name: "my-secret", + Value: "original-value", + EnvName: "MY_SECRET", }) require.NoError(t, err) @@ -507,8 +548,9 @@ func TestSecretDelete(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "service-token", - Value: "service-token-value", + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", }) require.NoError(t, err) @@ -542,8 +584,9 @@ func TestSecretDelete(t *testing.T) { setupCtx := testutil.Context(t, testutil.WaitMedium) _, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ - Name: "service-token", - Value: "service-token-value", + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", }) require.NoError(t, err) @@ -591,3 +634,86 @@ func TestSecretDelete(t *testing.T) { require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) } + +func TestSecretEnableDisable(t *testing.T) { + t.Parallel() + + t.Run("Disable", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + require.True(t, created.Enabled) + + inv, root := clitest.New(t, "secret", "disable", "service-token") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "Disabled secret") + require.Contains(t, output.Stdout(), "service-token") + + got, err := client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.NoError(t, err) + assert.False(t, got.Enabled) + }) + + t.Run("Enable", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + setupCtx := testutil.Context(t, testutil.WaitMedium) + disabled := false + created, err := client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + EnvName: "SERVICE_TOKEN", + Enabled: &disabled, + }) + require.NoError(t, err) + require.False(t, created.Enabled) + + inv, root := clitest.New(t, "secret", "enable", "service-token") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "Enabled secret") + require.Contains(t, output.Stdout(), "service-token") + + got, err := client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.NoError(t, err) + assert.True(t, got.Enabled) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "disable", "missing-secret") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/cli/testdata/coder_secret_--help.golden b/cli/testdata/coder_secret_--help.golden index 45447c96e39..5088a3caf4a 100644 --- a/cli/testdata/coder_secret_--help.golden +++ b/cli/testdata/coder_secret_--help.golden @@ -30,10 +30,12 @@ USAGE: $ coder secret delete api-key SUBCOMMANDS: - create Create a secret - delete Delete a secret - list List secrets, or show one by name - update Update a secret + create Create a secret + delete Delete a secret + disable Disable a secret without removing it + enable Enable a secret so it is injected into workspaces + list List secrets, or show one by name + update Update a secret ——— Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_disable_--help.golden b/cli/testdata/coder_secret_disable_--help.golden new file mode 100644 index 00000000000..5eeb305db64 --- /dev/null +++ b/cli/testdata/coder_secret_disable_--help.golden @@ -0,0 +1,9 @@ +coder v0.0.0-devel + +USAGE: + coder secret disable + + Disable a secret without removing it + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_enable_--help.golden b/cli/testdata/coder_secret_enable_--help.golden new file mode 100644 index 00000000000..ea5896449c2 --- /dev/null +++ b/cli/testdata/coder_secret_enable_--help.golden @@ -0,0 +1,9 @@ +coder v0.0.0-devel + +USAGE: + coder secret enable + + Enable a secret so it is injected into workspaces + +——— +Run `coder --help` for a list of global options. diff --git a/coderd/agentapi/manifest.go b/coderd/agentapi/manifest.go index fd8e6f7739c..7af2e6fd85d 100644 --- a/coderd/agentapi/manifest.go +++ b/coderd/agentapi/manifest.go @@ -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{ diff --git a/coderd/agentapi/manifest_test.go b/coderd/agentapi/manifest_test.go index 4c5890052b0..fecfc1cf5be 100644 --- a/coderd/agentapi/manifest_test.go +++ b/coderd/agentapi/manifest_test.go @@ -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) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0696730e54e..b95e78772e2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bec8be3303f..1a3f184e8d3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index d0f94b71d1b..c6e49c4dd93 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 9cdad7e8e82..98c8718697e 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -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) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5127d0159d0..39670fbea45 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3624,7 +3624,8 @@ 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 ); CREATE TABLE user_skills ( diff --git a/coderd/database/migrations/000553_user_secrets_enabled.down.sql b/coderd/database/migrations/000553_user_secrets_enabled.down.sql new file mode 100644 index 00000000000..468b6b3bd26 --- /dev/null +++ b/coderd/database/migrations/000553_user_secrets_enabled.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_secrets + DROP COLUMN enabled; diff --git a/coderd/database/migrations/000553_user_secrets_enabled.up.sql b/coderd/database/migrations/000553_user_secrets_enabled.up.sql new file mode 100644 index 00000000000..73716258616 --- /dev/null +++ b/coderd/database/migrations/000553_user_secrets_enabled.up.sql @@ -0,0 +1,22 @@ +-- 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. +-- The write-time invariant (an enabled secret must have at least one of +-- env_name / file_path non-empty) is enforced at the API layer, so no +-- CHECK constraint is added here. Disabled secrets may have no targets; +-- bulk imports use that state for keys that cannot be env-injected. +ALTER TABLE user_secrets + ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true; + +UPDATE user_secrets +SET enabled = false +WHERE env_name = '' AND file_path = ''; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 14119ed81e3..82041f41d32 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -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 TestMigration000553UserSecretsEnabled(t *testing.T) { + t.Parallel() + + const migrationVersion = 553 + + 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") +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 08b71a244ef..74e982f5fc4 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1428f399832..ee88e6cdba8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/user_secrets.sql b/coderd/database/queries/user_secrets.sql index 2bca3a0ca4b..56e07b57ad3 100644 --- a/coderd/database/queries/user_secrets.sql +++ b/coderd/database/queries/user_secrets.sql @@ -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 *; diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 633983cb612..e83864cf5bf 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -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,6 +83,7 @@ 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 { @@ -155,6 +168,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 +181,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 @@ -319,7 +337,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 +360,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 +375,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 +398,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 +431,13 @@ 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 := userSecretConflictValidationErrors(err); len(validations) > 0 { writeUserSecretValidationErrors(ctx, rw, http.StatusConflict, validations) return diff --git a/coderd/usersecrets_audit_test.go b/coderd/usersecrets_audit_test.go index ba1fdd96f3b..9cf38dcc2ff 100644 --- a/coderd/usersecrets_audit_test.go +++ b/coderd/usersecrets_audit_test.go @@ -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 diff --git a/coderd/usersecrets_test.go b/coderd/usersecrets_test.go index 4fedcb2dec3..1b4a6c97768 100644 --- a/coderd/usersecrets_test.go +++ b/coderd/usersecrets_test.go @@ -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) diff --git a/coderd/usersecretsimport_test.go b/coderd/usersecretsimport_test.go index 0cddeba7a16..e14846eeda3 100644 --- a/coderd/usersecretsimport_test.go +++ b/coderd/usersecretsimport_test.go @@ -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) } diff --git a/codersdk/usersecrets.go b/codersdk/usersecrets.go index 7d59d9b6d86..1f36100c4d7 100644 --- a/codersdk/usersecrets.go +++ b/codersdk/usersecrets.go @@ -18,30 +18,42 @@ type UserSecret struct { Description string `json:"description"` EnvName string `json:"env_name"` FilePath string `json:"file_path"` - CreatedAt time.Time `json:"created_at" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" format:"date-time"` + // Enabled controls whether the secret is injected into workspaces. + // Disabled secrets remain visible and editable, but are not added + // to the agent manifest, so they are not exposed as environment + // variables or written to secret files. + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` } // CreateUserSecretRequest is the payload for creating a new user -// secret. Name and Value are required. All other fields are optional -// and default to empty string. +// secret. Name and Value are required. An enabled secret must have at +// least one of EnvName or FilePath non-empty so it has an injection +// target; to keep a secret without injecting it, set Enabled to false. +// All other fields are optional and default to empty string. Enabled +// defaults to true when omitted. type CreateUserSecretRequest struct { Name string `json:"name"` Value string `json:"value"` Description string `json:"description,omitempty"` EnvName string `json:"env_name,omitempty"` FilePath string `json:"file_path,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } // UpdateUserSecretRequest is the payload for partially updating a // user secret. At least one field must be non-nil. Pointer fields // distinguish "not sent" (nil) from "set to empty string" (pointer -// to empty string). +// to empty string). If the post-update row is enabled it must still +// have at least one of EnvName or FilePath non-empty; clearing both +// targets is only allowed when the secret is (or becomes) disabled. type UpdateUserSecretRequest struct { Value *string `json:"value,omitempty"` Description *string `json:"description,omitempty"` EnvName *string `json:"env_name,omitempty"` FilePath *string `json:"file_path,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } func (c *Client) CreateUserSecret(ctx context.Context, user string, req CreateUserSecretRequest) (UserSecret, error) { diff --git a/codersdk/usersecretsimport.go b/codersdk/usersecretsimport.go index b7c020ad463..f26a725e25f 100644 --- a/codersdk/usersecretsimport.go +++ b/codersdk/usersecretsimport.go @@ -87,6 +87,14 @@ func ParseSecretsFile(format SecretsFileFormat, content string) ([]CreateUserSec // so multiple empty env_names are allowed. if UserSecretEnvNameValid(e.key) == nil { req.EnvName = e.key + } else { + // Keys that cannot be env-injected (reserved names, invalid + // identifiers) are imported without an injection target, so + // they must be disabled: an enabled secret always has at + // least one of env_name or file_path set. The user can add + // a target and re-enable the secret afterwards. + disabled := false + req.Enabled = &disabled } reqs = append(reqs, req) } diff --git a/codersdk/usersecretsimport_test.go b/codersdk/usersecretsimport_test.go index 7faa8e0606b..86e616d4f44 100644 --- a/codersdk/usersecretsimport_test.go +++ b/codersdk/usersecretsimport_test.go @@ -406,9 +406,13 @@ func TestParseSecretsFileBestEffortEnvName(t *testing.T) { t.Parallel() reqs, err := codersdk.ParseSecretsFile(tc.format, tc.content) require.NoError(t, err) + // Reserved keys cannot be env-injected, so they are imported + // without an injection target and therefore disabled. + disabled := false require.Equal(t, []codersdk.CreateUserSecretRequest{{ - Name: "PATH", - Value: "value", + Name: "PATH", + Value: "value", + Enabled: &disabled, }}, reqs) }) } diff --git a/codersdk/usersecretvalidation.go b/codersdk/usersecretvalidation.go index 5892d1c35c7..6fdc73058bb 100644 --- a/codersdk/usersecretvalidation.go +++ b/codersdk/usersecretvalidation.go @@ -236,9 +236,27 @@ func ValidateCreateUserSecretRequest(req CreateUserSecretRequest) []ValidationEr if err := UserSecretFilePathValid(req.FilePath); err != nil { validations = append(validations, ValidationError{Field: UserSecretFilePathField, Detail: err.Error()}) } + // An enabled secret must have an injection target. The agent + // manifest layer relies on this invariant so it can gate injection + // solely on the enabled flag; "stored but not injected" is + // expressed by enabled=false, not by clearing both targets. + // Disabled secrets may have no target (e.g. bulk imports of keys + // that cannot be env-injected). + if req.EnvName == "" && req.FilePath == "" && (req.Enabled == nil || *req.Enabled) { + validations = append(validations, ValidationError{ + Field: UserSecretEnvNameField, + Detail: UserSecretInjectionTargetRequiredDetail, + }) + } return validations } +// UserSecretInjectionTargetRequiredDetail explains the injection-target +// invariant. It is shared by the create validator above and the PATCH +// handler's post-state check in coderd. The value is a user-facing +// validation message, not a credential. +const UserSecretInjectionTargetRequiredDetail = "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets." //nolint:gosec // G101: message text, not a hardcoded credential. + // UserSecretNameValid validates a user secret name. Names are used in // API route path segments, so they must not include route separators. func UserSecretNameValid(s string) error { diff --git a/codersdk/usersecretvalidation_test.go b/codersdk/usersecretvalidation_test.go index 55d7d001b7f..28584ac1993 100644 --- a/codersdk/usersecretvalidation_test.go +++ b/codersdk/usersecretvalidation_test.go @@ -29,13 +29,25 @@ func TestValidateCreateUserSecretRequest(t *testing.T) { { name: "MissingValue", req: codersdk.CreateUserSecretRequest{ - Name: "missing-value-secret", + Name: "missing-value-secret", + EnvName: "MISSING_VALUE_SECRET", }, want: []codersdk.ValidationError{{ Field: "value", Detail: "Value is required.", }}, }, + { + name: "MissingInjectionTarget", + req: codersdk.CreateUserSecretRequest{ + Name: "missing-target-secret", + Value: "value", + }, + want: []codersdk.ValidationError{{ + Field: "env_name", + Detail: codersdk.UserSecretInjectionTargetRequiredDetail, + }}, + }, { name: "MultiInvalid", req: codersdk.CreateUserSecretRequest{ diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index b83bcdc42ae..127eefd19a8 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -43,7 +43,7 @@ We track the following resources: | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
cors_behaviortrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
disable_module_cachetrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_autostop_notifytrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| | TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
chat_spend_limit_microstrue
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_service_accounttrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| +| UserSecret
create, write, delete | |
FieldTracked
created_atfalse
descriptiontrue
enabledtrue
env_nametrue
file_pathtrue
idtrue
nametrue
updated_atfalse
user_idtrue
valuetrue
value_key_idfalse
| | UserSkill
create, write, delete | |
FieldTracked
contenttrue
created_atfalse
descriptiontrue
idtrue
nametrue
updated_atfalse
user_idtrue
| | WorkspaceBuild
start, stop | |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
notified_autostop_deadlinefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d3277298fe4..cae7eb143c5 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5117,6 +5117,7 @@ This is required on creation to enable a user-flow of validating a template work ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "name": "string", @@ -5126,13 +5127,14 @@ This is required on creation to enable a user-flow of validating a template work ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `description` | string | false | | | -| `env_name` | string | false | | | -| `file_path` | string | false | | | -| `name` | string | false | | | -| `value` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------|----------|--------------|-------------| +| `description` | string | false | | | +| `enabled` | boolean | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `name` | string | false | | | +| `value` | string | false | | | ## codersdk.CreateUserSkillRequest @@ -13833,6 +13835,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "value": "string" @@ -13841,12 +13844,13 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `description` | string | false | | | -| `env_name` | string | false | | | -| `file_path` | string | false | | | -| `value` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------|----------|--------------|-------------| +| `description` | string | false | | | +| `enabled` | boolean | false | | | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `value` | string | false | | | ## codersdk.UpdateUserSkillRequest @@ -14532,6 +14536,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -14542,15 +14547,16 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------|--------|----------|--------------|-------------| -| `created_at` | string | false | | | -| `description` | string | false | | | -| `env_name` | string | false | | | -| `file_path` | string | false | | | -| `id` | string | false | | | -| `name` | string | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------|---------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `created_at` | string | false | | | +| `description` | string | false | | | +| `enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. | +| `env_name` | string | false | | | +| `file_path` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `updated_at` | string | false | | | ## codersdk.UserSkill diff --git a/docs/reference/api/secrets.md b/docs/reference/api/secrets.md index 74b44c0ec23..d1eee9875d2 100644 --- a/docs/reference/api/secrets.md +++ b/docs/reference/api/secrets.md @@ -28,6 +28,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -47,16 +48,17 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | false | | | -| `» description` | string | false | | | -| `» env_name` | string | false | | | -| `» file_path` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» description` | string | false | | | +| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. | +| `» env_name` | string | false | | | +| `» file_path` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -79,6 +81,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \ ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "name": "string", @@ -101,6 +104,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -156,6 +160,7 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -178,16 +183,17 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/secrets/batch \ Status Code **201** -| Name | Type | Required | Restrictions | Description | -|-----------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | false | | | -| `» description` | string | false | | | -| `» env_name` | string | false | | | -| `» file_path` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------|-------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» description` | string | false | | | +| `» enabled` | boolean | false | | Enabled controls whether the secret is injected into workspaces. Disabled secrets remain visible and editable, but are not added to the agent manifest, so they are not exposed as environment variables or written to secret files. | +| `» env_name` | string | false | | | +| `» file_path` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» name` | string | false | | | +| `» updated_at` | string(date-time) | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -219,6 +225,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/secrets/{name} \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", @@ -281,6 +288,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \ ```json { "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "value": "string" @@ -303,6 +311,7 @@ curl -X PATCH http://coder-server:8080/api/v2/users/{user}/secrets/{name} \ { "created_at": "2019-08-24T14:15:22Z", "description": "string", + "enabled": true, "env_name": "string", "file_path": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", diff --git a/docs/reference/cli/secret.md b/docs/reference/cli/secret.md index 7d022f3dfd6..e9460e38a75 100644 --- a/docs/reference/cli/secret.md +++ b/docs/reference/cli/secret.md @@ -39,9 +39,11 @@ coder secret ## Subcommands -| Name | Purpose | -|-------------------------------------------|-----------------------------------| -| [create](./secret_create.md) | Create a secret | -| [update](./secret_update.md) | Update a secret | -| [list](./secret_list.md) | List secrets, or show one by name | -| [delete](./secret_delete.md) | Delete a secret | +| Name | Purpose | +|---------------------------------------------|---------------------------------------------------| +| [create](./secret_create.md) | Create a secret | +| [update](./secret_update.md) | Update a secret | +| [enable](./secret_enable.md) | Enable a secret so it is injected into workspaces | +| [disable](./secret_disable.md) | Disable a secret without removing it | +| [list](./secret_list.md) | List secrets, or show one by name | +| [delete](./secret_delete.md) | Delete a secret | diff --git a/docs/reference/cli/secret_disable.md b/docs/reference/cli/secret_disable.md new file mode 100644 index 00000000000..1ee4c3de822 --- /dev/null +++ b/docs/reference/cli/secret_disable.md @@ -0,0 +1,10 @@ + +# secret disable + +Disable a secret without removing it + +## Usage + +```console +coder secret disable +``` diff --git a/docs/reference/cli/secret_enable.md b/docs/reference/cli/secret_enable.md new file mode 100644 index 00000000000..56d83a38673 --- /dev/null +++ b/docs/reference/cli/secret_enable.md @@ -0,0 +1,10 @@ + +# secret enable + +Enable a secret so it is injected into workspaces + +## Usage + +```console +coder secret enable +``` diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 540f4cce34d..ea66b579332 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -503,6 +503,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "env_name": ActionTrack, "file_path": ActionTrack, + "enabled": ActionTrack, "value": ActionSecret, diff --git a/enterprise/coderd/usersecrets_audit_test.go b/enterprise/coderd/usersecrets_audit_test.go index 46deac17f76..f95023be70f 100644 --- a/enterprise/coderd/usersecrets_audit_test.go +++ b/enterprise/coderd/usersecrets_audit_test.go @@ -58,6 +58,7 @@ func TestUserSecretAuditDiffRedaction(t *testing.T) { Name: "createDiff-target", Description: initialDescription, Value: initialValue, + EnvName: "CREATE_DIFF_TARGET", }) require.NoError(t, err) diff --git a/enterprise/dbcrypt/cliutil.go b/enterprise/dbcrypt/cliutil.go index b298828055d..086c9ba6b4f 100644 --- a/enterprise/dbcrypt/cliutil.go +++ b/enterprise/dbcrypt/cliutil.go @@ -95,6 +95,8 @@ func Rotate(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciphe EnvName: "", UpdateFilePath: false, FilePath: "", + UpdateEnabled: false, + Enabled: false, }); err != nil { return xerrors.Errorf("rotate user secret user_id=%s name=%s: %w", uid, secret.Name, err) } @@ -307,6 +309,8 @@ func Decrypt(ctx context.Context, log slog.Logger, sqlDB *sql.DB, ciphers []Ciph EnvName: "", UpdateFilePath: false, FilePath: "", + UpdateEnabled: false, + Enabled: false, }); err != nil { return xerrors.Errorf("decrypt user secret user_id=%s name=%s: %w", uid, secret.Name, err) } diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 98a9cb4eff3..446467c52bb 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -484,6 +484,7 @@ describe("api.ts", () => { description: "Example token for tests", env_name: secretName, file_path: "", + enabled: true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 322bad1b6d1..2d3f42b2b37 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4310,8 +4310,11 @@ export interface CreateUserRequestWithOrgs { // From codersdk/usersecrets.go /** * CreateUserSecretRequest is the payload for creating a new user - * secret. Name and Value are required. All other fields are optional - * and default to empty string. + * secret. Name and Value are required. An enabled secret must have at + * least one of EnvName or FilePath non-empty so it has an injection + * target; to keep a secret without injecting it, set Enabled to false. + * All other fields are optional and default to empty string. Enabled + * defaults to true when omitted. */ export interface CreateUserSecretRequest { readonly name: string; @@ -4319,6 +4322,7 @@ export interface CreateUserSecretRequest { readonly description?: string; readonly env_name?: string; readonly file_path?: string; + readonly enabled?: boolean; } // From codersdk/userskills.go @@ -9958,13 +9962,16 @@ export interface UpdateUserQuietHoursScheduleRequest { * UpdateUserSecretRequest is the payload for partially updating a * user secret. At least one field must be non-nil. Pointer fields * distinguish "not sent" (nil) from "set to empty string" (pointer - * to empty string). + * to empty string). If the post-update row is enabled it must still + * have at least one of EnvName or FilePath non-empty; clearing both + * targets is only allowed when the secret is (or becomes) disabled. */ export interface UpdateUserSecretRequest { readonly value?: string; readonly description?: string; readonly env_name?: string; readonly file_path?: string; + readonly enabled?: boolean; } // From codersdk/userskills.go @@ -10468,6 +10475,13 @@ export interface UserSecret { readonly description: string; readonly env_name: string; readonly file_path: string; + /** + * Enabled controls whether the secret is injected into workspaces. + * Disabled secrets remain visible and editable, but are not added + * to the agent manifest, so they are not exposed as environment + * variables or written to secret files. + */ + readonly enabled: boolean; readonly created_at: string; readonly updated_at: string; } @@ -10488,6 +10502,16 @@ export const UserSecretEnvNameField = "env_name"; */ export const UserSecretFilePathField = "file_path"; +// From codersdk/usersecretvalidation.go +/** + * UserSecretInjectionTargetRequiredDetail explains the injection-target + * invariant. It is shared by the create validator above and the PATCH + * handler's post-state check in coderd. The value is a user-facing + * validation message, not a credential. + */ +export const UserSecretInjectionTargetRequiredDetail = + "An enabled secret must have at least one of env_name or file_path set. To keep a secret without injecting it, set enabled to false instead of clearing both targets."; //nolint:gosec // G101: message text, not a hardcoded credential. + // From codersdk/usersecretvalidation.go /** * UserSecret*Field constants are the canonical ValidationError.Field values diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index bac0308dfad..68b597af8fc 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -64,6 +64,7 @@ const createSecretFromRequest = ( description: request.description ?? "", env_name: request.env_name ?? "", file_path: request.file_path ?? "", + enabled: request.enabled ?? true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }); diff --git a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts index f45415bdfa0..a449272857c 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts +++ b/site/src/pages/UserSettingsPage/SecretsPage/secretForm.test.ts @@ -14,6 +14,7 @@ const existingSecrets: UserSecret[] = [ description: "Service token", env_name: "SERVICE_TOKEN", file_path: "", + enabled: true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }, @@ -23,6 +24,7 @@ const existingSecrets: UserSecret[] = [ description: "", env_name: "SERVICE_API_KEY", file_path: "~/.config/service/key", + enabled: true, created_at: "2026-05-04T00:00:00Z", updated_at: "2026-05-04T00:00:00Z", }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 001af980d0e..20e693e4944 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -578,6 +578,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Used by example templates.", env_name: "EXAMPLE_TOKEN", file_path: "", + enabled: true, created_at: "2026-04-28T16:30:00Z", updated_at: "2026-04-30T16:30:00Z", }, @@ -587,6 +588,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Mounted as a workspace file.", env_name: "", file_path: "~/.config/example/config.json", + enabled: true, created_at: "2026-04-29T16:30:00Z", updated_at: "2026-05-01T16:30:00Z", }, @@ -596,15 +598,20 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Available as an environment variable and file.", env_name: "SERVICE_API_KEY", file_path: "/var/run/secrets/service-api-key", + enabled: true, created_at: "2026-04-30T16:30:00Z", updated_at: "2026-05-02T16:30:00Z", }, { + // Mirrors a pre-migration secret that had both env_name and + // file_path empty. The migration flips such rows to + // enabled: false, so this is the shape they have after upgrade. id: "secret-not-injected", name: "SERVICE_PASSWORD", description: "", env_name: "", file_path: "", + enabled: false, created_at: "2026-05-01T16:30:00Z", updated_at: "2026-05-03T16:30:00Z", }, @@ -614,6 +621,7 @@ export const MockUserSecrets: TypesGen.UserSecret[] = [ description: "Used to exercise duplicate validation.", env_name: "DUPLICATE_API_KEY", file_path: "", + enabled: true, created_at: "2026-05-01T18:30:00Z", updated_at: "2026-05-03T18:30:00Z", }, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 4b81b94afcb..82833ff6885 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -444,6 +444,7 @@ function userSecretFromCreateRequest( description: request.description ?? "", env_name: request.env_name ?? "", file_path: request.file_path ?? "", + enabled: request.enabled ?? true, created_at: now, updated_at: now, }; From d4f7859053cb8d692584cfaf3b10dc7d76d60043 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Mon, 27 Jul 2026 16:22:06 +0000 Subject: [PATCH 2/3] feat(site): add enable/disable toggle to user secrets page Adds a per-row Switch control on the user secrets management page so a user can enable or disable each secret. Disabled secrets remain visible and editable but are not injected into workspaces. A target-less secret (no environment variable and no file path) cannot be enabled from the UI; the toggle is disabled with a tooltip prompting the user to add an injection target first. This mirrors the API invariant that an enabled secret must have at least one target. Toggle failures are surfaced via toast. Storybook stories cover the submit, error, and target-less tooltip cases. --- .../SecretsPage/SecretsPage.tsx | 20 ++++ .../SecretsPage/SecretsPageView.stories.tsx | 70 ++++++++++++ .../SecretsPage/SecretsPageView.tsx | 6 + .../SecretsPage/SecretsTable.tsx | 107 +++++++++++++++++- 4 files changed, 197 insertions(+), 6 deletions(-) diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx index fa74cfd4a5d..43b74638842 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPage.tsx @@ -65,6 +65,26 @@ const SecretsPage: FC = () => { throw error; } }} + onToggleSecretEnabled={async (secret, enabled) => { + try { + await updateSecretMutation.mutateAsync({ + name: secret.name, + request: { enabled }, + }); + toast.success( + `${enabled ? "Enabled" : "Disabled"} secret "${secret.name}".`, + ); + } catch (error) { + toast.error( + getErrorMessage( + error, + `Failed to ${enabled ? "enable" : "disable"} secret.`, + ), + { description: getErrorDetail(error) }, + ); + throw error; + } + }} /> ); }; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx index 68b597af8fc..0988c7172ba 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { onCreateSecret: fn(), onUpdateSecret: fn(), onDeleteSecret: fn(), + onToggleSecretEnabled: fn(), }, }; @@ -45,6 +46,9 @@ type UpdateSecretMock = ReturnType< type DeleteSecretMock = ReturnType< typeof fn<(secret: UserSecret) => Promise | void> >; +type ToggleSecretEnabledMock = ReturnType< + typeof fn<(secret: UserSecret, enabled: boolean) => Promise | void> +>; const waitForDialogToClose = async (body: ReturnType) => { await waitFor(() => { @@ -598,3 +602,69 @@ export const CreateMutationErrorDisplay: Story = { expectNoValueField(body); }, }; + +export const ToggleEnabledSubmit: Story = { + args: { + onToggleSecretEnabled: fn< + (secret: UserSecret, enabled: boolean) => Promise + >(async () => {}), + }, + play: async ({ canvasElement, args }) => { + const onToggleSecretEnabled = + args.onToggleSecretEnabled as ToggleSecretEnabledMock; + onToggleSecretEnabled.mockClear(); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const secret = findVisibleSecretByName("EXAMPLE_TOKEN"); + + const toggle = canvas.getByRole("switch", { + name: `Toggle secret ${secret.name}`, + }); + await expect(toggle).toBeChecked(); + await user.click(toggle); + + await waitFor(() => expect(onToggleSecretEnabled).toHaveBeenCalledTimes(1)); + expect(onToggleSecretEnabled).toHaveBeenCalledWith(secret, false); + }, +}; + +export const ToggleEnabledMutationErrorDisplay: Story = { + args: { + onToggleSecretEnabled: fn< + (secret: UserSecret, enabled: boolean) => Promise + >(async () => { + throw mockApiError({ message: "Failed to disable secret." }); + }), + }, + play: async ({ canvasElement, args }) => { + const onToggleSecretEnabled = + args.onToggleSecretEnabled as ToggleSecretEnabledMock; + onToggleSecretEnabled.mockClear(); + const user = userEvent.setup(); + const canvas = within(canvasElement); + const secret = findVisibleSecretByName("EXAMPLE_TOKEN"); + + const toggle = canvas.getByRole("switch", { + name: `Toggle secret ${secret.name}`, + }); + await user.click(toggle); + + await waitFor(() => expect(onToggleSecretEnabled).toHaveBeenCalledTimes(1)); + // Handler rejected; the parent owns the secret state so the switch + // remains checked in this story where no state change is applied. + await expect(toggle).toBeChecked(); + }, +}; + +export const ToggleEnabledDisabledForTargetlessSecret: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const secret = findVisibleSecretByName("SERVICE_PASSWORD"); + + const toggle = canvas.getByRole("switch", { + name: `Toggle secret ${secret.name}`, + }); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeDisabled(); + }, +}; diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx index 8306ea2d203..26543a988fe 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsPageView.tsx @@ -36,6 +36,10 @@ type SecretsPageViewProps = { request: UpdateUserSecretRequest, ) => Promise | UserSecret; onDeleteSecret: (secret: UserSecret) => Promise | void; + onToggleSecretEnabled: ( + secret: UserSecret, + enabled: boolean, + ) => Promise | void; }; type SecretDialogState = @@ -55,6 +59,7 @@ export const SecretsPageView: FC = ({ onCreateSecret, onUpdateSecret, onDeleteSecret, + onToggleSecretEnabled, }) => { const [dialogState, setDialogState] = useState({ mode: "add", @@ -143,6 +148,7 @@ export const SecretsPageView: FC = ({ onAddSecret={openAddSecret} onEditSecret={openEditSecret} onDeleteSecret={onDeleteSecret} + onToggleEnabled={onToggleSecretEnabled} /> diff --git a/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx b/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx index a74dc30c481..ac1966f9b1d 100644 --- a/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx +++ b/site/src/pages/UserSettingsPage/SecretsPage/SecretsTable.tsx @@ -11,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; +import { Switch } from "#/components/Switch/Switch"; import { Table, TableBody, @@ -21,6 +22,11 @@ import { } from "#/components/Table/Table"; import { TableEmpty } from "#/components/TableEmpty/TableEmpty"; import { TableLoader } from "#/components/TableLoader/TableLoader"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { relativeTime } from "#/utils/time"; type SecretsTableProps = { @@ -34,6 +40,10 @@ type SecretsTableProps = { returnFocusElement?: HTMLElement | null, ) => void; onDeleteSecret: (secret: UserSecret) => Promise | void; + onToggleEnabled: ( + secret: UserSecret, + enabled: boolean, + ) => Promise | void; }; export const SecretsTable: FC = ({ @@ -44,8 +54,25 @@ export const SecretsTable: FC = ({ onAddSecret, onEditSecret, onDeleteSecret, + onToggleEnabled, }) => { const [secretToDelete, setSecretToDelete] = useState(); + const [togglingSecretId, setTogglingSecretId] = useState(null); + + const handleToggle = (secret: UserSecret, enabled: boolean) => { + setTogglingSecretId(secret.id); + void Promise.resolve() + .then(() => onToggleEnabled(secret, enabled)) + .catch(() => { + // onToggleEnabled reports failures with a toast before rejecting. + // Swallow the rejection here to avoid an unhandled promise rejection warning. + }) + .finally(() => { + setTogglingSecretId((current) => + current === secret.id ? null : current, + ); + }); + }; return ( <> @@ -69,12 +96,13 @@ export const SecretsTable: FC = ({ - Name - Environment variable - File path - Type - Description - Updated + Name + Environment variable + File path + Type + Description + Updated + Enabled @@ -115,6 +143,13 @@ export const SecretsTable: FC = ({ {relativeTime(secret.updated_at)} + + + = ({ secret }) => { return not injected; }; +type EnabledToggleProps = { + secret: UserSecret; + isPending: boolean; + onToggle: (secret: UserSecret, enabled: boolean) => void; +}; + +const EnabledToggle: FC = ({ + secret, + isPending, + onToggle, +}) => { + const hasTarget = Boolean(secret.env_name) || Boolean(secret.file_path); + // An enabled secret must have at least one injection target. Prevent + // enabling a target-less secret; the user must add a target first. + const cannotEnable = !secret.enabled && !hasTarget; + const label = `Toggle secret ${secret.name}`; + const stateLabel = secret.enabled ? "Enabled" : "Disabled"; + + const control = ( + onToggle(secret, checked)} + /> + ); + + if (cannotEnable) { + return ( +
+ + + {/* + * Wrap the disabled Switch in a focusable span so the + * tooltip can be triggered by keyboard and pointer. + * biome-ignore lint/a11y/noNoninteractiveTabindex: needed to + * surface the tooltip on a disabled control via keyboard focus. + */} + + {control} + + + + Add an environment variable or file path before enabling this + secret. + + + {stateLabel} +
+ ); + } + + return ( +
+ {control} + {stateLabel} +
+ ); +}; + type SecretRowActionsProps = { secret: UserSecret; onEditSecret: ( From ae9168bbed9753f655a5a0e14952a35b182cfb6f Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Tue, 28 Jul 2026 15:21:56 +0000 Subject: [PATCH 3/3] feat(coderd): enforce user secret injection-target invariant in the database Close a read-modify-write race in the user secret PATCH handler. The handler computes the post-update env_name/file_path/enabled from a stale pre-read, so two concurrent PATCHes that each clear a different target could both pass the check and serialize to an enabled row with no target. Add a user_secrets_enabled_requires_target CHECK constraint (NOT enabled OR env_name <> '' OR file_path <> '') as the race-safe source of truth, and map the violation to the same 400 the API returns. Also add CLI ergonomics and docs requested in review: - coder secret create/update gain an --enabled flag; --enabled=false stores a secret without injecting it. - coder secret list gains an enabled column. - Document the enabled field, disable semantics, and the new commands in the user secrets guide and admin secrets overview. Add a constraint regression test and update tests that created target-less enabled secrets to be disabled. --- cli/secret.go | 34 +++++++- .../coder_secret_create_--help.golden | 5 ++ cli/testdata/coder_secret_list_--help.golden | 2 +- .../coder_secret_update_--help.golden | 11 ++- coderd/database/check_constraint.go | 1 + coderd/database/dump.sql | 3 +- ...l => 000556_user_secrets_enabled.down.sql} | 0 ...sql => 000556_user_secrets_enabled.up.sql} | 16 +++- coderd/database/migrations/migrate_test.go | 4 +- coderd/database/querier_test.go | 83 ++++++++++++++++++- coderd/telemetry/telemetry_test.go | 12 ++- coderd/usersecrets.go | 33 ++++++++ docs/admin/security/secrets.md | 3 +- docs/reference/cli/secret_create.md | 9 ++ docs/reference/cli/secret_list.md | 8 +- docs/reference/cli/secret_update.md | 10 ++- docs/user-guides/user-secrets.md | 82 ++++++++++++++---- 17 files changed, 277 insertions(+), 39 deletions(-) rename coderd/database/migrations/{000553_user_secrets_enabled.down.sql => 000556_user_secrets_enabled.down.sql} (100%) rename coderd/database/migrations/{000553_user_secrets_enabled.up.sql => 000556_user_secrets_enabled.up.sql} (55%) diff --git a/cli/secret.go b/cli/secret.go index beb8daf27b2..630a1720b55 100644 --- a/cli/secret.go +++ b/cli/secret.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "strconv" "strings" "time" @@ -65,6 +66,7 @@ func (r *RootCmd) secretCreate() *serpent.Command { description string env string file string + enabled bool ) cmd := &serpent.Command{ @@ -99,6 +101,13 @@ func (r *RootCmd) secretCreate() *serpent.Command { Description: "Workspace file path where this secret will be written. Must start with ~/ or /.", Value: serpent.StringOf(&file), }, + { + Name: "enabled", + Flag: "enabled", + Description: "Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it.", + Default: "true", + Value: serpent.BoolOf(&enabled), + }, }, Handler: func(inv *serpent.Invocation) error { client, err := r.InitClient(inv) @@ -117,13 +126,18 @@ func (r *RootCmd) secretCreate() *serpent.Command { return xerrors.New("secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") } - secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, codersdk.CreateUserSecretRequest{ + req := codersdk.CreateUserSecretRequest{ Name: inv.Args[0], Value: resolvedValue, Description: description, EnvName: env, FilePath: file, - }) + } + if userSetOption(inv, "enabled") { + req.Enabled = ptr.Ref(enabled) + } + + secret, err := client.CreateUserSecret(inv.Context(), codersdk.Me, req) if err != nil { return xerrors.Errorf("create secret %q: %w", inv.Args[0], err) } @@ -142,13 +156,14 @@ func (r *RootCmd) secretUpdate() *serpent.Command { description string env string file string + enabled bool ) cmd := &serpent.Command{ Use: "update ", Short: "Update a secret", Long: strings.Join([]string{ - "At least one of --value, --description, --env, or --file must be specified.", + "At least one of --value, --description, --env, --file, or --enabled must be specified.", "Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).", }, " "), Middleware: serpent.Chain( @@ -179,6 +194,12 @@ func (r *RootCmd) secretUpdate() *serpent.Command { Description: "Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.", Value: serpent.StringOf(&file), }, + { + Name: "enabled", + Flag: "enabled", + Description: "Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it.", + Value: serpent.BoolOf(&enabled), + }, }, Handler: func(inv *serpent.Invocation) error { client, err := r.InitClient(inv) @@ -203,6 +224,9 @@ func (r *RootCmd) secretUpdate() *serpent.Command { if userSetOption(inv, "file") { req.FilePath = &file } + if userSetOption(inv, "enabled") { + req.Enabled = ptr.Ref(enabled) + } secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, inv.Args[0], req) if err != nil { @@ -298,6 +322,7 @@ type secretListRow struct { Updated string `json:"-" table:"updated"` Env string `json:"-" table:"env"` File string `json:"-" table:"file"` + Enabled string `json:"-" table:"enabled"` Description string `json:"-" table:"description"` } @@ -309,6 +334,7 @@ func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow { Updated: humanize.Time(secret.UpdatedAt), Env: secret.EnvName, File: secret.FilePath, + Enabled: strconv.FormatBool(secret.Enabled), Description: secret.Description, } } @@ -389,7 +415,7 @@ func (r *RootCmd) secretList() *serpent.Command { cliui.ChangeFormatterData( cliui.TableFormat( []secretListRow{}, - []string{"name", "created", "updated", "env", "file", "description"}, + []string{"name", "created", "updated", "env", "file", "enabled", "description"}, ), func(data any) (any, error) { switch rows := data.(type) { diff --git a/cli/testdata/coder_secret_create_--help.golden b/cli/testdata/coder_secret_create_--help.golden index 0a5d53d1198..8b829bade8d 100644 --- a/cli/testdata/coder_secret_create_--help.golden +++ b/cli/testdata/coder_secret_create_--help.golden @@ -12,6 +12,11 @@ OPTIONS: --description string Set the secret description. + --enabled bool (default: true) + Whether the secret is injected into workspaces. An enabled secret must + set --env or --file; pass --enabled=false to store a secret without + injecting it. + --env string Name of the workspace environment variable that this secret will set. diff --git a/cli/testdata/coder_secret_list_--help.golden b/cli/testdata/coder_secret_list_--help.golden index 803968373cf..3d2fd83dc5b 100644 --- a/cli/testdata/coder_secret_list_--help.golden +++ b/cli/testdata/coder_secret_list_--help.golden @@ -10,7 +10,7 @@ USAGE: Secret values are omitted from the output. OPTIONS: - -c, --column [created|name|updated|env|file|description] (default: name,created,updated,env,file,description) + -c, --column [created|name|updated|env|file|enabled|description] (default: name,created,updated,env,file,enabled,description) Columns to display in table output. -o, --output table|json (default: table) diff --git a/cli/testdata/coder_secret_update_--help.golden b/cli/testdata/coder_secret_update_--help.golden index 6864ca22daa..12407a74afd 100644 --- a/cli/testdata/coder_secret_update_--help.golden +++ b/cli/testdata/coder_secret_update_--help.golden @@ -5,14 +5,19 @@ USAGE: Update a secret - At least one of --value, --description, --env, or --file must be specified. - Provide the secret value by at most one of --value or non-interactive stdin - (pipe or redirect). + At least one of --value, --description, --env, --file, or --enabled must be + specified. Provide the secret value by at most one of --value or + non-interactive stdin (pipe or redirect). OPTIONS: --description string Update the secret description. Pass an empty string to clear it. + --enabled bool + Whether the secret is injected into workspaces. An enabled secret must + keep at least one of --env or --file; pass --enabled=false to stop + injecting it without deleting it. + --env string Name of the workspace environment variable that this secret will set. Pass an empty string to clear it. diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 224928f2c11..dbf0debfcab 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -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 diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 39670fbea45..d99062c3671 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3625,7 +3625,8 @@ CREATE TABLE user_secrets ( 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, - enabled boolean DEFAULT true NOT NULL + 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 ( diff --git a/coderd/database/migrations/000553_user_secrets_enabled.down.sql b/coderd/database/migrations/000556_user_secrets_enabled.down.sql similarity index 100% rename from coderd/database/migrations/000553_user_secrets_enabled.down.sql rename to coderd/database/migrations/000556_user_secrets_enabled.down.sql diff --git a/coderd/database/migrations/000553_user_secrets_enabled.up.sql b/coderd/database/migrations/000556_user_secrets_enabled.up.sql similarity index 55% rename from coderd/database/migrations/000553_user_secrets_enabled.up.sql rename to coderd/database/migrations/000556_user_secrets_enabled.up.sql index 73716258616..20a11905f73 100644 --- a/coderd/database/migrations/000553_user_secrets_enabled.up.sql +++ b/coderd/database/migrations/000556_user_secrets_enabled.up.sql @@ -10,13 +10,21 @@ -- 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. --- The write-time invariant (an enabled secret must have at least one of --- env_name / file_path non-empty) is enforced at the API layer, so no --- CHECK constraint is added here. Disabled secrets may have no targets; --- bulk imports use that state for keys that cannot be env-injected. 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 <> ''); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 82041f41d32..fdabc3a7df1 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -2252,10 +2252,10 @@ func TestMigration000543ChatSearchSchemaBehavior(t *testing.T) { toolMsg.ID, modelOnly.ID, deletedMsg.ID) } -func TestMigration000553UserSecretsEnabled(t *testing.T) { +func TestMigration000556UserSecretsEnabled(t *testing.T) { t.Parallel() - const migrationVersion = 553 + const migrationVersion = 556 sqlDB := testSQLDB(t) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 8a21c366a93..5cc5c9bd1a7 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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) { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 305c149a6b8..a626c9faa1d 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -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) diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index e83864cf5bf..fe5e41b50b4 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -90,6 +90,10 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { 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 @@ -203,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) @@ -438,6 +451,10 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { }}) 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 @@ -568,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): diff --git a/docs/admin/security/secrets.md b/docs/admin/security/secrets.md index 91cfdc5da47..f8276a3ad46 100644 --- a/docs/admin/security/secrets.md +++ b/docs/admin/security/secrets.md @@ -49,7 +49,8 @@ Users can view their public key in their account settings: User secrets are developer-managed values that Coder injects at workspace start. If a user secret targets the same environment variable name or file path as a template-provided variable or file, Coder injects the user secret into that -workspace. User secret values are covered by +workspace. A secret can be disabled, in which case it is stored but not injected +until it is re-enabled. User secret values are covered by [Database Encryption](./database-encryption.md) when it is enabled. See the [User secrets guide](../../user-guides/user-secrets.md). diff --git a/docs/reference/cli/secret_create.md b/docs/reference/cli/secret_create.md index df9086f6930..84e09ec2940 100644 --- a/docs/reference/cli/secret_create.md +++ b/docs/reference/cli/secret_create.md @@ -48,3 +48,12 @@ Name of the workspace environment variable that this secret will set. | Type | string | Workspace file path where this secret will be written. Must start with ~/ or /. + +### --enabled + +| | | +|---------|-------------------| +| Type | bool | +| Default | true | + +Whether the secret is injected into workspaces. An enabled secret must set --env or --file; pass --enabled=false to store a secret without injecting it. diff --git a/docs/reference/cli/secret_list.md b/docs/reference/cli/secret_list.md index 9bffcd6a495..2a93cf659b6 100644 --- a/docs/reference/cli/secret_list.md +++ b/docs/reference/cli/secret_list.md @@ -23,10 +23,10 @@ Secret values are omitted from the output. ### -c, --column -| | | -|---------|---------------------------------------------------------------| -| Type | [created\|name\|updated\|env\|file\|description] | -| Default | name,created,updated,env,file,description | +| | | +|---------|------------------------------------------------------------------------| +| Type | [created\|name\|updated\|env\|file\|enabled\|description] | +| Default | name,created,updated,env,file,enabled,description | Columns to display in table output. diff --git a/docs/reference/cli/secret_update.md b/docs/reference/cli/secret_update.md index 83b03b2b3a5..fd628f03ebe 100644 --- a/docs/reference/cli/secret_update.md +++ b/docs/reference/cli/secret_update.md @@ -12,7 +12,7 @@ coder secret update [flags] ## Description ```console -At least one of --value, --description, --env, or --file must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect). +At least one of --value, --description, --env, --file, or --enabled must be specified. Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect). ``` ## Options @@ -48,3 +48,11 @@ Name of the workspace environment variable that this secret will set. Pass an em | Type | string | Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it. + +### --enabled + +| | | +|------|-------------------| +| Type | bool | + +Whether the secret is injected into workspaces. An enabled secret must keep at least one of --env or --file; pass --enabled=false to stop injecting it without deleting it. diff --git a/docs/user-guides/user-secrets.md b/docs/user-guides/user-secrets.md index 7dd3d8f4d0a..f3be7306758 100644 --- a/docs/user-guides/user-secrets.md +++ b/docs/user-guides/user-secrets.md @@ -11,9 +11,19 @@ Each user secret has: - A value, which contains the sensitive content. - An optional description. - An optional environment variable target, file target, or both. +- An enabled flag that controls whether Coder injects the secret into your + workspaces. -A secret without an environment variable target or file target is stored, but is -not injected into workspaces. +An enabled secret must have at least one of an environment variable target or a +file target. To keep a secret stored without injecting it, disable it +(`enabled = false`) instead of clearing both targets. A create or update that +would leave an enabled secret with no target is rejected with a 400 and +directs you to disable the secret instead. + +Disabled secrets stay visible and editable in the CLI, REST API, and dashboard, +but are not injected into workspaces. Secrets that predate the enabled flag and +had no target were migrated to disabled, so they show as disabled and need a +target before you can enable them. User secrets apply to all workspaces that you own. @@ -39,6 +49,12 @@ time the workspace agent reconnects to Coder, for example after the workspace or the agent restarts. To pick up a change to a secret while a workspace is running, restart the workspace. +Disabling a secret (`coder secret disable`) stops it from being injected from +the next workspace start onward. Running sessions keep values that were already +injected until the agent manifest is refetched, which happens on workspace +restart. Disabling does not remove a file that was already written; the same +"Coder never deletes secret files" rule below applies. + ### Environment variable secrets Coder injects environment variable secrets into every new shell, terminal, @@ -46,11 +62,13 @@ app, SSH session, and startup script that you start in your workspace. Existing shells and processes keep the environment they were given when they started. -| If you... | ...then in your workspace | -|--------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| Create or update an env secret | The change applies after the next workspace start. Until then, your running workspace continues to use the secrets it had when it last started. | -| Rename the env var (`--env NEW_NAME`) | After the next workspace start, new shells get `NEW_NAME` and the old name is no longer set. | -| Clear the env target (`--env ""`) or delete the secret | After the next workspace start, the variable is no longer injected. | +| If you... | ...then in your workspace | +|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create or update an env secret | The change applies after the next workspace start. Until then, your running workspace continues to use the secrets it had when it last started. | +| Rename the env var (`--env NEW_NAME`) | After the next workspace start, new shells get `NEW_NAME` and the old name is no longer set. | +| Clear the env target (`--env ""`) | Only succeeds if the secret keeps its file target or is disabled in the same request; otherwise the request is rejected with a 400. After the next workspace start, the variable is no longer injected. | +| Disable the secret (`coder secret disable`) | After the next workspace start, the variable is no longer injected. Running sessions keep the value until the agent manifest is refetched (workspace restart). | +| Delete the secret | After the next workspace start, the variable is no longer injected. | To pick up a change in a long-running shell or app started after a restart, restart that shell or app. @@ -62,11 +80,13 @@ starts, before any startup scripts run. New parent directories are created as needed. If the file already exists, Coder overwrites the contents and leaves the existing permissions alone. -| If you... | ...then in your workspace | -|----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| -| Create or update a file secret | The file is written or overwritten at the next workspace start. | -| Change the file path (`--file NEW_PATH`) | At the next workspace start, a file is written at `NEW_PATH`. **The file at the previous path stays on disk with its old value.** | -| Clear the file target (`--file ""`) or delete the secret | **The previously-written file stays on disk with its last value.** | +| If you... | ...then in your workspace | +|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Create or update a file secret | The file is written or overwritten at the next workspace start. | +| Change the file path (`--file NEW_PATH`) | At the next workspace start, a file is written at `NEW_PATH`. **The file at the previous path stays on disk with its old value.** | +| Clear the file target (`--file ""`) | Only succeeds if the secret keeps its env target or is disabled in the same request; otherwise the request is rejected with a 400. **The previously-written file stays on disk with its last value.** | +| Disable the secret (`coder secret disable`) | The file is no longer written at the next workspace start. **The previously-written file stays on disk with its last value.** | +| Delete the secret | **The previously-written file stays on disk with its last value.** | > [!IMPORTANT] > Coder never deletes secret files it has written for you. If you remove a @@ -120,7 +140,10 @@ You can create, edit, and delete user secrets from the Coder dashboard: From this page you can add a new secret, update an existing secret's value, description, or environment variable and file targets, and delete secrets you -no longer need. +no longer need. Each row has an enable/disable toggle that controls whether +Coder injects the secret. A secret with no environment variable or file target +cannot be enabled from the dashboard; the toggle is disabled with a tooltip, +mirroring the API invariant that an enabled secret must have a target. The rest of this guide shows the equivalent CLI commands. The same behaviors, limits, and injection rules apply whether you manage secrets from the @@ -194,11 +217,21 @@ want to store a trailing newline: echo -n "$API_KEY" | coder secret create api-key --env API_KEY ``` +### Create a disabled secret + +An enabled secret must set `--env`, `--file`, or both. To store a secret +without injecting it, pass `--enabled=false`. You can add a target and enable +it later with `coder secret enable`. + +```sh +echo -n "$API_KEY" | coder secret create api-key --enabled=false +``` + ## Update a secret Use `coder secret update` to update a secret value, description, environment variable target, or file target. At least one of `--value`, `--description`, -`--env`, or `--file` must be specified. +`--env`, `--file`, or `--enabled` must be specified. ```sh # Update a secret value. @@ -207,10 +240,26 @@ echo -n "$NEW_API_KEY" | coder secret update api-key # Change the environment variable target. coder secret update api-key --env NEW_API_KEY -# Clear the file injection target while keeping the secret. +# Clear the file injection target while keeping the secret. This only +# succeeds because api-key still has an environment variable target; a +# request that clears the last target of an enabled secret is rejected. coder secret update api-key --file "" ``` +### Enable and disable a secret + +Disable a secret to stop injecting it without deleting it, then enable it again +to resume. Enabling a secret that has no target is rejected; add a target +first. + +```sh +# Stop injecting a secret without deleting it. +coder secret disable api-key + +# Resume injection. +coder secret enable api-key +``` + ## List and delete secrets List, show, and delete your secrets with the `coder secret` CLI: @@ -227,7 +276,8 @@ coder secret delete api-key ``` The list and show commands return secret metadata only. They never return the -secret value. +secret value. The `coder secret list` table includes an `enabled` column so you +can see which secrets are currently injected. See [How your secrets reach a workspace](#how-your-secrets-reach-a-workspace) for what happens to running workspaces when you delete a secret.