diff --git a/internal/pkg/aws/iam/iam.go b/internal/pkg/aws/iam/iam.go index 92e674449d2..44232140bbe 100644 --- a/internal/pkg/aws/iam/iam.go +++ b/internal/pkg/aws/iam/iam.go @@ -25,6 +25,7 @@ type api interface { ListRolePolicies(input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error) DeleteRole(input *iam.DeleteRoleInput) (*iam.DeleteRoleOutput, error) CreateServiceLinkedRole(input *iam.CreateServiceLinkedRoleInput) (*iam.CreateServiceLinkedRoleOutput, error) + ListPolicies(input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) } // IAM wraps the AWS SDK's IAM client. @@ -98,6 +99,32 @@ func (c *IAM) CreateECSServiceLinkedRole() error { return nil } +// ListPolicyNames returns a list of local policy names. +func (c *IAM) ListPolicyNames() ([]string, error) { + var policies []*iam.Policy + var marker *string + for { + output, err := c.client.ListPolicies(&iam.ListPoliciesInput{ + Marker: marker, + Scope: aws.String("Local"), + PolicyUsageFilter: aws.String("PermissionsBoundary"), + }) + if err != nil { + return nil, fmt.Errorf("list IAM policies: %w", err) + } + policies = append(policies, output.Policies...) + if !aws.BoolValue(output.IsTruncated) { + break + } + marker = output.Marker + } + var policyNames = make([]string, len(policies)) + for i, policy := range policies { + policyNames[i] = aws.StringValue(policy.PolicyName) + } + return policyNames, nil +} + func (c *IAM) deleteRolePolicies(roleName string) error { policyNames, err := c.listRolePolicyNames(roleName) if err != nil { diff --git a/internal/pkg/aws/iam/iam_test.go b/internal/pkg/aws/iam/iam_test.go index 7223baf0d2f..705f8da0bb6 100644 --- a/internal/pkg/aws/iam/iam_test.go +++ b/internal/pkg/aws/iam/iam_test.go @@ -242,3 +242,64 @@ func TestIAM_CreateECSServiceLinkedRole(t *testing.T) { }) } } + +func TestIAM_ListPolicies(t *testing.T) { + testCases := map[string]struct { + inClient func(ctrl *gomock.Controller) *mocks.Mockapi + + wantedPolicies []string + wantedErr error + }{ + "wraps error on failure": { + inClient: func(ctrl *gomock.Controller) *mocks.Mockapi { + m := mocks.NewMockapi(ctrl) + m.EXPECT(). + ListPolicies(gomock.Any()). + Return(nil, errors.New("some error")) + return m + }, + + wantedErr: errors.New("list IAM policies: some error"), + }, + "returns list of policy names": { + inClient: func(ctrl *gomock.Controller) *mocks.Mockapi { + m := mocks.NewMockapi(ctrl) + m.EXPECT(). + ListPolicies(gomock.Any()). + Return(&iam.ListPoliciesOutput{ + Policies: []*iam.Policy{ + { + PolicyName: aws.String("myFirstPolicyName"), + }, + { + PolicyName: aws.String("mySecondPolicyName"), + }, + }, + }, nil) + return m + }, + wantedPolicies: []string{"myFirstPolicyName", "mySecondPolicyName"}, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + iam := &IAM{ + client: tc.inClient(ctrl), + } + + // WHEN + output, err := iam.ListPolicyNames() + + // THEN + if tc.wantedErr != nil { + require.EqualError(t, err, tc.wantedErr.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantedPolicies, output) + } + }) + } +} diff --git a/internal/pkg/aws/iam/mocks/mock_iam.go b/internal/pkg/aws/iam/mocks/mock_iam.go index 0858de5b034..973b3781dd3 100644 --- a/internal/pkg/aws/iam/mocks/mock_iam.go +++ b/internal/pkg/aws/iam/mocks/mock_iam.go @@ -79,6 +79,21 @@ func (mr *MockapiMockRecorder) DeleteRolePolicy(input interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRolePolicy", reflect.TypeOf((*Mockapi)(nil).DeleteRolePolicy), input) } +// ListPolicies mocks base method. +func (m *Mockapi) ListPolicies(input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPolicies", input) + ret0, _ := ret[0].(*iam.ListPoliciesOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPolicies indicates an expected call of ListPolicies. +func (mr *MockapiMockRecorder) ListPolicies(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPolicies", reflect.TypeOf((*Mockapi)(nil).ListPolicies), input) +} + // ListRolePolicies mocks base method. func (m *Mockapi) ListRolePolicies(input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/cli/app_init.go b/internal/pkg/cli/app_init.go index c97a45514a3..3126348d175 100644 --- a/internal/pkg/cli/app_init.go +++ b/internal/pkg/cli/app_init.go @@ -6,6 +6,7 @@ package cli import ( "errors" "fmt" + "github.com/aws/copilot-cli/internal/pkg/aws/iam" "os" "github.com/aws/aws-sdk-go/aws" @@ -51,6 +52,7 @@ type initAppOpts struct { cfn appDeployer prompt prompter prog progress + iam policyLister isSessionFromEnvVars func() (bool, error) cachedHostedZoneID string @@ -77,6 +79,7 @@ func newInitAppOpts(vars initAppVars) (*initAppOpts, error) { cfn: cloudformation.New(sess, cloudformation.WithProgressTracker(os.Stderr)), prompt: prompt.New(), prog: termprogress.NewSpinner(log.DiagnosticWriter), + iam: iam.New(sess), isSessionFromEnvVars: func() (bool, error) { return sessions.AreCredsFromEnvVars(sess) }, @@ -90,6 +93,11 @@ func (o *initAppOpts) Validate() error { return err } } + if o.permissionsBoundary != "" { + if err := o.validatePermBound(o.permissionsBoundary); err != nil { + return err + } + } if o.domainName != "" { if err := validateDomainName(o.domainName); err != nil { return fmt.Errorf("domain name %s is invalid: %w", o.domainName, err) @@ -234,6 +242,19 @@ func (o *initAppOpts) validateAppName(name string) error { return nil } +func (o *initAppOpts) validatePermBound(policyName string) error { + IAMPolicies, err := o.iam.ListPolicyNames() + if err != nil { + return fmt.Errorf("list permissions boundary policies: %w", err) + } + for _, policy := range IAMPolicies { + if policy == policyName { + return nil + } + } + return fmt.Errorf("IAM policy %q not found in this account", policyName) +} + func (o *initAppOpts) isDomainOwned() error { err := o.domainInfoGetter.IsRegisteredDomain(o.domainName) if err == nil { diff --git a/internal/pkg/cli/app_init_test.go b/internal/pkg/cli/app_init_test.go index 5ac5ea9c64d..b0b9461bcfb 100644 --- a/internal/pkg/cli/app_init_test.go +++ b/internal/pkg/cli/app_init_test.go @@ -22,12 +22,14 @@ type initAppMocks struct { mockRoute53Svc *mocks.MockdomainHostedZoneGetter mockStore *mocks.Mockstore mockDomainInfoGetter *mocks.MockdomainInfoGetter + mockPolicyLister *mocks.MockpolicyLister } func TestInitAppOpts_Validate(t *testing.T) { testCases := map[string]struct { - inAppName string - inDomainName string + inAppName string + inDomainName string + inPBPolicyName string mock func(m *initAppMocks) @@ -90,6 +92,21 @@ func TestInitAppOpts_Validate(t *testing.T) { }, wantedError: errors.New("check if domain is owned by the account: some error"), }, + "wrap error from ListPolicies": { + inPBPolicyName: "nonexistentPolicyName", + mock: func(m *initAppMocks) { + m.mockPolicyLister.EXPECT().ListPolicyNames().Return(nil, errors.New("some error")) + }, + wantedError: errors.New("list permissions boundary policies: some error"), + }, + "invalid permissions boundary policy name": { + inPBPolicyName: "nonexistentPolicyName", + mock: func(m *initAppMocks) { + m.mockPolicyLister.EXPECT().ListPolicyNames().Return( + []string{"existentPolicyName"}, nil) + }, + wantedError: errors.New("IAM policy \"nonexistentPolicyName\" not found in this account"), + }, "invalid domain name that doesn't have a hosted zone": { inDomainName: "badMockDomain.com", mock: func(m *initAppMocks) { @@ -132,6 +149,7 @@ func TestInitAppOpts_Validate(t *testing.T) { mockStore: mocks.NewMockstore(ctrl), mockRoute53Svc: mocks.NewMockdomainHostedZoneGetter(ctrl), mockDomainInfoGetter: mocks.NewMockdomainInfoGetter(ctrl), + mockPolicyLister: mocks.NewMockpolicyLister(ctrl), } tc.mock(m) @@ -139,9 +157,11 @@ func TestInitAppOpts_Validate(t *testing.T) { route53: m.mockRoute53Svc, domainInfoGetter: m.mockDomainInfoGetter, store: m.mockStore, + iam: m.mockPolicyLister, initAppVars: initAppVars{ - name: tc.inAppName, - domainName: tc.inDomainName, + name: tc.inAppName, + domainName: tc.inDomainName, + permissionsBoundary: tc.inPBPolicyName, }, } diff --git a/internal/pkg/cli/interfaces.go b/internal/pkg/cli/interfaces.go index 85734437452..783f3554d30 100644 --- a/internal/pkg/cli/interfaces.go +++ b/internal/pkg/cli/interfaces.go @@ -555,6 +555,10 @@ type roleDeleter interface { DeleteRole(string) error } +type policyLister interface { + ListPolicyNames() ([]string, error) +} + type serviceDescriber interface { DescribeService(app, env, svc string) (*ecs.ServiceDesc, error) } diff --git a/internal/pkg/cli/mocks/mock_interfaces.go b/internal/pkg/cli/mocks/mock_interfaces.go index ed151f1aa8f..f3df9b139cd 100644 --- a/internal/pkg/cli/mocks/mock_interfaces.go +++ b/internal/pkg/cli/mocks/mock_interfaces.go @@ -5891,6 +5891,44 @@ func (mr *MockroleDeleterMockRecorder) DeleteRole(arg0 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRole", reflect.TypeOf((*MockroleDeleter)(nil).DeleteRole), arg0) } +// MockpolicyLister is a mock of policyLister interface. +type MockpolicyLister struct { + ctrl *gomock.Controller + recorder *MockpolicyListerMockRecorder +} + +// MockpolicyListerMockRecorder is the mock recorder for MockpolicyLister. +type MockpolicyListerMockRecorder struct { + mock *MockpolicyLister +} + +// NewMockpolicyLister creates a new mock instance. +func NewMockpolicyLister(ctrl *gomock.Controller) *MockpolicyLister { + mock := &MockpolicyLister{ctrl: ctrl} + mock.recorder = &MockpolicyListerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockpolicyLister) EXPECT() *MockpolicyListerMockRecorder { + return m.recorder +} + +// ListPolicyNames mocks base method. +func (m *MockpolicyLister) ListPolicyNames() ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPolicyNames") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPolicyNames indicates an expected call of ListPolicyNames. +func (mr *MockpolicyListerMockRecorder) ListPolicyNames() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPolicyNames", reflect.TypeOf((*MockpolicyLister)(nil).ListPolicyNames)) +} + // MockserviceDescriber is a mock of serviceDescriber interface. type MockserviceDescriber struct { ctrl *gomock.Controller