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)) }