From 59e21951c536e45404b5c8e34c5000bf09a9e1a1 Mon Sep 17 00:00:00 2001 From: Devansh Thakur Date: Wed, 26 Aug 2026 17:09:01 +0200 Subject: [PATCH 1/2] fix(intake): enable interactive and file-based password input for intake user --- .../cmd/beta/intake/user/create/create.go | 46 ++++++-- .../beta/intake/user/create/create_test.go | 5 +- .../cmd/beta/intake/user/update/update.go | 100 +++++++++++++++--- 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/internal/cmd/beta/intake/user/create/create.go b/internal/cmd/beta/intake/user/create/create.go index 83fc8ffb9..79c622795 100644 --- a/internal/cmd/beta/intake/user/create/create.go +++ b/internal/cmd/beta/intake/user/create/create.go @@ -3,6 +3,7 @@ package create import ( "context" "fmt" + "strings" "github.com/spf13/cobra" intake "github.com/stackitcloud/stackit-sdk-go/services/intake/v1betaapi" @@ -49,11 +50,14 @@ func NewCmd(p *types.CmdParams) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Create a new Intake User with required parameters`, - `$ stackit beta intake user create --display-name intake-user --intake-id xxx --password "SuperSafepass123\!"`), + `Create a new Intake User. The password is entered interactively in the terminal`, + `$ stackit beta intake user create --display-name intake-user --intake-id xxx`), + examples.NewExample( + `Create a new Intake User providing the password from a file`, + `$ stackit beta intake user create --display-name intake-user --intake-id xxx --password @./secret.txt`), examples.NewExample( `Create a new Intake User for the dead-letter queue with labels`, - `$ stackit beta intake user create --display-name dlq-user --intake-id xxx --password "SuperSafepass123\!" --type dead-letter --labels "env=prod"`), + `$ stackit beta intake user create --display-name dlq-user --intake-id xxx --password @./secret.txt --type dead-letter --labels "env=prod"`), ), RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() @@ -109,12 +113,12 @@ func configureFlags(cmd *cobra.Command, params *types.CmdParams) { cmd.Flags().String(displayNameFlag, "", "Display name") cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "The UUID of the Intake to associate the user with") password := flags.SecretFlag(passwordFlag, params) - cmd.Flags().Var(password, passwordFlag, password.Usage()+" Must contain lower, upper, number, and special characters (min 12 chars)") + cmd.Flags().Var(password, passwordFlag, password.Usage()+" Must contain lower, upper, digits, and special characters (min 12 chars).") cmd.Flags().String(userTypeFlag, string(intake.USERTYPE_INTAKE), "Type of user. One of 'intake' (default) or 'dead-letter'") cmd.Flags().String(descriptionFlag, "", "Description") cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas") - err := flags.MarkFlagsRequired(cmd, displayNameFlag, intakeIdFlag, passwordFlag) + err := flags.MarkFlagsRequired(cmd, displayNameFlag, intakeIdFlag) cobra.CheckErr(err) } @@ -124,11 +128,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return nil, &cliErr.ProjectIdError{} } + password, err := parsePassword(p, cmd) + if err != nil { + return nil, err + } + model := inputModel{ GlobalFlagModel: globalFlags, DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), IntakeId: flags.FlagToStringPointer(p, cmd, intakeIdFlag), - Password: flags.SecretFlagToStringPointer(p, cmd, passwordFlag), + Password: password, UserType: flags.FlagToStringPointer(p, cmd, userTypeFlag), Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), @@ -138,6 +147,31 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { return &model, nil } +func parsePassword(p *print.Printer, cmd *cobra.Command) (*string, error) { + if cmd.Flag(passwordFlag).Changed { + val, err := cmd.Flags().GetString(passwordFlag) + if err != nil { + return nil, fmt.Errorf("reading password: %w", err) + } + val = strings.TrimRight(val, "\r\n") + if val == "" { + return nil, fmt.Errorf("the provided password (or secret file) is empty") + } + return &val, nil + } + + password := flags.SecretFlagToStringPointer(p, cmd, passwordFlag) + if password != nil { + trimmed := strings.TrimRight(*password, "\r\n") + if trimmed == "" { + return nil, fmt.Errorf("password cannot be empty") + } + return &trimmed, nil + } + + return nil, nil +} + func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeUserRequest { req := apiClient.DefaultAPI.CreateIntakeUser(ctx, model.ProjectId, model.Region, *model.IntakeId) diff --git a/internal/cmd/beta/intake/user/create/create_test.go b/internal/cmd/beta/intake/user/create/create_test.go index e8babf6d0..b1b308959 100644 --- a/internal/cmd/beta/intake/user/create/create_test.go +++ b/internal/cmd/beta/intake/user/create/create_test.go @@ -152,7 +152,10 @@ func TestParseInput(t *testing.T) { flagValues: fixtureFlagValues(func(flagValues map[string]string) { delete(flagValues, passwordFlag) }), - isValid: false, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Password = nil + }), }, { description: "required fields only", diff --git a/internal/cmd/beta/intake/user/update/update.go b/internal/cmd/beta/intake/user/update/update.go index 4e1fc9d73..16b7d36f9 100644 --- a/internal/cmd/beta/intake/user/update/update.go +++ b/internal/cmd/beta/intake/user/update/update.go @@ -3,6 +3,8 @@ package update import ( "context" "fmt" + "io/fs" + "strings" "github.com/spf13/cobra" intake "github.com/stackitcloud/stackit-sdk-go/services/intake/v1betaapi" @@ -29,8 +31,48 @@ const ( passwordFlag = "password" userTypeFlag = "type" labelsFlag = "labels" + + interactivePasswordPlaceholder = "__INTERACTIVE__" ) +type secretUpdateFlag struct { + printer *print.Printer + fs fs.FS + value string + isPrompt bool +} + +func (f *secretUpdateFlag) String() string { + return f.value +} + +func (f *secretUpdateFlag) Set(value string) error { + if value == interactivePasswordPlaceholder { + f.isPrompt = true + return nil + } + if strings.HasPrefix(value, "@") { + path := strings.Trim(value[1:], `"'`) + bytes, err := fs.ReadFile(f.fs, path) + if err != nil { + return fmt.Errorf("reading secret %s: %w", passwordFlag, err) + } + val := strings.TrimRight(string(bytes), "\r\n") + if val == "" { + return fmt.Errorf("the provided secret file %q is empty", path) + } + f.value = val + return nil + } + f.printer.Warn("Passing a secret value on the command line is insecure and deprecated. This usage will stop working October 2026.\n") + f.value = value + return nil +} + +func (f *secretUpdateFlag) Type() string { + return "string" +} + type inputModel struct { *globalflags.GlobalFlagModel IntakeId string @@ -43,6 +85,11 @@ type inputModel struct { } func NewCmd(p *types.CmdParams) *cobra.Command { + password := &secretUpdateFlag{ + printer: p.Printer, + fs: p.Fs, + } + cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", userIdArg), Short: "Updates an Intake User", @@ -53,8 +100,11 @@ func NewCmd(p *types.CmdParams) *cobra.Command { `Update the display name of an Intake User`, `$ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name"`), examples.NewExample( - `Update the password and description for an Intake User`, - `$ stackit beta intake user update xxx --intake-id yyy --password "NewSecret123\!" --description "Updated description"`), + `Update the password interactively for an Intake User`, + `$ stackit beta intake user update xxx --intake-id yyy --password`), + examples.NewExample( + `Update the password and description for an Intake User from a file`, + `$ stackit beta intake user update xxx --intake-id yyy --password @./secret.txt --description "Updated description"`), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -82,7 +132,6 @@ func NewCmd(p *types.CmdParams) *cobra.Command { _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) return err }) - if err != nil { return fmt.Errorf("wait for STACKIT Intake User update: %w", err) } @@ -91,16 +140,16 @@ func NewCmd(p *types.CmdParams) *cobra.Command { return outputResult(p.Printer, model, resp) }, } - configureFlags(cmd, p) + configureFlags(cmd, password) return cmd } -func configureFlags(cmd *cobra.Command, p *types.CmdParams) { +func configureFlags(cmd *cobra.Command, password *secretUpdateFlag) { cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") cmd.Flags().String(displayNameFlag, "", "Display name") cmd.Flags().String(descriptionFlag, "", "Description") - password := flags.SecretFlag(passwordFlag, p) - cmd.Flags().Var(password, passwordFlag, password.Usage()+" Must contain lower, upper, number, and special characters (min 12 chars)") + cmd.Flags().Var(password, passwordFlag, "Password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). If provided without a value, you will be prompted interactively. Must contain lower, upper, digits, and special characters (min 12 chars).") + cmd.Flags().Lookup(passwordFlag).NoOptDefVal = interactivePasswordPlaceholder cmd.Flags().String(userTypeFlag, "", "Type of user. One of 'intake' or 'dead-letter'") cmd.Flags().StringToString(labelsFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) @@ -116,13 +165,18 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu return nil, &cliErr.ProjectIdError{} } + password, err := parsePassword(p, cmd) + if err != nil { + return nil, err + } + model := &inputModel{ GlobalFlagModel: globalFlags, IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), UserId: userId, DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), - Password: flags.SecretFlagToStringPointer(p, cmd, passwordFlag), + Password: password, UserType: flags.FlagToStringPointer(p, cmd, userTypeFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), } @@ -135,6 +189,29 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu return model, nil } +func parsePassword(p *print.Printer, cmd *cobra.Command) (*string, error) { + flag := cmd.Flag(passwordFlag) + if flag == nil || !flag.Changed { + return nil, nil + } + if secretFlag, ok := flag.Value.(*secretUpdateFlag); ok && secretFlag.isPrompt { + input, err := p.PromptForPassword("enter new password: ") + if err != nil { + return nil, fmt.Errorf("prompt for password: %w", err) + } + input = strings.TrimRight(input, "\r\n") + if input == "" { + return nil, fmt.Errorf("password cannot be empty") + } + return &input, nil + } + val := strings.TrimRight(flag.Value.String(), "\r\n") + if val == "" { + return nil, fmt.Errorf("the provided password (or secret file) is empty") + } + return &val, nil +} + func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeUserRequest { req := apiClient.DefaultAPI.UpdateIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) @@ -156,16 +233,11 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIC func outputResult(p *print.Printer, model *inputModel, resp *intake.IntakeUserResponse) error { return p.OutputResult(model.OutputFormat, resp, func() error { - if resp == nil { - p.Outputf("Triggered update of Intake User for intake %q, but no user ID was returned.\n", model.IntakeId) - return nil - } - operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Outputf("%s Intake User for intake %q. User ID: %s\n", operationState, model.IntakeId, resp.Id) + p.Outputf("%s Intake User %s\n", operationState, model.UserId) return nil }) } From 3eae549b19dbe68bebb637610f137b09f3404990 Mon Sep 17 00:00:00 2001 From: Devansh Thakur Date: Wed, 26 Aug 2026 17:27:02 +0200 Subject: [PATCH 2/2] make generate-docs --- docs/stackit_beta_intake_user_create.md | 11 +++++++---- docs/stackit_beta_intake_user_update.md | 21 ++++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/stackit_beta_intake_user_create.md b/docs/stackit_beta_intake_user_create.md index fd7b6a93f..320acea7c 100644 --- a/docs/stackit_beta_intake_user_create.md +++ b/docs/stackit_beta_intake_user_create.md @@ -13,11 +13,14 @@ stackit beta intake user create [flags] ### Examples ``` - Create a new Intake User with required parameters - $ stackit beta intake user create --display-name intake-user --intake-id xxx --password "SuperSafepass123\!" + Create a new Intake User. The password is entered interactively in the terminal + $ stackit beta intake user create --display-name intake-user --intake-id xxx + + Create a new Intake User providing the password from a file + $ stackit beta intake user create --display-name intake-user --intake-id xxx --password @./secret.txt Create a new Intake User for the dead-letter queue with labels - $ stackit beta intake user create --display-name dlq-user --intake-id xxx --password "SuperSafepass123\!" --type dead-letter --labels "env=prod" + $ stackit beta intake user create --display-name dlq-user --intake-id xxx --password @./secret.txt --type dead-letter --labels "env=prod" ``` ### Options @@ -28,7 +31,7 @@ stackit beta intake user create [flags] -h, --help Help for "stackit beta intake user create" --intake-id string The UUID of the Intake to associate the user with --labels stringToString Labels in key=value format, separated by commas (default []) - --password string Password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty. Must contain lower, upper, number, and special characters (min 12 chars) + --password string Password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty. Must contain lower, upper, digits, and special characters (min 12 chars). --type string Type of user. One of 'intake' (default) or 'dead-letter' (default "intake") ``` diff --git a/docs/stackit_beta_intake_user_update.md b/docs/stackit_beta_intake_user_update.md index c33b57d06..cb01e2616 100644 --- a/docs/stackit_beta_intake_user_update.md +++ b/docs/stackit_beta_intake_user_update.md @@ -16,20 +16,23 @@ stackit beta intake user update USER_ID [flags] Update the display name of an Intake User $ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name" - Update the password and description for an Intake User - $ stackit beta intake user update xxx --intake-id yyy --password "NewSecret123\!" --description "Updated description" + Update the password interactively for an Intake User + $ stackit beta intake user update xxx --intake-id yyy --password + + Update the password and description for an Intake User from a file + $ stackit beta intake user update xxx --intake-id yyy --password @./secret.txt --description "Updated description" ``` ### Options ``` - --description string Description - --display-name string Display name - -h, --help Help for "stackit beta intake user update" - --intake-id string Intake ID - --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) - --password string Password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). Will be read from stdin when empty. Must contain lower, upper, number, and special characters (min 12 chars) - --type string Type of user. One of 'intake' or 'dead-letter' + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user update" + --intake-id string Intake ID + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --password string[="__INTERACTIVE__"] Password. Can be a string (deprecated) or a file path, if prefixed with '@' (example: @./secret.txt). If provided without a value, you will be prompted interactively. Must contain lower, upper, digits, and special characters (min 12 chars). + --type string Type of user. One of 'intake' or 'dead-letter' ``` ### Options inherited from parent commands