From 05e66bd6a0726708bcf0c0518cc0f70d0f0cf602 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:25:12 +1000 Subject: [PATCH] feat: ListPolicyVersions --- docs/COMMANDS.md | 1 + spinifex/gateway/auth_test.go | 3 ++ .../IamInstanceProfileAssociation_test.go | 3 ++ spinifex/gateway/iam.go | 3 ++ spinifex/gateway/iam/handler_test.go | 28 +++++++++++++++++++ spinifex/gateway/iam/policy.go | 7 +++++ spinifex/gateway/iam_request_test.go | 4 +++ spinifex/gateway/sts/handler_test.go | 3 ++ spinifex/handlers/iam/service.go | 1 + spinifex/handlers/iam/service_impl.go | 20 +++++++++++++ spinifex/handlers/iam/service_impl_test.go | 26 +++++++++++++++++ 11 files changed, 99 insertions(+) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a53ba7b3f..a2f0aebfc 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -489,6 +489,7 @@ bypasses policy evaluation entirely. | `create-policy` | `--policy-name`, `--policy-document`, `--path`, `--description` | `--tags` | **DONE** | | `get-policy` | `--policy-arn` | — | **DONE** | | `get-policy-version` | `--policy-arn`, `--version-id` | — | **DONE** | +| `list-policy-versions` | `--policy-arn` | `--max-items`, `--marker` | **DONE** | | `list-policies` | — | `--scope`, `--only-attached`, `--path-prefix`, `--max-items`, `--marker` | **DONE** | | `delete-policy` | `--policy-arn` | — | **DONE** | | `attach-user-policy` | `--user-name`, `--policy-arn` | — | **DONE** | diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index e5e65dd02..78d016df3 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -82,6 +82,9 @@ func (m *mockIAMService) GetPolicy(_ string, _ *iam.GetPolicyInput) (*iam.GetPol func (m *mockIAMService) GetPolicyVersion(_ string, _ *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) { return nil, nil } +func (m *mockIAMService) ListPolicyVersions(_ string, _ *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + return nil, nil +} func (m *mockIAMService) ListPolicies(_ string, _ *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { return nil, nil } diff --git a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go index 1a86954b2..8f76429b4 100644 --- a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go +++ b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go @@ -79,6 +79,9 @@ func (f *fakeIAMService) GetPolicy(string, *iam.GetPolicyInput) (*iam.GetPolicyO func (f *fakeIAMService) GetPolicyVersion(string, *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) { return &iam.GetPolicyVersionOutput{}, nil } +func (f *fakeIAMService) ListPolicyVersions(string, *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + return &iam.ListPolicyVersionsOutput{}, nil +} func (f *fakeIAMService) ListPolicies(string, *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { return &iam.ListPoliciesOutput{}, nil } diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go index 1c62cef7e..32e3f6aaa 100644 --- a/spinifex/gateway/iam.go +++ b/spinifex/gateway/iam.go @@ -78,6 +78,9 @@ var iamActions = map[string]IAMHandler{ "GetPolicyVersion": iamHandler(func(accountID string, input *iam.GetPolicyVersionInput, gw *GatewayConfig) (any, error) { return gateway_iam.GetPolicyVersion(accountID, input, gw.IAMService) }), + "ListPolicyVersions": iamHandler(func(accountID string, input *iam.ListPolicyVersionsInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListPolicyVersions(accountID, input, gw.IAMService) + }), "ListPolicies": iamHandler(func(accountID string, input *iam.ListPoliciesInput, gw *GatewayConfig) (any, error) { return gateway_iam.ListPolicies(accountID, input, gw.IAMService) }), diff --git a/spinifex/gateway/iam/handler_test.go b/spinifex/gateway/iam/handler_test.go index ecc6ffdb2..ab2e3a5e3 100644 --- a/spinifex/gateway/iam/handler_test.go +++ b/spinifex/gateway/iam/handler_test.go @@ -62,6 +62,10 @@ func (s *stubIAMService) GetPolicyVersion(_ string, _ *iam.GetPolicyVersionInput return &iam.GetPolicyVersionOutput{}, nil } +func (s *stubIAMService) ListPolicyVersions(_ string, _ *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + return &iam.ListPolicyVersionsOutput{}, nil +} + func (s *stubIAMService) ListPolicies(_ string, _ *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { return &iam.ListPoliciesOutput{}, nil } @@ -413,6 +417,30 @@ func TestGetPolicyVersion(t *testing.T) { } } +func TestListPolicyVersions(t *testing.T) { + svc := &stubIAMService{} + tests := []struct { + name string + input *iam.ListPolicyVersionsInput + wantErr string + }{ + {"nil PolicyArn", &iam.ListPolicyVersionsInput{}, awserrors.ErrorMissingParameter}, + {"empty PolicyArn", &iam.ListPolicyVersionsInput{PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter}, + {"valid", &iam.ListPolicyVersionsInput{PolicyArn: aws.String("arn:aws:iam::000000000000:policy/mypolicy")}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := ListPolicyVersions(testAccountID, tc.input, svc) + if tc.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tc.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + func TestListPolicies(t *testing.T) { svc := &stubIAMService{} _, err := ListPolicies(testAccountID, &iam.ListPoliciesInput{}, svc) diff --git a/spinifex/gateway/iam/policy.go b/spinifex/gateway/iam/policy.go index 4b259d8b6..f023b8d08 100644 --- a/spinifex/gateway/iam/policy.go +++ b/spinifex/gateway/iam/policy.go @@ -35,6 +35,13 @@ func GetPolicyVersion(accountID string, input *iam.GetPolicyVersionInput, svc ha return svc.GetPolicyVersion(accountID, input) } +func ListPolicyVersions(accountID string, input *iam.ListPolicyVersionsInput, svc handlers_iam.IAMService) (*iam.ListPolicyVersionsOutput, error) { + if input.PolicyArn == nil || *input.PolicyArn == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.ListPolicyVersions(accountID, input) +} + func ListPolicies(accountID string, input *iam.ListPoliciesInput, svc handlers_iam.IAMService) (*iam.ListPoliciesOutput, error) { return svc.ListPolicies(accountID, input) } diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go index 11784209f..2174b164b 100644 --- a/spinifex/gateway/iam_request_test.go +++ b/spinifex/gateway/iam_request_test.go @@ -96,6 +96,10 @@ func (m *flexMockIAMService) GetPolicyVersion(_ string, _ *iam.GetPolicyVersionI return &iam.GetPolicyVersionOutput{}, nil } +func (m *flexMockIAMService) ListPolicyVersions(_ string, _ *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + return &iam.ListPolicyVersionsOutput{}, nil +} + func (m *flexMockIAMService) ListPolicies(_ string, _ *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { return &iam.ListPoliciesOutput{}, nil } diff --git a/spinifex/gateway/sts/handler_test.go b/spinifex/gateway/sts/handler_test.go index c038d0c1f..f438b67ff 100644 --- a/spinifex/gateway/sts/handler_test.go +++ b/spinifex/gateway/sts/handler_test.go @@ -124,6 +124,9 @@ func (s *stubIAMService) GetPolicy(string, *iam.GetPolicyInput) (*iam.GetPolicyO func (s *stubIAMService) GetPolicyVersion(string, *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) { panic("unexpected GetPolicyVersion call") } +func (s *stubIAMService) ListPolicyVersions(string, *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + panic("unexpected ListPolicyVersions call") +} func (s *stubIAMService) ListPolicies(string, *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { panic("unexpected ListPolicies call") } diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go index 4fb5e969a..eae97a19d 100644 --- a/spinifex/handlers/iam/service.go +++ b/spinifex/handlers/iam/service.go @@ -22,6 +22,7 @@ type IAMService interface { CreatePolicy(accountID string, input *iam.CreatePolicyInput) (*iam.CreatePolicyOutput, error) GetPolicy(accountID string, input *iam.GetPolicyInput) (*iam.GetPolicyOutput, error) GetPolicyVersion(accountID string, input *iam.GetPolicyVersionInput) (*iam.GetPolicyVersionOutput, error) + ListPolicyVersions(accountID string, input *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) ListPolicies(accountID string, input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) DeletePolicy(accountID string, input *iam.DeletePolicyInput) (*iam.DeletePolicyOutput, error) diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 23609d22b..39b9fe950 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -1089,6 +1089,26 @@ func (s *IAMServiceImpl) GetPolicyVersion(accountID string, input *iam.GetPolicy }, nil } +func (s *IAMServiceImpl) ListPolicyVersions(accountID string, input *iam.ListPolicyVersionsInput) (*iam.ListPolicyVersionsOutput, error) { + policy, err := s.getPolicyByARN(accountID, *input.PolicyArn) + if err != nil { + return nil, err + } + + // CreatePolicyVersion is unrouted, so every policy has exactly one immutable + // default version (v1). Real IAM omits the document from list entries — + // callers fetch it via GetPolicyVersion — so Document is left nil here. + createdAt := parseCreatedAt(policy.CreatedAt) + return &iam.ListPolicyVersionsOutput{ + Versions: []*iam.PolicyVersion{{ + VersionId: aws.String(policy.DefaultVersion), + IsDefaultVersion: aws.Bool(true), + CreateDate: aws.Time(createdAt), + }}, + IsTruncated: aws.Bool(false), + }, nil +} + func (s *IAMServiceImpl) ListPolicies(accountID string, input *iam.ListPoliciesInput) (*iam.ListPoliciesOutput, error) { keys, err := s.policiesBucket.Keys() if err != nil { diff --git a/spinifex/handlers/iam/service_impl_test.go b/spinifex/handlers/iam/service_impl_test.go index f59e6384a..06e3ee3d7 100644 --- a/spinifex/handlers/iam/service_impl_test.go +++ b/spinifex/handlers/iam/service_impl_test.go @@ -1149,6 +1149,32 @@ func TestGetPolicyVersion_PolicyNotFound(t *testing.T) { assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) } +func TestListPolicyVersions(t *testing.T) { + svc := setupTestIAMService(t) + created := createTestPolicy(t, svc, "ListVersionPolicy") + + out, err := svc.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{ + PolicyArn: created.Arn, + }) + require.NoError(t, err) + assert.False(t, *out.IsTruncated) + require.Len(t, out.Versions, 1) + assert.Equal(t, "v1", *out.Versions[0].VersionId) + assert.True(t, *out.Versions[0].IsDefaultVersion) + // AWS omits the document from list entries — callers fetch it via GetPolicyVersion. + assert.Nil(t, out.Versions[0].Document) +} + +func TestListPolicyVersions_PolicyNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.ListPolicyVersions(testAccountID, &iam.ListPolicyVersionsInput{ + PolicyArn: aws.String("arn:aws:iam::000000000000:policy/Ghost"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + func TestListPolicies(t *testing.T) { svc := setupTestIAMService(t)