feat: IAM Groups v1 (#449)

This commit is contained in:
Julian Sommer
2026-06-30 14:30:59 +10:00
parent 2d4f0fa94c
commit c9629d3bf6
17 changed files with 2380 additions and 12 deletions
+53 -12
View File
@@ -464,8 +464,7 @@ EIP commands are only registered when an external IPAM pool is configured.
## IAM
All IAM operations are account-scoped. Root user (account `000000000000`)
bypasses policy evaluation entirely.
All IAM operations are account-scoped. Root user (account `000000000000`) bypasses policy evaluation entirely.
### IAM — Users
@@ -475,6 +474,21 @@ bypasses policy evaluation entirely.
| `get-user` | `--user-name` | — | **DONE** |
| `list-users` | `--path-prefix` | `--max-items`, `--marker` | **DONE** |
| `delete-user` | `--user-name` | — | **DONE** |
| `update-user` | — | `--user-name`, `--new-path`, `--new-user-name` | **NOT STARTED** |
| `put-user-policy` | — | `--user-name`, `--policy-name`, `--policy-document` | **NOT STARTED** |
| `get-user-policy` | — | `--user-name`, `--policy-name` | **NOT STARTED** |
| `delete-user-policy` | — | `--user-name`, `--policy-name` | **NOT STARTED** |
| `list-user-policies` | — | `--user-name` | **NOT STARTED** |
| `tag-user` | — | `--user-name`, `--tags` | **NOT STARTED** |
| `untag-user` | — | `--user-name`, `--tag-keys` | **NOT STARTED** |
| `list-user-tags` | — | `--user-name` | **NOT STARTED** |
| `put-user-permissions-boundary` | — | `--user-name`, `--permissions-boundary` | **NOT STARTED** |
| `delete-user-permissions-boundary` | — | `--user-name` | **NOT STARTED** |
| `create-login-profile` | — | `--user-name`, `--password` | **NOT STARTED** |
| `get-login-profile` | — | `--user-name` | **NOT STARTED** |
| `update-login-profile` | — | `--user-name`, `--password` | **NOT STARTED** |
| `delete-login-profile` | — | `--user-name` | **NOT STARTED** |
| `change-password` | — | `--old-password`, `--new-password` | **NOT STARTED** |
### IAM — Access Keys
@@ -484,6 +498,7 @@ bypasses policy evaluation entirely.
| `list-access-keys` | `--user-name` | `--max-items`, `--marker` | **DONE** |
| `delete-access-key` | `--access-key-id`, `--user-name` | — | **DONE** |
| `update-access-key` | `--access-key-id`, `--user-name`, `--status` (Active/Inactive) | — | **DONE** |
| `get-access-key-last-used` | — | `--access-key-id` | **NOT STARTED** |
### IAM — Policies
@@ -498,6 +513,17 @@ bypasses policy evaluation entirely.
| `attach-user-policy` | `--user-name`, `--policy-arn` | — | **DONE** |
| `detach-user-policy` | `--user-name`, `--policy-arn` | — | **DONE** |
| `list-attached-user-policies` | `--user-name` | `--path-prefix`, `--max-items`, `--marker` | **DONE** |
| `create-policy-version` | — | `--policy-arn`, `--policy-document`, `--set-as-default` | **NOT STARTED** |
| `delete-policy-version` | — | `--policy-arn`, `--version-id` | **NOT STARTED** |
| `set-default-policy-version` | — | `--policy-arn`, `--version-id` | **NOT STARTED** |
| `list-entities-for-policy` | — | `--policy-arn`, `--entity-filter`, `--path-prefix`, `--policy-usage-filter` | **NOT STARTED** |
| `tag-policy` | — | `--policy-arn`, `--tags` | **NOT STARTED** |
| `untag-policy` | — | `--policy-arn`, `--tag-keys` | **NOT STARTED** |
| `list-policy-tags` | — | `--policy-arn` | **NOT STARTED** |
| `generate-service-last-accessed-details` | — | `--arn`, `--granularity` | **NOT STARTED** |
| `get-service-last-accessed-details` | — | `--job-id` | **NOT STARTED** |
| `get-service-last-accessed-details-with-entities` | — | `--job-id`, `--service-namespace` | **NOT STARTED** |
| `list-policies-granting-service-access` | — | `--arn`, `--service-namespaces` | **NOT STARTED** |
### IAM — Roles
@@ -516,6 +542,12 @@ bypasses policy evaluation entirely.
| `put-role-policy` | `--role-name`, `--policy-name`, `--policy-document` | — | **DONE** |
| `get-role-policy` | `--role-name`, `--policy-name` | — | **DONE** (document returned as raw JSON, not URL-encoded) |
| `delete-role-policy` | `--role-name`, `--policy-name` | — | **DONE** |
| `put-role-permissions-boundary` | — | `--role-name`, `--permissions-boundary` | **NOT STARTED** |
| `delete-role-permissions-boundary` | — | `--role-name` | **NOT STARTED** |
| `tag-role` | — | `--role-name`, `--tags` | **NOT STARTED** |
| `untag-role` | — | `--role-name`, `--tag-keys` | **NOT STARTED** |
| `list-role-tags` | — | `--role-name` | **NOT STARTED** |
| `update-role-description` | — | `--role-name`, `--description` | **NOT STARTED** |
### IAM — Instance Profiles
@@ -528,6 +560,9 @@ bypasses policy evaluation entirely.
| `delete-instance-profile` | `--instance-profile-name` | — | **DONE** |
| `add-role-to-instance-profile` | `--instance-profile-name`, `--role-name` | — | **DONE** |
| `remove-role-from-instance-profile` | `--instance-profile-name`, `--role-name` | — | **DONE** |
| `tag-instance-profile` | — | `--instance-profile-name`, `--tags` | **NOT STARTED** |
| `untag-instance-profile` | — | `--instance-profile-name`, `--tag-keys` | **NOT STARTED** |
| `list-instance-profile-tags` | — | `--instance-profile-name` | **NOT STARTED** |
### IAM — OIDC Providers
@@ -541,21 +576,27 @@ bypasses policy evaluation entirely.
| `remove-client-id-from-open-id-connect-provider` | — | `--open-id-connect-provider-arn`, `--client-id` | **NOT STARTED** |
| `update-open-id-connect-provider-thumbprint` | — | `--open-id-connect-provider-arn`, `--thumbprint-list` | **NOT STARTED** |
| `tag-open-id-connect-provider` / `untag-open-id-connect-provider` | — | `--open-id-connect-provider-arn`, `--tags`/`--tag-keys` | **NOT STARTED** |
| `list-open-id-connect-provider-tags` | — | `--open-id-connect-provider-arn` | **NOT STARTED** |
### IAM — Groups
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-group` | — | `--group-name`, `--path` | **NOT STARTED** |
| `get-group` | — | `--group-name` | **NOT STARTED** |
| `list-groups` | — | `--path-prefix`, `--max-items`, `--marker` | **NOT STARTED** |
| `delete-group` | — | `--group-name` | **NOT STARTED** |
| `add-user-to-group` | — | `--group-name`, `--user-name` | **NOT STARTED** |
| `remove-user-from-group` | — | `--group-name`, `--user-name` | **NOT STARTED** |
| `list-groups-for-user` | — | `--user-name`, `--max-items`, `--marker` | **NOT STARTED** |
| `attach-group-policy` | — | `--group-name`, `--policy-arn` | **NOT STARTED** |
| `detach-group-policy` | — | `--group-name`, `--policy-arn` | **NOT STARTED** |
| `list-attached-group-policies` | — | `--group-name`, `--path-prefix`, `--max-items`, `--marker` | **NOT STARTED** |
| `create-group` | `--group-name`, `--path` | — | **DONE** |
| `get-group` | `--group-name` | — | **DONE** |
| `list-groups` | `--path-prefix` | `--max-items`, `--marker` | **DONE** |
| `delete-group` | `--group-name` | — | **DONE** |
| `update-group` | — | `--group-name`, `--new-path`, `--new-group-name` | **NOT STARTED** |
| `add-user-to-group` | `--group-name`, `--user-name` | — | **DONE** |
| `remove-user-from-group` | `--group-name`, `--user-name` | — | **DONE** |
| `list-groups-for-user` | `--user-name` | `--max-items`, `--marker` | **DONE** |
| `attach-group-policy` | `--group-name`, `--policy-arn` | — | **DONE** |
| `detach-group-policy` | `--group-name`, `--policy-arn` | — | **DONE** |
| `list-attached-group-policies` | `--group-name`, `--path-prefix` | `--max-items`, `--marker` | **DONE** |
| `put-group-policy` | — | `--group-name`, `--policy-name`, `--policy-document` | **NOT STARTED** |
| `get-group-policy` | — | `--group-name`, `--policy-name` | **NOT STARTED** |
| `delete-group-policy` | — | `--group-name`, `--policy-name` | **NOT STARTED** |
| `list-group-policies` | — | `--group-name` | **NOT STARTED** |
---
+30
View File
@@ -180,6 +180,36 @@ func (m *mockIAMService) RemoveRoleFromInstanceProfile(_ string, _ *iam.RemoveRo
func (m *mockIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) {
return nil, nil
}
func (m *mockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
func (m *mockIAMService) GetGroup(_ string, _ *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
return &iam.GetGroupOutput{}, nil
}
func (m *mockIAMService) ListGroups(_ string, _ *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return &iam.ListGroupsOutput{}, nil
}
func (m *mockIAMService) DeleteGroup(_ string, _ *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
return &iam.DeleteGroupOutput{}, nil
}
func (m *mockIAMService) AddUserToGroup(_ string, _ *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return &iam.AddUserToGroupOutput{}, nil
}
func (m *mockIAMService) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (m *mockIAMService) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return &iam.ListGroupsForUserOutput{}, nil
}
func (m *mockIAMService) AttachGroupPolicy(_ string, _ *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return &iam.AttachGroupPolicyOutput{}, nil
}
func (m *mockIAMService) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
return &iam.DetachGroupPolicyOutput{}, nil
}
func (m *mockIAMService) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
return &iam.ListAttachedGroupPoliciesOutput{}, nil
}
// testMasterKey is a fixed 32-byte key for deterministic tests.
var testMasterKey []byte
@@ -170,6 +170,36 @@ func (f *fakeIAMService) IsEmpty() (bool, error)
func (f *fakeIAMService) CreateAccount(string) (*handlers_iam.Account, error) { return nil, nil }
func (f *fakeIAMService) GetAccount(string) (*handlers_iam.Account, error) { return nil, nil }
func (f *fakeIAMService) ListAccounts() ([]*handlers_iam.Account, error) { return nil, nil }
func (f *fakeIAMService) CreateGroup(string, *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
func (f *fakeIAMService) GetGroup(string, *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
return &iam.GetGroupOutput{}, nil
}
func (f *fakeIAMService) ListGroups(string, *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return &iam.ListGroupsOutput{}, nil
}
func (f *fakeIAMService) DeleteGroup(string, *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
return &iam.DeleteGroupOutput{}, nil
}
func (f *fakeIAMService) AddUserToGroup(string, *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return &iam.AddUserToGroupOutput{}, nil
}
func (f *fakeIAMService) RemoveUserFromGroup(string, *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (f *fakeIAMService) ListGroupsForUser(string, *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return &iam.ListGroupsForUserOutput{}, nil
}
func (f *fakeIAMService) AttachGroupPolicy(string, *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return &iam.AttachGroupPolicyOutput{}, nil
}
func (f *fakeIAMService) DetachGroupPolicy(string, *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
return &iam.DetachGroupPolicyOutput{}, nil
}
func (f *fakeIAMService) ListAttachedGroupPolicies(string, *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
return &iam.ListAttachedGroupPoliciesOutput{}, nil
}
var _ handlers_iam.IAMService = (*fakeIAMService)(nil)
+36
View File
@@ -181,6 +181,42 @@ var iamActions = map[string]IAMHandler{
"DeleteOpenIDConnectProvider": iamHandler(func(accountID string, input *iam.DeleteOpenIDConnectProviderInput, gw *GatewayConfig) (any, error) {
return gateway_iam.DeleteOpenIDConnectProvider(accountID, input, gw.IAMService)
}),
// Group CRUD
"CreateGroup": iamHandler(func(accountID string, input *iam.CreateGroupInput, gw *GatewayConfig) (any, error) {
return gateway_iam.CreateGroup(accountID, input, gw.IAMService)
}),
"GetGroup": iamHandler(func(accountID string, input *iam.GetGroupInput, gw *GatewayConfig) (any, error) {
return gateway_iam.GetGroup(accountID, input, gw.IAMService)
}),
"ListGroups": iamHandler(func(accountID string, input *iam.ListGroupsInput, gw *GatewayConfig) (any, error) {
return gateway_iam.ListGroups(accountID, input, gw.IAMService)
}),
"DeleteGroup": iamHandler(func(accountID string, input *iam.DeleteGroupInput, gw *GatewayConfig) (any, error) {
return gateway_iam.DeleteGroup(accountID, input, gw.IAMService)
}),
// Group membership
"AddUserToGroup": iamHandler(func(accountID string, input *iam.AddUserToGroupInput, gw *GatewayConfig) (any, error) {
return gateway_iam.AddUserToGroup(accountID, input, gw.IAMService)
}),
"RemoveUserFromGroup": iamHandler(func(accountID string, input *iam.RemoveUserFromGroupInput, gw *GatewayConfig) (any, error) {
return gateway_iam.RemoveUserFromGroup(accountID, input, gw.IAMService)
}),
"ListGroupsForUser": iamHandler(func(accountID string, input *iam.ListGroupsForUserInput, gw *GatewayConfig) (any, error) {
return gateway_iam.ListGroupsForUser(accountID, input, gw.IAMService)
}),
// Group policy attachment
"AttachGroupPolicy": iamHandler(func(accountID string, input *iam.AttachGroupPolicyInput, gw *GatewayConfig) (any, error) {
return gateway_iam.AttachGroupPolicy(accountID, input, gw.IAMService)
}),
"DetachGroupPolicy": iamHandler(func(accountID string, input *iam.DetachGroupPolicyInput, gw *GatewayConfig) (any, error) {
return gateway_iam.DetachGroupPolicy(accountID, input, gw.IAMService)
}),
"ListAttachedGroupPolicies": iamHandler(func(accountID string, input *iam.ListAttachedGroupPoliciesInput, gw *GatewayConfig) (any, error) {
return gateway_iam.ListAttachedGroupPolicies(accountID, input, gw.IAMService)
}),
}
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
+88
View File
@@ -0,0 +1,88 @@
package gateway_iam
import (
"errors"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
)
func CreateGroup(accountID string, input *iam.CreateGroupInput, svc handlers_iam.IAMService) (*iam.CreateGroupOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.CreateGroup(accountID, input)
}
func GetGroup(accountID string, input *iam.GetGroupInput, svc handlers_iam.IAMService) (*iam.GetGroupOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.GetGroup(accountID, input)
}
func ListGroups(accountID string, input *iam.ListGroupsInput, svc handlers_iam.IAMService) (*iam.ListGroupsOutput, error) {
return svc.ListGroups(accountID, input)
}
func DeleteGroup(accountID string, input *iam.DeleteGroupInput, svc handlers_iam.IAMService) (*iam.DeleteGroupOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.DeleteGroup(accountID, input)
}
func AddUserToGroup(accountID string, input *iam.AddUserToGroupInput, svc handlers_iam.IAMService) (*iam.AddUserToGroupOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.AddUserToGroup(accountID, input)
}
func RemoveUserFromGroup(accountID string, input *iam.RemoveUserFromGroupInput, svc handlers_iam.IAMService) (*iam.RemoveUserFromGroupOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.RemoveUserFromGroup(accountID, input)
}
func ListGroupsForUser(accountID string, input *iam.ListGroupsForUserInput, svc handlers_iam.IAMService) (*iam.ListGroupsForUserOutput, error) {
if input.UserName == nil || *input.UserName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.ListGroupsForUser(accountID, input)
}
func AttachGroupPolicy(accountID string, input *iam.AttachGroupPolicyInput, svc handlers_iam.IAMService) (*iam.AttachGroupPolicyOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.PolicyArn == nil || *input.PolicyArn == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.AttachGroupPolicy(accountID, input)
}
func DetachGroupPolicy(accountID string, input *iam.DetachGroupPolicyInput, svc handlers_iam.IAMService) (*iam.DetachGroupPolicyOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if input.PolicyArn == nil || *input.PolicyArn == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.DetachGroupPolicy(accountID, input)
}
func ListAttachedGroupPolicies(accountID string, input *iam.ListAttachedGroupPoliciesInput, svc handlers_iam.IAMService) (*iam.ListAttachedGroupPoliciesOutput, error) {
if input.GroupName == nil || *input.GroupName == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
return svc.ListAttachedGroupPolicies(accountID, input)
}
+243
View File
@@ -0,0 +1,243 @@
package gateway_iam
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const groupPolicyArn = "arn:aws:iam::000000000000:policy/mypolicy"
func TestCreateGroup(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.CreateGroupInput
wantErr string
}{
{"nil GroupName", &iam.CreateGroupInput{}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.CreateGroupInput{GroupName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.CreateGroupInput{GroupName: aws.String("g")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := CreateGroup(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestGetGroup(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.GetGroupInput
wantErr string
}{
{"nil GroupName", &iam.GetGroupInput{}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.GetGroupInput{GroupName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.GetGroupInput{GroupName: aws.String("g")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := GetGroup(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestListGroups(t *testing.T) {
svc := &stubIAMService{}
_, err := ListGroups(testAccountID, &iam.ListGroupsInput{}, svc)
require.NoError(t, err)
}
func TestDeleteGroup(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.DeleteGroupInput
wantErr string
}{
{"nil GroupName", &iam.DeleteGroupInput{}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.DeleteGroupInput{GroupName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.DeleteGroupInput{GroupName: aws.String("g")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := DeleteGroup(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestAddUserToGroup(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.AddUserToGroupInput
wantErr string
}{
{"nil GroupName", &iam.AddUserToGroupInput{UserName: aws.String("u")}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.AddUserToGroupInput{GroupName: aws.String(""), UserName: aws.String("u")}, awserrors.ErrorMissingParameter},
{"nil UserName", &iam.AddUserToGroupInput{GroupName: aws.String("g")}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.AddUserToGroupInput{GroupName: aws.String("g"), UserName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.AddUserToGroupInput{GroupName: aws.String("g"), UserName: aws.String("u")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := AddUserToGroup(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestRemoveUserFromGroup(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.RemoveUserFromGroupInput
wantErr string
}{
{"nil GroupName", &iam.RemoveUserFromGroupInput{UserName: aws.String("u")}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.RemoveUserFromGroupInput{GroupName: aws.String(""), UserName: aws.String("u")}, awserrors.ErrorMissingParameter},
{"nil UserName", &iam.RemoveUserFromGroupInput{GroupName: aws.String("g")}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.RemoveUserFromGroupInput{GroupName: aws.String("g"), UserName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.RemoveUserFromGroupInput{GroupName: aws.String("g"), UserName: aws.String("u")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := RemoveUserFromGroup(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestListGroupsForUser(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.ListGroupsForUserInput
wantErr string
}{
{"nil UserName", &iam.ListGroupsForUserInput{}, awserrors.ErrorMissingParameter},
{"empty UserName", &iam.ListGroupsForUserInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.ListGroupsForUserInput{UserName: aws.String("u")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ListGroupsForUser(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestAttachGroupPolicy(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.AttachGroupPolicyInput
wantErr string
}{
{"nil GroupName", &iam.AttachGroupPolicyInput{PolicyArn: aws.String(groupPolicyArn)}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.AttachGroupPolicyInput{GroupName: aws.String(""), PolicyArn: aws.String(groupPolicyArn)}, awserrors.ErrorMissingParameter},
{"nil PolicyArn", &iam.AttachGroupPolicyInput{GroupName: aws.String("g")}, awserrors.ErrorMissingParameter},
{"empty PolicyArn", &iam.AttachGroupPolicyInput{GroupName: aws.String("g"), PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.AttachGroupPolicyInput{GroupName: aws.String("g"), PolicyArn: aws.String(groupPolicyArn)}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := AttachGroupPolicy(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestDetachGroupPolicy(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.DetachGroupPolicyInput
wantErr string
}{
{"nil GroupName", &iam.DetachGroupPolicyInput{PolicyArn: aws.String(groupPolicyArn)}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.DetachGroupPolicyInput{GroupName: aws.String(""), PolicyArn: aws.String(groupPolicyArn)}, awserrors.ErrorMissingParameter},
{"nil PolicyArn", &iam.DetachGroupPolicyInput{GroupName: aws.String("g")}, awserrors.ErrorMissingParameter},
{"empty PolicyArn", &iam.DetachGroupPolicyInput{GroupName: aws.String("g"), PolicyArn: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.DetachGroupPolicyInput{GroupName: aws.String("g"), PolicyArn: aws.String(groupPolicyArn)}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := DetachGroupPolicy(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestListAttachedGroupPolicies(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
name string
input *iam.ListAttachedGroupPoliciesInput
wantErr string
}{
{"nil GroupName", &iam.ListAttachedGroupPoliciesInput{}, awserrors.ErrorMissingParameter},
{"empty GroupName", &iam.ListAttachedGroupPoliciesInput{GroupName: aws.String("")}, awserrors.ErrorMissingParameter},
{"valid", &iam.ListAttachedGroupPoliciesInput{GroupName: aws.String("g")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ListAttachedGroupPolicies(testAccountID, tc.input, svc)
if tc.wantErr != "" {
require.Error(t, err)
assert.Equal(t, tc.wantErr, err.Error())
} else {
require.NoError(t, err)
}
})
}
}
+31
View File
@@ -175,6 +175,37 @@ func (s *stubIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.Inst
return nil, nil
}
func (s *stubIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
func (s *stubIAMService) GetGroup(_ string, _ *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
return &iam.GetGroupOutput{}, nil
}
func (s *stubIAMService) ListGroups(_ string, _ *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return &iam.ListGroupsOutput{}, nil
}
func (s *stubIAMService) DeleteGroup(_ string, _ *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
return &iam.DeleteGroupOutput{}, nil
}
func (s *stubIAMService) AddUserToGroup(_ string, _ *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return &iam.AddUserToGroupOutput{}, nil
}
func (s *stubIAMService) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (s *stubIAMService) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return &iam.ListGroupsForUserOutput{}, nil
}
func (s *stubIAMService) AttachGroupPolicy(_ string, _ *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return &iam.AttachGroupPolicyOutput{}, nil
}
func (s *stubIAMService) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
return &iam.DetachGroupPolicyOutput{}, nil
}
func (s *stubIAMService) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
return &iam.ListAttachedGroupPoliciesOutput{}, nil
}
func TestCreateUser(t *testing.T) {
svc := &stubIAMService{}
tests := []struct {
+30
View File
@@ -205,6 +205,36 @@ func (m *flexMockIAMService) RemoveRoleFromInstanceProfile(_ string, _ *iam.Remo
func (m *flexMockIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) {
return nil, nil
}
func (m *flexMockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
return &iam.CreateGroupOutput{}, nil
}
func (m *flexMockIAMService) GetGroup(_ string, _ *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
return &iam.GetGroupOutput{}, nil
}
func (m *flexMockIAMService) ListGroups(_ string, _ *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
return &iam.ListGroupsOutput{}, nil
}
func (m *flexMockIAMService) DeleteGroup(_ string, _ *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
return &iam.DeleteGroupOutput{}, nil
}
func (m *flexMockIAMService) AddUserToGroup(_ string, _ *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
return &iam.AddUserToGroupOutput{}, nil
}
func (m *flexMockIAMService) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (m *flexMockIAMService) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
return &iam.ListGroupsForUserOutput{}, nil
}
func (m *flexMockIAMService) AttachGroupPolicy(_ string, _ *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
return &iam.AttachGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
return &iam.DetachGroupPolicyOutput{}, nil
}
func (m *flexMockIAMService) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
return &iam.ListAttachedGroupPoliciesOutput{}, nil
}
// setupIAMRequestHandler wires an http.Handler for IAM_Request with injected SigV4 context values.
func setupIAMRequestHandler(svc handlers_iam.IAMService) http.Handler {
+30
View File
@@ -230,6 +230,36 @@ func (s *stubIAMService) GetAccount(string) (*handlers_iam.Account, error) {
func (s *stubIAMService) ListAccounts() ([]*handlers_iam.Account, error) {
panic("unexpected ListAccounts call")
}
func (s *stubIAMService) CreateGroup(string, *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
panic("unexpected CreateGroup call")
}
func (s *stubIAMService) GetGroup(string, *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
panic("unexpected GetGroup call")
}
func (s *stubIAMService) ListGroups(string, *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
panic("unexpected ListGroups call")
}
func (s *stubIAMService) DeleteGroup(string, *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
panic("unexpected DeleteGroup call")
}
func (s *stubIAMService) AddUserToGroup(string, *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
panic("unexpected AddUserToGroup call")
}
func (s *stubIAMService) RemoveUserFromGroup(string, *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
panic("unexpected RemoveUserFromGroup call")
}
func (s *stubIAMService) ListGroupsForUser(string, *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
panic("unexpected ListGroupsForUser call")
}
func (s *stubIAMService) AttachGroupPolicy(string, *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
panic("unexpected AttachGroupPolicy call")
}
func (s *stubIAMService) DetachGroupPolicy(string, *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
panic("unexpected DetachGroupPolicy call")
}
func (s *stubIAMService) ListAttachedGroupPolicies(string, *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
panic("unexpected ListAttachedGroupPolicies call")
}
func TestAssumeRole_HappyPath(t *testing.T) {
var calledWith struct {
+472
View File
@@ -0,0 +1,472 @@
package handlers_iam
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// maxGroupsPerUser caps how many groups a single user may belong to. Mirrors the
// hardcoded maxAccessKeysPerUser limit and bounds the per-request getGroup
// fan-out that GetUserPolicies performs on the authorization hot path.
const maxGroupsPerUser = 10
func (s *IAMServiceImpl) CreateGroup(accountID string, input *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
groupName := *input.GroupName
if err := validateGroupName(groupName); err != nil {
return nil, errors.New(awserrors.ErrorIAMInvalidInput)
}
path := "/"
if input.Path != nil {
path = *input.Path
if err := validatePath(path); err != nil {
return nil, errors.New(awserrors.ErrorIAMInvalidInput)
}
}
groupID, err := generateIAMID("AGPA")
if err != nil {
return nil, fmt.Errorf("generate group ID: %w", err)
}
group := Group{
GroupName: groupName,
GroupID: groupID,
AccountID: accountID,
ARN: fmt.Sprintf("arn:aws:iam::%s:group%s%s", accountID, path, groupName),
Path: path,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
AttachedPolicies: []string{},
Tags: []Tag{},
}
data, err := json.Marshal(group)
if err != nil {
return nil, fmt.Errorf("marshal group: %w", err)
}
kvKey := accountID + "." + groupName
if _, err := s.groupsBucket.Create(kvKey, data); err != nil {
if errors.Is(err, nats.ErrKeyExists) {
return nil, errors.New(awserrors.ErrorIAMEntityAlreadyExists)
}
return nil, fmt.Errorf("store group: %w", err)
}
slog.Info("IAM group created", "accountID", accountID, "groupName", groupName, "groupID", group.GroupID)
return &iam.CreateGroupOutput{Group: groupToSDK(&group)}, nil
}
func (s *IAMServiceImpl) GetGroup(accountID string, input *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
group, err := s.getGroup(accountID, *input.GroupName)
if err != nil {
return nil, err
}
members, err := s.findGroupMembers(accountID, group.GroupName)
if err != nil {
return nil, fmt.Errorf("find group members: %w", err)
}
// GetGroupOutput.Users is a required SDK field — return an empty slice, never nil.
users := make([]*iam.User, 0, len(members))
for _, u := range members {
users = append(users, &iam.User{
UserName: aws.String(u.UserName),
UserId: aws.String(u.UserID),
Arn: aws.String(u.ARN),
Path: aws.String(u.Path),
CreateDate: aws.Time(parseCreatedAt(u.CreatedAt)),
})
}
return &iam.GetGroupOutput{
Group: groupToSDK(group),
Users: users,
IsTruncated: aws.Bool(false),
}, nil
}
func (s *IAMServiceImpl) ListGroups(accountID string, input *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
keys, err := s.groupsBucket.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return &iam.ListGroupsOutput{
Groups: []*iam.Group{},
IsTruncated: aws.Bool(false),
}, nil
}
return nil, fmt.Errorf("list group keys: %w", err)
}
pathPrefix := "/"
if input.PathPrefix != nil {
pathPrefix = *input.PathPrefix
}
keyPrefix := accountID + "."
var groups []*iam.Group
for _, key := range keys {
if key == utils.VersionKey {
continue
}
if !strings.HasPrefix(key, keyPrefix) {
continue
}
entry, err := s.groupsBucket.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
slog.Debug("ListGroups: group key disappeared (concurrent delete)", "key", key)
} else {
slog.Warn("ListGroups: failed to get group", "key", key, "err", err)
}
continue
}
var group Group
if err := json.Unmarshal(entry.Value(), &group); err != nil {
slog.Warn("ListGroups: failed to unmarshal group", "key", key, "err", err)
continue
}
if !strings.HasPrefix(group.Path, pathPrefix) {
continue
}
groups = append(groups, groupToSDK(&group))
}
return &iam.ListGroupsOutput{
Groups: groups,
IsTruncated: aws.Bool(false),
}, nil
}
func (s *IAMServiceImpl) DeleteGroup(accountID string, input *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
groupName := *input.GroupName
group, err := s.getGroup(accountID, groupName)
if err != nil {
return nil, err
}
if len(group.AttachedPolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
members, err := s.findGroupMembers(accountID, groupName)
if err != nil {
return nil, fmt.Errorf("check group members: %w", err)
}
if len(members) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
if err := s.groupsBucket.Delete(accountID + "." + groupName); err != nil {
return nil, fmt.Errorf("delete group: %w", err)
}
slog.Info("IAM group deleted", "accountID", accountID, "groupName", groupName)
return &iam.DeleteGroupOutput{}, nil
}
// ---------------------------------------------------------------------------
// Group membership — canonical on the User record (User.Groups)
// ---------------------------------------------------------------------------
func (s *IAMServiceImpl) AddUserToGroup(accountID string, input *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
groupName := *input.GroupName
userName := *input.UserName
userKVKey := accountID + "." + userName
if _, err := s.getGroup(accountID, groupName); err != nil {
return nil, err
}
user, err := s.getUser(accountID, userName)
if err != nil {
return nil, err
}
if slices.Contains(user.Groups, groupName) { // idempotent
return &iam.AddUserToGroupOutput{}, nil
}
if len(user.Groups) >= maxGroupsPerUser {
return nil, errors.New(awserrors.ErrorIAMLimitExceeded)
}
user.Groups = append(user.Groups, groupName)
userData, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("marshal user: %w", err)
}
if _, err := s.usersBucket.Put(userKVKey, userData); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
slog.Info("IAM user added to group", "accountID", accountID, "userName", userName, "groupName", groupName)
return &iam.AddUserToGroupOutput{}, nil
}
func (s *IAMServiceImpl) RemoveUserFromGroup(accountID string, input *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
groupName := *input.GroupName
userName := *input.UserName
userKVKey := accountID + "." + userName
user, err := s.getUser(accountID, userName)
if err != nil {
return nil, err
}
// Operate purely on the membership reference so a dangling pointer to an
// already-deleted group is still cleanable; never fetch the group here.
found := false
remaining := make([]string, 0, len(user.Groups))
for _, name := range user.Groups {
if name == groupName {
found = true
} else {
remaining = append(remaining, name)
}
}
if !found {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
user.Groups = remaining
userData, err := json.Marshal(user)
if err != nil {
return nil, fmt.Errorf("marshal user: %w", err)
}
if _, err := s.usersBucket.Put(userKVKey, userData); err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
slog.Info("IAM user removed from group", "accountID", accountID, "userName", userName, "groupName", groupName)
return &iam.RemoveUserFromGroupOutput{}, nil
}
func (s *IAMServiceImpl) ListGroupsForUser(accountID string, input *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
user, err := s.getUser(accountID, *input.UserName)
if err != nil {
return nil, err
}
var groups []*iam.Group
for _, name := range user.Groups {
group, err := s.getGroup(accountID, name)
if err != nil {
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
// A dangling pointer to a vanished group is inert; skip it.
// Mirrors GetUserPolicies — transient/corrupt errors fail closed.
slog.Warn("ListGroupsForUser: member references missing group; skipping",
"accountID", accountID, "user", *input.UserName, "group", name)
continue
}
return nil, err
}
groups = append(groups, groupToSDK(group))
}
return &iam.ListGroupsForUserOutput{
Groups: groups,
IsTruncated: aws.Bool(false),
}, nil
}
// ---------------------------------------------------------------------------
// Group policy attachment
// ---------------------------------------------------------------------------
func (s *IAMServiceImpl) AttachGroupPolicy(accountID string, input *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
groupName := *input.GroupName
policyARN := *input.PolicyArn
kvKey := accountID + "." + groupName
// AWS-managed ARNs are stored opaquely (like roles); customer-managed ARNs must exist.
if !isAWSManagedPolicyARN(policyARN) {
if _, err := s.getPolicyByARN(accountID, policyARN); err != nil {
return nil, err
}
}
group, err := s.getGroup(accountID, groupName)
if err != nil {
return nil, err
}
if slices.Contains(group.AttachedPolicies, policyARN) { // idempotent
return &iam.AttachGroupPolicyOutput{}, nil
}
group.AttachedPolicies = append(group.AttachedPolicies, policyARN)
data, err := json.Marshal(group)
if err != nil {
return nil, fmt.Errorf("marshal group: %w", err)
}
if _, err := s.groupsBucket.Put(kvKey, data); err != nil {
return nil, fmt.Errorf("update group: %w", err)
}
slog.Info("IAM policy attached to group", "accountID", accountID, "groupName", groupName, "policyArn", policyARN)
return &iam.AttachGroupPolicyOutput{}, nil
}
func (s *IAMServiceImpl) DetachGroupPolicy(accountID string, input *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
groupName := *input.GroupName
policyARN := *input.PolicyArn
kvKey := accountID + "." + groupName
group, err := s.getGroup(accountID, groupName)
if err != nil {
return nil, err
}
idx := slices.Index(group.AttachedPolicies, policyARN)
if idx < 0 {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
group.AttachedPolicies = slices.Delete(group.AttachedPolicies, idx, idx+1)
data, err := json.Marshal(group)
if err != nil {
return nil, fmt.Errorf("marshal group: %w", err)
}
if _, err := s.groupsBucket.Put(kvKey, data); err != nil {
return nil, fmt.Errorf("update group: %w", err)
}
slog.Info("IAM policy detached from group", "accountID", accountID, "groupName", groupName, "policyArn", policyARN)
return &iam.DetachGroupPolicyOutput{}, nil
}
func (s *IAMServiceImpl) ListAttachedGroupPolicies(accountID string, input *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
group, err := s.getGroup(accountID, *input.GroupName)
if err != nil {
return nil, err
}
var attached []*iam.AttachedPolicy
for _, arn := range group.AttachedPolicies {
if isAWSManagedPolicyARN(arn) {
attached = append(attached, &iam.AttachedPolicy{
PolicyArn: aws.String(arn),
PolicyName: aws.String(managedPolicyNameFromARN(arn)),
})
continue
}
policy, err := s.getPolicyByARN(accountID, arn)
if err != nil {
slog.Warn("ListAttachedGroupPolicies: policy not found for ARN", "arn", arn, "err", err)
continue
}
attached = append(attached, &iam.AttachedPolicy{
PolicyArn: aws.String(policy.ARN),
PolicyName: aws.String(policy.PolicyName),
})
}
return &iam.ListAttachedGroupPoliciesOutput{
AttachedPolicies: attached,
IsTruncated: aws.Bool(false),
}, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func (s *IAMServiceImpl) getGroup(accountID, groupName string) (*Group, error) {
entry, err := s.groupsBucket.Get(accountID + "." + groupName)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}
return nil, fmt.Errorf("get group: %w", err)
}
var group Group
if err := json.Unmarshal(entry.Value(), &group); err != nil {
return nil, fmt.Errorf("unmarshal group: %w", err)
}
return &group, nil
}
// findGroupMembers scans the users bucket for any user in the account whose
// User.Groups references the given group. Fails closed on per-key Get or
// unmarshal errors so DeleteGroup cannot succeed while a real-but-unreadable
// member exists. Mirrors findInstanceProfilesForRole.
func (s *IAMServiceImpl) findGroupMembers(accountID, groupName string) ([]*User, error) {
keys, err := s.usersBucket.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
return nil, nil
}
return nil, fmt.Errorf("list user keys: %w", err)
}
keyPrefix := accountID + "."
var members []*User
for _, key := range keys {
if key == utils.VersionKey {
continue
}
if !strings.HasPrefix(key, keyPrefix) {
continue
}
entry, err := s.usersBucket.Get(key)
if err != nil {
if errors.Is(err, nats.ErrKeyNotFound) {
slog.Debug("findGroupMembers: user key disappeared", "key", key)
continue
}
return nil, fmt.Errorf("get user %q: %w", key, err)
}
var user User
if err := json.Unmarshal(entry.Value(), &user); err != nil {
return nil, fmt.Errorf("unmarshal user %q: %w", key, err)
}
if slices.Contains(user.Groups, groupName) {
u := user
members = append(members, &u)
}
}
return members, nil
}
// groupToSDK converts the internal Group record into the AWS SDK shape used by
// CreateGroup / GetGroup / ListGroups responses.
func groupToSDK(g *Group) *iam.Group {
return &iam.Group{
GroupName: aws.String(g.GroupName),
GroupId: aws.String(g.GroupID),
Arn: aws.String(g.ARN),
Path: aws.String(g.Path),
CreateDate: aws.Time(parseCreatedAt(g.CreatedAt)),
}
}
// validateGroupName enforces the IAM group-name limits: 1–128 chars from the
// IAM name charset. The constraints match validatePolicyName exactly.
func validateGroupName(name string) error {
return validatePolicyName(name)
}
+836
View File
@@ -0,0 +1,836 @@
package handlers_iam
import (
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestGroup(t *testing.T, svc *IAMServiceImpl, name string) *iam.Group {
t.Helper()
out, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String(name),
})
require.NoError(t, err)
return out.Group
}
// ============================================================================
// Group CRUD Tests
// ============================================================================
func TestCreateGroup(t *testing.T) {
svc := setupTestIAMService(t)
out, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String("developers"),
Path: aws.String("/eng/"),
})
require.NoError(t, err)
require.NotNil(t, out.Group)
assert.Equal(t, "developers", *out.Group.GroupName)
assert.Equal(t, "/eng/", *out.Group.Path)
assert.Equal(t, "arn:aws:iam::"+testAccountID+":group/eng/developers", *out.Group.Arn)
require.True(t, len(*out.Group.GroupId) > 4)
assert.Equal(t, "AGPA", (*out.Group.GroupId)[:4])
}
func TestCreateGroup_DefaultPath(t *testing.T) {
svc := setupTestIAMService(t)
out, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String("default-path"),
})
require.NoError(t, err)
assert.Equal(t, "/", *out.Group.Path)
assert.Equal(t, "arn:aws:iam::"+testAccountID+":group/default-path", *out.Group.Arn)
}
func TestCreateGroup_Duplicate(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "dup-group")
_, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String("dup-group"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMEntityAlreadyExists)
}
func TestCreateGroup_InvalidName(t *testing.T) {
svc := setupTestIAMService(t)
longName := strings.Repeat("a", 129)
_, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String(longName),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput)
}
func TestCreateGroup_InvalidPath(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String("badpath-group"),
Path: aws.String("no-leading-slash/"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput)
}
func TestGetGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "get-group")
createTestUser(t, svc, "member")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("get-group"),
UserName: aws.String("member"),
})
require.NoError(t, err)
out, err := svc.GetGroup(testAccountID, &iam.GetGroupInput{
GroupName: aws.String("get-group"),
})
require.NoError(t, err)
assert.Equal(t, "get-group", *out.Group.GroupName)
require.Len(t, out.Users, 1)
assert.Equal(t, "member", *out.Users[0].UserName)
}
func TestGetGroup_NotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.GetGroup(testAccountID, &iam.GetGroupInput{
GroupName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestGetGroup_UsersNeverNil(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "empty-group")
out, err := svc.GetGroup(testAccountID, &iam.GetGroupInput{
GroupName: aws.String("empty-group"),
})
require.NoError(t, err)
require.NotNil(t, out.Users, "GetGroupOutput.Users must be an empty slice, never nil")
assert.Len(t, out.Users, 0)
}
func TestListGroups(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "group1")
createTestGroup(t, svc, "group2")
createTestGroup(t, svc, "group3")
out, err := svc.ListGroups(testAccountID, &iam.ListGroupsInput{})
require.NoError(t, err)
require.Len(t, out.Groups, 3)
names := make(map[string]bool)
for _, g := range out.Groups {
names[*g.GroupName] = true
}
assert.True(t, names["group1"])
assert.True(t, names["group2"])
assert.True(t, names["group3"])
}
func TestListGroups_Empty(t *testing.T) {
svc := setupTestIAMService(t)
out, err := svc.ListGroups(testAccountID, &iam.ListGroupsInput{})
require.NoError(t, err)
assert.Len(t, out.Groups, 0)
}
func TestListGroups_PathPrefix(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String("eng-group"),
Path: aws.String("/eng/"),
})
require.NoError(t, err)
_, err = svc.CreateGroup(testAccountID, &iam.CreateGroupInput{
GroupName: aws.String("ops-group"),
Path: aws.String("/ops/"),
})
require.NoError(t, err)
out, err := svc.ListGroups(testAccountID, &iam.ListGroupsInput{
PathPrefix: aws.String("/eng/"),
})
require.NoError(t, err)
require.Len(t, out.Groups, 1)
assert.Equal(t, "eng-group", *out.Groups[0].GroupName)
}
func TestDeleteGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "del-group")
_, err := svc.DeleteGroup(testAccountID, &iam.DeleteGroupInput{
GroupName: aws.String("del-group"),
})
require.NoError(t, err)
_, err = svc.GetGroup(testAccountID, &iam.GetGroupInput{
GroupName: aws.String("del-group"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestDeleteGroup_NotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.DeleteGroup(testAccountID, &iam.DeleteGroupInput{
GroupName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestDeleteGroup_WithAttachedPolicy(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "attached-group")
policy := createTestPolicy(t, svc, "GroupPolicy")
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("attached-group"),
PolicyArn: policy.Arn,
})
require.NoError(t, err)
_, err = svc.DeleteGroup(testAccountID, &iam.DeleteGroupInput{
GroupName: aws.String("attached-group"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMDeleteConflict)
}
func TestDeleteGroup_WithMembers(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "populated-group")
createTestUser(t, svc, "occupant")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("populated-group"),
UserName: aws.String("occupant"),
})
require.NoError(t, err)
_, err = svc.DeleteGroup(testAccountID, &iam.DeleteGroupInput{
GroupName: aws.String("populated-group"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMDeleteConflict)
}
// ============================================================================
// Group Membership Tests
// ============================================================================
func TestAddUserToGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "team")
createTestUser(t, svc, "alice")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("alice"),
})
require.NoError(t, err)
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("alice"),
})
require.NoError(t, err)
require.Len(t, out.Groups, 1)
assert.Equal(t, "team", *out.Groups[0].GroupName)
}
func TestAddUserToGroup_Idempotent(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "team")
createTestUser(t, svc, "bob")
for range 2 {
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("bob"),
})
require.NoError(t, err)
}
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("bob"),
})
require.NoError(t, err)
assert.Len(t, out.Groups, 1, "duplicate add must not double-count membership")
}
func TestAddUserToGroup_LimitExceeded(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "busy")
// Fill the user to the maxGroupsPerUser cap, then prove the next add fails.
for i := range maxGroupsPerUser {
name := "g" + strings.Repeat("x", i)
createTestGroup(t, svc, name)
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String(name),
UserName: aws.String("busy"),
})
require.NoError(t, err)
}
createTestGroup(t, svc, "one-too-many")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("one-too-many"),
UserName: aws.String("busy"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMLimitExceeded)
}
func TestAddUserToGroup_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "team")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestAddUserToGroup_GroupNotFound(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "carol")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("ghost"),
UserName: aws.String("carol"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestRemoveUserFromGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "team")
createTestUser(t, svc, "dave")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("dave"),
})
require.NoError(t, err)
_, err = svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("dave"),
})
require.NoError(t, err)
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("dave"),
})
require.NoError(t, err)
assert.Len(t, out.Groups, 0)
}
func TestRemoveUserFromGroup_NotMember(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "team")
createTestUser(t, svc, "erin")
_, err := svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("erin"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestRemoveUserFromGroup_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "team")
_, err := svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("team"),
UserName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
// TestRemoveUserFromGroup_DanglingPointer proves a membership reference to a
// group that was deleted out from under the user is still cleanable — removal
// operates purely on User.Groups and never fetches the group record.
func TestRemoveUserFromGroup_DanglingPointer(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "doomed")
createTestUser(t, svc, "frank")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("doomed"),
UserName: aws.String("frank"),
})
require.NoError(t, err)
// Delete the group record directly, bypassing the non-empty-group guard.
require.NoError(t, svc.groupsBucket.Delete(testAccountID+".doomed"))
_, err = svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("doomed"),
UserName: aws.String("frank"),
})
require.NoError(t, err)
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("frank"),
})
require.NoError(t, err)
assert.Len(t, out.Groups, 0)
}
func TestListGroupsForUser(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "grace")
for _, name := range []string{"alpha", "bravo"} {
createTestGroup(t, svc, name)
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String(name),
UserName: aws.String("grace"),
})
require.NoError(t, err)
}
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("grace"),
})
require.NoError(t, err)
require.Len(t, out.Groups, 2)
names := make(map[string]bool)
for _, g := range out.Groups {
names[*g.GroupName] = true
}
assert.True(t, names["alpha"])
assert.True(t, names["bravo"])
}
func TestListGroupsForUser_Empty(t *testing.T) {
svc := setupTestIAMService(t)
createTestUser(t, svc, "loner")
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("loner"),
})
require.NoError(t, err)
assert.Len(t, out.Groups, 0)
}
func TestListGroupsForUser_UserNotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
// TestListGroupsForUser_SkipsMissingGroup proves a dangling membership pointer
// is silently skipped rather than erroring the whole listing.
func TestListGroupsForUser_SkipsMissingGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "live")
createTestGroup(t, svc, "vanishing")
createTestUser(t, svc, "henry")
for _, name := range []string{"live", "vanishing"} {
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String(name),
UserName: aws.String("henry"),
})
require.NoError(t, err)
}
require.NoError(t, svc.groupsBucket.Delete(testAccountID+".vanishing"))
out, err := svc.ListGroupsForUser(testAccountID, &iam.ListGroupsForUserInput{
UserName: aws.String("henry"),
})
require.NoError(t, err)
require.Len(t, out.Groups, 1)
assert.Equal(t, "live", *out.Groups[0].GroupName)
}
// ============================================================================
// Group Policy Attachment Tests
// ============================================================================
func TestAttachGroupPolicy(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "policy-group")
policy := createTestPolicy(t, svc, "AttachPolicy")
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("policy-group"),
PolicyArn: policy.Arn,
})
require.NoError(t, err)
out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("policy-group"),
})
require.NoError(t, err)
require.Len(t, out.AttachedPolicies, 1)
assert.Equal(t, *policy.Arn, *out.AttachedPolicies[0].PolicyArn)
assert.Equal(t, "AttachPolicy", *out.AttachedPolicies[0].PolicyName)
}
func TestAttachGroupPolicy_Idempotent(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "idem-group")
policy := createTestPolicy(t, svc, "IdemPolicy")
for range 2 {
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("idem-group"),
PolicyArn: policy.Arn,
})
require.NoError(t, err)
}
out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("idem-group"),
})
require.NoError(t, err)
assert.Len(t, out.AttachedPolicies, 1, "duplicate attach must not double-count")
}
func TestAttachGroupPolicy_PolicyNotFound(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "needs-policy")
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("needs-policy"),
PolicyArn: aws.String("arn:aws:iam::" + testAccountID + ":policy/Ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestAttachGroupPolicy_GroupNotFound(t *testing.T) {
svc := setupTestIAMService(t)
policy := createTestPolicy(t, svc, "OrphanPolicy")
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("ghost"),
PolicyArn: policy.Arn,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
// TestAttachGroupPolicy_AWSManagedOpaque proves an AWS-managed ARN with no
// backing policy record round-trips opaquely instead of failing NoSuchEntity.
func TestAttachGroupPolicy_AWSManagedOpaque(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "managed-group")
const managedARN = "arn:aws:iam::aws:policy/service-role/AmazonEKS_CNI_Policy"
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("managed-group"),
PolicyArn: aws.String(managedARN),
})
require.NoError(t, err)
out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("managed-group"),
})
require.NoError(t, err)
require.Len(t, out.AttachedPolicies, 1)
assert.Equal(t, managedARN, *out.AttachedPolicies[0].PolicyArn)
assert.Equal(t, "AmazonEKS_CNI_Policy", *out.AttachedPolicies[0].PolicyName)
}
func TestDetachGroupPolicy(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "detach-group")
policy := createTestPolicy(t, svc, "DetachPolicy")
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("detach-group"),
PolicyArn: policy.Arn,
})
require.NoError(t, err)
_, err = svc.DetachGroupPolicy(testAccountID, &iam.DetachGroupPolicyInput{
GroupName: aws.String("detach-group"),
PolicyArn: policy.Arn,
})
require.NoError(t, err)
out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("detach-group"),
})
require.NoError(t, err)
assert.Len(t, out.AttachedPolicies, 0)
}
func TestDetachGroupPolicy_NotAttached(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "bare-group")
policy := createTestPolicy(t, svc, "NotAttachedPolicy")
_, err := svc.DetachGroupPolicy(testAccountID, &iam.DetachGroupPolicyInput{
GroupName: aws.String("bare-group"),
PolicyArn: policy.Arn,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestDetachGroupPolicy_GroupNotFound(t *testing.T) {
svc := setupTestIAMService(t)
policy := createTestPolicy(t, svc, "OrphanDetach")
_, err := svc.DetachGroupPolicy(testAccountID, &iam.DetachGroupPolicyInput{
GroupName: aws.String("ghost"),
PolicyArn: policy.Arn,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
func TestListAttachedGroupPolicies_Empty(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "empty-attach")
out, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("empty-attach"),
})
require.NoError(t, err)
assert.Len(t, out.AttachedPolicies, 0)
}
func TestListAttachedGroupPolicies_GroupNotFound(t *testing.T) {
svc := setupTestIAMService(t)
_, err := svc.ListAttachedGroupPolicies(testAccountID, &iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("ghost"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity)
}
// ============================================================================
// Authorization Integration — the linchpin (GetUserPolicies resolves groups)
// ============================================================================
// TestGetUserPolicies_GroupInheritance is the load-bearing test: a policy
// attached to a group must contribute its grant to every member's effective
// permission set, and stop contributing once the user leaves the group. Without
// this behaviour groups are cosmetic and grant nothing.
func TestGetUserPolicies_GroupInheritance(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "grantor")
createTestUser(t, svc, "ivy")
policy := createTestPolicy(t, svc, "GroupGrant")
_, err := svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("grantor"),
PolicyArn: policy.Arn,
})
require.NoError(t, err)
// Before joining: the group's grant is absent.
docs, err := svc.GetUserPolicies(testAccountID, "ivy")
require.NoError(t, err)
assert.False(t, policiesGrant(docs, "ec2:DescribeInstances"), "grant must be absent before joining")
_, err = svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("grantor"),
UserName: aws.String("ivy"),
})
require.NoError(t, err)
// After joining: the group's grant flows through.
docs, err = svc.GetUserPolicies(testAccountID, "ivy")
require.NoError(t, err)
require.Len(t, docs, 1)
assert.True(t, policiesGrant(docs, "ec2:DescribeInstances"), "group grant must flow to the member")
// The grant is group-inherited, not a direct attachment.
attached, err := svc.ListAttachedUserPolicies(testAccountID, &iam.ListAttachedUserPoliciesInput{
UserName: aws.String("ivy"),
})
require.NoError(t, err)
assert.Empty(t, attached.AttachedPolicies, "group policy must not appear as a directly attached user policy")
// After leaving: the grant disappears again.
_, err = svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("grantor"),
UserName: aws.String("ivy"),
})
require.NoError(t, err)
docs, err = svc.GetUserPolicies(testAccountID, "ivy")
require.NoError(t, err)
assert.False(t, policiesGrant(docs, "ec2:DescribeInstances"), "grant must vanish after leaving the group")
}
// TestGetUserPolicies_DirectAndGroup proves direct user attachments and
// group-inherited policies both surface in one combined effective set.
func TestGetUserPolicies_DirectAndGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "combined-group")
createTestUser(t, svc, "jack")
direct := createTestPolicy(t, svc, "DirectPolicy")
grouped := createTestPolicy(t, svc, "GroupedPolicy")
_, err := svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{
UserName: aws.String("jack"),
PolicyArn: direct.Arn,
})
require.NoError(t, err)
_, err = svc.AttachGroupPolicy(testAccountID, &iam.AttachGroupPolicyInput{
GroupName: aws.String("combined-group"),
PolicyArn: grouped.Arn,
})
require.NoError(t, err)
_, err = svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("combined-group"),
UserName: aws.String("jack"),
})
require.NoError(t, err)
docs, err := svc.GetUserPolicies(testAccountID, "jack")
require.NoError(t, err)
assert.Len(t, docs, 2, "both the direct and the group-inherited policy must resolve")
}
// TestGetUserPolicies_SkipsMissingGroup proves a dangling membership pointer is
// skipped (not fail-closed): the user keeps their direct grants and the dangling
// name remains cleanable via RemoveUserFromGroup.
func TestGetUserPolicies_SkipsMissingGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "soon-gone")
createTestUser(t, svc, "kim")
direct := createTestPolicy(t, svc, "KimDirect")
_, err := svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{
UserName: aws.String("kim"),
PolicyArn: direct.Arn,
})
require.NoError(t, err)
_, err = svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("soon-gone"),
UserName: aws.String("kim"),
})
require.NoError(t, err)
// Delete the group record directly, leaving a dangling User.Groups pointer.
require.NoError(t, svc.groupsBucket.Delete(testAccountID+".soon-gone"))
docs, err := svc.GetUserPolicies(testAccountID, "kim")
require.NoError(t, err, "a missing group must be skipped, not fail closed")
require.Len(t, docs, 1, "the user's direct policy must still resolve")
assert.True(t, policiesGrant(docs, "ec2:DescribeInstances"))
// The dangling name is still cleanable.
_, err = svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("soon-gone"),
UserName: aws.String("kim"),
})
require.NoError(t, err)
}
// ============================================================================
// DeleteUser group guard
// ============================================================================
func TestDeleteUser_InGroup(t *testing.T) {
svc := setupTestIAMService(t)
createTestGroup(t, svc, "blocker")
createTestUser(t, svc, "leo")
_, err := svc.AddUserToGroup(testAccountID, &iam.AddUserToGroupInput{
GroupName: aws.String("blocker"),
UserName: aws.String("leo"),
})
require.NoError(t, err)
_, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{
UserName: aws.String("leo"),
})
assert.Error(t, err)
assert.Contains(t, err.Error(), awserrors.ErrorIAMDeleteConflict)
// Succeeds once the user leaves the group.
_, err = svc.RemoveUserFromGroup(testAccountID, &iam.RemoveUserFromGroupInput{
GroupName: aws.String("blocker"),
UserName: aws.String("leo"),
})
require.NoError(t, err)
_, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{
UserName: aws.String("leo"),
})
require.NoError(t, err)
}
// ============================================================================
// Account Scoping
// ============================================================================
func TestGroups_AccountScoping(t *testing.T) {
svc := setupTestIAMService(t)
accA, err := svc.CreateAccount("Org A")
require.NoError(t, err)
accB, err := svc.CreateAccount("Org B")
require.NoError(t, err)
// Same group name in two accounts must not collide.
_, err = svc.CreateGroup(accA.AccountID, &iam.CreateGroupInput{GroupName: aws.String("shared-name")})
require.NoError(t, err)
_, err = svc.CreateGroup(accB.AccountID, &iam.CreateGroupInput{GroupName: aws.String("shared-name")})
require.NoError(t, err)
listA, err := svc.ListGroups(accA.AccountID, &iam.ListGroupsInput{})
require.NoError(t, err)
require.Len(t, listA.Groups, 1)
assert.Contains(t, *listA.Groups[0].Arn, accA.AccountID)
listB, err := svc.ListGroups(accB.AccountID, &iam.ListGroupsInput{})
require.NoError(t, err)
require.Len(t, listB.Groups, 1)
assert.Contains(t, *listB.Groups[0].Arn, accB.AccountID)
// Deleting in A leaves B's identically-named group intact.
_, err = svc.DeleteGroup(accA.AccountID, &iam.DeleteGroupInput{GroupName: aws.String("shared-name")})
require.NoError(t, err)
_, err = svc.GetGroup(accB.AccountID, &iam.GetGroupInput{GroupName: aws.String("shared-name")})
require.NoError(t, err, "Account B's group must survive Account A's delete")
}
+16
View File
@@ -48,6 +48,22 @@ type IAMService interface {
DeleteRolePolicy(accountID string, input *iam.DeleteRolePolicyInput) (*iam.DeleteRolePolicyOutput, error)
ListRolePolicies(accountID string, input *iam.ListRolePoliciesInput) (*iam.ListRolePoliciesOutput, error)
// Group CRUD — account-scoped
CreateGroup(accountID string, input *iam.CreateGroupInput) (*iam.CreateGroupOutput, error)
GetGroup(accountID string, input *iam.GetGroupInput) (*iam.GetGroupOutput, error)
ListGroups(accountID string, input *iam.ListGroupsInput) (*iam.ListGroupsOutput, error)
DeleteGroup(accountID string, input *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error)
// Group membership — account-scoped
AddUserToGroup(accountID string, input *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error)
RemoveUserFromGroup(accountID string, input *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error)
ListGroupsForUser(accountID string, input *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error)
// Group policy attachment — account-scoped
AttachGroupPolicy(accountID string, input *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error)
DetachGroupPolicy(accountID string, input *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error)
ListAttachedGroupPolicies(accountID string, input *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error)
// Instance profile CRUD — account-scoped
CreateInstanceProfile(accountID string, input *iam.CreateInstanceProfileInput) (*iam.CreateInstanceProfileOutput, error)
GetInstanceProfile(accountID string, input *iam.GetInstanceProfileInput) (*iam.GetInstanceProfileOutput, error)
+46
View File
@@ -29,6 +29,7 @@ const (
KVBucketAccountCounter = "spinifex-account-counter"
KVBucketRoles = "spinifex-iam-roles"
KVBucketInstanceProfiles = "spinifex-iam-instance-profiles"
KVBucketGroups = "spinifex-iam-groups"
KVBucketUsersVersion = 1
KVBucketAccessKeysVersion = 1
@@ -37,6 +38,7 @@ const (
KVBucketAccountCounterVersion = 1
KVBucketRolesVersion = 1
KVBucketInstanceProfilesVersion = 1
KVBucketGroupsVersion = 1
maxAccessKeysPerUser = 2
@@ -82,6 +84,7 @@ type IAMServiceImpl struct {
accountCounterBucket nats.KeyValue
rolesBucket nats.KeyValue
instanceProfilesBucket nats.KeyValue
groupsBucket nats.KeyValue
masterKey []byte
decrypter *Decrypter
// replicas is the JetStream replication factor for lazily-created per-account buckets.
@@ -160,6 +163,14 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (
return nil, fmt.Errorf("migrate %s: %w", KVBucketInstanceProfiles, err)
}
groupsBucket, err := getOrCreateBucket(js, KVBucketGroups, 10, replicas)
if err != nil {
return nil, fmt.Errorf("init groups bucket: %w", err)
}
if err := migrate.DefaultRegistry.RunKV(KVBucketGroups, groupsBucket, KVBucketGroupsVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", KVBucketGroups, err)
}
decrypter, err := NewDecrypter(masterKey)
if err != nil {
return nil, fmt.Errorf("init decrypter: %w", err)
@@ -172,6 +183,7 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (
"accounts_bucket", KVBucketAccounts,
"roles_bucket", KVBucketRoles,
"instance_profiles_bucket", KVBucketInstanceProfiles,
"groups_bucket", KVBucketGroups,
"replicas", replicas)
return &IAMServiceImpl{
@@ -184,6 +196,7 @@ func NewIAMServiceImpl(natsConn *nats.Conn, masterKey []byte, clusterSize int) (
accountCounterBucket: accountCounterBucket,
rolesBucket: rolesBucket,
instanceProfilesBucket: instanceProfilesBucket,
groupsBucket: groupsBucket,
masterKey: masterKey,
decrypter: decrypter,
replicas: replicas,
@@ -251,6 +264,7 @@ func (s *IAMServiceImpl) CreateUser(accountID string, input *iam.CreateUserInput
AccessKeys: []string{},
Tags: copyTags(input.Tags),
AttachedPolicies: []string{},
Groups: []string{},
}
data, err := json.Marshal(user)
@@ -376,6 +390,9 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput
if len(user.AttachedPolicies) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
if len(user.Groups) > 0 {
return nil, errors.New(awserrors.ErrorIAMDeleteConflict)
}
if err := s.usersBucket.Delete(kvKey); err != nil {
return nil, fmt.Errorf("delete user: %w", err)
@@ -642,6 +659,7 @@ func (s *IAMServiceImpl) SeedBootstrap(data *BootstrapData) error {
AccessKeys: []string{data.AccessKeyID},
Tags: []Tag{},
AttachedPolicies: []string{},
Groups: []string{},
}
userData, err := json.Marshal(rootUser)
@@ -759,6 +777,7 @@ func (s *IAMServiceImpl) seedAdminAccount(admin *AdminBootstrapData) error {
AccessKeys: []string{admin.AccessKeyID},
Tags: []Tag{},
AttachedPolicies: []string{policyARN},
Groups: []string{},
}
userData, err := json.Marshal(adminUser)
@@ -1282,6 +1301,7 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo
}
var docs []PolicyDocument
// Direct user-attached managed policies.
for _, arn := range user.AttachedPolicies {
doc, include, err := s.resolveAttachedPolicy(accountID, arn)
if err != nil {
@@ -1293,6 +1313,32 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo
}
}
// Group-inherited managed policies. A membership to a deleted group is inert
// (AWS makes that state impossible by refusing to delete a non-empty group),
// so a missing group is skipped with a warn and the user keeps their other
// grants — never over-permitting. A missing/corrupt policy within a
// resolvable group still fails closed, mirroring the direct-policy handling.
for _, groupName := range user.Groups {
group, err := s.getGroup(accountID, groupName)
if err != nil {
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
slog.Warn("GetUserPolicies: member references missing group; skipping",
"accountID", accountID, "user", userName, "group", groupName)
continue
}
return nil, err // transient/corrupt → fail closed
}
for _, arn := range group.AttachedPolicies {
doc, include, err := s.resolveAttachedPolicy(accountID, arn)
if err != nil {
return nil, err // fail closed
}
if include {
docs = append(docs, doc)
}
}
}
return docs, nil
}
+15
View File
@@ -24,6 +24,21 @@ type User struct {
AccessKeys []string `json:"access_keys"`
Tags []Tag `json:"tags"`
AttachedPolicies []string `json:"attached_policies"` // policy ARNs
Groups []string `json:"groups"` // group NAMES the user belongs to (≤10)
}
// Group is a permission-grouping IAM identity stored in JetStream KV.
// Members are NOT stored here; membership is canonical on the User record
// (User.Groups) so the authorization hot path never scans the groups bucket.
type Group struct {
GroupName string `json:"group_name"`
GroupID string `json:"group_id"`
AccountID string `json:"account_id"`
ARN string `json:"arn"`
Path string `json:"path"`
CreatedAt string `json:"created_at"`
AttachedPolicies []string `json:"attached_policies"` // policy ARNs
Tags []Tag `json:"tags"`
}
// AccessKey represents an IAM access key stored in JetStream KV.
+48
View File
@@ -22,6 +22,11 @@ func IAMPolicyARN(account, key string) string {
return "arn:aws:iam::" + account + ":policy/" + key
}
// IAMGroupARN constructs arn:aws:iam::<account>:group/<name> (default path /).
func IAMGroupARN(account, name string) string {
return "arn:aws:iam::" + account + ":group/" + name
}
// IAMAccountID returns the active account ID via STS GetCallerIdentity. Unlike
// deriving it from a created IAM user's ARN, this is side-effect-free, so suites
// sharing one account can call it concurrently without minting colliding users.
@@ -68,3 +73,46 @@ func IAMDeleteRoleAndProfilesBestEffort(c *AWSClient, roleName string, profileNa
}
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
}
// IAMDeleteGroupBestEffort tears down a group: removes any members, detaches all
// attached policies, then deletes the group. Each step swallows errors so a
// missing fragment never cascades; usable both as a pre-test sweep and as a
// fixture-teardown cleanup. Order matters: a group cannot be deleted while it
// still has members or attached policies.
func IAMDeleteGroupBestEffort(c *AWSClient, groupName string, memberUsers []string, policyARNs ...string) {
for _, u := range memberUsers {
_, _ = c.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(groupName),
UserName: aws.String(u),
})
}
for _, arn := range policyARNs {
_, _ = c.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyArn: aws.String(arn),
})
}
// Defensive: pull any other attached policies so the final DeleteGroup isn't
// blocked by a stray attach from a partial run.
if attached, err := c.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String(groupName),
}); err == nil {
for _, p := range attached.AttachedPolicies {
_, _ = c.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
GroupName: aws.String(groupName),
PolicyArn: p.PolicyArn,
})
}
}
// Defensive: drop any members the caller didn't pass in (partial run could
// have added one), so the non-empty guard can't block the delete.
if got, err := c.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)}); err == nil {
for _, u := range got.Users {
_, _ = c.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(groupName),
UserName: u.UserName,
})
}
}
_, _ = c.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
}
+362
View File
@@ -0,0 +1,362 @@
//go:build e2e
package iam
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/require"
)
// Dedicated identifiers for the Groups suite. Distinct from the shared
// alice/bob/charlie graph so the group tests can pre-sweep and tear down their
// own state without colliding with the User/Policy phases.
const (
grpLifecycleGroup = "grp-e2e-developers"
grpLifecycleUser = "grp-e2e-lc-user"
grpLifecyclePolicy = "grp-e2e-lc-describe-regions"
grpEnforceGroup = "grp-e2e-enforce"
grpEnforceUser = "grp-e2e-enf-user"
grpEnforcePolicy = "grp-e2e-enf-describe-regions"
// A single-action grant so the enforcement positive case proves a specific
// group-attached policy was resolved and evaluated — not a blanket allow.
grpPolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}`
)
// runIAMGroupsLifecycle exercises the full group surface: CRUD, membership,
// group-policy attachment, the listing reverse-lookups, and every deletion guard
// (delete-group while attached, delete-group while non-empty, delete-user while
// in a group), then a clean teardown. Mirrors runIAMRolesAndProfiles in shape.
func runIAMGroupsLifecycle(t *testing.T, fix *Fixture) {
harness.Phase(t, "Single — IAM Groups Lifecycle")
adminAccount := harness.IAMAccountID(t, fix.AWS)
groupARN := harness.IAMGroupARN(adminAccount, grpLifecycleGroup)
policyARN := harness.IAMPolicyARN(adminAccount, grpLifecyclePolicy)
// Defensive sweep — a prior failed run may have left fragments. Order: remove
// member + detach policy (group delete refuses a non-empty/attached group),
// then the user, then the policy.
sweep := func() {
harness.IAMDeleteGroupBestEffort(fix.AWS, grpLifecycleGroup, []string{grpLifecycleUser}, policyARN)
iamDeleteUserBestEffort(fix, grpLifecycleUser)
iamDeletePolicyBestEffort(fix, policyARN)
}
sweep()
fix.Harness.RegisterCleanup(sweep)
// CreateGroup — happy path; assert ARN + AWS group-id prefix.
harness.Step(t, "create-group %q", grpLifecycleGroup)
createOut, err := fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{
GroupName: aws.String(grpLifecycleGroup),
})
require.NoError(t, err, "create-group")
require.Equal(t, grpLifecycleGroup, aws.StringValue(createOut.Group.GroupName))
require.Equal(t, groupARN, aws.StringValue(createOut.Group.Arn),
"group ARN must follow arn:aws:iam::<acct>:group/<name>")
require.True(t, len(aws.StringValue(createOut.Group.GroupId)) > 4 &&
aws.StringValue(createOut.Group.GroupId)[:4] == "AGPA",
"group id must carry the AWS AGPA prefix, got %q", aws.StringValue(createOut.Group.GroupId))
harness.Detail(t, "group", grpLifecycleGroup, "arn", groupARN)
// Duplicate.
harness.Step(t, "create-group duplicate (expect EntityAlreadyExists)")
harness.ExpectError(t, "EntityAlreadyExists", func() error {
_, e := fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpLifecycleGroup)})
return e
})
// GetGroup — fresh group has an empty (non-nil) member list.
harness.Step(t, "get-group %q (no members yet)", grpLifecycleGroup)
got, err := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)})
require.NoError(t, err, "get-group")
require.Equal(t, grpLifecycleGroup, aws.StringValue(got.Group.GroupName))
require.Empty(t, got.Users, "new group must have zero members")
harness.Step(t, "get-group nonexistent (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String("ghost-group")})
return e
})
// ListGroups + PathPrefix scan.
harness.Step(t, "list-groups (>= 1)")
listed, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{})
require.NoError(t, err, "list-groups")
require.GreaterOrEqual(t, len(listed.Groups), 1, "expected >= 1 group, got %d", len(listed.Groups))
harness.Step(t, "list-groups --path-prefix / (surfaces groups at /)")
pp, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{PathPrefix: aws.String("/")})
require.NoError(t, err, "list-groups --path-prefix /")
require.GreaterOrEqual(t, len(pp.Groups), 1, "path-prefix / must surface groups at /")
harness.Step(t, "list-groups --path-prefix /nonexistent/ (expect 0)")
none, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{PathPrefix: aws.String("/nonexistent/")})
require.NoError(t, err, "list-groups --path-prefix /nonexistent/")
require.Empty(t, none.Groups, "no group lives under /nonexistent/")
// Member for the membership + guard sub-steps.
harness.Step(t, "create-user %q (group member)", grpLifecycleUser)
_, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpLifecycleUser)})
require.NoError(t, err, "create-user")
// remove-user-from-group on a non-member surfaces NoSuchEntity (must not no-op).
harness.Step(t, "remove-user-from-group non-member (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(grpLifecycleGroup),
UserName: aws.String(grpLifecycleUser),
})
return e
})
// Ghost-entity binds → NoSuchEntity. Group is validated before the user.
harness.Step(t, "add-user-to-group ghost group (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
GroupName: aws.String("ghost-group"),
UserName: aws.String(grpLifecycleUser),
})
return e
})
harness.Step(t, "add-user-to-group ghost user (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
GroupName: aws.String(grpLifecycleGroup),
UserName: aws.String("ghost-user"),
})
return e
})
// AddUserToGroup — idempotent re-add must not duplicate the membership.
harness.Step(t, "add-user-to-group %s <- %s", grpLifecycleGroup, grpLifecycleUser)
_, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
GroupName: aws.String(grpLifecycleGroup),
UserName: aws.String(grpLifecycleUser),
})
require.NoError(t, err, "add-user-to-group")
_, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
GroupName: aws.String(grpLifecycleGroup),
UserName: aws.String(grpLifecycleUser),
})
require.NoError(t, err, "idempotent re-add")
// ListGroupsForUser — reverse lookup sees exactly the one group.
harness.Step(t, "list-groups-for-user %s (expect 1)", grpLifecycleUser)
forUser, err := fix.AWS.IAM.ListGroupsForUser(&iam.ListGroupsForUserInput{
UserName: aws.String(grpLifecycleUser),
})
require.NoError(t, err, "list-groups-for-user")
require.Len(t, forUser.Groups, 1, "user should belong to exactly one group")
require.Equal(t, grpLifecycleGroup, aws.StringValue(forUser.Groups[0].GroupName))
// GetGroup — member now visible from the group side.
harness.Step(t, "get-group %q (member visible)", grpLifecycleGroup)
got, err = fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)})
require.NoError(t, err, "get-group after add")
require.Len(t, got.Users, 1, "group should list exactly one member")
require.Equal(t, grpLifecycleUser, aws.StringValue(got.Users[0].UserName))
// AttachGroupPolicy — idempotent re-attach must not grow the count.
harness.Step(t, "create-policy %s (grants ec2:DescribeRegions)", grpLifecyclePolicy)
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
PolicyName: aws.String(grpLifecyclePolicy),
PolicyDocument: aws.String(grpPolicyDoc),
})
require.NoError(t, err, "create-policy")
harness.Step(t, "attach-group-policy unknown policy (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
GroupName: aws.String(grpLifecycleGroup),
PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")),
})
return e
})
harness.Step(t, "attach-group-policy %s <- %s", grpLifecycleGroup, grpLifecyclePolicy)
_, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
GroupName: aws.String(grpLifecycleGroup),
PolicyArn: aws.String(policyARN),
})
require.NoError(t, err, "attach-group-policy")
attached, err := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String(grpLifecycleGroup),
})
require.NoError(t, err, "list-attached-group-policies")
require.Len(t, attached.AttachedPolicies, 1)
require.Equal(t, policyARN, aws.StringValue(attached.AttachedPolicies[0].PolicyArn))
_, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
GroupName: aws.String(grpLifecycleGroup),
PolicyArn: aws.String(policyARN),
})
require.NoError(t, err, "idempotent re-attach")
reAttached, err := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String(grpLifecycleGroup),
})
require.NoError(t, err)
require.Len(t, reAttached.AttachedPolicies, 1, "re-attach must be idempotent")
harness.Step(t, "list-attached-group-policies ghost group (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
GroupName: aws.String("ghost-group"),
})
return e
})
// --- Deletion guards ---
// delete-user while still a group member → DeleteConflict.
harness.Step(t, "delete-user while in group (expect DeleteConflict)")
harness.ExpectError(t, "DeleteConflict", func() error {
_, e := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(grpLifecycleUser)})
return e
})
// delete-group while non-empty AND attached → DeleteConflict.
harness.Step(t, "delete-group while member + policy present (expect DeleteConflict)")
harness.ExpectError(t, "DeleteConflict", func() error {
_, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)})
return e
})
// Remove the member; the attached-policy guard alone must still block delete.
harness.Step(t, "remove-user-from-group %s", grpLifecycleUser)
_, err = fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(grpLifecycleGroup),
UserName: aws.String(grpLifecycleUser),
})
require.NoError(t, err, "remove-user-from-group")
harness.Step(t, "delete-group while only policy attached (expect DeleteConflict)")
harness.ExpectError(t, "DeleteConflict", func() error {
_, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)})
return e
})
// detach a policy that isn't attached → NoSuchEntity.
harness.Step(t, "detach-group-policy not-attached (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
GroupName: aws.String(grpLifecycleGroup),
PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")),
})
return e
})
// --- Teardown (asserted) ---
harness.Step(t, "detach-group-policy %s", grpLifecyclePolicy)
_, err = fix.AWS.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
GroupName: aws.String(grpLifecycleGroup),
PolicyArn: aws.String(policyARN),
})
require.NoError(t, err, "detach-group-policy")
harness.Step(t, "delete-group %s", grpLifecycleGroup)
_, err = fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)})
require.NoError(t, err, "delete-group")
harness.Step(t, "get-group after delete (expect NoSuchEntity)")
harness.ExpectError(t, "NoSuchEntity", func() error {
_, e := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)})
return e
})
// User is no longer a member, so DeleteUser now succeeds.
harness.Step(t, "delete-user %s (no longer in a group)", grpLifecycleUser)
_, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(grpLifecycleUser)})
require.NoError(t, err, "delete-user")
_, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)})
require.NoError(t, err, "delete-policy")
}
// runIAMGroupEnforcement proves the linchpin: a policy attached to a group
// actually grants its permission to every member. A user with its own access
// key is denied a guarded action; once a policy granting that action is attached
// to a group the user joins, the same live credentials are allowed (policies
// resolved per request); after leaving the group the action is denied again.
// Modelled on runAssumedRoleControlPlaneEnforcement.
func runIAMGroupEnforcement(t *testing.T, fix *Fixture) {
harness.Phase(t, "Single — IAM Group authorization enforcement")
adminAccount := harness.IAMAccountID(t, fix.AWS)
policyARN := harness.IAMPolicyARN(adminAccount, grpEnforcePolicy)
sweep := func() {
harness.IAMDeleteGroupBestEffort(fix.AWS, grpEnforceGroup, []string{grpEnforceUser}, policyARN)
iamDeleteUserBestEffort(fix, grpEnforceUser)
iamDeletePolicyBestEffort(fix, policyARN)
}
sweep()
fix.Harness.RegisterCleanup(sweep)
// Member with its own static credentials.
harness.Step(t, "create-user %q + access key", grpEnforceUser)
_, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpEnforceUser)})
require.NoError(t, err, "create-user")
key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(grpEnforceUser)})
require.NoError(t, err, "create-access-key")
memberCli := harness.NewAWSClientWithCreds(t, fix.Env,
aws.StringValue(key.AccessKey.AccessKeyId),
aws.StringValue(key.AccessKey.SecretAccessKey))
// No policy anywhere yet: the active key authenticates, then default-denies.
harness.Step(t, "describe-regions with no grant (expect AccessDenied)")
harness.ExpectError(t, "AccessDenied", func() error {
_, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
return e
})
// Group + policy granting ec2:DescribeRegions, then join the user.
harness.Step(t, "create-group %q", grpEnforceGroup)
_, err = fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpEnforceGroup)})
require.NoError(t, err, "create-group")
harness.Step(t, "create+attach policy %q to group (grants ec2:DescribeRegions)", grpEnforcePolicy)
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
PolicyName: aws.String(grpEnforcePolicy),
PolicyDocument: aws.String(grpPolicyDoc),
})
require.NoError(t, err, "create-policy")
_, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
GroupName: aws.String(grpEnforceGroup),
PolicyArn: aws.String(policyARN),
})
require.NoError(t, err, "attach-group-policy")
harness.Step(t, "add-user-to-group %s <- %s", grpEnforceGroup, grpEnforceUser)
_, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
GroupName: aws.String(grpEnforceGroup),
UserName: aws.String(grpEnforceUser),
})
require.NoError(t, err, "add-user-to-group")
// Same live credentials are now allowed — the grant flows through the group.
harness.Step(t, "describe-regions after group grant (expect success)")
_, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
require.NoError(t, err, "describe-regions must be allowed once the group grants it")
// Leave the group and the inherited grant must evaporate.
harness.Step(t, "remove-user-from-group %s", grpEnforceUser)
_, err = fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
GroupName: aws.String(grpEnforceGroup),
UserName: aws.String(grpEnforceUser),
})
require.NoError(t, err, "remove-user-from-group")
harness.Step(t, "describe-regions after leaving group (expect AccessDenied)")
harness.ExpectError(t, "AccessDenied", func() error {
_, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
return e
})
}
+14
View File
@@ -64,6 +64,20 @@ func TestAssumedRoleControlPlaneEnforcement(t *testing.T) {
runAssumedRoleControlPlaneEnforcement(t, requireIAMFixture(t))
}
// TestIAMGroupsLifecycle exercises group CRUD, membership, group-policy
// attachment, the reverse-lookup listings, and every deletion guard. Sequential:
// it creates/destroys its own dedicated group/user/policy graph.
func TestIAMGroupsLifecycle(t *testing.T) {
runIAMGroupsLifecycle(t, requireIAMFixture(t))
}
// TestIAMGroupEnforcement proves a group-attached policy grants its permission
// to members: denied with no grant, allowed once in the group, denied again
// after leaving. Sequential to avoid racing the mid-test grant.
func TestIAMGroupEnforcement(t *testing.T) {
runIAMGroupEnforcement(t, requireIAMFixture(t))
}
// TestIAMInstanceProfileAssociation exercises the EC2 IAM instance-profile
// association lifecycle (associate/replace/disassociate, RunInstances
// --iam-instance-profile, auto-disassociate on terminate). It boots its own