From 1ab714b1252a6280688d98a88fb23dec80ec24ac Mon Sep 17 00:00:00 2001
From: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
Date: Thu, 1 Oct 2026 14:28:14 +1000
Subject: [PATCH] feat: ListAccountAliases
---
docs/coverage/README.md | 6 +--
docs/coverage/coverage.json | 3 +-
docs/coverage/iam/README.md | 3 +-
docs/coverage/internal-report.md | 3 +-
spinifex/gateway/iam.go | 3 ++
spinifex/gateway/iam/account.go | 15 +++++++
spinifex/gateway/iam/authz.go | 1 +
spinifex/gateway/iam/pagination_test.go | 32 +++++++++++++++
.../gateway/iam_examples_contract_test.go | 4 ++
spinifex/gateway/iam_request_test.go | 40 +++++++++++++++++++
spinifex/handlers/iam/service.go | 4 ++
spinifex/handlers/iam/service_impl.go | 6 +++
12 files changed, 113 insertions(+), 7 deletions(-)
diff --git a/docs/coverage/README.md b/docs/coverage/README.md
index e75c5e02d..7584e6084 100644
--- a/docs/coverage/README.md
+++ b/docs/coverage/README.md
@@ -17,7 +17,7 @@ tags:
## Overview
-The platform serves **397 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
+The platform serves **398 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
| Service | Operations |
|---|---:|
@@ -27,8 +27,8 @@ The platform serves **397 operations** across the AWS APIs below. Every page nam
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 36 |
| [ELBv2](/coverage/elbv2) | 36 |
-| [IAM](/coverage/iam) | 79 |
+| [IAM](/coverage/iam) | 80 |
| [RDS](/coverage/rds) | 26 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
-| **Total** | **397** |
+| **Total** | **398** |
diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json
index 66cc6d13d..ca73485a1 100644
--- a/docs/coverage/coverage.json
+++ b/docs/coverage/coverage.json
@@ -2862,6 +2862,7 @@
"GetUser",
"GetUserPolicy",
"ListAccessKeys",
+ "ListAccountAliases",
"ListAttachedGroupPolicies",
"ListAttachedRolePolicies",
"ListAttachedUserPolicies",
@@ -2943,6 +2944,7 @@
"GetUser",
"GetUserPolicy",
"ListAccessKeys",
+ "ListAccountAliases",
"ListAttachedGroupPolicies",
"ListAttachedRolePolicies",
"ListAttachedUserPolicies",
@@ -3042,7 +3044,6 @@
"GetServiceLastAccessedDetails",
"GetServiceLastAccessedDetailsWithEntities",
"GetServiceLinkedRoleDeletionStatus",
- "ListAccountAliases",
"ListDelegationRequests",
"ListMFADeviceTags",
"ListMFADevices",
diff --git a/docs/coverage/iam/README.md b/docs/coverage/iam/README.md
index b761003fc..f4515f490 100644
--- a/docs/coverage/iam/README.md
+++ b/docs/coverage/iam/README.md
@@ -18,7 +18,7 @@ tags:
## Overview
-Spinifex implements **79 operations** in the IAM `2010-05-08` API model.
+Spinifex implements **80 operations** in the IAM `2010-05-08` API model.
### Scope
@@ -67,6 +67,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `GetUser` |
| `GetUserPolicy` |
| `ListAccessKeys` |
+| `ListAccountAliases` |
| `ListAttachedGroupPolicies` |
| `ListAttachedRolePolicies` |
| `ListAttachedUserPolicies` |
diff --git a/docs/coverage/internal-report.md b/docs/coverage/internal-report.md
index e14858596..9fcc99419 100644
--- a/docs/coverage/internal-report.md
+++ b/docs/coverage/internal-report.md
@@ -957,7 +957,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## iam
-`2010-05-08` — 79 of 180 modelled operations implemented (43.9%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
+`2010-05-08` — 80 of 180 modelled operations implemented (44.4%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
### Not implemented
@@ -1006,7 +1006,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `GetServerCertificate`
- `GetServiceLastAccessedDetails`
- `GetServiceLastAccessedDetailsWithEntities`
-- `ListAccountAliases`
- `ListDelegationRequests`
- `ListMFADeviceTags`
- `ListMFADevices`
diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go
index 6a708a110..3830bea5c 100644
--- a/spinifex/gateway/iam.go
+++ b/spinifex/gateway/iam.go
@@ -323,6 +323,9 @@ var iamActions = map[string]iamAction{
"GetAccountSummary": iamHandler(func(accountID string, input *iam.GetAccountSummaryInput, gw *GatewayConfig) (any, error) {
return gateway_iam.GetAccountSummary(accountID, input, gw.IAMService)
}),
+ "ListAccountAliases": iamHandler(func(accountID string, input *iam.ListAccountAliasesInput, gw *GatewayConfig) (any, error) {
+ return gateway_iam.ListAccountAliases(accountID, input, gw.IAMService)
+ }),
}
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
diff --git a/spinifex/gateway/iam/account.go b/spinifex/gateway/iam/account.go
index 1610abf8b..cfc20dd1a 100644
--- a/spinifex/gateway/iam/account.go
+++ b/spinifex/gateway/iam/account.go
@@ -1,6 +1,7 @@
package gateway_iam
import (
+ "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
)
@@ -10,3 +11,17 @@ import (
func GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput, svc handlers_iam.IAMService) (*iam.GetAccountSummaryOutput, error) {
return svc.GetAccountSummary(accountID, input)
}
+
+func ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput, svc handlers_iam.IAMService) (*iam.ListAccountAliasesOutput, error) {
+ p, err := newPager(input.Marker, input.MaxItems)
+ if err != nil {
+ return nil, err
+ }
+ out, err := svc.ListAccountAliases(accountID, input)
+ if err != nil {
+ return nil, err
+ }
+ out.AccountAliases, out.Marker = paginate(p, out.AccountAliases, aws.StringValue)
+ out.IsTruncated = aws.Bool(out.Marker != nil)
+ return out, nil
+}
diff --git a/spinifex/gateway/iam/authz.go b/spinifex/gateway/iam/authz.go
index 28e75e205..065ebc83a 100644
--- a/spinifex/gateway/iam/authz.go
+++ b/spinifex/gateway/iam/authz.go
@@ -136,6 +136,7 @@ var iamScopes = map[string]resourceScope{
"ListOpenIDConnectProviders": {source: sourceAccount},
"ListGroups": {source: sourceAccount},
"GetAccountSummary": {source: sourceAccount},
+ "ListAccountAliases": {source: sourceAccount},
}
// HasScope reports whether action has an explicit IAM scope-table entry.
diff --git a/spinifex/gateway/iam/pagination_test.go b/spinifex/gateway/iam/pagination_test.go
index d550ae2b0..8ea8de352 100644
--- a/spinifex/gateway/iam/pagination_test.go
+++ b/spinifex/gateway/iam/pagination_test.go
@@ -132,6 +132,38 @@ func TestListUsers_RejectsBadPagingBeforeListing(t *testing.T) {
}
}
+// listAliasesStub counts calls so a rejected page proves the service never ran.
+type listAliasesStub struct {
+ handlers_iam.IAMService
+
+ calls int
+}
+
+func (s *listAliasesStub) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
+ s.calls++
+ return &iam.ListAccountAliasesOutput{AccountAliases: []*string{}}, nil
+}
+
+func TestListAccountAliases_RejectsBadPagingBeforeListing(t *testing.T) {
+ tests := []struct {
+ name string
+ input *iam.ListAccountAliasesInput
+ }{
+ {"MaxItems zero", &iam.ListAccountAliasesInput{MaxItems: aws.Int64(0)}},
+ {"MaxItems above 1000", &iam.ListAccountAliasesInput{MaxItems: aws.Int64(1001)}},
+ {"undecodable Marker", &iam.ListAccountAliasesInput{Marker: aws.String("not a marker!")}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ svc := &listAliasesStub{}
+ _, err := gateway_iam.ListAccountAliases(testAccountID, tc.input, svc)
+ require.Error(t, err)
+ assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorValidationError), "got %v", err)
+ assert.Zero(t, svc.calls)
+ })
+ }
+}
+
func TestListUsers_MaxItemsBoundsAccepted(t *testing.T) {
svc := &listStub{users: []string{"a", "b"}}
assert.Len(t, listUsers(t, svc, nil, 1).Users, 1)
diff --git a/spinifex/gateway/iam_examples_contract_test.go b/spinifex/gateway/iam_examples_contract_test.go
index 7a1554c0e..d4f26788e 100644
--- a/spinifex/gateway/iam_examples_contract_test.go
+++ b/spinifex/gateway/iam_examples_contract_test.go
@@ -242,6 +242,10 @@ func (svc *echoIAMService) GetAccountSummary(accountID string, input *iam.GetAcc
return echoIAMCall[iam.GetAccountSummaryInput, iam.GetAccountSummaryOutput](svc, accountID, input)
}
+func (svc *echoIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
+ return echoIAMCall[iam.ListAccountAliasesInput, iam.ListAccountAliasesOutput](svc, accountID, input)
+}
+
func (svc *echoIAMService) GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error) {
if _, ok := svc.wantOutput.(*iam.GetInstanceProfileOutput); !ok {
return &iam.GetInstanceProfileOutput{InstanceProfile: &iam.InstanceProfile{}}, nil
diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go
index 1ecba816d..24908bbf4 100644
--- a/spinifex/gateway/iam_request_test.go
+++ b/spinifex/gateway/iam_request_test.go
@@ -10,11 +10,14 @@ import (
"testing"
"github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/credentials"
+ "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
// flexMockIAMService is a configurable mock with per-method overrides. It
@@ -125,6 +128,9 @@ func (m *flexMockIAMService) GetAccountSummary(accountID string, input *iam.GetA
}
return &iam.GetAccountSummaryOutput{}, nil
}
+func (m *flexMockIAMService) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
+ return &iam.ListAccountAliasesOutput{}, nil
+}
func (m *flexMockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
@@ -282,6 +288,40 @@ func TestIAMRequest_GetAccountSummary_Success(t *testing.T) {
assert.Contains(t, xmlStr, "2")
}
+// realAliasesIAMService answers ListAccountAliases from the real service, which
+// needs no storage, so the test sees the response the gateway actually sends.
+type realAliasesIAMService struct {
+ flexMockIAMService
+}
+
+func (realAliasesIAMService) ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
+ return (&handlers_iam.IAMServiceImpl{}).ListAccountAliases(accountID, input)
+}
+
+// AWS answers an account with no alias with an empty AccountAliases list and
+// IsTruncated false, and no Marker.
+func TestIAMRequest_ListAccountAliases_Empty(t *testing.T) {
+ server := httptest.NewServer(setupIAMRequestHandler(&realAliasesIAMService{}))
+ defer server.Close()
+ client := iam.New(session.Must(session.NewSession(&aws.Config{
+ Credentials: credentials.NewStaticCredentials("test", "test", ""),
+ DisableSSL: aws.Bool(true),
+ Endpoint: aws.String(server.URL),
+ MaxRetries: aws.Int(0),
+ Region: aws.String("us-east-1"),
+ })))
+
+ out, err := client.ListAccountAliases(&iam.ListAccountAliasesInput{})
+ require.NoError(t, err)
+ assert.Empty(t, out.AccountAliases)
+ assert.False(t, aws.BoolValue(out.IsTruncated))
+ assert.Nil(t, out.Marker)
+
+ _, err = client.ListAccountAliases(&iam.ListAccountAliasesInput{MaxItems: aws.Int64(1001)})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), awserrors.ErrorValidationError)
+}
+
// 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) {
diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go
index 5196863df..e4185954d 100644
--- a/spinifex/handlers/iam/service.go
+++ b/spinifex/handlers/iam/service.go
@@ -151,4 +151,8 @@ type IAMService interface {
// GetAccountSummary returns account-wide IAM usage counts plus AWS-parity
// quota values as a SummaryMap. Read-only and account-scoped.
GetAccountSummary(accountID string, input *iam.GetAccountSummaryInput) (*iam.GetAccountSummaryOutput, error)
+
+ // ListAccountAliases lists the account's aliases. Spinifex has no account
+ // aliases, so every account has none.
+ ListAccountAliases(accountID string, input *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error)
}
diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go
index 531edd817..ded77d7c2 100644
--- a/spinifex/handlers/iam/service_impl.go
+++ b/spinifex/handlers/iam/service_impl.go
@@ -2666,6 +2666,12 @@ func (s *IAMServiceImpl) GetAccountSummary(accountID string, _ *iam.GetAccountSu
return &iam.GetAccountSummaryOutput{SummaryMap: summary}, nil
}
+// ListAccountAliases returns no aliases, which is what AWS returns for an account
+// that has none. Spinifex does not serve CreateAccountAlias.
+func (s *IAMServiceImpl) ListAccountAliases(_ string, _ *iam.ListAccountAliasesInput) (*iam.ListAccountAliasesOutput, error) {
+ return &iam.ListAccountAliasesOutput{AccountAliases: []*string{}}, 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) {