diff --git a/docs/stackit_secrets-manager_user.md b/docs/stackit_secrets-manager_user.md index 33fc70ac7..606af1500 100644 --- a/docs/stackit_secrets-manager_user.md +++ b/docs/stackit_secrets-manager_user.md @@ -29,5 +29,8 @@ stackit secrets-manager user [flags] * [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager * [stackit secrets-manager user create](./stackit_secrets-manager_user_create.md) - Creates a Secrets Manager user +* [stackit secrets-manager user delete](./stackit_secrets-manager_user_delete.md) - Deletes a Secrets Manager user +* [stackit secrets-manager user describe](./stackit_secrets-manager_user_describe.md) - Shows details of a Secrets Manager user * [stackit secrets-manager user list](./stackit_secrets-manager_user_list.md) - Lists all Secrets Manager users +* [stackit secrets-manager user update](./stackit_secrets-manager_user_update.md) - Updates the write privileges Secrets Manager user diff --git a/docs/stackit_secrets-manager_user_create.md b/docs/stackit_secrets-manager_user_create.md index aed85ad8d..4a671d95f 100644 --- a/docs/stackit_secrets-manager_user_create.md +++ b/docs/stackit_secrets-manager_user_create.md @@ -4,7 +4,9 @@ Creates a Secrets Manager user ### Synopsis -Creates a user for a Secrets Manager instance with generated username and password +Creates a Secrets Manager user. +The username and password are auto-generated and provided upon creation. +A description can be provided to identify a user. ``` stackit secrets-manager user create [flags] @@ -13,17 +15,14 @@ stackit secrets-manager user create [flags] ### Examples ``` - Create a Secrets Manager user for instance with ID "xxx" - $ stackit mongodbflex user create --instance-id xxx - Create a Secrets Manager user for instance with ID "xxx" and description "yyy" - $ stackit mongodbflex user create --instance-id xxx --description yyy + $ stackit secrets-manager user create --instance-id xxx --description yyy - Create a Secrets Manager user for instance with ID "xxx" and doesn't display the password - $ stackit mongodbflex user create --instance-id xxx --hide-password + Create a Secrets Manager user for instance with ID "xxx" and hides the generated password + $ stackit secrets-manager user create --instance-id xxx --hide-password Create a Secrets Manager user for instance with ID "xxx" with write access to the secrets engine - $ stackit mongodbflex user create --instance-id xxx --write + $ stackit secrets-manager user create --instance-id xxx --write ``` ### Options diff --git a/docs/stackit_secrets-manager_user_delete.md b/docs/stackit_secrets-manager_user_delete.md new file mode 100644 index 000000000..fa28e1690 --- /dev/null +++ b/docs/stackit_secrets-manager_user_delete.md @@ -0,0 +1,40 @@ +## stackit secrets-manager user delete + +Deletes a Secrets Manager user + +### Synopsis + +Deletes a Secrets Manager user by ID. You can get the IDs of users for an instance by running: + $ stackit secrets-manager user list --instance-id + +``` +stackit secrets-manager user delete USER_ID [flags] +``` + +### Examples + +``` + Delete a Secrets Manager user with ID "xxx" for instance with ID "yyy" + $ stackit secrets-manager user delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit secrets-manager user delete" + --instance-id string Instance ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit secrets-manager user](./stackit_secrets-manager_user.md) - Provides functionality for Secrets Manager users + diff --git a/docs/stackit_secrets-manager_user_describe.md b/docs/stackit_secrets-manager_user_describe.md new file mode 100644 index 000000000..64e48b87c --- /dev/null +++ b/docs/stackit_secrets-manager_user_describe.md @@ -0,0 +1,42 @@ +## stackit secrets-manager user describe + +Shows details of a Secrets Manager user + +### Synopsis + +Shows details of a Secrets Manager user. + +``` +stackit secrets-manager user describe USER_ID [flags] +``` + +### Examples + +``` + Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy" + $ stackit secrets-manager user list xxx --instance-id yyy + + Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy" in table format + $ stackit secrets-manager user list xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit secrets-manager user describe" + --instance-id string ID of the instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit secrets-manager user](./stackit_secrets-manager_user.md) - Provides functionality for Secrets Manager users + diff --git a/docs/stackit_secrets-manager_user_update.md b/docs/stackit_secrets-manager_user_update.md new file mode 100644 index 000000000..274d6e7c6 --- /dev/null +++ b/docs/stackit_secrets-manager_user_update.md @@ -0,0 +1,44 @@ +## stackit secrets-manager user update + +Updates the write privileges Secrets Manager user + +### Synopsis + +Updates the write privileges Secrets Manager user. + +``` +stackit secrets-manager user update USER_ID [flags] +``` + +### Examples + +``` + Enable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy" + $ stackit secrets-manager user update xxx --instance-id yyy --enable-write + + Disable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy" + $ stackit secrets-manager user update xxx --instance-id yyy --disable-write +``` + +### Options + +``` + --disable-write Set the user to have read-only access. + --enable-write Set the user to have write access. + -h, --help Help for "stackit secrets-manager user update" + --instance-id string ID of the instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit secrets-manager user](./stackit_secrets-manager_user.md) - Provides functionality for Secrets Manager users + diff --git a/internal/cmd/secrets-manager/user/delete/delete.go b/internal/cmd/secrets-manager/user/delete/delete.go new file mode 100644 index 000000000..ff3423726 --- /dev/null +++ b/internal/cmd/secrets-manager/user/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/client" + secretsManagerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", userIdArg), + Short: "Deletes a Secrets Manager user", + Long: fmt.Sprintf("%s\n%s", + "Deletes a Secrets Manager user by ID. You can get the IDs of users for an instance by running:", + " $ stackit secrets-manager user list --instance-id ", + ), + Example: examples.Build( + examples.NewExample( + `Delete a Secrets Manager user with ID "xxx" for instance with ID "yyy"`, + "$ stackit secrets-manager user delete xxx --instance-id yyy"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + userLabel, userDescription, err := secretsManagerUtils.GetUserDetails(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete user %q (%q) of instance %q? (This cannot be undone)", userLabel, userDescription, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Secrets Manager user: %w", err) + } + + cmd.Printf("Deleted user %q of instance %q\n", userLabel, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiDeleteUserRequest { + req := apiClient.DeleteUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} diff --git a/internal/cmd/secrets-manager/user/delete/delete_test.go b/internal/cmd/secrets-manager/user/delete/delete_test.go new file mode 100644 index 000000000..2fec10573 --- /dev/null +++ b/internal/cmd/secrets-manager/user/delete/delete_test.go @@ -0,0 +1,230 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &secretsmanager.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *secretsmanager.ApiDeleteUserRequest)) secretsmanager.ApiDeleteUserRequest { + request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest secretsmanager.ApiDeleteUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/secrets-manager/user/describe/describe.go b/internal/cmd/secrets-manager/user/describe/describe.go new file mode 100644 index 000000000..50845ae51 --- /dev/null +++ b/internal/cmd/secrets-manager/user/describe/describe.go @@ -0,0 +1,134 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", userIdArg), + Short: "Shows details of a Secrets Manager user", + Long: "Shows details of a Secrets Manager user.", + Example: examples.Build( + examples.NewExample( + `Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy"`, + "$ stackit secrets-manager user list xxx --instance-id yyy"), + examples.NewExample( + `Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy" in table format`, + "$ stackit secrets-manager user list xxx --instance-id yyy --output-format pretty"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Secrets Manager user: %w", err) + } + + return outputResult(cmd, model.OutputFormat, *resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiGetUserRequest { + req := apiClient.GetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, user secretsmanager.User) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *user.Id) + table.AddSeparator() + table.AddRow("USERNAME", *user.Username) + table.AddSeparator() + table.AddRow("DESCRIPTION", *user.Description) + if user.Password != nil && *user.Password != "" { + table.AddSeparator() + table.AddRow("PASSWORD", *user.Password) + } + table.AddSeparator() + table.AddRow("WRITE ACCESS", *user.Write) + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("marshal Secrets Manager user: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/secrets-manager/user/describe/describe_test.go b/internal/cmd/secrets-manager/user/describe/describe_test.go new file mode 100644 index 000000000..226967fe5 --- /dev/null +++ b/internal/cmd/secrets-manager/user/describe/describe_test.go @@ -0,0 +1,242 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &secretsmanager.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *secretsmanager.ApiGetUserRequest)) secretsmanager.ApiGetUserRequest { + request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest secretsmanager.ApiGetUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go new file mode 100644 index 000000000..3027ad079 --- /dev/null +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -0,0 +1,163 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/client" + secretsManagerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" + enableWriteFlag = "enable-write" + disableWriteFlag = "disable-write" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string + EnableWrite *bool + DisableWrite *bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", userIdArg), + Short: "Updates the write privileges Secrets Manager user", + Long: "Updates the write privileges Secrets Manager user.", + Example: examples.Build( + examples.NewExample( + `Enable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy"`, + "$ stackit secrets-manager user update xxx --instance-id yyy --enable-write"), + examples.NewExample( + `Disable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy"`, + "$ stackit secrets-manager user update xxx --instance-id yyy --disable-write"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + var userLabel string + + userName, userDescription, err := secretsManagerUtils.GetUserDetails(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = fmt.Sprintf("%q", model.UserId) + } else { + userLabel = fmt.Sprintf("%q (%s)", userName, userDescription) + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update user %s of instance %q?", userLabel, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + err = req.Execute() + if err != nil { + return fmt.Errorf("update Secrets Manager user: %w", err) + } + + cmd.Printf("Updated user %s of instance %q\n", userLabel, instanceLabel) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + cmd.Flags().Bool(enableWriteFlag, false, "Set the user to have write access.") + cmd.Flags().Bool(disableWriteFlag, false, "Set the user to have read-only access.") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + + cmd.MarkFlagsMutuallyExclusive(enableWriteFlag, disableWriteFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + enableWrite := flags.FlagToBoolPointer(cmd, enableWriteFlag) + disableWrite := flags.FlagToBoolPointer(cmd, disableWriteFlag) + + if enableWrite == nil && disableWrite == nil { + return nil, &errors.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + EnableWrite: enableWrite, + DisableWrite: disableWrite, + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) (secretsmanager.ApiUpdateUserRequest, error) { + req := apiClient.UpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + + var write bool + + if model.EnableWrite != nil && model.DisableWrite == nil { + write = true + } else if model.DisableWrite != nil && model.EnableWrite == nil { + write = false + } else if model.DisableWrite == nil && model.EnableWrite == nil { + // Should never happen + return req, fmt.Errorf("one of %s and %s flags needs to be set", enableWriteFlag, disableWriteFlag) + } else if model.DisableWrite != nil && model.EnableWrite != nil { + // Should never happen + return req, fmt.Errorf("%s and %s flags can't be both set", enableWriteFlag, disableWriteFlag) + } + + req = req.UpdateUserPayload(secretsmanager.UpdateUserPayload{ + Write: utils.Ptr(write), + }) + return req, nil +} diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go new file mode 100644 index 000000000..3b80192ce --- /dev/null +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -0,0 +1,309 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &secretsmanager.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + enableWriteFlag: "true", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + EnableWrite: utils.Ptr(true), + DisableWrite: nil, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *secretsmanager.ApiUpdateUserRequest)) secretsmanager.ApiUpdateUserRequest { + request := testClient.UpdateUser(testCtx, testProjectId, testInstanceId, testUserId) + request = request.UpdateUserPayload(secretsmanager.UpdateUserPayload{ + Write: utils.Ptr(true), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "disable write access", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableWriteFlag] = "true" + delete(flagValues, enableWriteFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = nil + model.DisableWrite = utils.Ptr(true) + }), + }, + { + description: "neither write flag provided", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, enableWriteFlag) + delete(flagValues, disableWriteFlag) + }), + isValid: false, + }, + { + description: "both flags provided", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableWriteFlag] = "true" + }), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flag groups: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest secretsmanager.ApiUpdateUserRequest + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "disable write", + model: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = nil + model.DisableWrite = utils.Ptr(true) + }), + expectedRequest: fixtureRequest().UpdateUserPayload(secretsmanager.UpdateUserPayload{ + Write: utils.Ptr(false), + }), + isValid: true, + }, + { + description: "both write flags set", + model: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = utils.Ptr(true) + model.DisableWrite = utils.Ptr(true) + }), + isValid: false, + }, + { + description: "none of the write flags set", + model: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = nil + model.DisableWrite = nil + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/secrets-manager/user/user.go b/internal/cmd/secrets-manager/user/user.go index 6c4d661bb..76379621b 100644 --- a/internal/cmd/secrets-manager/user/user.go +++ b/internal/cmd/secrets-manager/user/user.go @@ -6,7 +6,10 @@ import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/update" ) func NewCmd() *cobra.Command { @@ -24,4 +27,7 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(list.NewCmd()) cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(update.NewCmd()) } diff --git a/internal/pkg/services/secrets-manager/utils/utils.go b/internal/pkg/services/secrets-manager/utils/utils.go index 97aa1f6c1..7927de71c 100644 --- a/internal/pkg/services/secrets-manager/utils/utils.go +++ b/internal/pkg/services/secrets-manager/utils/utils.go @@ -9,6 +9,7 @@ import ( type SecretsManagerClient interface { GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*secretsmanager.Instance, error) + GetUserExecute(ctx context.Context, projectId string, instanceId string, userId string) (*secretsmanager.User, error) } func GetInstanceName(ctx context.Context, apiClient SecretsManagerClient, projectId, instanceId string) (string, error) { @@ -18,3 +19,11 @@ func GetInstanceName(ctx context.Context, apiClient SecretsManagerClient, projec } return *resp.Name, nil } + +func GetUserDetails(ctx context.Context, apiClient SecretsManagerClient, projectId, instanceId, userId string) (username, description string, err error) { + resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId) + if err != nil { + return "", "", fmt.Errorf("get Secrets Manager user: %w", err) + } + return *resp.Username, *resp.Description, nil +} diff --git a/internal/pkg/services/secrets-manager/utils/utils_test.go b/internal/pkg/services/secrets-manager/utils/utils_test.go index 6ae75fbdc..add1773aa 100644 --- a/internal/pkg/services/secrets-manager/utils/utils_test.go +++ b/internal/pkg/services/secrets-manager/utils/utils_test.go @@ -13,16 +13,20 @@ import ( var ( testProjectId = uuid.NewString() testInstanceId = uuid.NewString() + testUserId = uuid.NewString() ) const ( testInstanceName = "instance" testUserName = "user" + testDescription = "sample description" ) type secretsManagerClientMocked struct { getInstanceFails bool getInstanceResp *secretsmanager.Instance + getUserFails bool + getUserResp *secretsmanager.User } func (s *secretsManagerClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*secretsmanager.Instance, error) { @@ -32,6 +36,13 @@ func (s *secretsManagerClientMocked) GetInstanceExecute(_ context.Context, _, _ return s.getInstanceResp, nil } +func (s *secretsManagerClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*secretsmanager.User, error) { + if s.getUserFails { + return nil, fmt.Errorf("could not get user") + } + return s.getUserResp, nil +} + func TestGetInstanceName(t *testing.T) { tests := []struct { description string @@ -79,3 +90,57 @@ func TestGetInstanceName(t *testing.T) { }) } } + +func TestGetUserDetails(t *testing.T) { + tests := []struct { + description string + getUserFails bool + GetUserResp *secretsmanager.User + isValid bool + expectedUserName string + expectedDescription string + }{ + { + description: "base", + GetUserResp: &secretsmanager.User{ + Username: utils.Ptr(testUserName), + Description: utils.Ptr(testDescription), + }, + isValid: true, + expectedUserName: testUserName, + expectedDescription: testDescription, + }, + { + description: "get user fails", + getUserFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &secretsManagerClientMocked{ + getUserFails: tt.getUserFails, + getUserResp: tt.GetUserResp, + } + + username, description, err := GetUserDetails(context.Background(), client, testProjectId, testInstanceId, testUserId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if username != tt.expectedUserName { + t.Errorf("expected username to be %s, got %s", tt.expectedUserName, username) + } + if description != tt.expectedDescription { + t.Errorf("expected description to be %s, got %s", tt.expectedDescription, description) + } + }) + } +}