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

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

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

Support spans the REST API, SDK, CLI, dashboard, and audit log.
This commit is contained in:
Zach
2026-07-28 09:58:33 -06:00
committed by GitHub
parent 3c61a9a939
commit 85984ff142
56 changed files with 1391 additions and 186 deletions
+104 -4
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"strconv"
"strings"
"time"
@@ -10,6 +11,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 +50,8 @@ func (r *RootCmd) secrets() *serpent.Command {
Children: []*serpent.Command{
r.secretCreate(),
r.secretUpdate(),
r.secretEnable(),
r.secretDisable(),
r.secretList(),
r.secretDelete(),
},
@@ -62,6 +66,7 @@ func (r *RootCmd) secretCreate() *serpent.Command {
description string
env string
file string
enabled bool
)
cmd := &serpent.Command{
@@ -96,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)
@@ -114,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)
}
@@ -139,13 +156,14 @@ func (r *RootCmd) secretUpdate() *serpent.Command {
description string
env string
file string
enabled bool
)
cmd := &serpent.Command{
Use: "update <name>",
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(
@@ -176,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)
@@ -200,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 {
@@ -295,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"`
}
@@ -306,16 +334,88 @@ 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,
}
}
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 <name>", 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(
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) {
+140 -14
View File
@@ -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())
})
}
+6 -4
View File
@@ -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.
+5
View File
@@ -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.
+9
View File
@@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder secret disable <name>
Disable a secret without removing it
———
Run `coder --help` for a list of global options.
+9
View File
@@ -0,0 +1,9 @@
coder v0.0.0-devel
USAGE:
coder secret enable <name>
Enable a secret so it is injected into workspaces
———
Run `coder --help` for a list of global options.
+1 -1
View File
@@ -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)
+8 -3
View File
@@ -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.