From 347c2b2f80ecac9456f3dc9347c0ddc05bb5eb68 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:51:35 +1000 Subject: [PATCH] fix: emit required IAM list members when nothing matches ListUsers, ListRoles, ListInstanceProfiles and ListGroupsForUser built their results from a nil slice when the filtering loop matched nothing. xmlutil drops a nil slice entirely rather than emitting an empty element, so each response omitted a member its output shape marks required. Each operation's empty-bucket shortcut already returned an empty non-nil slice, so only the filtering path was affected. Start those slices empty and non-nil too. ListAttachedGroupPolicies, ListAttachedRolePolicies, ListPolicies and ListAttachedUserPolicies declare no required output members, so their nil slices are legal and are left alone. --- spinifex/handlers/iam/groups.go | 4 +- spinifex/handlers/iam/instance_profiles.go | 4 +- spinifex/handlers/iam/roles.go | 4 +- spinifex/handlers/iam/service_impl.go | 4 +- spinifex/handlers/iam/service_impl_test.go | 90 ++++++++++++++++++++++ 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/spinifex/handlers/iam/groups.go b/spinifex/handlers/iam/groups.go index 4cd1e9dc4..93222732d 100644 --- a/spinifex/handlers/iam/groups.go +++ b/spinifex/handlers/iam/groups.go @@ -277,7 +277,9 @@ func (s *IAMServiceImpl) ListGroupsForUser(accountID string, input *iam.ListGrou return nil, err } - var groups []*iam.Group + // Non-nil: Groups is a required member, and a nil slice marshals to no + // element at all rather than an empty one. + groups := []*iam.Group{} for _, name := range user.Groups { group, err := s.getGroup(ctx, accountID, name) if err != nil { diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go index c8834cc49..c3ba5ad34 100644 --- a/spinifex/handlers/iam/instance_profiles.go +++ b/spinifex/handlers/iam/instance_profiles.go @@ -111,7 +111,9 @@ func (s *IAMServiceImpl) ListInstanceProfiles(accountID string, input *iam.ListI } keyPrefix := accountID + "." - var profiles []*iam.InstanceProfile + // Non-nil: InstanceProfiles is a required member, and a nil slice marshals + // to no element at all rather than an empty one. + profiles := []*iam.InstanceProfile{} for _, key := range keys { if key == utils.VersionKey { continue diff --git a/spinifex/handlers/iam/roles.go b/spinifex/handlers/iam/roles.go index 83ff455f7..7cb6f3fca 100644 --- a/spinifex/handlers/iam/roles.go +++ b/spinifex/handlers/iam/roles.go @@ -131,7 +131,9 @@ func (s *IAMServiceImpl) ListRoles(accountID string, input *iam.ListRolesInput) } keyPrefix := accountID + "." - var roles []*iam.Role + // Non-nil: Roles is a required member, and a nil slice marshals to no + // element at all rather than an empty one. + roles := []*iam.Role{} for _, key := range keys { if key == utils.VersionKey { continue diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index a1a158ac2..71ddd9b09 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -416,7 +416,9 @@ func (s *IAMServiceImpl) ListUsers(accountID string, input *iam.ListUsersInput) } keyPrefix := accountID + "." - var users []*iam.User + // Non-nil: Users is a required member, and a nil slice marshals to no + // element at all rather than an empty one. + users := []*iam.User{} for _, key := range keys { if key == utils.VersionKey { continue diff --git a/spinifex/handlers/iam/service_impl_test.go b/spinifex/handlers/iam/service_impl_test.go index c118cb890..f603ec1e4 100644 --- a/spinifex/handlers/iam/service_impl_test.go +++ b/spinifex/handlers/iam/service_impl_test.go @@ -140,6 +140,96 @@ func TestListUsers_Empty(t *testing.T) { assert.Empty(t, out.Users) } +// TestListRequiredMembersAreEmptyNotNil covers every IAM list operation whose +// output shape marks the list member required. Each seeds a record that cannot +// match, so the filtering loop runs rather than the no-keys shortcut, and the +// result must come back empty but non-nil. +func TestListRequiredMembersAreEmptyNotNil(t *testing.T) { + const otherAccountID = "999988887777" + + tests := []struct { + operation string + member string + // seed writes a record the listing must filter out, so an empty result + // is reached through the loop rather than the empty-bucket shortcut. + seed func(t *testing.T, svc *IAMServiceImpl) + list func(t *testing.T, svc *IAMServiceImpl) any + }{ + { + operation: "ListUsers", + member: "Users", + seed: func(t *testing.T, svc *IAMServiceImpl) { + _, err := svc.CreateUser(otherAccountID, &iam.CreateUserInput{UserName: aws.String("otheruser")}) + require.NoError(t, err) + }, + list: func(t *testing.T, svc *IAMServiceImpl) any { + out, err := svc.ListUsers(testAccountID, &iam.ListUsersInput{}) + require.NoError(t, err) + return out.Users + }, + }, + { + operation: "ListRoles", + member: "Roles", + seed: func(t *testing.T, svc *IAMServiceImpl) { + _, err := svc.CreateRole(otherAccountID, &iam.CreateRoleInput{ + RoleName: aws.String("otherrole"), + AssumeRolePolicyDocument: aws.String(validTrustPolicy()), + }) + require.NoError(t, err) + }, + list: func(t *testing.T, svc *IAMServiceImpl) any { + out, err := svc.ListRoles(testAccountID, &iam.ListRolesInput{}) + require.NoError(t, err) + return out.Roles + }, + }, + { + operation: "ListInstanceProfiles", + member: "InstanceProfiles", + seed: func(t *testing.T, svc *IAMServiceImpl) { + _, err := svc.CreateInstanceProfile(otherAccountID, &iam.CreateInstanceProfileInput{ + InstanceProfileName: aws.String("otherprofile"), + }) + require.NoError(t, err) + }, + list: func(t *testing.T, svc *IAMServiceImpl) any { + out, err := svc.ListInstanceProfiles(testAccountID, &iam.ListInstanceProfilesInput{}) + require.NoError(t, err) + return out.InstanceProfiles + }, + }, + { + operation: "ListGroupsForUser", + member: "Groups", + // Membership is read off the user, so a user belonging to no group + // is the empty case; a foreign record would not reach the loop. + seed: func(t *testing.T, svc *IAMServiceImpl) { + createTestUser(t, svc, "grouplessuser") + createTestGroup(t, svc, "unrelatedgroup") + }, + list: func(t *testing.T, svc *IAMServiceImpl) any { + out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{ + UserName: aws.String("grouplessuser"), + }) + require.NoError(t, err) + return out.Groups + }, + }, + } + + for _, tt := range tests { + t.Run(tt.operation, func(t *testing.T) { + svc := setupTestIAMService(t) + tt.seed(t, svc) + + got := tt.list(t, svc) + assert.Empty(t, got) + assert.NotNil(t, got, emptyRequiredListMsg(tt.operation, tt.member)) + }) + } +} + func TestListUsers_PathFilter(t *testing.T) { svc := setupTestIAMService(t)