mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: IAM Groups v1 (#449)
This commit is contained in:
+53
-12
@@ -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** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -180,6 +180,36 @@ func (m *mockIAMService) RemoveRoleFromInstanceProfile(_ string, _ *iam.RemoveRo
|
||||
func (m *mockIAMService) ResolveInstanceProfile(_, _ string) (*handlers_iam.InstanceProfile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockIAMService) CreateGroup(_ string, _ *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
|
||||
return &iam.CreateGroupOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) GetGroup(_ string, _ *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
|
||||
return &iam.GetGroupOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) ListGroups(_ string, _ *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
|
||||
return &iam.ListGroupsOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) DeleteGroup(_ string, _ *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
|
||||
return &iam.DeleteGroupOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) AddUserToGroup(_ string, _ *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
|
||||
return &iam.AddUserToGroupOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) RemoveUserFromGroup(_ string, _ *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
|
||||
return &iam.RemoveUserFromGroupOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) ListGroupsForUser(_ string, _ *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
|
||||
return &iam.ListGroupsForUserOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) AttachGroupPolicy(_ string, _ *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
|
||||
return &iam.AttachGroupPolicyOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) DetachGroupPolicy(_ string, _ *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
|
||||
return &iam.DetachGroupPolicyOutput{}, nil
|
||||
}
|
||||
func (m *mockIAMService) ListAttachedGroupPolicies(_ string, _ *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
|
||||
return &iam.ListAttachedGroupPoliciesOutput{}, nil
|
||||
}
|
||||
|
||||
// testMasterKey is a fixed 32-byte key for deterministic tests.
|
||||
var testMasterKey []byte
|
||||
|
||||
@@ -170,6 +170,36 @@ func (f *fakeIAMService) IsEmpty() (bool, error)
|
||||
func (f *fakeIAMService) CreateAccount(string) (*handlers_iam.Account, error) { return nil, nil }
|
||||
func (f *fakeIAMService) GetAccount(string) (*handlers_iam.Account, error) { return nil, nil }
|
||||
func (f *fakeIAMService) ListAccounts() ([]*handlers_iam.Account, error) { return nil, nil }
|
||||
func (f *fakeIAMService) CreateGroup(string, *iam.CreateGroupInput) (*iam.CreateGroupOutput, error) {
|
||||
return &iam.CreateGroupOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) GetGroup(string, *iam.GetGroupInput) (*iam.GetGroupOutput, error) {
|
||||
return &iam.GetGroupOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) ListGroups(string, *iam.ListGroupsInput) (*iam.ListGroupsOutput, error) {
|
||||
return &iam.ListGroupsOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) DeleteGroup(string, *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) {
|
||||
return &iam.DeleteGroupOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) AddUserToGroup(string, *iam.AddUserToGroupInput) (*iam.AddUserToGroupOutput, error) {
|
||||
return &iam.AddUserToGroupOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) RemoveUserFromGroup(string, *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) {
|
||||
return &iam.RemoveUserFromGroupOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) ListGroupsForUser(string, *iam.ListGroupsForUserInput) (*iam.ListGroupsForUserOutput, error) {
|
||||
return &iam.ListGroupsForUserOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) AttachGroupPolicy(string, *iam.AttachGroupPolicyInput) (*iam.AttachGroupPolicyOutput, error) {
|
||||
return &iam.AttachGroupPolicyOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) DetachGroupPolicy(string, *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) {
|
||||
return &iam.DetachGroupPolicyOutput{}, nil
|
||||
}
|
||||
func (f *fakeIAMService) ListAttachedGroupPolicies(string, *iam.ListAttachedGroupPoliciesInput) (*iam.ListAttachedGroupPoliciesOutput, error) {
|
||||
return &iam.ListAttachedGroupPoliciesOutput{}, nil
|
||||
}
|
||||
|
||||
var _ handlers_iam.IAMService = (*fakeIAMService)(nil)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,6 +22,11 @@ func IAMPolicyARN(account, key string) string {
|
||||
return "arn:aws:iam::" + account + ":policy/" + key
|
||||
}
|
||||
|
||||
// IAMGroupARN constructs arn:aws:iam::<account>:group/<name> (default path /).
|
||||
func IAMGroupARN(account, name string) string {
|
||||
return "arn:aws:iam::" + account + ":group/" + name
|
||||
}
|
||||
|
||||
// IAMAccountID returns the active account ID via STS GetCallerIdentity. Unlike
|
||||
// deriving it from a created IAM user's ARN, this is side-effect-free, so suites
|
||||
// sharing one account can call it concurrently without minting colliding users.
|
||||
@@ -68,3 +73,46 @@ func IAMDeleteRoleAndProfilesBestEffort(c *AWSClient, roleName string, profileNa
|
||||
}
|
||||
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
|
||||
}
|
||||
|
||||
// IAMDeleteGroupBestEffort tears down a group: removes any members, detaches all
|
||||
// attached policies, then deletes the group. Each step swallows errors so a
|
||||
// missing fragment never cascades; usable both as a pre-test sweep and as a
|
||||
// fixture-teardown cleanup. Order matters: a group cannot be deleted while it
|
||||
// still has members or attached policies.
|
||||
func IAMDeleteGroupBestEffort(c *AWSClient, groupName string, memberUsers []string, policyARNs ...string) {
|
||||
for _, u := range memberUsers {
|
||||
_, _ = c.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: aws.String(u),
|
||||
})
|
||||
}
|
||||
for _, arn := range policyARNs {
|
||||
_, _ = c.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: aws.String(arn),
|
||||
})
|
||||
}
|
||||
// Defensive: pull any other attached policies so the final DeleteGroup isn't
|
||||
// blocked by a stray attach from a partial run.
|
||||
if attached, err := c.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
|
||||
GroupName: aws.String(groupName),
|
||||
}); err == nil {
|
||||
for _, p := range attached.AttachedPolicies {
|
||||
_, _ = c.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(groupName),
|
||||
PolicyArn: p.PolicyArn,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Defensive: drop any members the caller didn't pass in (partial run could
|
||||
// have added one), so the non-empty guard can't block the delete.
|
||||
if got, err := c.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)}); err == nil {
|
||||
for _, u := range got.Users {
|
||||
_, _ = c.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(groupName),
|
||||
UserName: u.UserName,
|
||||
})
|
||||
}
|
||||
}
|
||||
_, _ = c.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//go:build e2e
|
||||
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Dedicated identifiers for the Groups suite. Distinct from the shared
|
||||
// alice/bob/charlie graph so the group tests can pre-sweep and tear down their
|
||||
// own state without colliding with the User/Policy phases.
|
||||
const (
|
||||
grpLifecycleGroup = "grp-e2e-developers"
|
||||
grpLifecycleUser = "grp-e2e-lc-user"
|
||||
grpLifecyclePolicy = "grp-e2e-lc-describe-regions"
|
||||
|
||||
grpEnforceGroup = "grp-e2e-enforce"
|
||||
grpEnforceUser = "grp-e2e-enf-user"
|
||||
grpEnforcePolicy = "grp-e2e-enf-describe-regions"
|
||||
|
||||
// A single-action grant so the enforcement positive case proves a specific
|
||||
// group-attached policy was resolved and evaluated — not a blanket allow.
|
||||
grpPolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}`
|
||||
)
|
||||
|
||||
// runIAMGroupsLifecycle exercises the full group surface: CRUD, membership,
|
||||
// group-policy attachment, the listing reverse-lookups, and every deletion guard
|
||||
// (delete-group while attached, delete-group while non-empty, delete-user while
|
||||
// in a group), then a clean teardown. Mirrors runIAMRolesAndProfiles in shape.
|
||||
func runIAMGroupsLifecycle(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM Groups Lifecycle")
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
groupARN := harness.IAMGroupARN(adminAccount, grpLifecycleGroup)
|
||||
policyARN := harness.IAMPolicyARN(adminAccount, grpLifecyclePolicy)
|
||||
|
||||
// Defensive sweep — a prior failed run may have left fragments. Order: remove
|
||||
// member + detach policy (group delete refuses a non-empty/attached group),
|
||||
// then the user, then the policy.
|
||||
sweep := func() {
|
||||
harness.IAMDeleteGroupBestEffort(fix.AWS, grpLifecycleGroup, []string{grpLifecycleUser}, policyARN)
|
||||
iamDeleteUserBestEffort(fix, grpLifecycleUser)
|
||||
iamDeletePolicyBestEffort(fix, policyARN)
|
||||
}
|
||||
sweep()
|
||||
fix.Harness.RegisterCleanup(sweep)
|
||||
|
||||
// CreateGroup — happy path; assert ARN + AWS group-id prefix.
|
||||
harness.Step(t, "create-group %q", grpLifecycleGroup)
|
||||
createOut, err := fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
})
|
||||
require.NoError(t, err, "create-group")
|
||||
require.Equal(t, grpLifecycleGroup, aws.StringValue(createOut.Group.GroupName))
|
||||
require.Equal(t, groupARN, aws.StringValue(createOut.Group.Arn),
|
||||
"group ARN must follow arn:aws:iam::<acct>:group/<name>")
|
||||
require.True(t, len(aws.StringValue(createOut.Group.GroupId)) > 4 &&
|
||||
aws.StringValue(createOut.Group.GroupId)[:4] == "AGPA",
|
||||
"group id must carry the AWS AGPA prefix, got %q", aws.StringValue(createOut.Group.GroupId))
|
||||
harness.Detail(t, "group", grpLifecycleGroup, "arn", groupARN)
|
||||
|
||||
// Duplicate.
|
||||
harness.Step(t, "create-group duplicate (expect EntityAlreadyExists)")
|
||||
harness.ExpectError(t, "EntityAlreadyExists", func() error {
|
||||
_, e := fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
return e
|
||||
})
|
||||
|
||||
// GetGroup — fresh group has an empty (non-nil) member list.
|
||||
harness.Step(t, "get-group %q (no members yet)", grpLifecycleGroup)
|
||||
got, err := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
require.NoError(t, err, "get-group")
|
||||
require.Equal(t, grpLifecycleGroup, aws.StringValue(got.Group.GroupName))
|
||||
require.Empty(t, got.Users, "new group must have zero members")
|
||||
|
||||
harness.Step(t, "get-group nonexistent (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String("ghost-group")})
|
||||
return e
|
||||
})
|
||||
|
||||
// ListGroups + PathPrefix scan.
|
||||
harness.Step(t, "list-groups (>= 1)")
|
||||
listed, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{})
|
||||
require.NoError(t, err, "list-groups")
|
||||
require.GreaterOrEqual(t, len(listed.Groups), 1, "expected >= 1 group, got %d", len(listed.Groups))
|
||||
|
||||
harness.Step(t, "list-groups --path-prefix / (surfaces groups at /)")
|
||||
pp, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{PathPrefix: aws.String("/")})
|
||||
require.NoError(t, err, "list-groups --path-prefix /")
|
||||
require.GreaterOrEqual(t, len(pp.Groups), 1, "path-prefix / must surface groups at /")
|
||||
|
||||
harness.Step(t, "list-groups --path-prefix /nonexistent/ (expect 0)")
|
||||
none, err := fix.AWS.IAM.ListGroups(&iam.ListGroupsInput{PathPrefix: aws.String("/nonexistent/")})
|
||||
require.NoError(t, err, "list-groups --path-prefix /nonexistent/")
|
||||
require.Empty(t, none.Groups, "no group lives under /nonexistent/")
|
||||
|
||||
// Member for the membership + guard sub-steps.
|
||||
harness.Step(t, "create-user %q (group member)", grpLifecycleUser)
|
||||
_, err = fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpLifecycleUser)})
|
||||
require.NoError(t, err, "create-user")
|
||||
|
||||
// remove-user-from-group on a non-member surfaces NoSuchEntity (must not no-op).
|
||||
harness.Step(t, "remove-user-from-group non-member (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
UserName: aws.String(grpLifecycleUser),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
// Ghost-entity binds → NoSuchEntity. Group is validated before the user.
|
||||
harness.Step(t, "add-user-to-group ghost group (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String("ghost-group"),
|
||||
UserName: aws.String(grpLifecycleUser),
|
||||
})
|
||||
return e
|
||||
})
|
||||
harness.Step(t, "add-user-to-group ghost user (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
UserName: aws.String("ghost-user"),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
// AddUserToGroup — idempotent re-add must not duplicate the membership.
|
||||
harness.Step(t, "add-user-to-group %s <- %s", grpLifecycleGroup, grpLifecycleUser)
|
||||
_, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
UserName: aws.String(grpLifecycleUser),
|
||||
})
|
||||
require.NoError(t, err, "add-user-to-group")
|
||||
|
||||
_, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
UserName: aws.String(grpLifecycleUser),
|
||||
})
|
||||
require.NoError(t, err, "idempotent re-add")
|
||||
|
||||
// ListGroupsForUser — reverse lookup sees exactly the one group.
|
||||
harness.Step(t, "list-groups-for-user %s (expect 1)", grpLifecycleUser)
|
||||
forUser, err := fix.AWS.IAM.ListGroupsForUser(&iam.ListGroupsForUserInput{
|
||||
UserName: aws.String(grpLifecycleUser),
|
||||
})
|
||||
require.NoError(t, err, "list-groups-for-user")
|
||||
require.Len(t, forUser.Groups, 1, "user should belong to exactly one group")
|
||||
require.Equal(t, grpLifecycleGroup, aws.StringValue(forUser.Groups[0].GroupName))
|
||||
|
||||
// GetGroup — member now visible from the group side.
|
||||
harness.Step(t, "get-group %q (member visible)", grpLifecycleGroup)
|
||||
got, err = fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
require.NoError(t, err, "get-group after add")
|
||||
require.Len(t, got.Users, 1, "group should list exactly one member")
|
||||
require.Equal(t, grpLifecycleUser, aws.StringValue(got.Users[0].UserName))
|
||||
|
||||
// AttachGroupPolicy — idempotent re-attach must not grow the count.
|
||||
harness.Step(t, "create-policy %s (grants ec2:DescribeRegions)", grpLifecyclePolicy)
|
||||
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(grpLifecyclePolicy),
|
||||
PolicyDocument: aws.String(grpPolicyDoc),
|
||||
})
|
||||
require.NoError(t, err, "create-policy")
|
||||
|
||||
harness.Step(t, "attach-group-policy unknown policy (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "attach-group-policy %s <- %s", grpLifecycleGroup, grpLifecyclePolicy)
|
||||
_, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
PolicyArn: aws.String(policyARN),
|
||||
})
|
||||
require.NoError(t, err, "attach-group-policy")
|
||||
|
||||
attached, err := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
})
|
||||
require.NoError(t, err, "list-attached-group-policies")
|
||||
require.Len(t, attached.AttachedPolicies, 1)
|
||||
require.Equal(t, policyARN, aws.StringValue(attached.AttachedPolicies[0].PolicyArn))
|
||||
|
||||
_, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
PolicyArn: aws.String(policyARN),
|
||||
})
|
||||
require.NoError(t, err, "idempotent re-attach")
|
||||
reAttached, err := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reAttached.AttachedPolicies, 1, "re-attach must be idempotent")
|
||||
|
||||
harness.Step(t, "list-attached-group-policies ghost group (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
|
||||
GroupName: aws.String("ghost-group"),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
// --- Deletion guards ---
|
||||
|
||||
// delete-user while still a group member → DeleteConflict.
|
||||
harness.Step(t, "delete-user while in group (expect DeleteConflict)")
|
||||
harness.ExpectError(t, "DeleteConflict", func() error {
|
||||
_, e := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(grpLifecycleUser)})
|
||||
return e
|
||||
})
|
||||
|
||||
// delete-group while non-empty AND attached → DeleteConflict.
|
||||
harness.Step(t, "delete-group while member + policy present (expect DeleteConflict)")
|
||||
harness.ExpectError(t, "DeleteConflict", func() error {
|
||||
_, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
return e
|
||||
})
|
||||
|
||||
// Remove the member; the attached-policy guard alone must still block delete.
|
||||
harness.Step(t, "remove-user-from-group %s", grpLifecycleUser)
|
||||
_, err = fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
UserName: aws.String(grpLifecycleUser),
|
||||
})
|
||||
require.NoError(t, err, "remove-user-from-group")
|
||||
|
||||
harness.Step(t, "delete-group while only policy attached (expect DeleteConflict)")
|
||||
harness.ExpectError(t, "DeleteConflict", func() error {
|
||||
_, e := fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
return e
|
||||
})
|
||||
|
||||
// detach a policy that isn't attached → NoSuchEntity.
|
||||
harness.Step(t, "detach-group-policy not-attached (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
// --- Teardown (asserted) ---
|
||||
|
||||
harness.Step(t, "detach-group-policy %s", grpLifecyclePolicy)
|
||||
_, err = fix.AWS.IAM.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
|
||||
GroupName: aws.String(grpLifecycleGroup),
|
||||
PolicyArn: aws.String(policyARN),
|
||||
})
|
||||
require.NoError(t, err, "detach-group-policy")
|
||||
|
||||
harness.Step(t, "delete-group %s", grpLifecycleGroup)
|
||||
_, err = fix.AWS.IAM.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
require.NoError(t, err, "delete-group")
|
||||
|
||||
harness.Step(t, "get-group after delete (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.GetGroup(&iam.GetGroupInput{GroupName: aws.String(grpLifecycleGroup)})
|
||||
return e
|
||||
})
|
||||
|
||||
// User is no longer a member, so DeleteUser now succeeds.
|
||||
harness.Step(t, "delete-user %s (no longer in a group)", grpLifecycleUser)
|
||||
_, err = fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(grpLifecycleUser)})
|
||||
require.NoError(t, err, "delete-user")
|
||||
|
||||
_, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)})
|
||||
require.NoError(t, err, "delete-policy")
|
||||
}
|
||||
|
||||
// runIAMGroupEnforcement proves the linchpin: a policy attached to a group
|
||||
// actually grants its permission to every member. A user with its own access
|
||||
// key is denied a guarded action; once a policy granting that action is attached
|
||||
// to a group the user joins, the same live credentials are allowed (policies
|
||||
// resolved per request); after leaving the group the action is denied again.
|
||||
// Modelled on runAssumedRoleControlPlaneEnforcement.
|
||||
func runIAMGroupEnforcement(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM Group authorization enforcement")
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
policyARN := harness.IAMPolicyARN(adminAccount, grpEnforcePolicy)
|
||||
|
||||
sweep := func() {
|
||||
harness.IAMDeleteGroupBestEffort(fix.AWS, grpEnforceGroup, []string{grpEnforceUser}, policyARN)
|
||||
iamDeleteUserBestEffort(fix, grpEnforceUser)
|
||||
iamDeletePolicyBestEffort(fix, policyARN)
|
||||
}
|
||||
sweep()
|
||||
fix.Harness.RegisterCleanup(sweep)
|
||||
|
||||
// Member with its own static credentials.
|
||||
harness.Step(t, "create-user %q + access key", grpEnforceUser)
|
||||
_, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(grpEnforceUser)})
|
||||
require.NoError(t, err, "create-user")
|
||||
key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(grpEnforceUser)})
|
||||
require.NoError(t, err, "create-access-key")
|
||||
memberCli := harness.NewAWSClientWithCreds(t, fix.Env,
|
||||
aws.StringValue(key.AccessKey.AccessKeyId),
|
||||
aws.StringValue(key.AccessKey.SecretAccessKey))
|
||||
|
||||
// No policy anywhere yet: the active key authenticates, then default-denies.
|
||||
harness.Step(t, "describe-regions with no grant (expect AccessDenied)")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
// Group + policy granting ec2:DescribeRegions, then join the user.
|
||||
harness.Step(t, "create-group %q", grpEnforceGroup)
|
||||
_, err = fix.AWS.IAM.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(grpEnforceGroup)})
|
||||
require.NoError(t, err, "create-group")
|
||||
|
||||
harness.Step(t, "create+attach policy %q to group (grants ec2:DescribeRegions)", grpEnforcePolicy)
|
||||
_, err = fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
||||
PolicyName: aws.String(grpEnforcePolicy),
|
||||
PolicyDocument: aws.String(grpPolicyDoc),
|
||||
})
|
||||
require.NoError(t, err, "create-policy")
|
||||
_, err = fix.AWS.IAM.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
|
||||
GroupName: aws.String(grpEnforceGroup),
|
||||
PolicyArn: aws.String(policyARN),
|
||||
})
|
||||
require.NoError(t, err, "attach-group-policy")
|
||||
|
||||
harness.Step(t, "add-user-to-group %s <- %s", grpEnforceGroup, grpEnforceUser)
|
||||
_, err = fix.AWS.IAM.AddUserToGroup(&iam.AddUserToGroupInput{
|
||||
GroupName: aws.String(grpEnforceGroup),
|
||||
UserName: aws.String(grpEnforceUser),
|
||||
})
|
||||
require.NoError(t, err, "add-user-to-group")
|
||||
|
||||
// Same live credentials are now allowed — the grant flows through the group.
|
||||
harness.Step(t, "describe-regions after group grant (expect success)")
|
||||
_, err = memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
|
||||
require.NoError(t, err, "describe-regions must be allowed once the group grants it")
|
||||
|
||||
// Leave the group and the inherited grant must evaporate.
|
||||
harness.Step(t, "remove-user-from-group %s", grpEnforceUser)
|
||||
_, err = fix.AWS.IAM.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
|
||||
GroupName: aws.String(grpEnforceGroup),
|
||||
UserName: aws.String(grpEnforceUser),
|
||||
})
|
||||
require.NoError(t, err, "remove-user-from-group")
|
||||
|
||||
harness.Step(t, "describe-regions after leaving group (expect AccessDenied)")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := memberCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{})
|
||||
return e
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user