fix: return AWS's per-action field sets for IAM roles, policies, users and account summary

CreateRole, GetRole, ListRoles and instance-profile roles each get their own projection; GetRole carries an empty RoleLastUsed. Policies carry UpdateDate and PermissionsBoundaryUsageCount, CreatePolicy drops Description and ListPolicies drops Tags. CreateUser echoes Tags, list instance-profile actions drop profile Tags, and GetAccountSummary gains AWS's seven missing keys and loses SigningCertificatesPerUser.
This commit is contained in:
Julian Sommer
2026-09-28 16:12:16 +10:00
parent 2898181837
commit 772c7657d8
8 changed files with 350 additions and 83 deletions
+24
View File
@@ -282,6 +282,30 @@ func TestIAMRequest_GetAccountSummary_Success(t *testing.T) {
assert.Contains(t, xmlStr, "<value>2</value>")
}
// AWS answers GetRole for a never-used role with an empty <RoleLastUsed/>
// element; a nil-field struct must still marshal to that element, not vanish.
func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) {
svc := &flexMockIAMService{
getRoleFn: func(_ string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
return &iam.GetRoleOutput{Role: &iam.Role{
RoleName: input.RoleName,
AssumeRolePolicyDocument: aws.String("{}"),
RoleLastUsed: &iam.RoleLastUsed{},
}}, nil
},
}
handler := setupIAMRequestHandler(svc)
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=GetRole&RoleName=app"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := doRequest(handler, req)
assert.Equal(t, 200, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "<RoleLastUsed></RoleLastUsed>")
}
func TestIAMRequest_UnknownAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
@@ -108,3 +108,48 @@ func TestGetAccountSummary_EmptyAccount(t *testing.T) {
// Quota parity constants are present even for an empty account.
assert.Equal(t, int64(5000), summaryCount(t, out.SummaryMap, "UsersQuota"))
}
func TestGetAccountSummary_AWSKeySet(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
acc, err := svc.CreateAccount("Key Set Org")
require.NoError(t, err)
out, err := svc.GetAccountSummary(acc.AccountID, &iam.GetAccountSummaryInput{})
require.NoError(t, err)
assert.NotContains(t, out.SummaryMap, "SigningCertificatesPerUser", "AWS has no such key")
for key, want := range map[string]int64{
"AssumeRolePolicySizeQuota": 2048,
"RolePolicySizeQuota": 10240,
"GlobalEndpointTokenVersion": 1,
"AccountAccessKeysPresent": 0,
"AccountPasswordPresent": 0,
"AccountSigningCertificatesPresent": 0,
"Providers": 0,
} {
assert.Equal(t, want, summaryCount(t, out.SummaryMap, key), key)
}
}
func TestGetAccountSummary_ProvidersCountsOIDCPerAccount(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
accA, err := svc.CreateAccount("Org A")
require.NoError(t, err)
accB, err := svc.CreateAccount("Org B")
require.NoError(t, err)
for _, issuer := range []string{"https://oidc.example/a", "https://oidc.example/b"} {
_, err := svc.CreateOpenIDConnectProvider(accA.AccountID, &iam.CreateOpenIDConnectProviderInput{Url: aws.String(issuer)})
require.NoError(t, err)
}
_, err = svc.CreateOpenIDConnectProvider(accB.AccountID, &iam.CreateOpenIDConnectProviderInput{Url: aws.String("https://oidc.example/c")})
require.NoError(t, err)
out, err := svc.GetAccountSummary(accA.AccountID, &iam.GetAccountSummaryInput{})
require.NoError(t, err)
assert.Equal(t, int64(2), summaryCount(t, out.SummaryMap, "Providers"))
}
+14 -8
View File
@@ -142,7 +142,7 @@ func (s *IAMServiceImpl) ListInstanceProfiles(accountID string, input *iam.ListI
continue
}
sdkProfile, err := s.profileToSDK(ctx, accountID, &profile)
sdkProfile, err := s.listedProfileToSDK(ctx, accountID, &profile)
if err != nil {
return nil, err
}
@@ -237,7 +237,7 @@ func (s *IAMServiceImpl) ListInstanceProfilesForRole(accountID string, input *ia
out := make([]*iam.InstanceProfile, 0, len(profiles))
for _, p := range profiles {
sdkProfile, err := s.profileToSDK(ctx, accountID, p)
sdkProfile, err := s.listedProfileToSDK(ctx, accountID, p)
if err != nil {
return nil, err
}
@@ -386,12 +386,7 @@ func (s *IAMServiceImpl) profileToSDK(ctx context.Context, accountID string, p *
CreateDate: aws.Time(parseCreatedAt(p.CreatedAt)),
Roles: []*iam.Role{},
}
for _, t := range p.Tags {
out.Tags = append(out.Tags, &iam.Tag{
Key: aws.String(t.Key),
Value: aws.String(t.Value),
})
}
out.Tags = tagsToSDK(p.Tags)
if p.RoleName != "" {
role, err := s.getRole(ctx, accountID, p.RoleName)
if err != nil {
@@ -401,3 +396,14 @@ func (s *IAMServiceImpl) profileToSDK(ctx context.Context, accountID string, p *
}
return out, nil
}
// listedProfileToSDK is the shape ListInstanceProfiles and
// ListInstanceProfilesForRole return: GetInstanceProfile's without Tags.
func (s *IAMServiceImpl) listedProfileToSDK(ctx context.Context, accountID string, p *InstanceProfile) (*iam.InstanceProfile, error) {
out, err := s.profileToSDK(ctx, accountID, p)
if err != nil {
return nil, err
}
out.Tags = nil
return out, nil
}
@@ -0,0 +1,159 @@
package handlers_iam
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// AWS returns a different field set for a role from each action that carries one.
func TestRoleShapes_PerAction(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
created, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
RoleName: aws.String("shaped"),
AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
Description: aws.String("described"),
MaxSessionDuration: aws.Int64(7200),
Tags: []*iam.Tag{sdkTag("team", "core")},
})
require.NoError(t, err)
assert.Nil(t, created.Role.Description, "CreateRole omits Description")
assert.Nil(t, created.Role.MaxSessionDuration, "CreateRole omits MaxSessionDuration")
assert.Nil(t, created.Role.RoleLastUsed)
assert.Equal(t, map[string]string{"team": "core"}, tagsAsMap(created.Role.Tags))
got, err := svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: aws.String("shaped")})
require.NoError(t, err)
assert.Equal(t, "described", aws.StringValue(got.Role.Description))
assert.Equal(t, int64(7200), aws.Int64Value(got.Role.MaxSessionDuration))
assert.Equal(t, map[string]string{"team": "core"}, tagsAsMap(got.Role.Tags))
assert.Equal(t, &iam.RoleLastUsed{}, got.Role.RoleLastUsed, "an unused role has an empty RoleLastUsed")
listed, err := svc.ListRoles(testAccountID, &iam.ListRolesInput{})
require.NoError(t, err)
require.Len(t, listed.Roles, 1)
assert.Equal(t, "described", aws.StringValue(listed.Roles[0].Description))
assert.Equal(t, int64(7200), aws.Int64Value(listed.Roles[0].MaxSessionDuration))
assert.Nil(t, listed.Roles[0].Tags, "ListRoles omits Tags")
assert.Nil(t, listed.Roles[0].RoleLastUsed, "ListRoles omits RoleLastUsed")
}
// A role inside an instance profile carries only its identity, trust policy and
// creation date; only GetInstanceProfile returns the profile's own Tags.
func TestInstanceProfileShapes_PerAction(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
RoleName: aws.String("embedded"),
AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
Description: aws.String("described"),
Tags: []*iam.Tag{sdkTag("team", "core")},
})
require.NoError(t, err)
_, err = svc.CreateInstanceProfile(testAccountID, &iam.CreateInstanceProfileInput{
InstanceProfileName: aws.String("profile"),
Tags: []*iam.Tag{sdkTag("env", "prod")},
})
require.NoError(t, err)
_, err = svc.AddRoleToInstanceProfile(testAccountID, &iam.AddRoleToInstanceProfileInput{
InstanceProfileName: aws.String("profile"),
RoleName: aws.String("embedded"),
})
require.NoError(t, err)
assertEmbeddedRole := func(t *testing.T, p *iam.InstanceProfile) {
t.Helper()
require.Len(t, p.Roles, 1)
assert.Equal(t, &iam.Role{
RoleName: aws.String("embedded"),
RoleId: p.Roles[0].RoleId,
Arn: aws.String("arn:aws:iam::" + testAccountID + ":role/embedded"),
Path: aws.String("/"),
AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
CreateDate: p.Roles[0].CreateDate,
}, p.Roles[0])
}
got, err := svc.GetInstanceProfile(testAccountID, &iam.GetInstanceProfileInput{InstanceProfileName: aws.String("profile")})
require.NoError(t, err)
assert.Equal(t, map[string]string{"env": "prod"}, tagsAsMap(got.InstanceProfile.Tags))
assertEmbeddedRole(t, got.InstanceProfile)
listed, err := svc.ListInstanceProfiles(testAccountID, &iam.ListInstanceProfilesInput{})
require.NoError(t, err)
require.Len(t, listed.InstanceProfiles, 1)
assert.Nil(t, listed.InstanceProfiles[0].Tags, "ListInstanceProfiles omits profile Tags")
assertEmbeddedRole(t, listed.InstanceProfiles[0])
forRole, err := svc.ListInstanceProfilesForRole(testAccountID, &iam.ListInstanceProfilesForRoleInput{RoleName: aws.String("embedded")})
require.NoError(t, err)
require.Len(t, forRole.InstanceProfiles, 1)
assert.Nil(t, forRole.InstanceProfiles[0].Tags, "ListInstanceProfilesForRole omits profile Tags")
assertEmbeddedRole(t, forRole.InstanceProfiles[0])
}
func TestCreateUser_EchoesTags(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
tagged, err := svc.CreateUser(testAccountID, &iam.CreateUserInput{
UserName: aws.String("tagged"),
Tags: []*iam.Tag{sdkTag("env", "prod")},
})
require.NoError(t, err)
assert.Equal(t, map[string]string{"env": "prod"}, tagsAsMap(tagged.User.Tags))
untagged, err := svc.CreateUser(testAccountID, &iam.CreateUserInput{UserName: aws.String("untagged")})
require.NoError(t, err)
assert.Nil(t, untagged.User.Tags)
}
func TestPolicyShapes_PerAction(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
created, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{
PolicyName: aws.String("shaped"),
PolicyDocument: aws.String(validPolicyDocument()),
Description: aws.String("described"),
Tags: []*iam.Tag{sdkTag("team", "core")},
})
require.NoError(t, err)
assert.Nil(t, created.Policy.Description, "CreatePolicy omits Description")
assert.Equal(t, map[string]string{"team": "core"}, tagsAsMap(created.Policy.Tags))
assert.Equal(t, int64(0), aws.Int64Value(created.Policy.PermissionsBoundaryUsageCount))
require.NotNil(t, created.Policy.UpdateDate)
assert.Equal(t, *created.Policy.CreateDate, *created.Policy.UpdateDate, "a single-version policy was updated when it was created")
got, err := svc.GetPolicy(testAccountID, &iam.GetPolicyInput{PolicyArn: created.Policy.Arn})
require.NoError(t, err)
assert.Equal(t, "described", aws.StringValue(got.Policy.Description))
assert.Equal(t, map[string]string{"team": "core"}, tagsAsMap(got.Policy.Tags))
assert.Equal(t, int64(0), aws.Int64Value(got.Policy.PermissionsBoundaryUsageCount))
assert.Equal(t, *created.Policy.CreateDate, aws.TimeValue(got.Policy.UpdateDate))
listed, err := svc.ListPolicies(testAccountID, &iam.ListPoliciesInput{})
require.NoError(t, err)
require.Len(t, listed.Policies, 1)
assert.Nil(t, listed.Policies[0].Tags, "ListPolicies omits Tags")
assert.Nil(t, listed.Policies[0].Description, "ListPolicies omits Description")
assert.Equal(t, int64(0), aws.Int64Value(listed.Policies[0].PermissionsBoundaryUsageCount))
assert.Equal(t, *created.Policy.CreateDate, aws.TimeValue(listed.Policies[0].UpdateDate))
version, err := svc.CreatePolicyVersion(testAccountID, &iam.CreatePolicyVersionInput{
PolicyArn: created.Policy.Arn,
PolicyDocument: aws.String(validPolicyDocument()),
SetAsDefault: aws.Bool(true),
})
require.NoError(t, err)
got, err = svc.GetPolicy(testAccountID, &iam.GetPolicyInput{PolicyArn: created.Policy.Arn})
require.NoError(t, err)
assert.Equal(t, *version.PolicyVersion.CreateDate, aws.TimeValue(got.Policy.UpdateDate),
"UpdateDate follows the default version")
}
+29 -13
View File
@@ -106,7 +106,7 @@ func (s *IAMServiceImpl) CreateRole(accountID string, input *iam.CreateRoleInput
slog.Info("IAM role created", "accountID", accountID, "roleName", roleName, "roleID", role.RoleID)
return &iam.CreateRoleOutput{Role: roleToSDK(&role)}, nil
return &iam.CreateRoleOutput{Role: createdRoleToSDK(&role)}, nil
}
func (s *IAMServiceImpl) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
@@ -116,7 +116,7 @@ func (s *IAMServiceImpl) GetRole(accountID string, input *iam.GetRoleInput) (*ia
return nil, err
}
return &iam.GetRoleOutput{Role: roleToSDK(role)}, nil
return &iam.GetRoleOutput{Role: gotRoleToSDK(role)}, nil
}
func (s *IAMServiceImpl) ListRoles(accountID string, input *iam.ListRolesInput) (*iam.ListRolesOutput, error) {
@@ -169,7 +169,7 @@ func (s *IAMServiceImpl) ListRoles(accountID string, input *iam.ListRolesInput)
continue
}
roles = append(roles, roleToSDK(&role))
roles = append(roles, listedRoleToSDK(&role))
}
return &iam.ListRolesOutput{
@@ -646,27 +646,43 @@ func (s *IAMServiceImpl) findInstanceProfilesForRole(ctx context.Context, accoun
return profiles, nil
}
// roleToSDK converts the internal Role record into the AWS SDK shape used by
// CreateRole / GetRole / ListRoles responses.
// roleToSDK is the field set AWS returns for a role embedded in an instance
// profile. Each role action adds to it: AWS returns a different set per call.
func roleToSDK(r *Role) *iam.Role {
out := &iam.Role{
return &iam.Role{
RoleName: aws.String(r.RoleName),
RoleId: aws.String(r.RoleID),
Arn: aws.String(r.ARN),
Path: aws.String(r.Path),
AssumeRolePolicyDocument: aws.String(r.AssumeRolePolicyDocument),
CreateDate: aws.Time(parseCreatedAt(r.CreatedAt)),
MaxSessionDuration: aws.Int64(r.MaxSessionDuration),
}
}
// createdRoleToSDK is CreateRole's shape, which omits Description and
// MaxSessionDuration even when they were supplied.
func createdRoleToSDK(r *Role) *iam.Role {
out := roleToSDK(r)
out.Tags = tagsToSDK(r.Tags)
return out
}
// listedRoleToSDK is ListRoles' shape, which carries no Tags or RoleLastUsed.
func listedRoleToSDK(r *Role) *iam.Role {
out := roleToSDK(r)
if r.Description != "" {
out.Description = aws.String(r.Description)
}
for _, t := range r.Tags {
out.Tags = append(out.Tags, &iam.Tag{
Key: aws.String(t.Key),
Value: aws.String(t.Value),
})
}
out.MaxSessionDuration = aws.Int64(r.MaxSessionDuration)
return out
}
// gotRoleToSDK is GetRole's shape. RoleLastUsed is always empty, which is what
// AWS returns for a role that has never been used.
func gotRoleToSDK(r *Role) *iam.Role {
out := listedRoleToSDK(r)
out.Tags = tagsToSDK(r.Tags)
out.RoleLastUsed = &iam.RoleLastUsed{}
return out
}
+8 -3
View File
@@ -52,14 +52,17 @@ func TestCreateRole(t *testing.T) {
require.NotNil(t, out.Role)
assert.Equal(t, "app-role", *out.Role.RoleName)
assert.Equal(t, "/service-roles/", *out.Role.Path)
assert.Equal(t, "Role for app servers", *out.Role.Description)
assert.Equal(t, int64(7200), *out.Role.MaxSessionDuration)
assert.Equal(t, "arn:aws:iam::"+testAccountID+":role/service-roles/app-role", *out.Role.Arn)
require.Greater(t, len(*out.Role.RoleId), 4)
assert.Equal(t, "AROA", (*out.Role.RoleId)[:4])
require.Len(t, out.Role.Tags, 1)
assert.Equal(t, "team", *out.Role.Tags[0].Key)
assert.Equal(t, "backend", *out.Role.Tags[0].Value)
got, err := svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: aws.String("app-role")})
require.NoError(t, err)
assert.Equal(t, "Role for app servers", *got.Role.Description)
assert.Equal(t, int64(7200), *got.Role.MaxSessionDuration)
}
func TestCreateRole_DefaultPath(t *testing.T) {
@@ -79,11 +82,13 @@ func TestCreateRole_DefaultMaxSessionDuration(t *testing.T) {
t.Parallel()
svc := setupTestIAMService(t)
out, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
_, err := svc.CreateRole(testAccountID, &iam.CreateRoleInput{
RoleName: aws.String("default-session"),
AssumeRolePolicyDocument: aws.String(validTrustPolicy()),
})
require.NoError(t, err)
out, err := svc.GetRole(testAccountID, &iam.GetRoleInput{RoleName: aws.String("default-session")})
require.NoError(t, err)
assert.Equal(t, defaultMaxSessionDuration, *out.Role.MaxSessionDuration)
}
+69 -56
View File
@@ -383,6 +383,7 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput
Arn: aws.String(user.ARN),
Path: aws.String(user.Path),
CreateDate: aws.Time(createdAt),
Tags: tagsToSDK(user.Tags),
},
}, nil
}
@@ -1237,21 +1238,10 @@ func (s *IAMServiceImpl) CreatePolicy(accountID string, input *iam.CreatePolicyI
slog.Info("IAM policy created", "accountID", accountID, "policyName", policyName, "policyID", policy.PolicyID)
createdAt := parseCreatedAt(policy.CreatedAt)
return &iam.CreatePolicyOutput{
Policy: &iam.Policy{
PolicyName: aws.String(policy.PolicyName),
PolicyId: aws.String(policy.PolicyID),
Arn: aws.String(policy.ARN),
Path: aws.String(policy.Path),
Description: aws.String(policy.Description),
DefaultVersionId: aws.String(policy.DefaultVersion),
CreateDate: aws.Time(createdAt),
AttachmentCount: aws.Int64(0),
IsAttachable: aws.Bool(true),
Tags: tagsToSDK(policy.Tags),
},
}, nil
// CreatePolicy omits Description even when it was supplied.
out := policyToSDK(&policy, 0)
out.Tags = tagsToSDK(policy.Tags)
return &iam.CreatePolicyOutput{Policy: out}, nil
}
func (s *IAMServiceImpl) GetPolicy(accountID string, input *iam.GetPolicyInput) (*iam.GetPolicyOutput, error) {
@@ -1265,23 +1255,11 @@ func (s *IAMServiceImpl) GetPolicy(accountID string, input *iam.GetPolicyInput)
if err != nil {
return nil, fmt.Errorf("check policy attachments: %w", err)
}
attachmentCount := counts[policy.ARN]
createdAt := parseCreatedAt(policy.CreatedAt)
return &iam.GetPolicyOutput{
Policy: &iam.Policy{
PolicyName: aws.String(policy.PolicyName),
PolicyId: aws.String(policy.PolicyID),
Arn: aws.String(policy.ARN),
Path: aws.String(policy.Path),
Description: aws.String(policy.Description),
DefaultVersionId: aws.String(policy.DefaultVersion),
CreateDate: aws.Time(createdAt),
AttachmentCount: aws.Int64(attachmentCount),
IsAttachable: aws.Bool(true),
Tags: tagsToSDK(policy.Tags),
},
}, nil
out := policyToSDK(policy, counts[policy.ARN])
out.Description = aws.String(policy.Description)
out.Tags = tagsToSDK(policy.Tags)
return &iam.GetPolicyOutput{Policy: out}, nil
}
func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) {
@@ -1338,18 +1316,8 @@ func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesI
continue
}
createdAt := parseCreatedAt(policy.CreatedAt)
policies = append(policies, &iam.Policy{
PolicyName: aws.String(policy.PolicyName),
PolicyId: aws.String(policy.PolicyID),
Arn: aws.String(policy.ARN),
Path: aws.String(policy.Path),
DefaultVersionId: aws.String(policy.DefaultVersion),
CreateDate: aws.Time(createdAt),
AttachmentCount: aws.Int64(attachCounts[policy.ARN]),
IsAttachable: aws.Bool(true),
Tags: tagsToSDK(policy.Tags),
})
// ListPolicies carries neither Tags nor Description.
policies = append(policies, policyToSDK(&policy, attachCounts[policy.ARN]))
}
return &iam.ListPoliciesOutput{
@@ -1358,6 +1326,27 @@ func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesI
}, nil
}
// policyToSDK is the field set every policy action returns. Permissions
// boundaries are rejected, so no policy is ever in use as one.
func policyToSDK(p *Policy, attachmentCount int64) *iam.Policy {
updatedAt := p.UpdatedAt
if updatedAt == "" {
updatedAt = p.CreatedAt
}
return &iam.Policy{
PolicyName: aws.String(p.PolicyName),
PolicyId: aws.String(p.PolicyID),
Arn: aws.String(p.ARN),
Path: aws.String(p.Path),
DefaultVersionId: aws.String(p.DefaultVersion),
CreateDate: aws.Time(parseCreatedAt(p.CreatedAt)),
UpdateDate: aws.Time(parseCreatedAt(updatedAt)),
AttachmentCount: aws.Int64(attachmentCount),
PermissionsBoundaryUsageCount: aws.Int64(0),
IsAttachable: aws.Bool(true),
}
}
// policyInScope applies ListPolicies' Scope: Local is customer-managed, AWS is
// arn:aws:iam::aws:policy/..., and All (or unset) keeps both.
func policyInScope(policyARN, scope string) bool {
@@ -2323,21 +2312,27 @@ var summaryQuotaDefaults = map[string]int64{
"GroupPolicySizeQuota": 5120,
"PolicySizeQuota": 6144,
"VersionsPerPolicyQuota": 5,
"AssumeRolePolicySizeQuota": maxTrustPolicyDocumentSize,
"RolePolicySizeQuota": 10240,
// AWS's default: STS global endpoint tokens valid only in default Regions.
"GlobalEndpointTokenVersion": 1,
// Resource types Spinifex does not model — reported as 0.
"MFADevices": 0,
"MFADevicesInUse": 0,
"AccountMFAEnabled": 0,
"ServerCertificates": 0,
"SigningCertificatesPerUser": 0,
"PolicyVersionsInUse": 0,
"MFADevices": 0,
"MFADevicesInUse": 0,
"AccountMFAEnabled": 0,
"ServerCertificates": 0,
"PolicyVersionsInUse": 0,
"AccountAccessKeysPresent": 0,
"AccountPasswordPresent": 0,
"AccountSigningCertificatesPresent": 0,
}
// countBucket counts records in a KV bucket that belong to accountID. Records
// are keyed "accountID.name"; counting is by key prefix only (no Get/unmarshal)
// so it stays cheap with many resources. The version key is skipped and a
// missing bucket counts as zero.
func countBucket(ctx context.Context, bucket jetstream.KeyValue, accountID string) (int64, error) {
// countBucket counts the keys in bucket that start with prefix, without a
// Get/unmarshal so it stays cheap with many resources. The version key is
// skipped and an empty bucket counts as zero.
func countBucket(ctx context.Context, bucket jetstream.KeyValue, prefix string) (int64, error) {
keys, err := kvutil.Keys(ctx, bucket)
if err != nil {
if errors.Is(err, jetstream.ErrNoKeysFound) {
@@ -2346,7 +2341,6 @@ func countBucket(ctx context.Context, bucket jetstream.KeyValue, accountID strin
return 0, err
}
prefix := accountID + "."
var count int64
for _, key := range keys {
if key == utils.VersionKey {
@@ -2377,12 +2371,31 @@ func (s *IAMServiceImpl) GetAccountSummary(accountID string, _ *iam.GetAccountSu
summary[key] = aws.Int64(value)
}
for key, bucket := range counted {
n, err := countBucket(ctx, bucket, accountID)
n, err := countBucket(ctx, bucket, accountID+".")
if err != nil {
return nil, fmt.Errorf("count %s: %w", key, err)
}
summary[key] = aws.Int64(n)
}
providers, err := s.countOIDCProviders(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("count Providers: %w", err)
}
summary["Providers"] = aws.Int64(providers)
return &iam.GetAccountSummaryOutput{SummaryMap: summary}, 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) {
kv, err := s.js.KeyValue(ctx, IAMAccountBucketName(accountID))
if err != nil {
if errors.Is(err, jetstream.ErrBucketNotFound) {
return 0, nil
}
return 0, fmt.Errorf("open IAM account bucket: %w", err)
}
return countBucket(ctx, kv, oidcProvidersKeyPrefix)
}
+2 -3
View File
@@ -1085,7 +1085,7 @@ func TestCreatePolicy(t *testing.T) {
require.NotNil(t, out.Policy)
assert.Equal(t, "AllowEC2", *out.Policy.PolicyName)
assert.Equal(t, "/devteam/", *out.Policy.Path)
assert.Equal(t, "Allow EC2 describe", *out.Policy.Description)
assert.Nil(t, out.Policy.Description, "AWS omits Description from CreatePolicy even when supplied")
assert.Equal(t, "v1", *out.Policy.DefaultVersionId)
assert.Contains(t, *out.Policy.Arn, "policy/devteam/AllowEC2")
assert.Greater(t, len(*out.Policy.PolicyId), 4)
@@ -1235,7 +1235,7 @@ func TestCreatePolicy_WithTags(t *testing.T) {
}
}
require.NotNil(t, listed, "TaggedPolicy missing from ListPolicies")
require.Len(t, listed.Tags, 2)
assert.Nil(t, listed.Tags, "AWS omits Tags from ListPolicies")
tagsOut, err := svc.ListPolicyTags(testAccountID, &iam.ListPolicyTagsInput{PolicyArn: createOut.Policy.Arn})
require.NoError(t, err)
@@ -1251,7 +1251,6 @@ func TestCreatePolicy_WithTags(t *testing.T) {
want := map[string]string{"team": "platform", "env": "prod"}
assert.Equal(t, want, byKey(createOut.Policy.Tags))
assert.Equal(t, want, byKey(getOut.Policy.Tags))
assert.Equal(t, want, byKey(listed.Tags))
assert.Equal(t, want, byKey(tagsOut.Tags))
}