From 866392e6805c52528232fa846dbc4db12a71b5c5 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:53:41 +1000 Subject: [PATCH] fix: honour MaxItems and Marker on IAM list actions (#1125) --- spinifex/gateway/iam/group.go | 61 +++++- spinifex/gateway/iam/instance_profile.go | 24 ++- spinifex/gateway/iam/key.go | 13 +- spinifex/gateway/iam/oidc_provider.go | 13 +- spinifex/gateway/iam/pagination.go | 109 ++++++++++ spinifex/gateway/iam/pagination_test.go | 188 ++++++++++++++++++ spinifex/gateway/iam/policy.go | 96 ++++++++- spinifex/gateway/iam/role.go | 43 +++- spinifex/gateway/iam/user.go | 37 +++- spinifex/handlers/iam/groups.go | 2 +- spinifex/handlers/iam/instance_profiles.go | 3 +- spinifex/handlers/iam/oidc_providers.go | 3 +- spinifex/handlers/iam/policy_versions.go | 2 +- spinifex/handlers/iam/roles.go | 5 +- spinifex/handlers/iam/service_impl.go | 8 +- .../frontend/src/queries/iam.test.ts | 14 +- .../spinifexui/frontend/src/queries/iam.ts | 23 ++- 17 files changed, 600 insertions(+), 44 deletions(-) create mode 100644 spinifex/gateway/iam/pagination.go create mode 100644 spinifex/gateway/iam/pagination_test.go diff --git a/spinifex/gateway/iam/group.go b/spinifex/gateway/iam/group.go index 6ab760f0b..38a6b1288 100644 --- a/spinifex/gateway/iam/group.go +++ b/spinifex/gateway/iam/group.go @@ -3,6 +3,7 @@ package gateway_iam import ( "errors" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -19,11 +20,31 @@ func GetGroup(accountID string, input *iam.GetGroupInput, svc handlers_iam.IAMSe if input.GroupName == nil || *input.GroupName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.GetGroup(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.GetGroup(accountID, input) + if err != nil { + return nil, err + } + out.Users, out.Marker = paginate(p, out.Users, userKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func ListGroups(accountID string, input *iam.ListGroupsInput, svc handlers_iam.IAMService) (*iam.ListGroupsOutput, error) { - return svc.ListGroups(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListGroups(accountID, input) + if err != nil { + return nil, err + } + out.Groups, out.Marker = paginate(p, out.Groups, groupKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func DeleteGroup(accountID string, input *iam.DeleteGroupInput, svc handlers_iam.IAMService) (*iam.DeleteGroupOutput, error) { @@ -57,7 +78,17 @@ func ListGroupsForUser(accountID string, input *iam.ListGroupsForUserInput, svc if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListGroupsForUser(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListGroupsForUser(accountID, input) + if err != nil { + return nil, err + } + out.Groups, out.Marker = paginate(p, out.Groups, groupKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func AttachGroupPolicy(accountID string, input *iam.AttachGroupPolicyInput, svc handlers_iam.IAMService) (*iam.AttachGroupPolicyOutput, error) { @@ -84,7 +115,17 @@ func ListAttachedGroupPolicies(accountID string, input *iam.ListAttachedGroupPol if input.GroupName == nil || *input.GroupName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListAttachedGroupPolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListAttachedGroupPolicies(accountID, input) + if err != nil { + return nil, err + } + out.AttachedPolicies, out.Marker = paginate(p, out.AttachedPolicies, attachedPolicyKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func PutGroupPolicy(accountID string, input *iam.PutGroupPolicyInput, svc handlers_iam.IAMService) (*iam.PutGroupPolicyOutput, error) { @@ -129,5 +170,15 @@ func ListGroupPolicies(accountID string, input *iam.ListGroupPoliciesInput, svc if input.GroupName == nil || *input.GroupName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListGroupPolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListGroupPolicies(accountID, input) + if err != nil { + return nil, err + } + out.PolicyNames, out.Marker = paginate(p, out.PolicyNames, aws.StringValue) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } diff --git a/spinifex/gateway/iam/instance_profile.go b/spinifex/gateway/iam/instance_profile.go index 3f319701a..3c6e8ef6b 100644 --- a/spinifex/gateway/iam/instance_profile.go +++ b/spinifex/gateway/iam/instance_profile.go @@ -34,10 +34,16 @@ func GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput, sv } func ListInstanceProfiles(accountID string, input *iam.ListInstanceProfilesInput, svc handlers_iam.IAMService) (*iam.ListInstanceProfilesOutput, error) { + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } out, err := svc.ListInstanceProfiles(accountID, input) if err != nil { return nil, err } + out.InstanceProfiles, out.Marker = paginate(p, out.InstanceProfiles, instanceProfileKey) + out.IsTruncated = aws.Bool(out.Marker != nil) encodeInstanceProfileDocuments(out.InstanceProfiles...) return out, nil } @@ -82,10 +88,16 @@ func ListInstanceProfilesForRole(accountID string, input *iam.ListInstanceProfil if input.RoleName == nil || *input.RoleName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } out, err := svc.ListInstanceProfilesForRole(accountID, input) if err != nil { return nil, err } + out.InstanceProfiles, out.Marker = paginate(p, out.InstanceProfiles, instanceProfileKey) + out.IsTruncated = aws.Bool(out.Marker != nil) encodeInstanceProfileDocuments(out.InstanceProfiles...) return out, nil } @@ -134,5 +146,15 @@ func ListInstanceProfileTags(accountID string, input *iam.ListInstanceProfileTag if input.InstanceProfileName == nil || *input.InstanceProfileName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListInstanceProfileTags(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListInstanceProfileTags(accountID, input) + if err != nil { + return nil, err + } + out.Tags, out.Marker = paginate(p, out.Tags, tagKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } diff --git a/spinifex/gateway/iam/key.go b/spinifex/gateway/iam/key.go index f4e528ef5..b713f5b76 100644 --- a/spinifex/gateway/iam/key.go +++ b/spinifex/gateway/iam/key.go @@ -3,6 +3,7 @@ package gateway_iam import ( "errors" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -19,7 +20,17 @@ func ListAccessKeys(accountID string, input *iam.ListAccessKeysInput, svc handle if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListAccessKeys(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListAccessKeys(accountID, input) + if err != nil { + return nil, err + } + out.AccessKeyMetadata, out.Marker = paginate(p, out.AccessKeyMetadata, accessKeyKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func DeleteAccessKey(accountID string, input *iam.DeleteAccessKeyInput, svc handlers_iam.IAMService) (*iam.DeleteAccessKeyOutput, error) { diff --git a/spinifex/gateway/iam/oidc_provider.go b/spinifex/gateway/iam/oidc_provider.go index 976ecd38e..9533e2502 100644 --- a/spinifex/gateway/iam/oidc_provider.go +++ b/spinifex/gateway/iam/oidc_provider.go @@ -3,6 +3,7 @@ package gateway_iam import ( "errors" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -57,5 +58,15 @@ func ListOpenIDConnectProviderTags(accountID string, input *iam.ListOpenIDConnec if input.OpenIDConnectProviderArn == nil || *input.OpenIDConnectProviderArn == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListOpenIDConnectProviderTags(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListOpenIDConnectProviderTags(accountID, input) + if err != nil { + return nil, err + } + out.Tags, out.Marker = paginate(p, out.Tags, tagKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } diff --git a/spinifex/gateway/iam/pagination.go b/spinifex/gateway/iam/pagination.go new file mode 100644 index 000000000..1744a68dc --- /dev/null +++ b/spinifex/gateway/iam/pagination.go @@ -0,0 +1,109 @@ +package gateway_iam + +import ( + "cmp" + "encoding/base64" + "slices" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" +) + +// AWS's page size when MaxItems is omitted, and the largest MaxItems it accepts. +const ( + defaultMaxItems = 100 + maxMaxItems = 1000 +) + +// pager is a validated Marker/MaxItems pair. Pages are cut in the gateway, after +// the service has filtered, so in-process callers of the service still see the +// whole list. +type pager struct { + after string + resuming bool + size int +} + +// newPager validates the request before the service runs, so a bad Marker or +// MaxItems fails the call whatever the listed entity's state. +func newPager(marker *string, maxItems *int64) (pager, error) { + p := pager{size: defaultMaxItems} + if maxItems != nil { + n := *maxItems + if n < 1 { + return pager{}, awserrors.Errorf(awserrors.ErrorValidationError, + "1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value greater than or equal to 1", n) + } + if n > maxMaxItems { + return pager{}, awserrors.Errorf(awserrors.ErrorValidationError, + "1 validation error detected: Value '%d' at 'maxItems' failed to satisfy constraint: Member must have value less than or equal to %d", n, maxMaxItems) + } + p.size = int(n) + } + if marker != nil { + after, err := base64.RawURLEncoding.DecodeString(*marker) + if err != nil || len(after) == 0 { + return pager{}, awserrors.Errorf(awserrors.ErrorValidationError, "Invalid Marker.") + } + p.after, p.resuming = string(after), true + } + return p, nil +} + +// paginate sorts items by key and returns the page after the marker, with the +// marker for the next page, nil when this page is the last. +func paginate[T any](p pager, items []T, key func(T) string) ([]T, *string) { + return paginateFunc(p, items, key, strings.Compare) +} + +// paginateFunc is paginate for a list whose order is not ascending by key. The +// marker holds the last key returned rather than an offset, so a page boundary +// survives entries being added or removed between calls. +func paginateFunc[T any](p pager, items []T, key func(T) string, cmp func(a, b string) int) ([]T, *string) { + slices.SortStableFunc(items, func(a, b T) int { return cmp(key(a), key(b)) }) + start := 0 + if p.resuming { + i, found := slices.BinarySearchFunc(items, p.after, func(item T, after string) int { return cmp(key(item), after) }) + if found { + i++ + } + start = i + } + end := min(start+p.size, len(items)) + page := items[start:end] + if end == len(items) { + return page, nil + } + return page, aws.String(base64.RawURLEncoding.EncodeToString([]byte(key(page[len(page)-1])))) +} + +// Sort keys. A managed policy's name is unique only within its account, so the +// ARN breaks the tie between a customer policy and an AWS one of the same name. +func userKey(u *iam.User) string { return aws.StringValue(u.UserName) } +func roleKey(r *iam.Role) string { return aws.StringValue(r.RoleName) } +func groupKey(g *iam.Group) string { return aws.StringValue(g.GroupName) } +func tagKey(t *iam.Tag) string { return aws.StringValue(t.Key) } +func accessKeyKey(k *iam.AccessKeyMetadata) string { return aws.StringValue(k.AccessKeyId) } +func instanceProfileKey(p *iam.InstanceProfile) string { return aws.StringValue(p.InstanceProfileName) } +func policyKey(p *iam.Policy) string { + return aws.StringValue(p.PolicyName) + "\x00" + aws.StringValue(p.Arn) +} +func attachedPolicyKey(p *iam.AttachedPolicy) string { + return aws.StringValue(p.PolicyName) + "\x00" + aws.StringValue(p.PolicyArn) +} + +func policyVersionKey(v *iam.PolicyVersion) string { return aws.StringValue(v.VersionId) } + +// newestVersionFirst orders version IDs (v1, v2, ...) by descending number, the +// order ListPolicyVersions returns them in. +func newestVersionFirst(a, b string) int { + return cmp.Compare(versionNumber(b), versionNumber(a)) +} + +func versionNumber(id string) int { + n, _ := strconv.Atoi(strings.TrimPrefix(id, "v")) + return n +} diff --git a/spinifex/gateway/iam/pagination_test.go b/spinifex/gateway/iam/pagination_test.go new file mode 100644 index 000000000..356764a7c --- /dev/null +++ b/spinifex/gateway/iam/pagination_test.go @@ -0,0 +1,188 @@ +package gateway_iam + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// listStub serves fixed lists in whatever order the test gives, as the KV +// bucket does, and counts service calls. +type listStub struct { + stubIAMService + + users []string + versions []string + entities func() *iam.ListEntitiesForPolicyOutput + calls int +} + +func (s *listStub) ListUsers(_ string, _ *iam.ListUsersInput) (*iam.ListUsersOutput, error) { + s.calls++ + users := make([]*iam.User, 0, len(s.users)) + for _, name := range s.users { + users = append(users, &iam.User{UserName: aws.String(name)}) + } + return &iam.ListUsersOutput{Users: users, IsTruncated: aws.Bool(false)}, nil +} + +func (s *listStub) ListPolicyVersions(_ string, _ *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + versions := make([]*iam.PolicyVersion, 0, len(s.versions)) + for _, id := range s.versions { + versions = append(versions, &iam.PolicyVersion{VersionId: aws.String(id)}) + } + return &iam.ListPolicyVersionsOutput{Versions: versions, IsTruncated: aws.Bool(false)}, nil +} + +func (s *listStub) ListEntitiesForPolicy(_ string, _ *iam.ListEntitiesForPolicyInput) (*iam.ListEntitiesForPolicyOutput, error) { + return s.entities(), nil +} + +func userNames(users []*iam.User) []string { + names := make([]string, 0, len(users)) + for _, u := range users { + names = append(names, aws.StringValue(u.UserName)) + } + return names +} + +func listUsers(t *testing.T, svc *listStub, marker *string, maxItems int64) *iam.ListUsersOutput { + t.Helper() + out, err := ListUsers(testAccountID, &iam.ListUsersInput{Marker: marker, MaxItems: aws.Int64(maxItems)}, svc) + require.NoError(t, err) + return out +} + +func TestListUsers_MaxItemsTruncatesAndMarkerResumes(t *testing.T) { + svc := &listStub{users: []string{"carol", "alice", "bob"}} + + first := listUsers(t, svc, nil, 2) + assert.Equal(t, []string{"alice", "bob"}, userNames(first.Users)) + assert.True(t, aws.BoolValue(first.IsTruncated)) + require.NotNil(t, first.Marker) + + second := listUsers(t, svc, first.Marker, 2) + assert.Equal(t, []string{"carol"}, userNames(second.Users)) + assert.False(t, aws.BoolValue(second.IsTruncated)) + assert.Nil(t, second.Marker) +} + +func TestListUsers_DefaultPageIs100(t *testing.T) { + svc := &listStub{} + for i := range 101 { + svc.users = append(svc.users, fmt.Sprintf("user%03d", i)) + } + + out, err := ListUsers(testAccountID, &iam.ListUsersInput{}, svc) + require.NoError(t, err) + assert.Len(t, out.Users, 100) + assert.True(t, aws.BoolValue(out.IsTruncated)) + require.NotNil(t, out.Marker) +} + +func TestListUsers_ExactFitIsNotTruncated(t *testing.T) { + svc := &listStub{users: []string{"a", "b"}} + + out := listUsers(t, svc, nil, 2) + assert.Len(t, out.Users, 2) + assert.False(t, aws.BoolValue(out.IsTruncated)) + assert.Nil(t, out.Marker) +} + +// The marker names the last user returned, so the next page neither repeats nor +// skips a user when the list changes around the boundary between calls. +func TestListUsers_MarkerSurvivesChangesBetweenPages(t *testing.T) { + svc := &listStub{users: []string{"a", "b", "c", "d"}} + first := listUsers(t, svc, nil, 2) + require.Equal(t, []string{"a", "b"}, userNames(first.Users)) + + svc.users = []string{"d", "c", "aa", "a"} + second := listUsers(t, svc, first.Marker, 2) + assert.Equal(t, []string{"c", "d"}, userNames(second.Users)) + assert.False(t, aws.BoolValue(second.IsTruncated)) +} + +func TestListUsers_RejectsBadPagingBeforeListing(t *testing.T) { + tests := []struct { + name string + input *iam.ListUsersInput + }{ + {"MaxItems zero", &iam.ListUsersInput{MaxItems: aws.Int64(0)}}, + {"MaxItems above 1000", &iam.ListUsersInput{MaxItems: aws.Int64(1001)}}, + {"empty Marker", &iam.ListUsersInput{Marker: aws.String("")}}, + {"undecodable Marker", &iam.ListUsersInput{Marker: aws.String("not a marker!")}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &listStub{users: []string{"a"}} + _, err := ListUsers(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) + assert.Len(t, listUsers(t, svc, nil, 1000).Users, 2) +} + +func TestListPolicyVersions_PagesNewestFirst(t *testing.T) { + svc := &listStub{versions: []string{"v2", "v10", "v9"}} + ids := func(out *iam.ListPolicyVersionsOutput) []string { + var got []string + for _, v := range out.Versions { + got = append(got, aws.StringValue(v.VersionId)) + } + return got + } + + first, err := ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2)}, svc) + require.NoError(t, err) + assert.Equal(t, []string{"v10", "v9"}, ids(first)) + assert.True(t, aws.BoolValue(first.IsTruncated)) + + second, err := ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2), Marker: first.Marker}, svc) + require.NoError(t, err) + assert.Equal(t, []string{"v2"}, ids(second)) + assert.False(t, aws.BoolValue(second.IsTruncated)) +} + +// Groups, roles and users share one MaxItems, so a page can end partway +// through one list and the next page picks up in it. +func TestListEntitiesForPolicy_PagesAcrossAllThreeLists(t *testing.T) { + svc := &listStub{entities: func() *iam.ListEntitiesForPolicyOutput { + return &iam.ListEntitiesForPolicyOutput{ + PolicyUsers: []*iam.PolicyUser{{UserName: aws.String("u1")}}, + PolicyRoles: []*iam.PolicyRole{{RoleName: aws.String("r2")}, {RoleName: aws.String("r1")}}, + PolicyGroups: []*iam.PolicyGroup{{GroupName: aws.String("g1")}}, + } + }} + input := &iam.ListEntitiesForPolicyInput{PolicyArn: aws.String("arn"), MaxItems: aws.Int64(2)} + + first, err := ListEntitiesForPolicy(testAccountID, input, svc) + require.NoError(t, err) + require.Len(t, first.PolicyGroups, 1) + require.Len(t, first.PolicyRoles, 1) + assert.Equal(t, "r1", aws.StringValue(first.PolicyRoles[0].RoleName)) + assert.Empty(t, first.PolicyUsers) + assert.NotNil(t, first.PolicyUsers) + assert.True(t, aws.BoolValue(first.IsTruncated)) + + input.Marker = first.Marker + second, err := ListEntitiesForPolicy(testAccountID, input, svc) + require.NoError(t, err) + assert.Empty(t, second.PolicyGroups) + require.Len(t, second.PolicyRoles, 1) + assert.Equal(t, "r2", aws.StringValue(second.PolicyRoles[0].RoleName)) + require.Len(t, second.PolicyUsers, 1) + assert.False(t, aws.BoolValue(second.IsTruncated)) + assert.Nil(t, second.Marker) +} diff --git a/spinifex/gateway/iam/policy.go b/spinifex/gateway/iam/policy.go index a7a4d7504..0fd31f2c2 100644 --- a/spinifex/gateway/iam/policy.go +++ b/spinifex/gateway/iam/policy.go @@ -3,6 +3,7 @@ package gateway_iam import ( "errors" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -46,7 +47,17 @@ func ListPolicyVersions(accountID string, input *iam.ListPolicyVersionsInput, sv if input.PolicyArn == nil || *input.PolicyArn == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListPolicyVersions(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListPolicyVersions(accountID, input) + if err != nil { + return nil, err + } + out.Versions, out.Marker = paginateFunc(p, out.Versions, policyVersionKey, newestVersionFirst) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func CreatePolicyVersion(accountID string, input *iam.CreatePolicyVersionInput, svc handlers_iam.IAMService) (*iam.CreatePolicyVersionOutput, error) { @@ -80,7 +91,17 @@ func DeletePolicyVersion(accountID string, input *iam.DeletePolicyVersionInput, } func ListPolicies(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (*iam.ListPoliciesOutput, error) { - return svc.ListPolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListPolicies(accountID, input) + if err != nil { + return nil, err + } + out.Policies, out.Marker = paginate(p, out.Policies, policyKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func DeletePolicy(accountID string, input *iam.DeletePolicyInput, svc handlers_iam.IAMService) (*iam.DeletePolicyOutput, error) { @@ -94,7 +115,52 @@ func ListEntitiesForPolicy(accountID string, input *iam.ListEntitiesForPolicyInp if input.PolicyArn == nil || *input.PolicyArn == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListEntitiesForPolicy(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListEntitiesForPolicy(accountID, input) + if err != nil { + return nil, err + } + pageEntitiesForPolicy(p, out) + return out, nil +} + +// policyEntity is one row of ListEntitiesForPolicy's three lists, which page as +// a single sequence: groups, then roles, then users, each by name. +type policyEntity struct { + key string + group *iam.PolicyGroup + role *iam.PolicyRole + user *iam.PolicyUser +} + +func pageEntitiesForPolicy(p pager, out *iam.ListEntitiesForPolicyOutput) { + all := make([]policyEntity, 0, len(out.PolicyGroups)+len(out.PolicyRoles)+len(out.PolicyUsers)) + for _, g := range out.PolicyGroups { + all = append(all, policyEntity{key: iam.EntityTypeGroup + "\x00" + aws.StringValue(g.GroupName), group: g}) + } + for _, r := range out.PolicyRoles { + all = append(all, policyEntity{key: iam.EntityTypeRole + "\x00" + aws.StringValue(r.RoleName), role: r}) + } + for _, u := range out.PolicyUsers { + all = append(all, policyEntity{key: iam.EntityTypeUser + "\x00" + aws.StringValue(u.UserName), user: u}) + } + + page, marker := paginate(p, all, func(e policyEntity) string { return e.key }) + out.PolicyGroups, out.PolicyRoles, out.PolicyUsers = []*iam.PolicyGroup{}, []*iam.PolicyRole{}, []*iam.PolicyUser{} + for _, e := range page { + switch { + case e.group != nil: + out.PolicyGroups = append(out.PolicyGroups, e.group) + case e.role != nil: + out.PolicyRoles = append(out.PolicyRoles, e.role) + default: + out.PolicyUsers = append(out.PolicyUsers, e.user) + } + } + out.Marker, out.IsTruncated = marker, aws.Bool(marker != nil) } func AttachUserPolicy(accountID string, input *iam.AttachUserPolicyInput, svc handlers_iam.IAMService) (*iam.AttachUserPolicyOutput, error) { @@ -121,7 +187,17 @@ func ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPolic if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListAttachedUserPolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListAttachedUserPolicies(accountID, input) + if err != nil { + return nil, err + } + out.AttachedPolicies, out.Marker = paginate(p, out.AttachedPolicies, attachedPolicyKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func TagPolicy(accountID string, input *iam.TagPolicyInput, svc handlers_iam.IAMService) (*iam.TagPolicyOutput, error) { @@ -148,5 +224,15 @@ func ListPolicyTags(accountID string, input *iam.ListPolicyTagsInput, svc handle if input.PolicyArn == nil || *input.PolicyArn == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListPolicyTags(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListPolicyTags(accountID, input) + if err != nil { + return nil, err + } + out.Tags, out.Marker = paginate(p, out.Tags, tagKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } diff --git a/spinifex/gateway/iam/role.go b/spinifex/gateway/iam/role.go index 2d1bf375d..e7826eb41 100644 --- a/spinifex/gateway/iam/role.go +++ b/spinifex/gateway/iam/role.go @@ -3,6 +3,7 @@ package gateway_iam import ( "errors" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -36,10 +37,16 @@ func GetRole(accountID string, input *iam.GetRoleInput, svc handlers_iam.IAMServ } func ListRoles(accountID string, input *iam.ListRolesInput, svc handlers_iam.IAMService) (*iam.ListRolesOutput, error) { + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } out, err := svc.ListRoles(accountID, input) if err != nil { return nil, err } + out.Roles, out.Marker = paginate(p, out.Roles, roleKey) + out.IsTruncated = aws.Bool(out.Marker != nil) encodeRoleDocuments(out.Roles...) return out, nil } @@ -92,7 +99,17 @@ func ListAttachedRolePolicies(accountID string, input *iam.ListAttachedRolePolic if input.RoleName == nil || *input.RoleName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListAttachedRolePolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListAttachedRolePolicies(accountID, input) + if err != nil { + return nil, err + } + out.AttachedPolicies, out.Marker = paginate(p, out.AttachedPolicies, attachedPolicyKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func PutRolePolicy(accountID string, input *iam.PutRolePolicyInput, svc handlers_iam.IAMService) (*iam.PutRolePolicyOutput, error) { @@ -137,7 +154,17 @@ func ListRolePolicies(accountID string, input *iam.ListRolePoliciesInput, svc ha if input.RoleName == nil || *input.RoleName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListRolePolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListRolePolicies(accountID, input) + if err != nil { + return nil, err + } + out.PolicyNames, out.Marker = paginate(p, out.PolicyNames, aws.StringValue) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func TagRole(accountID string, input *iam.TagRoleInput, svc handlers_iam.IAMService) (*iam.TagRoleOutput, error) { @@ -164,5 +191,15 @@ func ListRoleTags(accountID string, input *iam.ListRoleTagsInput, svc handlers_i if input.RoleName == nil || *input.RoleName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListRoleTags(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListRoleTags(accountID, input) + if err != nil { + return nil, err + } + out.Tags, out.Marker = paginate(p, out.Tags, tagKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } diff --git a/spinifex/gateway/iam/user.go b/spinifex/gateway/iam/user.go index 782b73f0d..1d96ae7db 100644 --- a/spinifex/gateway/iam/user.go +++ b/spinifex/gateway/iam/user.go @@ -3,6 +3,7 @@ package gateway_iam import ( "errors" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -23,7 +24,17 @@ func GetUser(accountID string, input *iam.GetUserInput, svc handlers_iam.IAMServ } func ListUsers(accountID string, input *iam.ListUsersInput, svc handlers_iam.IAMService) (*iam.ListUsersOutput, error) { - return svc.ListUsers(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListUsers(accountID, input) + if err != nil { + return nil, err + } + out.Users, out.Marker = paginate(p, out.Users, userKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func DeleteUser(accountID string, input *iam.DeleteUserInput, svc handlers_iam.IAMService) (*iam.DeleteUserOutput, error) { @@ -75,7 +86,17 @@ func ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput, svc ha if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListUserPolicies(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListUserPolicies(accountID, input) + if err != nil { + return nil, err + } + out.PolicyNames, out.Marker = paginate(p, out.PolicyNames, aws.StringValue) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } func TagUser(accountID string, input *iam.TagUserInput, svc handlers_iam.IAMService) (*iam.TagUserOutput, error) { @@ -102,5 +123,15 @@ func ListUserTags(accountID string, input *iam.ListUserTagsInput, svc handlers_i if input.UserName == nil || *input.UserName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) } - return svc.ListUserTags(accountID, input) + p, err := newPager(input.Marker, input.MaxItems) + if err != nil { + return nil, err + } + out, err := svc.ListUserTags(accountID, input) + if err != nil { + return nil, err + } + out.Tags, out.Marker = paginate(p, out.Tags, tagKey) + out.IsTruncated = aws.Bool(out.Marker != nil) + return out, nil } diff --git a/spinifex/handlers/iam/groups.go b/spinifex/handlers/iam/groups.go index 1b29d074f..a08be96b9 100644 --- a/spinifex/handlers/iam/groups.go +++ b/spinifex/handlers/iam/groups.go @@ -505,7 +505,7 @@ func (s *IAMServiceImpl) DeleteGroupPolicy(accountID string, input *iam.DeleteGr } // ListGroupPolicies returns the names of a group's inline policies, sorted for -// deterministic output. Pagination is not implemented: IsTruncated is always false. +// deterministic output. Returns the whole list; the gateway pages it. func (s *IAMServiceImpl) ListGroupPolicies(accountID string, input *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { ctx := context.Background() group, err := s.getGroup(ctx, accountID, *input.GroupName) diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go index b6ad4eb95..8edd3b8e0 100644 --- a/spinifex/handlers/iam/instance_profiles.go +++ b/spinifex/handlers/iam/instance_profiles.go @@ -330,8 +330,7 @@ func (s *IAMServiceImpl) UntagInstanceProfile(accountID string, input *iam.Untag return &iam.UntagInstanceProfileOutput{}, nil } -// ListInstanceProfileTags returns an instance profile's tags. Pagination is -// not implemented: IsTruncated is always false. +// ListInstanceProfileTags returns an instance profile's tags, all of them; the gateway pages the list. func (s *IAMServiceImpl) ListInstanceProfileTags(accountID string, input *iam.ListInstanceProfileTagsInput) (*iam.ListInstanceProfileTagsOutput, error) { ctx := context.Background() profile, err := s.getInstanceProfile(ctx, accountID, *input.InstanceProfileName) diff --git a/spinifex/handlers/iam/oidc_providers.go b/spinifex/handlers/iam/oidc_providers.go index 515960f4b..0ff798458 100644 --- a/spinifex/handlers/iam/oidc_providers.go +++ b/spinifex/handlers/iam/oidc_providers.go @@ -306,8 +306,7 @@ func (s *IAMServiceImpl) UntagOpenIDConnectProvider(accountID string, input *iam return &iam.UntagOpenIDConnectProviderOutput{}, nil } -// ListOpenIDConnectProviderTags returns an OIDC provider's tags. Pagination is -// not implemented: IsTruncated is always false. +// ListOpenIDConnectProviderTags returns an OIDC provider's tags, all of them; the gateway pages the list. func (s *IAMServiceImpl) ListOpenIDConnectProviderTags(accountID string, input *iam.ListOpenIDConnectProviderTagsInput) (*iam.ListOpenIDConnectProviderTagsOutput, error) { ctx := context.Background() record, err := s.getOIDCProvider(ctx, accountID, aws.StringValue(input.OpenIDConnectProviderArn)) diff --git a/spinifex/handlers/iam/policy_versions.go b/spinifex/handlers/iam/policy_versions.go index 2e2df86bb..aaf9c0bbc 100644 --- a/spinifex/handlers/iam/policy_versions.go +++ b/spinifex/handlers/iam/policy_versions.go @@ -228,7 +228,7 @@ func (s *IAMServiceImpl) GetPolicyVersion(accountID string, input *iam.GetPolicy } // ListPolicyVersions returns every version newest first, without documents, as -// AWS does. Pagination is not implemented: IsTruncated is always false. +// AWS does. Returns the whole list; the gateway pages it. func (s *IAMServiceImpl) ListPolicyVersions(accountID string, input *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { ctx := context.Background() policy, err := s.getPolicyByARN(ctx, accountID, *input.PolicyArn) diff --git a/spinifex/handlers/iam/roles.go b/spinifex/handlers/iam/roles.go index 0b6c800e3..39b60a6db 100644 --- a/spinifex/handlers/iam/roles.go +++ b/spinifex/handlers/iam/roles.go @@ -442,7 +442,7 @@ func (s *IAMServiceImpl) DeleteRolePolicy(accountID string, input *iam.DeleteRol } // ListRolePolicies returns the names of a role's inline policies, sorted for -// deterministic output. Pagination is not implemented: IsTruncated is always false. +// deterministic output. Returns the whole list; the gateway pages it. func (s *IAMServiceImpl) ListRolePolicies(accountID string, input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error) { ctx := context.Background() role, err := s.getRole(ctx, accountID, *input.RoleName) @@ -503,8 +503,7 @@ func (s *IAMServiceImpl) UntagRole(accountID string, input *iam.UntagRoleInput) return &iam.UntagRoleOutput{}, nil } -// ListRoleTags returns a role's tags. Pagination is not implemented: -// IsTruncated is always false. +// ListRoleTags returns a role's tags, all of them; the gateway pages the list. func (s *IAMServiceImpl) ListRoleTags(accountID string, input *iam.ListRoleTagsInput) (*iam.ListRoleTagsOutput, error) { ctx := context.Background() role, err := s.getRole(ctx, accountID, *input.RoleName) diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 08ad07847..e36d42c33 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -1685,7 +1685,7 @@ func (s *IAMServiceImpl) DeleteUserPolicy(accountID string, input *iam.DeleteUse } // ListUserPolicies returns the names of a user's inline policies, sorted for -// deterministic output. Pagination is not implemented: IsTruncated is always false. +// deterministic output. Returns the whole list; the gateway pages it. func (s *IAMServiceImpl) ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { ctx := context.Background() user, err := s.getUser(ctx, accountID, *input.UserName) @@ -1765,8 +1765,7 @@ func (s *IAMServiceImpl) UntagUser(accountID string, input *iam.UntagUserInput) return &iam.UntagUserOutput{}, nil } -// ListUserTags returns a user's tags. Pagination is not implemented: -// IsTruncated is always false. +// ListUserTags returns a user's tags, all of them; the gateway pages the list. func (s *IAMServiceImpl) ListUserTags(accountID string, input *iam.ListUserTagsInput) (*iam.ListUserTagsOutput, error) { ctx := context.Background() user, err := s.getUser(ctx, accountID, *input.UserName) @@ -1818,8 +1817,7 @@ func (s *IAMServiceImpl) UntagPolicy(accountID string, input *iam.UntagPolicyInp return &iam.UntagPolicyOutput{}, nil } -// ListPolicyTags returns a policy's tags. Pagination is not implemented: -// IsTruncated is always false. +// ListPolicyTags returns a policy's tags, all of them; the gateway pages the list. func (s *IAMServiceImpl) ListPolicyTags(accountID string, input *iam.ListPolicyTagsInput) (*iam.ListPolicyTagsOutput, error) { ctx := context.Background() policy, err := s.getPolicyByARN(ctx, accountID, *input.PolicyArn) diff --git a/spinifex/services/spinifexui/frontend/src/queries/iam.test.ts b/spinifex/services/spinifexui/frontend/src/queries/iam.test.ts index ce75ad9ba..7c01c90ea 100644 --- a/spinifex/services/spinifexui/frontend/src/queries/iam.test.ts +++ b/spinifex/services/spinifexui/frontend/src/queries/iam.test.ts @@ -229,7 +229,7 @@ describe("queryFn", () => { it("iamUsersQueryOptions sends ListUsersCommand", async () => { await callQueryFn(iamUsersQueryOptions) expect(mockSend).toHaveBeenCalledOnce() - expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({}) + expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ MaxItems: 1000 }) }) it("iamUserQueryOptions sends GetUserCommand with userName", async () => { @@ -248,7 +248,10 @@ describe("queryFn", () => { it("iamPoliciesQueryOptions sends ListPoliciesCommand with Local scope", async () => { await callQueryFn(iamPoliciesQueryOptions) - expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ Scope: "Local" }) + expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ + Scope: "Local", + MaxItems: 1000, + }) }) it("iamPolicyQueryOptions sends GetPolicyCommand with policyArn", async () => { @@ -275,7 +278,7 @@ describe("queryFn", () => { it("iamRolesQueryOptions sends ListRolesCommand", async () => { await callQueryFn(iamRolesQueryOptions) - expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({}) + expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ MaxItems: 1000 }) }) it("iamRoleQueryOptions sends GetRoleCommand with roleName", async () => { @@ -294,7 +297,7 @@ describe("queryFn", () => { it("iamInstanceProfilesQueryOptions sends ListInstanceProfilesCommand", async () => { await callQueryFn(iamInstanceProfilesQueryOptions) - expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({}) + expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ MaxItems: 1000 }) }) it("iamInstanceProfileQueryOptions sends GetInstanceProfileCommand", async () => { @@ -313,13 +316,14 @@ describe("queryFn", () => { it("iamGroupsQueryOptions sends ListGroupsCommand", async () => { await callQueryFn(iamGroupsQueryOptions) - expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({}) + expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ MaxItems: 1000 }) }) it("iamGroupQueryOptions sends GetGroupCommand with groupName", async () => { await callQueryFn(iamGroupQueryOptions("my-group")) expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({ GroupName: "my-group", + MaxItems: 1000, }) }) diff --git a/spinifex/services/spinifexui/frontend/src/queries/iam.ts b/spinifex/services/spinifexui/frontend/src/queries/iam.ts index 7e8b873fa..231e30b00 100644 --- a/spinifex/services/spinifexui/frontend/src/queries/iam.ts +++ b/spinifex/services/spinifexui/frontend/src/queries/iam.ts @@ -28,10 +28,13 @@ import { queryOptions } from "@tanstack/react-query" import { getIamClient } from "@/lib/awsClient" import { decodePolicyDocument } from "@/lib/json" +// IAM returns 100 items when MaxItems is omitted; 1000 is the most it allows. +const LIST_MAX_ITEMS = 1000 + export const iamUsersQueryOptions = queryOptions({ queryKey: ["iam", "users"], queryFn: async () => { - const command = new ListUsersCommand({}) + const command = new ListUsersCommand({ MaxItems: LIST_MAX_ITEMS }) return await getIamClient().send(command) }, staleTime: 300_000, @@ -60,7 +63,10 @@ export const iamAccessKeysQueryOptions = (userName: string) => export const iamPoliciesQueryOptions = queryOptions({ queryKey: ["iam", "policies"], queryFn: async () => { - const command = new ListPoliciesCommand({ Scope: "Local" }) + const command = new ListPoliciesCommand({ + Scope: "Local", + MaxItems: LIST_MAX_ITEMS, + }) return await getIamClient().send(command) }, staleTime: 300_000, @@ -136,7 +142,7 @@ export const iamUserPolicyQueryOptions = ( export const iamRolesQueryOptions = queryOptions({ queryKey: ["iam", "roles"], queryFn: async () => { - const command = new ListRolesCommand({}) + const command = new ListRolesCommand({ MaxItems: LIST_MAX_ITEMS }) return await getIamClient().send(command) }, staleTime: 300_000, @@ -196,7 +202,9 @@ export const iamRolePolicyQueryOptions = ( export const iamInstanceProfilesQueryOptions = queryOptions({ queryKey: ["iam", "instance-profiles"], queryFn: async () => { - const command = new ListInstanceProfilesCommand({}) + const command = new ListInstanceProfilesCommand({ + MaxItems: LIST_MAX_ITEMS, + }) return await getIamClient().send(command) }, staleTime: 300_000, @@ -229,7 +237,7 @@ export const iamInstanceProfilesForRoleQueryOptions = (roleName: string) => export const iamGroupsQueryOptions = queryOptions({ queryKey: ["iam", "groups"], queryFn: async () => { - const command = new ListGroupsCommand({}) + const command = new ListGroupsCommand({ MaxItems: LIST_MAX_ITEMS }) return await getIamClient().send(command) }, staleTime: 300_000, @@ -239,7 +247,10 @@ export const iamGroupQueryOptions = (groupName: string) => queryOptions({ queryKey: ["iam", "groups", groupName], queryFn: async () => { - const command = new GetGroupCommand({ GroupName: groupName }) + const command = new GetGroupCommand({ + GroupName: groupName, + MaxItems: LIST_MAX_ITEMS, + }) return await getIamClient().send(command) }, staleTime: 300_000,