mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add enable/disable support for user secrets (#27537)
Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log.
This commit is contained in:
+104
-4
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user