diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 87d90c93f..11275636c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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** | --- diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index 0398c3a7a..699733ae8 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -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 diff --git a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go index 2381b9fc4..7e8231a8f 100644 --- a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go +++ b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go @@ -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) diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go index 71f1f59e4..0a00163cd 100644 --- a/spinifex/gateway/iam.go +++ b/spinifex/gateway/iam.go @@ -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 { diff --git a/spinifex/gateway/iam/group.go b/spinifex/gateway/iam/group.go new file mode 100644 index 000000000..c34dad3f1 --- /dev/null +++ b/spinifex/gateway/iam/group.go @@ -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) +} diff --git a/spinifex/gateway/iam/group_test.go b/spinifex/gateway/iam/group_test.go new file mode 100644 index 000000000..d609bb558 --- /dev/null +++ b/spinifex/gateway/iam/group_test.go @@ -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) + } + }) + } +} diff --git a/spinifex/gateway/iam/handler_test.go b/spinifex/gateway/iam/handler_test.go index d9ea77af5..202c26a2f 100644 --- a/spinifex/gateway/iam/handler_test.go +++ b/spinifex/gateway/iam/handler_test.go @@ -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 { diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go index bc397f483..c51a2bd16 100644 --- a/spinifex/gateway/iam_request_test.go +++ b/spinifex/gateway/iam_request_test.go @@ -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 { diff --git a/spinifex/gateway/sts/handler_test.go b/spinifex/gateway/sts/handler_test.go index e3a3596ff..95f46a916 100644 --- a/spinifex/gateway/sts/handler_test.go +++ b/spinifex/gateway/sts/handler_test.go @@ -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 { diff --git a/spinifex/handlers/iam/groups.go b/spinifex/handlers/iam/groups.go new file mode 100644 index 000000000..60d8f1081 --- /dev/null +++ b/spinifex/handlers/iam/groups.go @@ -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) +} diff --git a/spinifex/handlers/iam/groups_test.go b/spinifex/handlers/iam/groups_test.go new file mode 100644 index 000000000..c96f0b976 --- /dev/null +++ b/spinifex/handlers/iam/groups_test.go @@ -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") +} diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go index 9b1ec13e5..f5ee6df6e 100644 --- a/spinifex/handlers/iam/service.go +++ b/spinifex/handlers/iam/service.go @@ -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) diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index c7e94f51c..a39bc3594 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -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 } diff --git a/spinifex/handlers/iam/types.go b/spinifex/handlers/iam/types.go index 08f221d98..5ccb2aaa7 100644 --- a/spinifex/handlers/iam/types.go +++ b/spinifex/handlers/iam/types.go @@ -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. diff --git a/tests/e2e/harness/iam.go b/tests/e2e/harness/iam.go index 8885773a2..a280372a1 100644 --- a/tests/e2e/harness/iam.go +++ b/tests/e2e/harness/iam.go @@ -22,6 +22,11 @@ func IAMPolicyARN(account, key string) string { return "arn:aws:iam::" + account + ":policy/" + key } +// IAMGroupARN constructs arn:aws:iam:::group/ (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)}) +} diff --git a/tests/e2e/iam/iam_groups_test.go b/tests/e2e/iam/iam_groups_test.go new file mode 100644 index 000000000..7088dfe83 --- /dev/null +++ b/tests/e2e/iam/iam_groups_test.go @@ -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:::group/") + 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 + }) +} diff --git a/tests/e2e/iam/tests_test.go b/tests/e2e/iam/tests_test.go index ce358afc9..ef3b68678 100644 --- a/tests/e2e/iam/tests_test.go +++ b/tests/e2e/iam/tests_test.go @@ -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