diff --git a/.changelog/113.txt b/.changelog/113.txt new file mode 100644 index 00000000..a97db98f --- /dev/null +++ b/.changelog/113.txt @@ -0,0 +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. +- `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 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/iam/add_binding.go b/internal/commands/iam/groups/iam/add_binding.go new file mode 100644 index 00000000..80db0727 --- /dev/null +++ b/internal/commands/iam/groups/iam/add_binding.go @@ -0,0 +1,140 @@ +// 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 NewCmdAddBinding(ctx *cmd.Context, runF func(*AddBindingOpts) error) *cmd.Command { + opts := &AddBindingOpts{ + 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: "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" }}. + + 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=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 add the role binding to.", + 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 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 + 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 { + 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/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 new file mode 100644 index 00000000..cb37c19a --- /dev/null +++ b/internal/commands/iam/groups/iam/iam.go @@ -0,0 +1,36 @@ +// 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. + `), + 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 + `), + }, + }, + } + + 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..2368c0ce --- /dev/null +++ b/internal/commands/iam/groups/iam/read_policy.go @@ -0,0 +1,114 @@ +// 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.RequireOrganization(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) + + // Create the group IAM Updater + u := &iamUpdater{ + resourceName: resourceName, + 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..9f932ef6 --- /dev/null +++ b/internal/commands/iam/groups/iam/read_policy_test.go @@ -0,0 +1,97 @@ +// 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 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 Specified", + 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/set_policy.go b/internal/commands/iam/groups/iam/set_policy.go new file mode 100644 index 00000000..db15d0d7 --- /dev/null +++ b/internal/commands/iam/groups/iam/set_policy.go @@ -0,0 +1,185 @@ +// 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. + +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{ + { + Preamble: "Set the IAM Policy for a group:", + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ cat >policy.json <