From 0fd0e2ba15e6a65e7150e2f0d4ae4437d2b6ba38 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:34:52 +1000 Subject: [PATCH] fix: apply PathPrefix, Scope and OnlyAttached in IAM ListPolicies --- spinifex/handlers/iam/service_impl.go | 26 ++++++++- spinifex/handlers/iam/service_impl_test.go | 67 ++++++++++++++++++++++ tests/integration/iam_test.go | 19 ++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 8cfeedaa4..08ad07847 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -1303,7 +1303,10 @@ func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesI } keyPrefix := accountID + "." - var policies []*iam.Policy + pathPrefix := aws.StringValue(input.PathPrefix) + scope := aws.StringValue(input.Scope) + onlyAttached := aws.BoolValue(input.OnlyAttached) + policies := []*iam.Policy{} for _, key := range keys { if key == utils.VersionKey { continue @@ -1328,6 +1331,13 @@ func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesI continue } + if !strings.HasPrefix(policy.Path, pathPrefix) || !policyInScope(policy.ARN, scope) { + continue + } + if onlyAttached && attachCounts[policy.ARN] == 0 { + continue + } + createdAt := parseCreatedAt(policy.CreatedAt) policies = append(policies, &iam.Policy{ PolicyName: aws.String(policy.PolicyName), @@ -1348,6 +1358,20 @@ func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesI }, nil } +// 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 { + awsManaged := isAWSManagedPolicyARN(policyARN) + switch scope { + case iam.PolicyScopeTypeLocal: + return !awsManaged + case iam.PolicyScopeTypeAws: + return awsManaged + default: + return true + } +} + func (s *IAMServiceImpl) DeletePolicy(accountID string, input *iam.DeletePolicyInput) (*iam.DeletePolicyOutput, error) { ctx := context.Background() policyARN := *input.PolicyArn diff --git a/spinifex/handlers/iam/service_impl_test.go b/spinifex/handlers/iam/service_impl_test.go index 38dff0f81..9c9a2c0c8 100644 --- a/spinifex/handlers/iam/service_impl_test.go +++ b/spinifex/handlers/iam/service_impl_test.go @@ -1479,6 +1479,73 @@ func TestListPolicies_Empty(t *testing.T) { assert.Empty(t, out.Policies) } +func TestListPolicies_Filters(t *testing.T) { + t.Parallel() + svc := setupTestIAMService(t) + + createTestPolicy(t, svc, "RootPolicy") + _, err := svc.CreatePolicy(testAccountID, &iam.CreatePolicyInput{ + PolicyName: aws.String("TeamPolicy"), + Path: aws.String("/team/"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + attached := createTestPolicy(t, svc, "AttachedPolicy") + createTestUser(t, svc, "filterlistuser") + _, err = svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{ + UserName: aws.String("filterlistuser"), + PolicyArn: attached.Arn, + }) + require.NoError(t, err) + + tests := []struct { + name string + input *iam.ListPoliciesInput + want []string + }{ + {"no filters", &iam.ListPoliciesInput{}, []string{"AttachedPolicy", "RootPolicy", "TeamPolicy"}}, + {"path prefix", &iam.ListPoliciesInput{PathPrefix: aws.String("/team/")}, []string{"TeamPolicy"}}, + {"partial path prefix", &iam.ListPoliciesInput{PathPrefix: aws.String("/te")}, []string{"TeamPolicy"}}, + {"root path prefix", &iam.ListPoliciesInput{PathPrefix: aws.String("/")}, []string{"AttachedPolicy", "RootPolicy", "TeamPolicy"}}, + {"scope local", &iam.ListPoliciesInput{Scope: aws.String("Local")}, []string{"AttachedPolicy", "RootPolicy", "TeamPolicy"}}, + {"scope all", &iam.ListPoliciesInput{Scope: aws.String("All")}, []string{"AttachedPolicy", "RootPolicy", "TeamPolicy"}}, + {"scope aws", &iam.ListPoliciesInput{Scope: aws.String("AWS")}, []string{}}, + {"only attached", &iam.ListPoliciesInput{OnlyAttached: aws.Bool(true)}, []string{"AttachedPolicy"}}, + {"only attached false", &iam.ListPoliciesInput{OnlyAttached: aws.Bool(false)}, []string{"AttachedPolicy", "RootPolicy", "TeamPolicy"}}, + {"only attached under path", &iam.ListPoliciesInput{PathPrefix: aws.String("/team/"), OnlyAttached: aws.Bool(true)}, []string{}}, + {"local under path", &iam.ListPoliciesInput{Scope: aws.String("Local"), PathPrefix: aws.String("/team/")}, []string{"TeamPolicy"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + out, err := svc.ListPolicies(testAccountID, tc.input) + require.NoError(t, err) + // A nil slice would drop the Policies element from the XML response. + require.NotNil(t, out.Policies) + names := []string{} + for _, p := range out.Policies { + names = append(names, *p.PolicyName) + } + assert.ElementsMatch(t, tc.want, names) + }) + } +} + +func TestPolicyInScope(t *testing.T) { + t.Parallel() + local := "arn:aws:iam::000000000000:policy/AdministratorAccess" + awsManaged := "arn:aws:iam::aws:policy/AdministratorAccess" + + assert.True(t, policyInScope(local, "")) + assert.True(t, policyInScope(awsManaged, "")) + assert.True(t, policyInScope(local, "All")) + assert.True(t, policyInScope(awsManaged, "All")) + assert.True(t, policyInScope(local, "Local")) + assert.False(t, policyInScope(awsManaged, "Local")) + assert.False(t, policyInScope(local, "AWS")) + assert.True(t, policyInScope(awsManaged, "AWS")) +} + func TestDeletePolicy(t *testing.T) { t.Parallel() svc := setupTestIAMService(t) diff --git a/tests/integration/iam_test.go b/tests/integration/iam_test.go index 32d6296df..28f20ab4c 100644 --- a/tests/integration/iam_test.go +++ b/tests/integration/iam_test.go @@ -370,6 +370,25 @@ func TestIAMPolicyCRUD(t *testing.T) { all, err := iamCli.ListPolicies(&iam.ListPoliciesInput{}) require.NoError(t, err, "list-policies") require.GreaterOrEqual(t, len(all.Policies), 5, "expected >=5 policies, got %d", len(all.Policies)) + + pathed, err := iamCli.ListPolicies(&iam.ListPoliciesInput{ + Scope: aws.String("Local"), + PathPrefix: aws.String(iamPolicyFullAdminPath), + }) + require.NoError(t, err, "list-policies scope Local path %s", iamPolicyFullAdminPath) + require.Len(t, pathed.Policies, 1) + require.Equal(t, iamPolicyFullAdmin, aws.StringValue(pathed.Policies[0].PolicyName)) + + attached, err := iamCli.ListPolicies(&iam.ListPoliciesInput{ + PathPrefix: aws.String(iamPolicyFullAdminPath), + OnlyAttached: aws.Bool(true), + }) + require.NoError(t, err, "list-policies only attached") + require.Empty(t, attached.Policies) + + awsScope, err := iamCli.ListPolicies(&iam.ListPoliciesInput{Scope: aws.String("AWS")}) + require.NoError(t, err, "list-policies scope AWS") + require.Empty(t, awsScope.Policies) } // TestIAMPolicyAttachmentEnforcement proves AttachUserPolicy idempotency,