diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go
index 5537dfa69..1ecba816d 100644
--- a/spinifex/gateway/iam_request_test.go
+++ b/spinifex/gateway/iam_request_test.go
@@ -282,6 +282,30 @@ func TestIAMRequest_GetAccountSummary_Success(t *testing.T) {
assert.Contains(t, xmlStr, "2")
}
+// AWS answers GetRole for a never-used role with an empty
+// element; a nil-field struct must still marshal to that element, not vanish.
+func TestIAMRequest_GetRole_EmptyRoleLastUsed(t *testing.T) {
+ 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), "")
+}
+
func TestIAMRequest_UnknownAction(t *testing.T) {
handler := setupIAMRequestHandler(&flexMockIAMService{})
diff --git a/spinifex/handlers/iam/account_summary_test.go b/spinifex/handlers/iam/account_summary_test.go
index bd9264ef9..e44d8a3a7 100644
--- a/spinifex/handlers/iam/account_summary_test.go
+++ b/spinifex/handlers/iam/account_summary_test.go
@@ -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"))
+}
diff --git a/spinifex/handlers/iam/instance_profiles.go b/spinifex/handlers/iam/instance_profiles.go
index 8edd3b8e0..dad88ef5a 100644
--- a/spinifex/handlers/iam/instance_profiles.go
+++ b/spinifex/handlers/iam/instance_profiles.go
@@ -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
+}
diff --git a/spinifex/handlers/iam/response_shapes_test.go b/spinifex/handlers/iam/response_shapes_test.go
new file mode 100644
index 000000000..9a21e4826
--- /dev/null
+++ b/spinifex/handlers/iam/response_shapes_test.go
@@ -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")
+}
diff --git a/spinifex/handlers/iam/roles.go b/spinifex/handlers/iam/roles.go
index 39b60a6db..8e0355fc2 100644
--- a/spinifex/handlers/iam/roles.go
+++ b/spinifex/handlers/iam/roles.go
@@ -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
}
diff --git a/spinifex/handlers/iam/roles_test.go b/spinifex/handlers/iam/roles_test.go
index 2f9ffee7d..11aa9dc1a 100644
--- a/spinifex/handlers/iam/roles_test.go
+++ b/spinifex/handlers/iam/roles_test.go
@@ -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)
}
diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go
index a920a2098..106b25649 100644
--- a/spinifex/handlers/iam/service_impl.go
+++ b/spinifex/handlers/iam/service_impl.go
@@ -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)
+}
diff --git a/spinifex/handlers/iam/service_impl_test.go b/spinifex/handlers/iam/service_impl_test.go
index 4679244d5..19c395787 100644
--- a/spinifex/handlers/iam/service_impl_test.go
+++ b/spinifex/handlers/iam/service_impl_test.go
@@ -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))
}