diff --git a/docs/coverage/README.md b/docs/coverage/README.md index e75c5e02d..7584e6084 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -17,7 +17,7 @@ tags: ## Overview -The platform serves **397 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand. +The platform serves **398 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand. | Service | Operations | |---|---:| @@ -27,8 +27,8 @@ The platform serves **397 operations** across the AWS APIs below. Every page nam | [ECS](/coverage/ecs) | 31 | | [EKS](/coverage/eks) | 36 | | [ELBv2](/coverage/elbv2) | 36 | -| [IAM](/coverage/iam) | 79 | +| [IAM](/coverage/iam) | 80 | | [RDS](/coverage/rds) | 26 | | [S3](/coverage/s3) | 26 | | [STS](/coverage/sts) | 5 | -| **Total** | **397** | +| **Total** | **398** | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 66cc6d13d..ca73485a1 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -2862,6 +2862,7 @@ "GetUser", "GetUserPolicy", "ListAccessKeys", + "ListAccountAliases", "ListAttachedGroupPolicies", "ListAttachedRolePolicies", "ListAttachedUserPolicies", @@ -2943,6 +2944,7 @@ "GetUser", "GetUserPolicy", "ListAccessKeys", + "ListAccountAliases", "ListAttachedGroupPolicies", "ListAttachedRolePolicies", "ListAttachedUserPolicies", @@ -3042,7 +3044,6 @@ "GetServiceLastAccessedDetails", "GetServiceLastAccessedDetailsWithEntities", "GetServiceLinkedRoleDeletionStatus", - "ListAccountAliases", "ListDelegationRequests", "ListMFADeviceTags", "ListMFADevices", diff --git a/docs/coverage/iam/README.md b/docs/coverage/iam/README.md index b761003fc..f4515f490 100644 --- a/docs/coverage/iam/README.md +++ b/docs/coverage/iam/README.md @@ -18,7 +18,7 @@ tags: ## Overview -Spinifex implements **79 operations** in the IAM `2010-05-08` API model. +Spinifex implements **80 operations** in the IAM `2010-05-08` API model. ### Scope @@ -67,6 +67,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli | `GetUser` | | `GetUserPolicy` | | `ListAccessKeys` | +| `ListAccountAliases` | | `ListAttachedGroupPolicies` | | `ListAttachedRolePolicies` | | `ListAttachedUserPolicies` | diff --git a/docs/coverage/internal-report.md b/docs/coverage/internal-report.md index e14858596..9fcc99419 100644 --- a/docs/coverage/internal-report.md +++ b/docs/coverage/internal-report.md @@ -957,7 +957,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential, ## iam -`2010-05-08` — 79 of 180 modelled operations implemented (43.9%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model. +`2010-05-08` — 80 of 180 modelled operations implemented (44.4%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model. ### Not implemented @@ -1006,7 +1006,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential, - `GetServerCertificate` - `GetServiceLastAccessedDetails` - `GetServiceLastAccessedDetailsWithEntities` -- `ListAccountAliases` - `ListDelegationRequests` - `ListMFADeviceTags` - `ListMFADevices` diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go index 6a708a110..3830bea5c 100644 --- a/spinifex/gateway/iam.go +++ b/spinifex/gateway/iam.go @@ -323,6 +323,9 @@ var iamActions = map[string]iamAction{ "GetAccountSummary": iamHandler(func(accountID string, input *iam.GetAccountSummaryInput, gw *GatewayConfig) (any, error) { return gateway_iam.GetAccountSummary(accountID, input, gw.IAMService) }), + "ListAccountAliases": iamHandler(func(accountID string, input *iam.ListAccountAliasesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListAccountAliases(accountID, input, gw.IAMService) + }), } func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error { diff --git a/spinifex/gateway/iam/account.go b/spinifex/gateway/iam/account.go index 1610abf8b..cfc20dd1a 100644 --- a/spinifex/gateway/iam/account.go +++ b/spinifex/gateway/iam/account.go @@ -1,6 +1,7 @@ package gateway_iam import ( + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" ) @@ -10,3 +11,17 @@ import ( func GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput, svc handlers_iam.IAMService) (*iam.GetAccountSummaryOutput, error) { return svc.GetAccountSummary(accountID, input) } + +func ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput, svc handlers_iam.IAMService) (*iam.ListAccountAliasesOutput, error) { + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListAccountAliases(accountID, input) + if err != nil { + return nil, err + } + out.AccountAliases, out.Marker = paginate(p, out.AccountAliases, aws.StringValue) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil +} diff --git a/spinifex/gateway/iam/authz.go b/spinifex/gateway/iam/authz.go index 28e75e205..065ebc83a 100644 --- a/spinifex/gateway/iam/authz.go +++ b/spinifex/gateway/iam/authz.go @@ -136,6 +136,7 @@ var iamScopes = map[string]resourceScope{ "ListOpenIDConnectProviders": {source: sourceAccount}, "ListGroups": {source: sourceAccount}, "GetAccountSummary": {source: sourceAccount}, + "ListAccountAliases": {source: sourceAccount}, } // HasScope reports whether action has an explicit IAM scope-table entry. diff --git a/spinifex/gateway/iam/pagination_test.go b/spinifex/gateway/iam/pagination_test.go index d550ae2b0..8ea8de352 100644 --- a/spinifex/gateway/iam/pagination_test.go +++ b/spinifex/gateway/iam/pagination_test.go @@ -132,6 +132,38 @@ func TestListUsers_RejectsBadPagingBeforeListing(t *testing.T) { } } +// listAliasesStub counts calls so a rejected page proves the service never ran. +type listAliasesStub struct { + handlers_iam.IAMService + + calls int +} + +func (s *listAliasesStub) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) { + s.calls++ + return &iam.ListAccountAliasesOutput{AccountAliases: []*string{}}, nil +} + +func TestListAccountAliases_RejectsBadPagingBeforeListing(t *testing.T) { + tests := []struct { + name string + input *iam.ListAccountAliasesInput + }{ + {"MaxItems zero", &iam.ListAccountAliasesInput{MaxItems: aws.Int64(0)}}, + {"MaxItems above 1000", &iam.ListAccountAliasesInput{MaxItems: aws.Int64(1001)}}, + {"undecodable Marker", &iam.ListAccountAliasesInput{Marker: aws.String("not a marker!")}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &listAliasesStub{} + _, err := gateway_iam.ListAccountAliases(testAccountID, tc.input, svc) + require.Error(t, err) + assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationError), "got %v", err) + assert.Zero(t, svc.calls) + }) + } +} + func TestListUsers_MaxItemsBoundsAccepted(t *testing.T) { svc := &listStub{users: []string{"a", "b"}} assert.Len(t, listUsers(t, svc, nil, 1).Users, 1) diff --git a/spinifex/gateway/iam_examples_contract_test.go b/spinifex/gateway/iam_examples_contract_test.go index 7a1554c0e..d4f26788e 100644 --- a/spinifex/gateway/iam_examples_contract_test.go +++ b/spinifex/gateway/iam_examples_contract_test.go @@ -242,6 +242,10 @@ func (svc *echoIAMService) GetAccountSummary(accountID string, input *iam.GetAcc return echoIAMCall[iam.GetAccountSummaryInput, iam.GetAccountSummaryOutput](svc, accountID, input) } +func (svc *echoIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) { + return echoIAMCall[iam.ListAccountAliasesInput, iam.ListAccountAliasesOutput](svc, accountID, input) +} + func (svc *echoIAMService) GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) { if _, ok := svc.wantOutput.(*iam.GetInstanceProfileOutput); !ok { return &iam.GetInstanceProfileOutput{InstanceProfile: &iam.InstanceProfile{}}, nil diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go index 1ecba816d..24908bbf4 100644 --- a/spinifex/gateway/iam_request_test.go +++ b/spinifex/gateway/iam_request_test.go @@ -10,11 +10,14 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/arn" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // flexMockIAMService is a configurable mock with per-method overrides. It @@ -125,6 +128,9 @@ func (m *flexMockIAMService) GetAccountSummary(accountID string, input *iam.GetA } return &iam.GetAccountSummaryOutput{}, nil } +func (m *flexMockIAMService) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) { + return &iam.ListAccountAliasesOutput{}, nil +} func (m *flexMockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) { return &iam.CreateGroupOutput{}, nil } @@ -282,6 +288,40 @@ func TestIAMRequest_GetAccountSummary_Success(t *testing.T) { assert.Contains(t, xmlStr, "2") } +// realAliasesIAMService answers ListAccountAliases from the real service, which +// needs no storage, so the test sees the response the gateway actually sends. +type realAliasesIAMService struct { + flexMockIAMService +} + +func (realAliasesIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) { + return (&handlers_iam.IAMServiceImpl{}).ListAccountAliases(accountID, input) +} + +// AWS answers an account with no alias with an empty AccountAliases list and +// IsTruncated false, and no Marker. +func TestIAMRequest_ListAccountAliases_Empty(t *testing.T) { + server := httptest.NewServer(setupIAMRequestHandler(&realAliasesIAMService{})) + defer server.Close() + client := iam.New(session.Must(session.NewSession(&aws.Config{ + Credentials: credentials.NewStaticCredentials("test", "test", ""), + DisableSSL: aws.Bool(true), + Endpoint: aws.String(server.URL), + MaxRetries: aws.Int(0), + Region: aws.String("us-east-1"), + }))) + + out, err := client.ListAccountAliases(&iam.ListAccountAliasesInput{}) + require.NoError(t, err) + assert.Empty(t, out.AccountAliases) + assert.False(t, aws.BoolValue(out.IsTruncated)) + assert.Nil(t, out.Marker) + + _, err = client.ListAccountAliases(&iam.ListAccountAliasesInput{MaxItems: aws.Int64(1001)}) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorValidationError) +} + // AWS answers GetRole for a never-used role with an empty // element; a nil-field struct must still marshal to that element, not vanish. func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) { diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go index 5196863df..e4185954d 100644 --- a/spinifex/handlers/iam/service.go +++ b/spinifex/handlers/iam/service.go @@ -151,4 +151,8 @@ type IAMService interface { // GetAccountSummary returns account-wide IAM usage counts plus AWS-parity // quota values as a SummaryMap. Read-only and account-scoped. GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error) + + // ListAccountAliases lists the account's aliases. Spinifex has no account + // aliases, so every account has none. + ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) } diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 531edd817..ded77d7c2 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -2666,6 +2666,12 @@ func (s *IAMServiceImpl) GetAccountSummary(accountID string, _ *iam.GetAccountSu return &iam.GetAccountSummaryOutput{SummaryMap: summary}, nil } +// ListAccountAliases returns no aliases, which is what AWS returns for an account +// that has none. Spinifex does not serve CreateAccountAlias. +func (s *IAMServiceImpl) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) { + return &iam.ListAccountAliasesOutput{AccountAliases: []*string{}}, nil +} + // countOIDCProviders counts the account's OIDC providers. AWS also counts SAML // providers under Providers; Spinifex has none. func (s *IAMServiceImpl) countOIDCProviders(ctx context.Context, accountID string) (int64, error) {