diff --git a/cli/root.go b/cli/root.go index 8af6b3c96a..ed97bb88f5 100644 --- a/cli/root.go +++ b/cli/root.go @@ -104,6 +104,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.portForward(), r.publickey(), r.resetPassword(), + r.secrets(), r.sharing(), r.state(), r.tasksCommand(), @@ -319,14 +320,9 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err cmd.Walk(func(cmd *serpent.Command) { // TODO: we should really be consistent about naming. if cmd.Name() == "delete" || cmd.Name() == "remove" { - if slices.Contains(cmd.Aliases, "rm") { - merr = errors.Join( - merr, - xerrors.Errorf("command %q shouldn't have alias %q since it's added automatically", cmd.FullName(), "rm"), - ) - return + if !slices.Contains(cmd.Aliases, "rm") { + cmd.Aliases = append(cmd.Aliases, "rm") } - cmd.Aliases = append(cmd.Aliases, "rm") } }) diff --git a/cli/secret.go b/cli/secret.go new file mode 100644 index 0000000000..2fb6d75c4f --- /dev/null +++ b/cli/secret.go @@ -0,0 +1,437 @@ +package cli + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/dustin/go-humanize" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +func (r *RootCmd) secrets() *serpent.Command { + cmd := &serpent.Command{ + Use: "secret", + Aliases: []string{"secrets"}, + Short: "Manage secrets", + Long: FormatExamples( + Example{ + Description: "Create a secret", + Command: "printf %s \"$MYCLI_API_KEY\" | coder secret create api-key --description \"API key for workspace tools\" --env API_KEY --file \"~/.api-key\"", + }, + Example{ + Description: "Update a secret", + Command: "echo -n \"$NEW_SECRET_VALUE\" | coder secret update api-key --description \"Rotated API key\" --env API_KEY --file \"~/.api-key\"", + }, + Example{ + Description: "List your secrets", + Command: "coder secret list", + }, + Example{ + Description: "Show a specific secret", + Command: "coder secret list api-key", + }, + Example{ + Description: "Delete a secret", + Command: "coder secret delete api-key", + }, + ), + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.secretCreate(), + r.secretUpdate(), + r.secretList(), + r.secretDelete(), + }, + } + + return cmd +} + +func (r *RootCmd) secretCreate() *serpent.Command { + var ( + value string + description string + env string + file string + ) + + cmd := &serpent.Command{ + Use: "create ", + Short: "Create a secret", + Long: "Provide the secret value with --value or non-interactive stdin (pipe or redirect).", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + { + Name: "value", + Flag: "value", + Description: "Set the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).", + Value: serpent.StringOf(&value), + }, + { + Name: "description", + Flag: "description", + Description: "Set the secret description.", + Value: serpent.StringOf(&description), + }, + { + Name: "env", + Flag: "env", + Description: "Name of the workspace environment variable that this secret will set.", + Value: serpent.StringOf(&env), + }, + { + Name: "file", + Flag: "file", + Description: "Workspace file path where this secret will be written. Must start with ~/ or /.", + Value: serpent.StringOf(&file), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + resolvedValue, ok, err := secretValue(inv, value) + if err != nil { + return err + } + if !ok { + if isTTYIn(inv) { + return xerrors.New("secret value must be provided with --value or stdin via pipe or redirect") + } + 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{ + Name: inv.Args[0], + Value: resolvedValue, + Description: description, + EnvName: env, + FilePath: file, + }) + if err != nil { + return xerrors.Errorf("create secret %q: %w", inv.Args[0], err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Created secret %s.\n", cliui.Keyword(secret.Name)) + return nil + }, + } + + return cmd +} + +func (r *RootCmd) secretUpdate() *serpent.Command { + var ( + value string + description string + env string + file string + ) + + 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.", + "Provide the secret value by at most one of --value or non-interactive stdin (pipe or redirect).", + }, " "), + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + { + Name: "value", + Flag: "value", + Description: "Update the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect).", + Value: serpent.StringOf(&value), + }, + { + Name: "description", + Flag: "description", + Description: "Update the secret description. Pass an empty string to clear it.", + Value: serpent.StringOf(&description), + }, + { + Name: "env", + Flag: "env", + Description: "Name of the workspace environment variable that this secret will set. Pass an empty string to clear it.", + Value: serpent.StringOf(&env), + }, + { + Name: "file", + Flag: "file", + 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), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + req := codersdk.UpdateUserSecretRequest{} + resolvedValue, ok, err := secretValue(inv, value) + if err != nil { + return err + } + if ok { + req.Value = &resolvedValue + } + if userSetOption(inv, "description") { + req.Description = &description + } + if userSetOption(inv, "env") { + req.EnvName = &env + } + if userSetOption(inv, "file") { + req.FilePath = &file + } + + secret, err := client.UpdateUserSecret(inv.Context(), codersdk.Me, inv.Args[0], req) + if err != nil { + return xerrors.Errorf("update secret %q: %w", inv.Args[0], err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Updated secret %s.\n", cliui.Keyword(secret.Name)) + return nil + }, + } + + return cmd +} + +func secretValue(inv *serpent.Invocation, value string) (string, bool, error) { + valueProvided := userSetOption(inv, "value") + stdinValue, stdinProvided, err := readInvocationStdin(inv) + if err != nil { + return "", false, err + } + + sourceNames := make([]string, 0, 2) + if valueProvided { + sourceNames = append(sourceNames, "--value") + } + if stdinProvided { + sourceNames = append(sourceNames, "stdin") + } + if len(sourceNames) > 1 { + return "", false, xerrors.Errorf("secret value may be provided by only one source, got %s", strings.Join(sourceNames, ", ")) + } + + if valueProvided { + return value, true, nil + } + + if stdinProvided { + warnSuspiciousTrailingNewline(inv.Stderr, stdinValue) + return stdinValue, true, nil + } + + return "", false, nil +} + +func readInvocationStdin(inv *serpent.Invocation) (string, bool, error) { + if isTTYIn(inv) { + return "", false, nil + } + + bytes, err := io.ReadAll(inv.Stdin) + if err != nil { + return "", false, xerrors.Errorf("reading stdin: %w", err) + } + if len(bytes) == 0 { + return "", false, nil + } + + return string(bytes), true, nil +} + +// Shell helpers like echo usually append a line ending to piped stdin. We +// treat a single trailing LF or CRLF as suspicious, but avoid flagging values +// that are clearly multiline. +func hasSuspiciousTrailingNewline(value string) bool { + switch { + case strings.HasSuffix(value, "\r\n"): + trimmed := strings.TrimSuffix(value, "\r\n") + return !strings.ContainsAny(trimmed, "\r\n") + case strings.HasSuffix(value, "\n"): + trimmed := strings.TrimSuffix(value, "\n") + return !strings.ContainsAny(trimmed, "\r\n") + case strings.HasSuffix(value, "\r"): + trimmed := strings.TrimSuffix(value, "\r") + return !strings.ContainsAny(trimmed, "\r\n") + default: + return false + } +} + +func warnSuspiciousTrailingNewline(w io.Writer, value string) { + if !hasSuspiciousTrailingNewline(value) { + return + } + + cliui.Warn(w, "secret value from stdin ends with a trailing newline") +} + +type secretListRow struct { + codersdk.UserSecret `table:"-"` + + Created string `json:"-" table:"created"` + Name string `json:"-" table:"name,default_sort"` + Updated string `json:"-" table:"updated"` + Env string `json:"-" table:"env"` + File string `json:"-" table:"file"` + Description string `json:"-" table:"description"` +} + +func secretListRowFromSecret(secret codersdk.UserSecret) secretListRow { + return secretListRow{ + UserSecret: secret, + Created: humanize.Time(secret.CreatedAt), + Name: secret.Name, + Updated: humanize.Time(secret.UpdatedAt), + Env: secret.EnvName, + File: secret.FilePath, + Description: secret.Description, + } +} + +func (r *RootCmd) secretList() *serpent.Command { + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData( + cliui.TableFormat( + []secretListRow{}, + []string{"name", "created", "updated", "env", "file", "description"}, + ), + func(data any) (any, error) { + switch rows := data.(type) { + case []secretListRow: + return rows, nil + case secretListRow: + return []secretListRow{rows}, nil + default: + return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data) + } + }, + ), + cliui.ChangeFormatterData( + cliui.JSONFormat(), + func(data any) (any, error) { + switch rows := data.(type) { + case []secretListRow: + secrets := make([]codersdk.UserSecret, len(rows)) + for i := range rows { + secrets[i] = rows[i].UserSecret + } + return secrets, nil + case secretListRow: + return []codersdk.UserSecret{rows.UserSecret}, nil + default: + return nil, xerrors.Errorf("expected []secretListRow or secretListRow, got %T", data) + } + }, + ), + ) + + cmd := &serpent.Command{ + Use: "list [name]", + Aliases: []string{"ls"}, + Short: "List secrets, or show one by name", + Long: "Secret values are omitted from the output.", + Middleware: serpent.RequireRangeArgs(0, 1), + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + var data any + if len(inv.Args) == 1 { + secret, err := client.UserSecretByName(inv.Context(), codersdk.Me, inv.Args[0]) + if err != nil { + return xerrors.Errorf("get secret %q: %w", inv.Args[0], err) + } + data = secretListRowFromSecret(secret) + } else { + secrets, err := client.UserSecrets(inv.Context(), codersdk.Me) + if err != nil { + return xerrors.Errorf("list secrets: %w", err) + } + + rows := make([]secretListRow, len(secrets)) + for i := range secrets { + rows[i] = secretListRowFromSecret(secrets[i]) + } + data = rows + } + + out, err := formatter.Format(inv.Context(), data) + if err != nil { + return xerrors.Errorf("format secrets: %w", err) + } + if out == "" { + cliui.Infof(inv.Stderr, "No secrets found.") + return nil + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +func (r *RootCmd) secretDelete() *serpent.Command { + cmd := &serpent.Command{ + Use: "delete ", + Aliases: []string{"remove", "rm"}, + Short: "Delete a secret", + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + ), + Options: serpent.OptionSet{ + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + name := inv.Args[0] + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: fmt.Sprintf("Delete secret %s?", pretty.Sprint(cliui.DefaultStyles.Code, name)), + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + if err = client.DeleteUserSecret(inv.Context(), codersdk.Me, name); err != nil { + return xerrors.Errorf("delete secret %q: %w", name, err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Deleted secret %s at %s.\n", cliui.Keyword(name), cliui.Timestamp(time.Now())) + return nil + }, + } + + return cmd +} diff --git a/cli/secret_internal_test.go b/cli/secret_internal_test.go new file mode 100644 index 0000000000..70b4597feb --- /dev/null +++ b/cli/secret_internal_test.go @@ -0,0 +1,125 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/coder/serpent" +) + +func TestHasSuspiciousTrailingNewline(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + suspicious bool + }{ + {name: "NoTrailingNewline", input: "token", suspicious: false}, + {name: "SingleTrailingLF", input: "token\n", suspicious: true}, + {name: "SingleTrailingCRLF", input: "token\r\n", suspicious: true}, + {name: "SingleTrailingCR", input: "token\r", suspicious: true}, + {name: "MultilineValue", input: "line1\nline2\n", suspicious: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tt.suspicious, hasSuspiciousTrailingNewline(tt.input)) + }) + } +} + +func TestReadInvocationStdin(t *testing.T) { + t.Parallel() + + t.Run("ZeroBytesRead", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader(""), nil) + + got, provided, err := readInvocationStdin(inv) + require.NoError(t, err) + require.False(t, provided) + require.Empty(t, got) + }) + + t.Run("StringRead", func(t *testing.T) { + t.Parallel() + + inv := newSecretTestInvocation(t, strings.NewReader("token"), nil) + + got, provided, err := readInvocationStdin(inv) + require.NoError(t, err) + require.True(t, provided) + require.Equal(t, "token", got) + }) +} + +func TestTrailingNewlineWarnings(t *testing.T) { + t.Parallel() + + t.Run("WarnSuspiciousValue", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + warnSuspiciousTrailingNewline(&stderr, "token\n") + require.Contains(t, stderr.String(), "secret value from stdin ends with a trailing newline") + }) + + t.Run("DoesNotWarnForMultiline", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + warnSuspiciousTrailingNewline(&stderr, "line1\nline2\n") + require.Empty(t, stderr.String()) + }) + + t.Run("SecretValueWarnsAndPreservesValue", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + inv := newSecretTestInvocation(t, strings.NewReader("token\n"), &stderr) + + got, ok, err := secretValue(inv, "") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "token\n", got) + require.Contains(t, stderr.String(), "secret value from stdin ends with a trailing newline") + }) + + t.Run("SecretValueDoesNotWarnForMultiline", func(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + inv := newSecretTestInvocation(t, strings.NewReader("line1\nline2\n"), &stderr) + + got, ok, err := secretValue(inv, "") + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "line1\nline2\n", got) + require.Empty(t, stderr.String()) + }) +} + +func newSecretTestInvocation(t *testing.T, stdin io.Reader, stderr io.Writer) *serpent.Invocation { + t.Helper() + + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + if stderr == nil { + stderr = io.Discard + } + inv := (&serpent.Invocation{ + Stdin: stdin, + Stderr: stderr, + Command: &serpent.Command{}, + Args: []string{"api-key"}, + }).WithTestParsedFlags(t, flags) + return inv +} diff --git a/cli/secret_test.go b/cli/secret_test.go new file mode 100644 index 0000000000..3cbb6b89b8 --- /dev/null +++ b/cli/secret_test.go @@ -0,0 +1,589 @@ +package cli_test + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +func TestSecretCreate(t *testing.T) { + t.Parallel() + + t.Run("MissingValue", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "create", "api-key") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") + }) + + t.Run("MissingValueOnTTY", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "--force-tty", "secret", "create", "api-key") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value must be provided with --value or stdin via pipe or redirect") + }) + + t.Run("SuccessWithValueFlag", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--value", "super-secret-value", + "--description", "API key for workspace tools", + "--env", "API_KEY", + "--file", "~/.api-key", + ) + 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(), "api-key") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "api-key") + require.NoError(t, err) + require.Equal(t, "api-key", secret.Name) + require.Equal(t, "API key for workspace tools", secret.Description) + require.Equal(t, "API_KEY", secret.EnvName) + require.Equal(t, "~/.api-key", secret.FilePath) + }) + + t.Run("ValueFlagConflictsWithStdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--value", "super-secret-value", + ) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("different-value") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value may be provided by only one source, got --value, stdin") + }) + + t.Run("SuccessWithStdin", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--description", "API key for workspace tools", + "--env", "API_KEY", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("super-secret-value") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "api-key") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "api-key") + require.NoError(t, err) + require.Equal(t, "api-key", secret.Name) + require.Equal(t, "API key for workspace tools", secret.Description) + require.Equal(t, "API_KEY", secret.EnvName) + }) + + t.Run("StdinTrailingNewlineWarnsAndPreservesValue", func(t *testing.T) { + t.Parallel() + + ownerClient, db := coderdtest.NewWithDatabase(t, nil) + firstUser := coderdtest.CreateFirstUser(t, ownerClient) + client, user := coderdtest.CreateAnotherUser(t, ownerClient, firstUser.OrganizationID) + + inv, root := clitest.New( + t, + "secret", + "create", + "api-key", + "--description", "API key for workspace tools", + "--env", "API_KEY", + ) + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("super-secret-value\n") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "api-key") + require.Contains(t, output.Stderr(), "secret value from stdin ends with a trailing newline") + + secret, err := db.GetUserSecretByUserIDAndName( + dbauthz.AsSystemRestricted(ctx), + database.GetUserSecretByUserIDAndNameParams{ + UserID: user.ID, + Name: "api-key", + }, + ) + require.NoError(t, err) + require.Equal(t, "super-secret-value\n", secret.Value) + }) + + t.Run("EmptyStdinIsNotProvided", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "create", "api-key") + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("") + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value must be provided by exactly one of --value or non-interactive stdin (pipe or redirect)") + }) +} + +func TestSecretUpdate(t *testing.T) { + t.Parallel() + + t.Run("ServerValidationError", 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", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "update", "my-secret") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "At least one field must be provided") + }) + + t.Run("AllowsClearingFields", 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", + Description: "original description", + EnvName: "MY_SECRET", + FilePath: "~/.my-secret", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--value", "rotated-secret", + "--description", "", + "--env", "", + "--file", "", + ) + 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(), "my-secret") + + secret, err := client.UserSecretByName(ctx, codersdk.Me, "my-secret") + require.NoError(t, err) + require.Equal(t, "", secret.Description) + require.Equal(t, "", secret.EnvName) + require.Equal(t, "", secret.FilePath) + }) + + t.Run("UpdatesValueFromEmptyFlag", 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", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--value", "", + ) + 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(), "my-secret") + }) + + t.Run("UpdatesValueFromStdin", 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", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "update", "my-secret") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("rotated-secret") + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + require.Contains(t, output.Stdout(), "my-secret") + }) + + t.Run("ValueFlagConflictsWithStdin", 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", + }) + require.NoError(t, err) + + inv, root := clitest.New( + t, + "secret", + "update", + "my-secret", + "--value", "rotated-secret", + ) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("different-value") + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.ErrorContains(t, err, "secret value may be provided by only one source, got --value, stdin") + }) +} + +func TestSecretList(t *testing.T) { + t.Parallel() + + t.Run("TableOutput", 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: "tool-config", + Value: "config-value", + Description: "Tool configuration", + FilePath: "~/.config/tool/config.json", + }) + require.NoError(t, err) + _, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + out := output.Stdout() + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "CREATED") + assert.Contains(t, out, "UPDATED") + assert.Contains(t, out, "ENV") + assert.Contains(t, out, "FILE") + assert.Contains(t, out, "DESCRIPTION") + assert.Contains(t, out, "service-token") + assert.Contains(t, out, "SERVICE_TOKEN") + assert.Contains(t, out, "tool-config") + assert.Contains(t, out, "~/.config/tool/config.json") + }) + + t.Run("JSONOutput", 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", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list", "--output=json") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + var got []codersdk.UserSecret + require.NoError(t, json.Unmarshal([]byte(output.Stdout()), &got)) + require.Len(t, got, 1) + require.Equal(t, created, got[0]) + }) + + t.Run("SingleSecretTableOutput", 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: "tool-config", + Value: "config-value", + Description: "Tool configuration", + FilePath: "~/.config/tool/config.json", + }) + require.NoError(t, err) + _, err = client.CreateUserSecret(setupCtx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "service-token", + Value: "service-token-value", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list", "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) + + out := output.Stdout() + assert.Contains(t, out, "NAME") + assert.Contains(t, out, "CREATED") + assert.Contains(t, out, "UPDATED") + assert.Contains(t, out, "ENV") + assert.Contains(t, out, "FILE") + assert.Contains(t, out, "DESCRIPTION") + assert.Contains(t, out, "service-token") + assert.Contains(t, out, "SERVICE_TOKEN") + assert.NotContains(t, out, "tool-config") + assert.NotContains(t, out, "~/.config/tool/config.json") + }) + + t.Run("SingleSecretJSONOutput", 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", + Description: "Service access token", + EnvName: "SERVICE_TOKEN", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "list", "service-token", "--output=json") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + var got []codersdk.UserSecret + require.NoError(t, json.Unmarshal([]byte(output.Stdout()), &got)) + require.Len(t, got, 1) + require.Equal(t, created, got[0]) + }) + + t.Run("EmptyState", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "list") + output := clitest.Capture(inv) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + assert.Contains(t, output.Stderr(), "No secrets found.") + }) +} + +func TestSecretDelete(t *testing.T) { + t.Parallel() + + t.Run("Success", 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: "service-token", + Value: "service-token-value", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "delete", "service-token") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + waiter := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Delete secret") + pty.ExpectMatchContext(ctx, "service-token") + pty.WriteLine("yes") + pty.ExpectMatchContext(ctx, "Deleted secret") + + require.NoError(t, waiter.Wait()) + + _, err = client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("YesSkipsPrompt", 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: "service-token", + Value: "service-token-value", + }) + require.NoError(t, err) + + inv, root := clitest.New(t, "secret", "delete", "service-token", "--yes") + 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(), "Deleted secret") + require.NotContains(t, output.Stdout(), "Delete secret") + require.Empty(t, output.Stderr()) + + _, err = client.UserSecretByName(setupCtx, codersdk.Me, "service-token") + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "secret", "delete", "missing-secret") + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitMedium) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + waiter := clitest.StartWithWaiter(t, inv) + pty.ExpectMatchContext(ctx, "Delete secret") + pty.ExpectMatchContext(ctx, "missing-secret") + pty.WriteLine("yes") + + err := waiter.Wait() + require.ErrorContains(t, err, `delete secret "missing-secret"`) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index ea4ecdc8c6..5fa43d2ebf 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -43,6 +43,7 @@ SUBCOMMANDS: password restart Restart a workspace schedule Schedule automated start and stop times for workspaces + secret Manage secrets server Start a Coder server show Display details of a workspace's resources and agents speedtest Run upload and download tests from your machine to a diff --git a/cli/testdata/coder_secret_--help.golden b/cli/testdata/coder_secret_--help.golden new file mode 100644 index 0000000000..45447c96e3 --- /dev/null +++ b/cli/testdata/coder_secret_--help.golden @@ -0,0 +1,39 @@ +coder v0.0.0-devel + +USAGE: + coder secret + + Manage secrets + + Aliases: secrets + + - Create a secret: + + $ printf %s "$MYCLI_API_KEY" | coder secret create api-key --description + "API key for workspace tools" --env API_KEY --file "~/.api-key" + + - Update a secret: + + $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description + "Rotated API key" --env API_KEY --file "~/.api-key" + + - List your secrets: + + $ coder secret list + + - Show a specific secret: + + $ coder secret list api-key + + - Delete a secret: + + $ 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 + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_create_--help.golden b/cli/testdata/coder_secret_create_--help.golden new file mode 100644 index 0000000000..0a5d53d119 --- /dev/null +++ b/cli/testdata/coder_secret_create_--help.golden @@ -0,0 +1,27 @@ +coder v0.0.0-devel + +USAGE: + coder secret create [flags] + + Create a secret + + Provide the secret value with --value or non-interactive stdin (pipe or + redirect). + +OPTIONS: + --description string + Set the secret description. + + --env string + Name of the workspace environment variable that this secret will set. + + --file string + Workspace file path where this secret will be written. Must start with + ~/ or /. + + --value string + Set the secret value. For security reasons, prefer non-interactive + stdin (pipe or redirect). + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_delete_--help.golden b/cli/testdata/coder_secret_delete_--help.golden new file mode 100644 index 0000000000..a65cf3bb38 --- /dev/null +++ b/cli/testdata/coder_secret_delete_--help.golden @@ -0,0 +1,15 @@ +coder v0.0.0-devel + +USAGE: + coder secret delete [flags] + + Delete a secret + + Aliases: remove, rm + +OPTIONS: + -y, --yes bool + Bypass confirmation prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_list_--help.golden b/cli/testdata/coder_secret_list_--help.golden new file mode 100644 index 0000000000..803968373c --- /dev/null +++ b/cli/testdata/coder_secret_list_--help.golden @@ -0,0 +1,20 @@ +coder v0.0.0-devel + +USAGE: + coder secret list [flags] [name] + + List secrets, or show one by name + + Aliases: ls + + Secret values are omitted from the output. + +OPTIONS: + -c, --column [created|name|updated|env|file|description] (default: name,created,updated,env,file,description) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_secret_update_--help.golden b/cli/testdata/coder_secret_update_--help.golden new file mode 100644 index 0000000000..6864ca22da --- /dev/null +++ b/cli/testdata/coder_secret_update_--help.golden @@ -0,0 +1,29 @@ +coder v0.0.0-devel + +USAGE: + coder secret update [flags] + + 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). + +OPTIONS: + --description string + Update the secret description. Pass an empty string to clear it. + + --env string + Name of the workspace environment variable that this secret will set. + Pass an empty string to clear it. + + --file string + Workspace file path where this secret will be written. Must start with + ~/ or /. Pass an empty string to clear it. + + --value string + Update the secret value. For security reasons, prefer non-interactive + stdin (pipe or redirect). + +——— +Run `coder --help` for a list of global options. diff --git a/docs/admin/security/secrets.md b/docs/admin/security/secrets.md index 25ff1a6467..ed658b0475 100644 --- a/docs/admin/security/secrets.md +++ b/docs/admin/security/secrets.md @@ -42,6 +42,52 @@ Users can view their public key in their account settings: > SSH keys are never stored in Coder workspaces, and are fetched only when > SSH is invoked. The keys are held in-memory and never written to disk. +## User Secrets + +User secrets let each user store their own secret values in Coder and make +them available in workspaces without adding those values to template code. +They are a good fit for per-user credentials such as API keys, cloud +credentials, or other values that should follow a user across workspaces. + +Use the CLI to create and manage user secrets: + +```sh +# Create a secret from stdin and inject it into workspaces as an environment +# variable. +printf %s "$API_KEY" | coder secret create api-key \ + --description "API key for workspace tools" \ + --env API_KEY + +# Create a secret from stdin and inject it into a file in your workspace. +printf %s "$TOOL_CONFIG_CONTENTS" | coder secret create tool-config \ + --description "Tool configuration" \ + --file ~/.config/tool/config.json + +# List all of your secrets. +coder secret list + +# Show a single secret by name. +coder secret list api-key + +# Delete a secret you no longer need. +coder secret delete api-key +``` + +Use `--env` to inject a secret into your workspaces as an environment +variable. Use `--file` to inject it as a file in the workspace. File +paths must start with `~/` or `/`. Provide a secret value with `--value`, +or non-interactive stdin (pipe or redirect). Stdin is read verbatim. This +means `echo "$API_KEY" | ...` usually adds a trailing newline to the stored +value. Prefer `printf %s "$API_KEY" | ...` or `echo -n "$API_KEY" | ...` +when you do not want that newline. + +You can update a secret later with `coder secret update`, including rotating +the value or clearing an injection target by passing an empty string. Use +`coder secret delete` to remove a secret entirely. The secret value itself is +never returned by the API or CLI list output. For full command details, see +[`coder secret`](../../reference/cli/secret.md) and the +[Secrets API reference](../../reference/api/secrets.md). + ## Dynamic Secrets Dynamic secrets are attached to the workspace lifecycle and automatically diff --git a/docs/manifest.json b/docs/manifest.json index 30b4795a21..e7b20d231f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -2016,6 +2016,31 @@ "description": "Edit workspace stop schedule", "path": "reference/cli/schedule_stop.md" }, + { + "title": "secret", + "description": "Manage secrets", + "path": "reference/cli/secret.md" + }, + { + "title": "secret create", + "description": "Create a secret", + "path": "reference/cli/secret_create.md" + }, + { + "title": "secret update", + "description": "Update a secret", + "path": "reference/cli/secret_update.md" + }, + { + "title": "secret list", + "description": "List secrets, or show one by name", + "path": "reference/cli/secret_list.md" + }, + { + "title": "secret delete", + "description": "Delete a secret", + "path": "reference/cli/secret_delete.md" + }, { "title": "server", "description": "Start a Coder server", diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index de3a5c2cb8..fc242c85b6 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -35,6 +35,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | | [publickey](./publickey.md) | Output your Coder public key used for Git operations | | [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [secret](./secret.md) | Manage secrets | | [state](./state.md) | Manually manage Terraform state to fix broken workspaces | | [task](./task.md) | Manage tasks | | [templates](./templates.md) | Manage templates | diff --git a/docs/reference/cli/secret.md b/docs/reference/cli/secret.md new file mode 100644 index 0000000000..7d022f3dfd --- /dev/null +++ b/docs/reference/cli/secret.md @@ -0,0 +1,47 @@ + +# secret + +Manage secrets + +Aliases: + +* secrets + +## Usage + +```console +coder secret +``` + +## Description + +```console + - Create a secret: + + $ printf %s "$MYCLI_API_KEY" | coder secret create api-key --description "API key for workspace tools" --env API_KEY --file "~/.api-key" + + - Update a secret: + + $ echo -n "$NEW_SECRET_VALUE" | coder secret update api-key --description "Rotated API key" --env API_KEY --file "~/.api-key" + + - List your secrets: + + $ coder secret list + + - Show a specific secret: + + $ coder secret list api-key + + - Delete a secret: + + $ coder secret delete api-key +``` + +## 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 | diff --git a/docs/reference/cli/secret_create.md b/docs/reference/cli/secret_create.md new file mode 100644 index 0000000000..df9086f693 --- /dev/null +++ b/docs/reference/cli/secret_create.md @@ -0,0 +1,50 @@ + +# secret create + +Create a secret + +## Usage + +```console +coder secret create [flags] +``` + +## Description + +```console +Provide the secret value with --value or non-interactive stdin (pipe or redirect). +``` + +## Options + +### --value + +| | | +|------|---------------------| +| Type | string | + +Set the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect). + +### --description + +| | | +|------|---------------------| +| Type | string | + +Set the secret description. + +### --env + +| | | +|------|---------------------| +| Type | string | + +Name of the workspace environment variable that this secret will set. + +### --file + +| | | +|------|---------------------| +| Type | string | + +Workspace file path where this secret will be written. Must start with ~/ or /. diff --git a/docs/reference/cli/secret_delete.md b/docs/reference/cli/secret_delete.md new file mode 100644 index 0000000000..bc493d907c --- /dev/null +++ b/docs/reference/cli/secret_delete.md @@ -0,0 +1,25 @@ + +# secret delete + +Delete a secret + +Aliases: + +* remove +* rm + +## Usage + +```console +coder secret delete [flags] +``` + +## Options + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass confirmation prompts. diff --git a/docs/reference/cli/secret_list.md b/docs/reference/cli/secret_list.md new file mode 100644 index 0000000000..9bffcd6a49 --- /dev/null +++ b/docs/reference/cli/secret_list.md @@ -0,0 +1,40 @@ + +# secret list + +List secrets, or show one by name + +Aliases: + +* ls + +## Usage + +```console +coder secret list [flags] [name] +``` + +## Description + +```console +Secret values are omitted from the output. +``` + +## Options + +### -c, --column + +| | | +|---------|---------------------------------------------------------------| +| Type | [created\|name\|updated\|env\|file\|description] | +| Default | name,created,updated,env,file,description | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/secret_update.md b/docs/reference/cli/secret_update.md new file mode 100644 index 0000000000..83b03b2b3a --- /dev/null +++ b/docs/reference/cli/secret_update.md @@ -0,0 +1,50 @@ + +# secret update + +Update a secret + +## Usage + +```console +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). +``` + +## Options + +### --value + +| | | +|------|---------------------| +| Type | string | + +Update the secret value. For security reasons, prefer non-interactive stdin (pipe or redirect). + +### --description + +| | | +|------|---------------------| +| Type | string | + +Update the secret description. Pass an empty string to clear it. + +### --env + +| | | +|------|---------------------| +| Type | string | + +Name of the workspace environment variable that this secret will set. Pass an empty string to clear it. + +### --file + +| | | +|------|---------------------| +| Type | string | + +Workspace file path where this secret will be written. Must start with ~/ or /. Pass an empty string to clear it.