From fbf65a20980235aa06c232d9b28a18cd45a242e5 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Tue, 4 Jun 2024 14:02:52 -0400 Subject: [PATCH 01/14] add updater and read-policy --- internal/commands/iam/groups/groups.go | 2 + internal/commands/iam/groups/helper/groups.go | 13 ++ internal/commands/iam/groups/iam/iam.go | 27 ++++ .../commands/iam/groups/iam/read_policy.go | 119 ++++++++++++++++++ .../iam/groups/iam/read_policy_test.go | 104 +++++++++++++++ internal/commands/iam/groups/iam/updater.go | 47 +++++++ 6 files changed, 312 insertions(+) create mode 100644 internal/commands/iam/groups/iam/iam.go create mode 100644 internal/commands/iam/groups/iam/read_policy.go create mode 100644 internal/commands/iam/groups/iam/read_policy_test.go create mode 100644 internal/commands/iam/groups/iam/updater.go diff --git a/internal/commands/iam/groups/groups.go b/internal/commands/iam/groups/groups.go index f88eebee..50522edd 100644 --- a/internal/commands/iam/groups/groups.go +++ b/internal/commands/iam/groups/groups.go @@ -4,6 +4,7 @@ package groups import ( + "github.com/hashicorp/hcp/internal/commands/iam/groups/iam" "github.com/hashicorp/hcp/internal/commands/iam/groups/members" "github.com/hashicorp/hcp/internal/pkg/cmd" "github.com/hashicorp/hcp/internal/pkg/heredoc" @@ -30,5 +31,6 @@ func NewCmdGroups(ctx *cmd.Context) *cmd.Command { cmd.AddChild(NewCmdDelete(ctx, nil)) cmd.AddChild(NewCmdUpdate(ctx, nil)) cmd.AddChild(members.NewCmdMembers(ctx)) + cmd.AddChild(iam.NewCmdIAM(ctx)) return cmd } diff --git a/internal/commands/iam/groups/helper/groups.go b/internal/commands/iam/groups/helper/groups.go index b79e40ca..5c5ff45d 100644 --- a/internal/commands/iam/groups/helper/groups.go +++ b/internal/commands/iam/groups/helper/groups.go @@ -68,6 +68,19 @@ func PredictGroupResourceNameSuffix(ctx context.Context, orgID string, client gr } } +// GetResourceIDFromResourceName retrieves the resource name from a group ID. +func GetResourceIDFromResourceName(ctx context.Context, resourceName string, client groups_service.ClientService) (string, error) { + req := groups_service.NewGroupsServiceGetGroupParamsWithContext(ctx) + req.ResourceName = resourceName + + resp, err := client.GroupsServiceGetGroup(req, nil) + if err != nil { + return "", fmt.Errorf("failed to get group: %w", err) + } + + return resp.Payload.Group.ResourceID, nil +} + // GetGroups retrieves the groups in the organization. func GetGroups(ctx context.Context, orgID string, client groups_service.ClientService) ([]*models.HashicorpCloudIamGroup, error) { req := groups_service.NewGroupsServiceListGroupsParamsWithContext(ctx) diff --git a/internal/commands/iam/groups/iam/iam.go b/internal/commands/iam/groups/iam/iam.go new file mode 100644 index 00000000..3407911f --- /dev/null +++ b/internal/commands/iam/groups/iam/iam.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/heredoc" +) + +func NewCmdIAM(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "iam", + ShortHelp: "Manage a group's IAM policy.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp iam groups iam" }} command group lets you + manage a group's IAM Policy. + `), + } + + // TODO: Uncomment as subcommands are added + // cmd.AddChild(NewCmdAddBinding(ctx, nil)) + // cmd.AddChild(NewCmdDeleteBinding(ctx, nil)) + cmd.AddChild(NewCmdReadPolicy(ctx, nil)) + // cmd.AddChild(NewCmdSetPolicy(ctx, nil)) + return cmd +} diff --git a/internal/commands/iam/groups/iam/read_policy.go b/internal/commands/iam/groups/iam/read_policy.go new file mode 100644 index 00000000..2f14cd48 --- /dev/null +++ b/internal/commands/iam/groups/iam/read_policy.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/groups_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/resource_service" + "github.com/hashicorp/hcp/internal/commands/iam/groups/helper" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func NewCmdReadPolicy(ctx *cmd.Context, runF func(*ReadPolicyOpts) error) *cmd.Command { + opts := &ReadPolicyOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + ResourceClient: resource_service.New(ctx.HCP, nil), + GroupsClient: groups_service.New(ctx.HCP, nil), + IAMClient: iam_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "read-policy", + ShortHelp: "Read the IAM policy for a group.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp iam groups iam read-policy" }} command reads the IAM policy for a group. + `), + Examples: []cmd.Example{ + { + Preamble: "Read the IAM Policy for a group:", + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp iam groups iam read-policy \ + --group=iam/organization/cf8ef907-b9b9-4f2f-b675-e290448f0000/group/Group-Name + `), + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "group", + Shorthand: "g", + DisplayValue: "NAME", + Description: heredoc.New(ctx.IO).Mustf(helper.GroupNameArgDoc, "read the IAM policy for"), + Value: flagvalue.Simple("", &opts.GroupName), + Autocomplete: helper.PredictGroupResourceNameSuffix(opts.Ctx, opts.Profile.OrganizationID, opts.GroupsClient), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + if opts.GroupName == "" { + return fmt.Errorf("a group resource name must be specified") + } + + if runF != nil { + return runF(opts) + } + + return readPolicyRun(opts) + }, + PersistentPreRun: func(c *cmd.Command, args []string) error { + return cmd.RequireOrgAndProject(ctx) + }, + } + + return cmd +} + +type ReadPolicyOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + GroupName string + ResourceClient resource_service.ClientService + GroupsClient groups_service.ClientService + IAMClient iam_service.ClientService +} + +func readPolicyRun(opts *ReadPolicyOpts) error { + resourceName := helper.ResourceName(opts.GroupName, opts.Profile.OrganizationID) + + resourceID, err := helper.GetResourceIDFromResourceName(opts.Ctx, resourceName, opts.GroupsClient) + if err != nil { + return err + } + + // Create our group IAM Updater + u := &iamUpdater{ + groupID: resourceID, + client: opts.ResourceClient, + } + + // Get the existing policy + p, err := u.GetIamPolicy(opts.Ctx) + if err != nil { + return err + } + + // Get the displayer + d, err := iampolicy.NewDisplayer(opts.Ctx, opts.Profile.OrganizationID, p, opts.IAMClient) + if err != nil { + return err + } + + return opts.Output.Display(d) +} diff --git a/internal/commands/iam/groups/iam/read_policy_test.go b/internal/commands/iam/groups/iam/read_policy_test.go new file mode 100644 index 00000000..8cb8666e --- /dev/null +++ b/internal/commands/iam/groups/iam/read_policy_test.go @@ -0,0 +1,104 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/stretchr/testify/require" +) + +func TestNewCmdReadBinding(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + }{ + { + Name: "No Org", + Profile: profile.TestProfile, + Error: "Organization ID and Project ID must be configured", + }, + { + Name: "No Project passed/profile", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Error: "Organization ID and Project ID must be configured", + }, + { + Name: "Too many args", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + }, + Args: []string{"foo", "bar"}, + Error: "no arguments allowed, but received 2", + }, + { + Name: "No Group Specific", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + }, + Error: "ERROR: a group resource name must be specified", + }, + { + Name: "Good full resource name", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + }, + Args: []string{"-g=iam/organization/123/group/456"}, + }, + { + Name: "Good suffix resource name", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + }, + Args: []string{"-g=456"}, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *ReadPolicyOpts + readCmd := NewCmdReadPolicy(ctx, func(o *ReadPolicyOpts) error { + gotOpts = o + return nil + }) + readCmd.SetIO(io) + + code := readCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + }) + } +} diff --git a/internal/commands/iam/groups/iam/updater.go b/internal/commands/iam/groups/iam/updater.go new file mode 100644 index 00000000..86e728e9 --- /dev/null +++ b/internal/commands/iam/groups/iam/updater.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/resource_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/models" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" +) + +// iamUpdater meets the iampolicy.ResourceUpdater interface. It is used to +// manage IAM bindings. +type iamUpdater struct { + groupID string + client resource_service.ClientService +} + +func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudResourcemanagerPolicy, error) { + params := resource_service.NewResourceServiceGetIamPolicyParams() + params.ResourceID = &u.groupID + res, err := u.client.ResourceServiceGetIamPolicy(params, nil) + if err != nil { + return nil, fmt.Errorf("failed to retrieve group IAM policy: %w", err) + } + + return res.GetPayload().Policy, nil +} + +func (u *iamUpdater) SetIamPolicy(ctx context.Context, policy *models.HashicorpCloudResourcemanagerPolicy) (*models.HashicorpCloudResourcemanagerPolicy, error) { + params := resource_service.NewResourceServiceSetIamPolicyParams() + params.Body.ResourceID = u.groupID + params.Body.Policy = policy + + res, err := u.client.ResourceServiceSetIamPolicy(params, nil) + if err != nil { + return nil, fmt.Errorf("failed to set group IAM policy: %w", err) + } + + return res.GetPayload().Policy, nil +} + +// Ensure we meet the interface. +var _ iampolicy.ResourceUpdater = &iamUpdater{} From 2bc6fbc1b6bf3be5413a498d29630d4bb0ebe6a7 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Wed, 5 Jun 2024 08:46:30 -0400 Subject: [PATCH 02/14] remove project ID and resource ID reference --- internal/commands/iam/groups/helper/groups.go | 13 ------------- internal/commands/iam/groups/iam/read_policy.go | 13 ++++--------- .../commands/iam/groups/iam/read_policy_test.go | 9 +-------- internal/commands/iam/groups/iam/updater.go | 8 ++++---- 4 files changed, 9 insertions(+), 34 deletions(-) diff --git a/internal/commands/iam/groups/helper/groups.go b/internal/commands/iam/groups/helper/groups.go index 5c5ff45d..b79e40ca 100644 --- a/internal/commands/iam/groups/helper/groups.go +++ b/internal/commands/iam/groups/helper/groups.go @@ -68,19 +68,6 @@ func PredictGroupResourceNameSuffix(ctx context.Context, orgID string, client gr } } -// GetResourceIDFromResourceName retrieves the resource name from a group ID. -func GetResourceIDFromResourceName(ctx context.Context, resourceName string, client groups_service.ClientService) (string, error) { - req := groups_service.NewGroupsServiceGetGroupParamsWithContext(ctx) - req.ResourceName = resourceName - - resp, err := client.GroupsServiceGetGroup(req, nil) - if err != nil { - return "", fmt.Errorf("failed to get group: %w", err) - } - - return resp.Payload.Group.ResourceID, nil -} - // GetGroups retrieves the groups in the organization. func GetGroups(ctx context.Context, orgID string, client groups_service.ClientService) ([]*models.HashicorpCloudIamGroup, error) { req := groups_service.NewGroupsServiceListGroupsParamsWithContext(ctx) diff --git a/internal/commands/iam/groups/iam/read_policy.go b/internal/commands/iam/groups/iam/read_policy.go index 2f14cd48..2368c0ce 100644 --- a/internal/commands/iam/groups/iam/read_policy.go +++ b/internal/commands/iam/groups/iam/read_policy.go @@ -70,7 +70,7 @@ func NewCmdReadPolicy(ctx *cmd.Context, runF func(*ReadPolicyOpts) error) *cmd.C return readPolicyRun(opts) }, PersistentPreRun: func(c *cmd.Command, args []string) error { - return cmd.RequireOrgAndProject(ctx) + return cmd.RequireOrganization(ctx) }, } @@ -92,15 +92,10 @@ type ReadPolicyOpts struct { func readPolicyRun(opts *ReadPolicyOpts) error { resourceName := helper.ResourceName(opts.GroupName, opts.Profile.OrganizationID) - resourceID, err := helper.GetResourceIDFromResourceName(opts.Ctx, resourceName, opts.GroupsClient) - if err != nil { - return err - } - - // Create our group IAM Updater + // Create the group IAM Updater u := &iamUpdater{ - groupID: resourceID, - client: opts.ResourceClient, + resourceName: resourceName, + client: opts.ResourceClient, } // Get the existing policy diff --git a/internal/commands/iam/groups/iam/read_policy_test.go b/internal/commands/iam/groups/iam/read_policy_test.go index 8cb8666e..d04fd7a6 100644 --- a/internal/commands/iam/groups/iam/read_policy_test.go +++ b/internal/commands/iam/groups/iam/read_policy_test.go @@ -27,14 +27,7 @@ func TestNewCmdReadBinding(t *testing.T) { { Name: "No Org", Profile: profile.TestProfile, - Error: "Organization ID and Project ID must be configured", - }, - { - Name: "No Project passed/profile", - Profile: func(t *testing.T) *profile.Profile { - return profile.TestProfile(t).SetOrgID("123") - }, - Error: "Organization ID and Project ID must be configured", + Error: "Organization ID must be configured", }, { Name: "Too many args", diff --git a/internal/commands/iam/groups/iam/updater.go b/internal/commands/iam/groups/iam/updater.go index 86e728e9..ea42b7f6 100644 --- a/internal/commands/iam/groups/iam/updater.go +++ b/internal/commands/iam/groups/iam/updater.go @@ -15,13 +15,13 @@ import ( // iamUpdater meets the iampolicy.ResourceUpdater interface. It is used to // manage IAM bindings. type iamUpdater struct { - groupID string - client resource_service.ClientService + resourceName string + client resource_service.ClientService } func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudResourcemanagerPolicy, error) { params := resource_service.NewResourceServiceGetIamPolicyParams() - params.ResourceID = &u.groupID + params.ResourceName = &u.resourceName res, err := u.client.ResourceServiceGetIamPolicy(params, nil) if err != nil { return nil, fmt.Errorf("failed to retrieve group IAM policy: %w", err) @@ -32,7 +32,7 @@ func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudRe func (u *iamUpdater) SetIamPolicy(ctx context.Context, policy *models.HashicorpCloudResourcemanagerPolicy) (*models.HashicorpCloudResourcemanagerPolicy, error) { params := resource_service.NewResourceServiceSetIamPolicyParams() - params.Body.ResourceID = u.groupID + params.Body.ResourceName = u.resourceName params.Body.Policy = policy res, err := u.client.ResourceServiceSetIamPolicy(params, nil) From 6e99ac7eb4e6000e361beef71cba467a912a069d Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Wed, 5 Jun 2024 15:09:49 -0400 Subject: [PATCH 03/14] typo in test name --- internal/commands/iam/groups/iam/read_policy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/iam/groups/iam/read_policy_test.go b/internal/commands/iam/groups/iam/read_policy_test.go index d04fd7a6..9f932ef6 100644 --- a/internal/commands/iam/groups/iam/read_policy_test.go +++ b/internal/commands/iam/groups/iam/read_policy_test.go @@ -38,7 +38,7 @@ func TestNewCmdReadBinding(t *testing.T) { Error: "no arguments allowed, but received 2", }, { - Name: "No Group Specific", + Name: "No Group Specified", Profile: func(t *testing.T) *profile.Profile { return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") }, From 531ab7fa370459bb3f084ff9c65dc212290fe642 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Wed, 5 Jun 2024 15:44:21 -0400 Subject: [PATCH 04/14] implement add-binding --- .../commands/iam/groups/iam/add_binding.go | 132 ++++++++++++ .../iam/groups/iam/add_binding_test.go | 202 ++++++++++++++++++ internal/commands/iam/groups/iam/iam.go | 2 +- internal/commands/iam/groups/iam/updater.go | 7 +- 4 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 internal/commands/iam/groups/iam/add_binding.go create mode 100644 internal/commands/iam/groups/iam/add_binding_test.go diff --git a/internal/commands/iam/groups/iam/add_binding.go b/internal/commands/iam/groups/iam/add_binding.go new file mode 100644 index 00000000..4c3f3d99 --- /dev/null +++ b/internal/commands/iam/groups/iam/add_binding.go @@ -0,0 +1,132 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/organization_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/resource_service" + "github.com/hashicorp/hcp/internal/commands/iam/groups/helper" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" +) + +func NewCmdAddBinding(ctx *cmd.Context, runF func(*AddBindingOpts) error) *cmd.Command { + opts := &AddBindingOpts{ + Ctx: ctx.ShutdownCtx, + IO: ctx.IO, + + ResourceClient: resource_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "add-binding", + ShortHelp: "Add an IAM policy binding for a group.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp iam groups iam add-binding" }} + command adds an IAM policy binding for the given group. A binding grants the + specified principal the given role on the group. + + To view the available roles to bind, run {{ template "mdCodeOrBold" "hcp iam roles list" }}. + + Currently, the only supported role on a principal in a group is {{ template "mdCodeOrBold" "roles/iam.group-manager" }}. + `), + Examples: []cmd.Example{ + { + Preamble: heredoc.New(ctx.IO).Must(`Bind a principal to role {{ template "mdCodeOrBold" "roles/iam.group-manager" }}:`), + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp iam groups iam add-binding \ + --group=8647ae06-ca65-467a-b72d-edba1f908fc8 \ + --member=ef938a22-09cf-4be9-b4d0-1f4587f80f53 \ + --role=roles/iam.group-manager + `), + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "group", + Shorthand: "g", + DisplayValue: "NAME", + Description: "The name of the group to add the role binding to.", + Value: flagvalue.Simple("", &opts.GroupName), + Required: true, + }, + { + Name: "member", + Shorthand: "m", + DisplayValue: "PRINCIPAL_ID", + Description: "The ID of the principal to add the role binding to.", + Value: flagvalue.Simple("", &opts.PrincipalID), + Required: true, + }, + { + Name: "role", + Shorthand: "r", + DisplayValue: "ROLE_ID", + Description: `The role ID (e.g. "roles/iam.group-manager") to bind the member to.`, + Value: flagvalue.Simple("", &opts.Role), + Required: true, + Autocomplete: iampolicy.AutocompleteRoles(opts.Ctx, ctx.Profile.OrganizationID, organization_service.New(ctx.HCP, nil)), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + resourceName := helper.ResourceName(opts.GroupName, ctx.Profile.OrganizationID) + + // Create the group IAM Updater + u := &iamUpdater{ + resourceName: resourceName, + client: opts.ResourceClient, + } + + // Create the policy setter + opts.Setter = iampolicy.NewSetter( + ctx.Profile.OrganizationID, + u, + iam_service.New(ctx.HCP, nil), + c.Logger()) + + if runF != nil { + return runF(opts) + } + + return addBindingRun(opts) + }, + PersistentPreRun: func(c *cmd.Command, args []string) error { + return cmd.RequireOrganization(ctx) + }, + } + + return cmd +} + +type AddBindingOpts struct { + Ctx context.Context + IO iostreams.IOStreams + + Setter iampolicy.Setter + GroupName string + PrincipalID string + Role string + ResourceClient resource_service.ClientService +} + +func addBindingRun(opts *AddBindingOpts) error { + _, err := opts.Setter.AddBinding(opts.Ctx, opts.PrincipalID, opts.Role) + if err != nil { + fmt.Fprintf(opts.IO.Err(), "Principal %q bound to role %q.\n", opts.PrincipalID, opts.Role) + return err + } + + fmt.Fprintf(opts.IO.Err(), "%s Principal %q bound to role %q.\n", + opts.IO.ColorScheme().SuccessIcon(), opts.PrincipalID, opts.Role) + return nil +} diff --git a/internal/commands/iam/groups/iam/add_binding_test.go b/internal/commands/iam/groups/iam/add_binding_test.go new file mode 100644 index 00000000..64073849 --- /dev/null +++ b/internal/commands/iam/groups/iam/add_binding_test.go @@ -0,0 +1,202 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/models" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewCmdAddBinding(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *AddBindingOpts + }{ + { + Name: "No Org", + Profile: profile.TestProfile, + Args: []string{ + "--group=test-group", + "--member=123", + "--role=roles/iam.group-manager", + }, + Error: "Organization ID must be configured", + }, + { + Name: "Too many args", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--group=test-group", + "--member=123", + "--role=roles/iam.group-manager", + "foo", + "bar", + }, + Error: "no arguments allowed, but received 2", + }, + { + Name: "Missing group", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--member=123", + "--role=roles/iam.group-manager", + }, + Error: "ERROR: missing required flag: --group=NAME", + }, + { + Name: "Missing member", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--group=test-group", + "--role=roles/iam.group-manager", + }, + Error: "ERROR: missing required flag: --member=PRINCIPAL_ID", + }, + { + Name: "Missing role", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--group=test-group", + "--member=123", + }, + Error: "ERROR: missing required flag: --role=ROLE_ID", + }, + { + Name: "Good", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--group=test-group", + "--member=123", + "--role=roles/iam.group-manager"}, + Expect: &AddBindingOpts{ + GroupName: "test-group", + PrincipalID: "123", + Role: "roles/iam.group-manager", + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *AddBindingOpts + bindingCmd := NewCmdAddBinding(ctx, func(o *AddBindingOpts) error { + gotOpts = o + return nil + }) + bindingCmd.SetIO(io) + + code := bindingCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + r.Equal(c.Expect.GroupName, gotOpts.GroupName) + r.Equal(c.Expect.PrincipalID, gotOpts.PrincipalID) + r.Equal(c.Expect.Role, gotOpts.Role) + r.NotNil(gotOpts.Setter) + }) + } +} + +func TestAddBindingRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + RespErr error + Error string + }{ + { + Name: "Server error", + RespErr: fmt.Errorf("failed to add policy"), + Error: "failed to add policy", + }, + { + Name: "Good", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + setter := iampolicy.NewMockSetter(t) + opts := &AddBindingOpts{ + Ctx: context.Background(), + IO: io, + Setter: setter, + GroupName: "test-group", + PrincipalID: "principal-123", + Role: "roles/test", + } + + // Expect a request to add a binding. + call := setter.EXPECT().AddBinding(mock.Anything, opts.PrincipalID, opts.Role).Once() + + if c.RespErr != nil { + call.Return(nil, c.RespErr) + } else { + call.Return(&models.HashicorpCloudResourcemanagerPolicy{}, nil) + } + + // Run the command + err := addBindingRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + // Check we outputted the project + r.NoError(err) + r.Contains(io.Error.String(), `Principal "principal-123" bound to role "roles/test"`) + }) + } +} diff --git a/internal/commands/iam/groups/iam/iam.go b/internal/commands/iam/groups/iam/iam.go index 3407911f..f591a597 100644 --- a/internal/commands/iam/groups/iam/iam.go +++ b/internal/commands/iam/groups/iam/iam.go @@ -19,7 +19,7 @@ func NewCmdIAM(ctx *cmd.Context) *cmd.Command { } // TODO: Uncomment as subcommands are added - // cmd.AddChild(NewCmdAddBinding(ctx, nil)) + cmd.AddChild(NewCmdAddBinding(ctx, nil)) // cmd.AddChild(NewCmdDeleteBinding(ctx, nil)) cmd.AddChild(NewCmdReadPolicy(ctx, nil)) // cmd.AddChild(NewCmdSetPolicy(ctx, nil)) diff --git a/internal/commands/iam/groups/iam/updater.go b/internal/commands/iam/groups/iam/updater.go index ea42b7f6..6287af71 100644 --- a/internal/commands/iam/groups/iam/updater.go +++ b/internal/commands/iam/groups/iam/updater.go @@ -22,6 +22,7 @@ type iamUpdater struct { func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudResourcemanagerPolicy, error) { params := resource_service.NewResourceServiceGetIamPolicyParams() params.ResourceName = &u.resourceName + res, err := u.client.ResourceServiceGetIamPolicy(params, nil) if err != nil { return nil, fmt.Errorf("failed to retrieve group IAM policy: %w", err) @@ -32,8 +33,10 @@ func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudRe func (u *iamUpdater) SetIamPolicy(ctx context.Context, policy *models.HashicorpCloudResourcemanagerPolicy) (*models.HashicorpCloudResourcemanagerPolicy, error) { params := resource_service.NewResourceServiceSetIamPolicyParams() - params.Body.ResourceName = u.resourceName - params.Body.Policy = policy + params.Body = &models.HashicorpCloudResourcemanagerResourceSetIamPolicyRequest{ + ResourceName: u.resourceName, + Policy: policy, + } res, err := u.client.ResourceServiceSetIamPolicy(params, nil) if err != nil { From 34682b5c9b77679dc43efe30323de5b1db7b9a21 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Wed, 5 Jun 2024 15:47:12 -0400 Subject: [PATCH 05/14] fix formatting --- internal/commands/iam/groups/iam/updater.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/commands/iam/groups/iam/updater.go b/internal/commands/iam/groups/iam/updater.go index ea42b7f6..6287af71 100644 --- a/internal/commands/iam/groups/iam/updater.go +++ b/internal/commands/iam/groups/iam/updater.go @@ -22,6 +22,7 @@ type iamUpdater struct { func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudResourcemanagerPolicy, error) { params := resource_service.NewResourceServiceGetIamPolicyParams() params.ResourceName = &u.resourceName + res, err := u.client.ResourceServiceGetIamPolicy(params, nil) if err != nil { return nil, fmt.Errorf("failed to retrieve group IAM policy: %w", err) @@ -32,8 +33,10 @@ func (u *iamUpdater) GetIamPolicy(ctx context.Context) (*models.HashicorpCloudRe func (u *iamUpdater) SetIamPolicy(ctx context.Context, policy *models.HashicorpCloudResourcemanagerPolicy) (*models.HashicorpCloudResourcemanagerPolicy, error) { params := resource_service.NewResourceServiceSetIamPolicyParams() - params.Body.ResourceName = u.resourceName - params.Body.Policy = policy + params.Body = &models.HashicorpCloudResourcemanagerResourceSetIamPolicyRequest{ + ResourceName: u.resourceName, + Policy: policy, + } res, err := u.client.ResourceServiceSetIamPolicy(params, nil) if err != nil { From 0b1f4eda99c57ed3ec7708dfd7f84ecb159b534d Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Wed, 5 Jun 2024 15:09:49 -0400 Subject: [PATCH 06/14] typo in test name --- internal/commands/iam/groups/iam/read_policy_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/commands/iam/groups/iam/read_policy_test.go b/internal/commands/iam/groups/iam/read_policy_test.go index d04fd7a6..9f932ef6 100644 --- a/internal/commands/iam/groups/iam/read_policy_test.go +++ b/internal/commands/iam/groups/iam/read_policy_test.go @@ -38,7 +38,7 @@ func TestNewCmdReadBinding(t *testing.T) { Error: "no arguments allowed, but received 2", }, { - Name: "No Group Specific", + Name: "No Group Specified", Profile: func(t *testing.T) *profile.Profile { return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") }, From 6d025cd0e932e408950ddbe04860902e3d9971f7 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Thu, 6 Jun 2024 10:47:31 -0400 Subject: [PATCH 07/14] add example on how to add a group manager --- internal/commands/iam/groups/iam/iam.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/commands/iam/groups/iam/iam.go b/internal/commands/iam/groups/iam/iam.go index f591a597..883abe37 100644 --- a/internal/commands/iam/groups/iam/iam.go +++ b/internal/commands/iam/groups/iam/iam.go @@ -12,9 +12,16 @@ func NewCmdIAM(ctx *cmd.Context) *cmd.Command { cmd := &cmd.Command{ Name: "iam", ShortHelp: "Manage a group's IAM policy.", - LongHelp: heredoc.New(ctx.IO).Must(` - The {{ template "mdCodeOrBold" "hcp iam groups iam" }} command group lets you - manage a group's IAM Policy. + LongHelp: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + The {{ template "mdCodeOrBold" "hcp iam groups iam" }} command group lets you manage a group's IAM Policy. + + To set a member as a group manager, you can use the {{ template "mdCodeOrBold" "add-binding" }} subcommand with the {{ template "mdCodeOrBold" "roles/iam.group-manager" }} role. + + $ hcp iam groups iam add-binding \ + --group=8647ae06-ca65-467a-b72d-edba1f908fc8 \ + --member=ef938a22-09cf-4be9-b4d0-1f4587f80f53 \ + --role=roles/iam.group-manager + `), } From 108ebcc79081126d73ceafb9d8e4df942dedbf7f Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Thu, 6 Jun 2024 15:57:49 -0400 Subject: [PATCH 08/14] format examples --- .../commands/iam/groups/iam/add_binding.go | 20 ++++++++++++------ internal/commands/iam/groups/iam/iam.go | 21 +++++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/commands/iam/groups/iam/add_binding.go b/internal/commands/iam/groups/iam/add_binding.go index 4c3f3d99..80db0727 100644 --- a/internal/commands/iam/groups/iam/add_binding.go +++ b/internal/commands/iam/groups/iam/add_binding.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/groups_service" "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service" "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/organization_service" "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/resource_service" @@ -16,13 +17,16 @@ import ( "github.com/hashicorp/hcp/internal/pkg/flagvalue" "github.com/hashicorp/hcp/internal/pkg/heredoc" "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" ) func NewCmdAddBinding(ctx *cmd.Context, runF func(*AddBindingOpts) error) *cmd.Command { opts := &AddBindingOpts{ - Ctx: ctx.ShutdownCtx, - IO: ctx.IO, + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + GroupsClient: groups_service.New(ctx.HCP, nil), ResourceClient: resource_service.New(ctx.HCP, nil), } @@ -37,13 +41,15 @@ func NewCmdAddBinding(ctx *cmd.Context, runF func(*AddBindingOpts) error) *cmd.C To view the available roles to bind, run {{ template "mdCodeOrBold" "hcp iam roles list" }}. Currently, the only supported role on a principal in a group is {{ template "mdCodeOrBold" "roles/iam.group-manager" }}. + + A group manager can add/remove members from the group and update the group name/description. `), Examples: []cmd.Example{ { Preamble: heredoc.New(ctx.IO).Must(`Bind a principal to role {{ template "mdCodeOrBold" "roles/iam.group-manager" }}:`), Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` $ hcp iam groups iam add-binding \ - --group=8647ae06-ca65-467a-b72d-edba1f908fc8 \ + --group=Group-Name \ --member=ef938a22-09cf-4be9-b4d0-1f4587f80f53 \ --role=roles/iam.group-manager `), @@ -57,6 +63,7 @@ func NewCmdAddBinding(ctx *cmd.Context, runF func(*AddBindingOpts) error) *cmd.C DisplayValue: "NAME", Description: "The name of the group to add the role binding to.", Value: flagvalue.Simple("", &opts.GroupName), + Autocomplete: helper.PredictGroupResourceNameSuffix(opts.Ctx, opts.Profile.OrganizationID, opts.GroupsClient), Required: true, }, { @@ -109,20 +116,21 @@ func NewCmdAddBinding(ctx *cmd.Context, runF func(*AddBindingOpts) error) *cmd.C } type AddBindingOpts struct { - Ctx context.Context - IO iostreams.IOStreams + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams Setter iampolicy.Setter GroupName string PrincipalID string Role string + GroupsClient groups_service.ClientService ResourceClient resource_service.ClientService } func addBindingRun(opts *AddBindingOpts) error { _, err := opts.Setter.AddBinding(opts.Ctx, opts.PrincipalID, opts.Role) if err != nil { - fmt.Fprintf(opts.IO.Err(), "Principal %q bound to role %q.\n", opts.PrincipalID, opts.Role) return err } diff --git a/internal/commands/iam/groups/iam/iam.go b/internal/commands/iam/groups/iam/iam.go index 883abe37..6d18bc07 100644 --- a/internal/commands/iam/groups/iam/iam.go +++ b/internal/commands/iam/groups/iam/iam.go @@ -12,17 +12,20 @@ func NewCmdIAM(ctx *cmd.Context) *cmd.Command { cmd := &cmd.Command{ Name: "iam", ShortHelp: "Manage a group's IAM policy.", - LongHelp: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + LongHelp: heredoc.New(ctx.IO).Must(` The {{ template "mdCodeOrBold" "hcp iam groups iam" }} command group lets you manage a group's IAM Policy. - - To set a member as a group manager, you can use the {{ template "mdCodeOrBold" "add-binding" }} subcommand with the {{ template "mdCodeOrBold" "roles/iam.group-manager" }} role. - - $ hcp iam groups iam add-binding \ - --group=8647ae06-ca65-467a-b72d-edba1f908fc8 \ - --member=ef938a22-09cf-4be9-b4d0-1f4587f80f53 \ - --role=roles/iam.group-manager - `), + Examples: []cmd.Example{ + { + Preamble: heredoc.New(ctx.IO).Must(`To set a member as a group manager, you can use the {{ template "mdCodeOrBold" "add-binding" }} subcommand with the {{ template "mdCodeOrBold" "roles/iam.group-manager" }} role:`), + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp iam groups iam add-binding \ + --group=Group-Name \ + --member=ef938a22-09cf-4be9-b4d0-1f4587f80f53 \ + --role=roles/iam.group-manager + `), + }, + }, } // TODO: Uncomment as subcommands are added From 54612fa39ed100b2272a4c1fc5fd15d53416ddbe Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Mon, 10 Jun 2024 10:43:17 -0400 Subject: [PATCH 09/14] implement delete-binding --- .../commands/iam/groups/iam/delete_binding.go | 136 ++++++++++++ .../iam/groups/iam/delete_binding_test.go | 198 ++++++++++++++++++ internal/commands/iam/groups/iam/iam.go | 2 +- 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 internal/commands/iam/groups/iam/delete_binding.go create mode 100644 internal/commands/iam/groups/iam/delete_binding_test.go diff --git a/internal/commands/iam/groups/iam/delete_binding.go b/internal/commands/iam/groups/iam/delete_binding.go new file mode 100644 index 00000000..9524fe79 --- /dev/null +++ b/internal/commands/iam/groups/iam/delete_binding.go @@ -0,0 +1,136 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/groups_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/organization_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/resource_service" + "github.com/hashicorp/hcp/internal/commands/iam/groups/helper" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func NewCmdDeleteBinding(ctx *cmd.Context, runF func(*DeleteBindingOpts) error) *cmd.Command { + opts := &DeleteBindingOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + + GroupsClient: groups_service.New(ctx.HCP, nil), + ResourceClient: resource_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "delete-binding", + ShortHelp: "Delete an IAM policy binding for a group.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp iam groups iam delete-binding" }} + command deletes an IAM policy binding for the given group. A binding consists of a + principal and a role. + + To view the existing role bindings, run {{ template "mdCodeOrBold" "hcp iam groups iam read-policy" }}. + `), + Examples: []cmd.Example{ + { + Preamble: heredoc.New(ctx.IO).Must(`Delete a role binding for a principal's previously granted role {{ template "mdCodeOrBold" "roles/iam.group-manager" }}:`), + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp iam groups iam delete-binding \ + --group=Group-Name \ + --member=ef938a22-09cf-4be9-b4d0-1f4587f80f53 \ + --role=roles/iam.group-manager + `), + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "group", + Shorthand: "g", + DisplayValue: "NAME", + Description: "The name of the group to remove the role binding from.", + Value: flagvalue.Simple("", &opts.GroupName), + Autocomplete: helper.PredictGroupResourceNameSuffix(opts.Ctx, opts.Profile.OrganizationID, opts.GroupsClient), + Required: true, + }, + { + Name: "member", + Shorthand: "m", + DisplayValue: "PRINCIPAL_ID", + Description: "The ID of the principal to remove the role binding from.", + Value: flagvalue.Simple("", &opts.PrincipalID), + Required: true, + }, + { + Name: "role", + Shorthand: "r", + DisplayValue: "ROLE_ID", + Description: `The role ID (e.g. "roles/admin", "roles/contributor", "roles/viewer") to remove the member from.`, + Value: flagvalue.Simple("", &opts.Role), + Required: true, + Autocomplete: iampolicy.AutocompleteRoles(opts.Ctx, ctx.Profile.OrganizationID, organization_service.New(ctx.HCP, nil)), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + resourceName := helper.ResourceName(opts.GroupName, ctx.Profile.OrganizationID) + + // Create our group IAM Updater + u := &iamUpdater{ + resourceName: resourceName, + client: opts.ResourceClient, + } + + // Create the policy setter + opts.Setter = iampolicy.NewSetter( + ctx.Profile.OrganizationID, + u, + iam_service.New(ctx.HCP, nil), + c.Logger()) + + if runF != nil { + return runF(opts) + } + + return deleteBindingRun(opts) + }, + PersistentPreRun: func(c *cmd.Command, args []string) error { + return cmd.RequireOrganization(ctx) + }, + } + + return cmd +} + +type DeleteBindingOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + + Setter iampolicy.Setter + GroupName string + PrincipalID string + Role string + GroupsClient groups_service.ClientService + ResourceClient resource_service.ClientService +} + +func deleteBindingRun(opts *DeleteBindingOpts) error { + _, err := opts.Setter.DeleteBinding(opts.Ctx, opts.PrincipalID, opts.Role) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.Err(), "%s Principal %q binding to role %q deleted.\n", + opts.IO.ColorScheme().SuccessIcon(), opts.PrincipalID, opts.Role) + return nil +} diff --git a/internal/commands/iam/groups/iam/delete_binding_test.go b/internal/commands/iam/groups/iam/delete_binding_test.go new file mode 100644 index 00000000..2aa25e40 --- /dev/null +++ b/internal/commands/iam/groups/iam/delete_binding_test.go @@ -0,0 +1,198 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/models" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewCmdDeleteBinding(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *AddBindingOpts + }{ + { + Name: "No Org", + Profile: profile.TestProfile, + Args: []string{ + "--group=test-group", + "--member=123", + "--role=roles/iam.group-manager", + }, + Error: "Organization ID must be configured", + }, + { + Name: "Too many args", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + }, + Args: []string{ + "--group=test-group", + "--member=123", + "--role=iam.group-manager", + "foo", + "bar", + }, + Error: "no arguments allowed, but received 2", + }, + { + Name: "Missing group", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--member=123", + "--role=roles/iam.group-manager", + }, + Error: "ERROR: missing required flag: --group=NAME", + }, + { + Name: "Missing member", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--group=test-group", + "--role=roles/iam.group-manager", + }, + Error: "ERROR: missing required flag: --member=PRINCIPAL_ID", + }, + { + Name: "Missing role", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{ + "--group=test-group", + "--member=123", + }, + Error: "ERROR: missing required flag: --role=ROLE_ID", + }, + { + Name: "Good", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123") + }, + Args: []string{"--group=test-group", "--member=123", "--role=admin"}, + Expect: &AddBindingOpts{ + GroupName: "test-group", + PrincipalID: "123", + Role: "admin", + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *DeleteBindingOpts + deleteCmd := NewCmdDeleteBinding(ctx, func(o *DeleteBindingOpts) error { + gotOpts = o + return nil + }) + deleteCmd.SetIO(io) + + code := deleteCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + r.Equal(c.Expect.PrincipalID, gotOpts.PrincipalID) + r.Equal(c.Expect.Role, gotOpts.Role) + r.NotNil(gotOpts.Setter) + }) + } +} + +func TestDeleteBindingRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + RespErr error + Error string + }{ + { + Name: "Server error", + RespErr: fmt.Errorf("failed to add policy"), + Error: "failed to add policy", + }, + { + Name: "Good", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + setter := iampolicy.NewMockSetter(t) + opts := &DeleteBindingOpts{ + Ctx: context.Background(), + IO: io, + Setter: setter, + GroupName: "test-group", + PrincipalID: "principal-123", + Role: "roles/test", + } + + // Expect a request to add a binding. + call := setter.EXPECT().DeleteBinding(mock.Anything, opts.PrincipalID, opts.Role).Once() + + if c.RespErr != nil { + call.Return(nil, c.RespErr) + } else { + call.Return(&models.HashicorpCloudResourcemanagerPolicy{}, nil) + } + + // Run the command + err := deleteBindingRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + // Check we outputted the project + r.NoError(err) + r.Contains(io.Error.String(), `Principal "principal-123" binding to role "roles/test" deleted.`) + }) + } +} diff --git a/internal/commands/iam/groups/iam/iam.go b/internal/commands/iam/groups/iam/iam.go index 6d18bc07..b46018a3 100644 --- a/internal/commands/iam/groups/iam/iam.go +++ b/internal/commands/iam/groups/iam/iam.go @@ -30,7 +30,7 @@ func NewCmdIAM(ctx *cmd.Context) *cmd.Command { // TODO: Uncomment as subcommands are added cmd.AddChild(NewCmdAddBinding(ctx, nil)) - // cmd.AddChild(NewCmdDeleteBinding(ctx, nil)) + cmd.AddChild(NewCmdDeleteBinding(ctx, nil)) cmd.AddChild(NewCmdReadPolicy(ctx, nil)) // cmd.AddChild(NewCmdSetPolicy(ctx, nil)) return cmd From f2ce28ba9a508d14fa53a1b4ae0bfd851403f8ce Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Mon, 10 Jun 2024 15:33:45 -0400 Subject: [PATCH 10/14] implement iam groups iam set-policy --- internal/commands/iam/groups/iam/iam.go | 3 +- .../commands/iam/groups/iam/set_policy.go | 182 ++++++++++++++ .../iam/groups/iam/set_policy_test.go | 222 ++++++++++++++++++ 3 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 internal/commands/iam/groups/iam/set_policy.go create mode 100644 internal/commands/iam/groups/iam/set_policy_test.go diff --git a/internal/commands/iam/groups/iam/iam.go b/internal/commands/iam/groups/iam/iam.go index b46018a3..cb37c19a 100644 --- a/internal/commands/iam/groups/iam/iam.go +++ b/internal/commands/iam/groups/iam/iam.go @@ -28,10 +28,9 @@ func NewCmdIAM(ctx *cmd.Context) *cmd.Command { }, } - // TODO: Uncomment as subcommands are added cmd.AddChild(NewCmdAddBinding(ctx, nil)) cmd.AddChild(NewCmdDeleteBinding(ctx, nil)) cmd.AddChild(NewCmdReadPolicy(ctx, nil)) - // cmd.AddChild(NewCmdSetPolicy(ctx, nil)) + cmd.AddChild(NewCmdSetPolicy(ctx, nil)) return cmd } diff --git a/internal/commands/iam/groups/iam/set_policy.go b/internal/commands/iam/groups/iam/set_policy.go new file mode 100644 index 00000000..3a4f0c52 --- /dev/null +++ b/internal/commands/iam/groups/iam/set_policy.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/groups_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/client/resource_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/stable/2019-12-10/models" + "github.com/hashicorp/hcp/internal/commands/iam/groups/helper" + "github.com/hashicorp/hcp/internal/pkg/api/iampolicy" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/posener/complete" +) + +func NewCmdSetPolicy(ctx *cmd.Context, runF func(*SetPolicyOpts) error) *cmd.Command { + opts := &SetPolicyOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + + GroupsClient: groups_service.New(ctx.HCP, nil), + ResourceClient: resource_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "set-policy", + ShortHelp: "Set the IAM policy for a group.", + LongHelp: heredoc.New(ctx.IO).Must(` +The {{ template "mdCodeOrBold" "hcp iam groups iam set-policy" }} command sets +the IAM policy for the group, given a group name and a file encoded in +JSON that contains the IAM policy. If adding or removing a single principal from +the policy, prefer using {{ template "mdCodeOrBold" "hcp iam groups iam add-binding" }} +and the related {{ template "mdCodeOrBold" "hcp iam groups iam delete-binding" }}. + +The policy file is expected to be a file encoded in JSON that +contains the IAM policy. + +The format for the policy JSON file is an object with the following format: + +{{ define "bindings" -}} { + "bindings": [ + { + "role_id": "ROLE_ID", + "members": [ + { + "member_id": "PRINCIPAL_ID", + "member_type": "USER" + } + ] + } + ], + "etag": "ETAG" +} {{- end }} +{{- CodeBlock "bindings" "json" }} + +If set, the etag of the policy must be equal to that of the existing policy. To view the +existing policy and its etag, run {{ template "mdCodeOrBold" "hcp iam groups iam read-policy --format=json" }}. +If unset, the existing policy's etag will be fetched and used. + `), + Examples: []cmd.Example{ + { + Preamble: "Set the IAM Policy for a group:", + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ cat >policy.json < Date: Mon, 10 Jun 2024 15:43:08 -0400 Subject: [PATCH 11/14] add doc around group manager --- internal/commands/iam/groups/iam/set_policy.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/commands/iam/groups/iam/set_policy.go b/internal/commands/iam/groups/iam/set_policy.go index 3a4f0c52..63db7d0d 100644 --- a/internal/commands/iam/groups/iam/set_policy.go +++ b/internal/commands/iam/groups/iam/set_policy.go @@ -67,6 +67,8 @@ The format for the policy JSON file is an object with the following format: If set, the etag of the policy must be equal to that of the existing policy. To view the existing policy and its etag, run {{ template "mdCodeOrBold" "hcp iam groups iam read-policy --format=json" }}. If unset, the existing policy's etag will be fetched and used. + +Note that the only supported member_type is {{ template "mdCodeOrBold" "USER" }} and the only supported role_id is {{ template "mdCodeOrBold" "roles/iam.group-manager" }}". `), Examples: []cmd.Example{ { From 99cb90e0cdf49488b049559bdce84015c634f5f5 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Mon, 10 Jun 2024 15:50:01 -0400 Subject: [PATCH 12/14] working chanage --- internal/commands/iam/groups/iam/set_policy.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/commands/iam/groups/iam/set_policy.go b/internal/commands/iam/groups/iam/set_policy.go index 63db7d0d..db15d0d7 100644 --- a/internal/commands/iam/groups/iam/set_policy.go +++ b/internal/commands/iam/groups/iam/set_policy.go @@ -102,13 +102,14 @@ Note that the only supported member_type is {{ template "mdCodeOrBold" "USER" }} Name: "group", Shorthand: "g", DisplayValue: "NAME", - Description: "The name of the group to add the role binding to.", + Description: "The name of the group to set the policy on.", Value: flagvalue.Simple("", &opts.GroupName), Autocomplete: helper.PredictGroupResourceNameSuffix(opts.Ctx, opts.Profile.OrganizationID, opts.GroupsClient), Required: true, }, { Name: "policy-file", + Shorthand: "p", DisplayValue: "PATH", Description: "The path to a file containing an IAM policy object.", Value: flagvalue.Simple("", &opts.PolicyFile), From bdea39df0285371cde9b5c293b181e2bcbc0c75a Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Tue, 11 Jun 2024 10:55:28 -0400 Subject: [PATCH 13/14] changelog --- .changelog/113.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/113.txt diff --git a/.changelog/113.txt b/.changelog/113.txt new file mode 100644 index 00000000..7c6ad96e --- /dev/null +++ b/.changelog/113.txt @@ -0,0 +1,3 @@ +```release-note:feature +iam: Adds read-policy, set-policy, add-binding, and delete-binding subcommands to hcp iam groups iam which allow the ability to manage an IAM policy on a group. +``` \ No newline at end of file From 6cd9a8c2b08d73f67ff9ac1fac761d631a693504 Mon Sep 17 00:00:00 2001 From: Jasper Milan Date: Tue, 11 Jun 2024 10:57:25 -0400 Subject: [PATCH 14/14] changelog --- .changelog/113.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changelog/113.txt b/.changelog/113.txt index 7c6ad96e..a97db98f 100644 --- a/.changelog/113.txt +++ b/.changelog/113.txt @@ -1,3 +1,7 @@ ```release-note:feature -iam: Adds read-policy, set-policy, add-binding, and delete-binding subcommands to hcp iam groups iam which allow the ability to manage an IAM policy on a group. +iam: Adds `read-policy`, `set-policy`, `add-binding`, and `delete-binding` subcommands to `hcp iam groups iam` which allow the ability to manage an IAM policy on a group. +- `read-policy` Reads an IAM policy for a specified group. +- `set-policy` Sets an IAM policy for a group using a JSON file. +- `add-binding` Adds a single role binding to a user principal. +- `delete-binding` Removes a single role binding from a user principal. ``` \ No newline at end of file