fix: honour MaxItems and Marker on IAM list actions (#1125)

This commit is contained in:
Julian Sommer
2026-09-28 15:53:41 +10:00
committed by GitHub
parent 0fd0e2ba15
commit 866392e680
17 changed files with 600 additions and 44 deletions
+56 -5
View File
@@ -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
}
+23 -1
View File
@@ -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
}
+12 -1
View File
@@ -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) {
+12 -1
View File
@@ -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
}
+109
View File
@@ -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
}
+188
View File
@@ -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)
}
+91 -5
View File
@@ -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
}
+40 -3
View File
@@ -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
}
+34 -3
View File
@@ -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
}
+1 -1
View File
@@ -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)
+1 -2
View File
@@ -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)
+1 -2
View File
@@ -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))
+1 -1
View File
@@ -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)
+2 -3
View File
@@ -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)
+3 -5
View File
@@ -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)
@@ -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,
})
})
@@ -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,