diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 55008b1fd..a7797bd13 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -475,10 +475,10 @@ All IAM operations are account-scoped. Root user (account `000000000000`) bypass | `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** | +| `put-user-policy` | `--user-name`, `--policy-name`, `--policy-document` | — | **DONE** | +| `get-user-policy` | `--user-name`, `--policy-name` | — | **DONE** | +| `delete-user-policy` | `--user-name`, `--policy-name` | — | **DONE** | +| `list-user-policies` | `--user-name` | `--max-items`, `--marker` | **DONE** | | `tag-user` | — | `--user-name`, `--tags` | **NOT STARTED** | | `untag-user` | — | `--user-name`, `--tag-keys` | **NOT STARTED** | | `list-user-tags` | — | `--user-name` | **NOT STARTED** | diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index 13fe4989f..a7c000402 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -222,6 +222,18 @@ func (m *mockIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInp func (m *mockIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { return &iam.ListGroupPoliciesOutput{}, nil } +func (m *mockIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + return &iam.PutUserPolicyOutput{}, nil +} +func (m *mockIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + return &iam.GetUserPolicyOutput{}, nil +} +func (m *mockIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + return &iam.DeleteUserPolicyOutput{}, nil +} +func (m *mockIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + return &iam.ListUserPoliciesOutput{}, nil +} // testMasterKey is a fixed 32-byte key for deterministic tests. var testMasterKey []byte diff --git a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go index 008d97136..163a1a40a 100644 --- a/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go +++ b/spinifex/gateway/ec2/instance/IamInstanceProfileAssociation_test.go @@ -212,6 +212,18 @@ func (f *fakeIAMService) DeleteGroupPolicy(string, *iam.DeleteGroupPolicyInput) func (f *fakeIAMService) ListGroupPolicies(string, *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { return &iam.ListGroupPoliciesOutput{}, nil } +func (f *fakeIAMService) PutUserPolicy(string, *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + return &iam.PutUserPolicyOutput{}, nil +} +func (f *fakeIAMService) GetUserPolicy(string, *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + return &iam.GetUserPolicyOutput{}, nil +} +func (f *fakeIAMService) DeleteUserPolicy(string, *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + return &iam.DeleteUserPolicyOutput{}, nil +} +func (f *fakeIAMService) ListUserPolicies(string, *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + return &iam.ListUserPoliciesOutput{}, nil +} var _ handlers_iam.IAMService = (*fakeIAMService)(nil) diff --git a/spinifex/gateway/iam.go b/spinifex/gateway/iam.go index 3a758b55c..703d413a2 100644 --- a/spinifex/gateway/iam.go +++ b/spinifex/gateway/iam.go @@ -97,6 +97,20 @@ var iamActions = map[string]IAMHandler{ return gateway_iam.ListAttachedUserPolicies(accountID, input, gw.IAMService) }), + // User inline policies + "PutUserPolicy": iamHandler(func(accountID string, input *iam.PutUserPolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.PutUserPolicy(accountID, input, gw.IAMService) + }), + "GetUserPolicy": iamHandler(func(accountID string, input *iam.GetUserPolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.GetUserPolicy(accountID, input, gw.IAMService) + }), + "DeleteUserPolicy": iamHandler(func(accountID string, input *iam.DeleteUserPolicyInput, gw *GatewayConfig) (any, error) { + return gateway_iam.DeleteUserPolicy(accountID, input, gw.IAMService) + }), + "ListUserPolicies": iamHandler(func(accountID string, input *iam.ListUserPoliciesInput, gw *GatewayConfig) (any, error) { + return gateway_iam.ListUserPolicies(accountID, input, gw.IAMService) + }), + // Role CRUD "CreateRole": iamHandler(func(accountID string, input *iam.CreateRoleInput, gw *GatewayConfig) (any, error) { return gateway_iam.CreateRole(accountID, input, gw.IAMService) diff --git a/spinifex/gateway/iam/handler_test.go b/spinifex/gateway/iam/handler_test.go index 1469a280e..2eb64e18c 100644 --- a/spinifex/gateway/iam/handler_test.go +++ b/spinifex/gateway/iam/handler_test.go @@ -217,6 +217,18 @@ func (s *stubIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolicyInp func (s *stubIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { return &iam.ListGroupPoliciesOutput{}, nil } +func (s *stubIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + return &iam.PutUserPolicyOutput{}, nil +} +func (s *stubIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + return &iam.GetUserPolicyOutput{}, nil +} +func (s *stubIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + return &iam.DeleteUserPolicyOutput{}, nil +} +func (s *stubIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + return &iam.ListUserPoliciesOutput{}, nil +} func TestCreateUser(t *testing.T) { svc := &stubIAMService{} diff --git a/spinifex/gateway/iam/user.go b/spinifex/gateway/iam/user.go index 77f2130b2..0b7c06de7 100644 --- a/spinifex/gateway/iam/user.go +++ b/spinifex/gateway/iam/user.go @@ -32,3 +32,43 @@ func DeleteUser(accountID string, input *iam.DeleteUserInput, svc handlers_iam.I } return svc.DeleteUser(accountID, input) } + +func PutUserPolicy(accountID string, input *iam.PutUserPolicyInput, svc handlers_iam.IAMService) (*iam.PutUserPolicyOutput, error) { + if input.UserName == nil || *input.UserName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.PolicyName == nil || *input.PolicyName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.PolicyDocument == nil || *input.PolicyDocument == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.PutUserPolicy(accountID, input) +} + +func GetUserPolicy(accountID string, input *iam.GetUserPolicyInput, svc handlers_iam.IAMService) (*iam.GetUserPolicyOutput, error) { + if input.UserName == nil || *input.UserName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.PolicyName == nil || *input.PolicyName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.GetUserPolicy(accountID, input) +} + +func DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput, svc handlers_iam.IAMService) (*iam.DeleteUserPolicyOutput, error) { + if input.UserName == nil || *input.UserName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.PolicyName == nil || *input.PolicyName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.DeleteUserPolicy(accountID, input) +} + +func ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput, svc handlers_iam.IAMService) (*iam.ListUserPoliciesOutput, error) { + if input.UserName == nil || *input.UserName == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + return svc.ListUserPolicies(accountID, input) +} diff --git a/spinifex/gateway/iam/user_test.go b/spinifex/gateway/iam/user_test.go new file mode 100644 index 000000000..024400c0d --- /dev/null +++ b/spinifex/gateway/iam/user_test.go @@ -0,0 +1,117 @@ +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 validUserInlineDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeInstances","Resource":"*"}]}` + +func TestPutUserPolicy(t *testing.T) { + svc := &stubIAMService{} + tests := []struct { + name string + input *iam.PutUserPolicyInput + wantErr string + }{ + {"nil UserName", &iam.PutUserPolicyInput{PolicyName: aws.String("p"), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter}, + {"empty UserName", &iam.PutUserPolicyInput{UserName: aws.String(""), PolicyName: aws.String("p"), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter}, + {"nil PolicyName", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter}, + {"empty PolicyName", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String(""), PolicyDocument: aws.String(validUserInlineDoc)}, awserrors.ErrorMissingParameter}, + {"nil PolicyDocument", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter}, + {"empty PolicyDocument", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p"), PolicyDocument: aws.String("")}, awserrors.ErrorMissingParameter}, + {"valid", &iam.PutUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p"), PolicyDocument: aws.String(validUserInlineDoc)}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := PutUserPolicy(testAccountID, tc.input, svc) + if tc.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tc.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGetUserPolicy(t *testing.T) { + svc := &stubIAMService{} + tests := []struct { + name string + input *iam.GetUserPolicyInput + wantErr string + }{ + {"nil UserName", &iam.GetUserPolicyInput{PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter}, + {"empty UserName", &iam.GetUserPolicyInput{UserName: aws.String(""), PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter}, + {"nil PolicyName", &iam.GetUserPolicyInput{UserName: aws.String("u")}, awserrors.ErrorMissingParameter}, + {"empty PolicyName", &iam.GetUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("")}, awserrors.ErrorMissingParameter}, + {"valid", &iam.GetUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p")}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := GetUserPolicy(testAccountID, tc.input, svc) + if tc.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tc.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestDeleteUserPolicy(t *testing.T) { + svc := &stubIAMService{} + tests := []struct { + name string + input *iam.DeleteUserPolicyInput + wantErr string + }{ + {"nil UserName", &iam.DeleteUserPolicyInput{PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter}, + {"empty UserName", &iam.DeleteUserPolicyInput{UserName: aws.String(""), PolicyName: aws.String("p")}, awserrors.ErrorMissingParameter}, + {"nil PolicyName", &iam.DeleteUserPolicyInput{UserName: aws.String("u")}, awserrors.ErrorMissingParameter}, + {"empty PolicyName", &iam.DeleteUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("")}, awserrors.ErrorMissingParameter}, + {"valid", &iam.DeleteUserPolicyInput{UserName: aws.String("u"), PolicyName: aws.String("p")}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := DeleteUserPolicy(testAccountID, tc.input, svc) + if tc.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tc.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestListUserPolicies(t *testing.T) { + svc := &stubIAMService{} + tests := []struct { + name string + input *iam.ListUserPoliciesInput + wantErr string + }{ + {"nil UserName", &iam.ListUserPoliciesInput{}, awserrors.ErrorMissingParameter}, + {"empty UserName", &iam.ListUserPoliciesInput{UserName: aws.String("")}, awserrors.ErrorMissingParameter}, + {"valid", &iam.ListUserPoliciesInput{UserName: aws.String("u")}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := ListUserPolicies(testAccountID, tc.input, svc) + if tc.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tc.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/spinifex/gateway/iam_request_test.go b/spinifex/gateway/iam_request_test.go index 1056058d9..c6278b5bc 100644 --- a/spinifex/gateway/iam_request_test.go +++ b/spinifex/gateway/iam_request_test.go @@ -247,6 +247,18 @@ func (m *flexMockIAMService) DeleteGroupPolicy(_ string, _ *iam.DeleteGroupPolic func (m *flexMockIAMService) ListGroupPolicies(_ string, _ *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { return &iam.ListGroupPoliciesOutput{}, nil } +func (m *flexMockIAMService) PutUserPolicy(_ string, _ *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + return &iam.PutUserPolicyOutput{}, nil +} +func (m *flexMockIAMService) GetUserPolicy(_ string, _ *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + return &iam.GetUserPolicyOutput{}, nil +} +func (m *flexMockIAMService) DeleteUserPolicy(_ string, _ *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + return &iam.DeleteUserPolicyOutput{}, nil +} +func (m *flexMockIAMService) ListUserPolicies(_ string, _ *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + return &iam.ListUserPoliciesOutput{}, nil +} // setupIAMRequestHandler wires an http.Handler for IAM_Request with injected SigV4 context values. func setupIAMRequestHandler(svc handlers_iam.IAMService) http.Handler { diff --git a/spinifex/gateway/sts/handler_test.go b/spinifex/gateway/sts/handler_test.go index 109e9a8d0..e6c1a048a 100644 --- a/spinifex/gateway/sts/handler_test.go +++ b/spinifex/gateway/sts/handler_test.go @@ -272,6 +272,18 @@ func (s *stubIAMService) DeleteGroupPolicy(string, *iam.DeleteGroupPolicyInput) func (s *stubIAMService) ListGroupPolicies(string, *iam.ListGroupPoliciesInput) (*iam.ListGroupPoliciesOutput, error) { panic("unexpected ListGroupPolicies call") } +func (s *stubIAMService) PutUserPolicy(string, *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + panic("unexpected PutUserPolicy call") +} +func (s *stubIAMService) GetUserPolicy(string, *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + panic("unexpected GetUserPolicy call") +} +func (s *stubIAMService) DeleteUserPolicy(string, *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + panic("unexpected DeleteUserPolicy call") +} +func (s *stubIAMService) ListUserPolicies(string, *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + panic("unexpected ListUserPolicies call") +} func TestAssumeRole_HappyPath(t *testing.T) { var calledWith struct { diff --git a/spinifex/handlers/iam/service.go b/spinifex/handlers/iam/service.go index f5d218196..b54e299ab 100644 --- a/spinifex/handlers/iam/service.go +++ b/spinifex/handlers/iam/service.go @@ -31,6 +31,12 @@ type IAMService interface { DetachUserPolicy(accountID string, input *iam.DetachUserPolicyInput) (*iam.DetachUserPolicyOutput, error) ListAttachedUserPolicies(accountID string, input *iam.ListAttachedUserPoliciesInput) (*iam.ListAttachedUserPoliciesOutput, error) + // User inline policies — account-scoped + PutUserPolicy(accountID string, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) + GetUserPolicy(accountID string, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) + DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) + ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) + // Role CRUD — account-scoped CreateRole(accountID string, input *iam.CreateRoleInput) (*iam.CreateRoleOutput, error) GetRole(accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error) diff --git a/spinifex/handlers/iam/service_impl.go b/spinifex/handlers/iam/service_impl.go index 11e8d86d3..9b05783d8 100644 --- a/spinifex/handlers/iam/service_impl.go +++ b/spinifex/handlers/iam/service_impl.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "slices" "strconv" "strings" @@ -392,6 +393,9 @@ func (s *IAMServiceImpl) DeleteUser(accountID string, input *iam.DeleteUserInput if len(user.Groups) > 0 { return nil, errors.New(awserrors.ErrorIAMDeleteConflict) } + if len(user.InlinePolicies) > 0 { + return nil, errors.New(awserrors.ErrorIAMDeleteConflict) + } if err := s.usersBucket.Delete(kvKey); err != nil { return nil, fmt.Errorf("delete user: %w", err) @@ -1274,6 +1278,123 @@ func (s *IAMServiceImpl) ListAttachedUserPolicies(accountID string, input *iam.L }, nil } +// --------------------------------------------------------------------------- +// User inline policies +// --------------------------------------------------------------------------- + +// PutUserPolicy embeds an inline policy document in a user, keyed by PolicyName. +// Idempotent upsert: a same-name policy is overwritten, mirroring AWS. Uses a +// blind read-modify-write Put like the other user writers (no CAS). +func (s *IAMServiceImpl) PutUserPolicy(accountID string, input *iam.PutUserPolicyInput) (*iam.PutUserPolicyOutput, error) { + userName := *input.UserName + policyName := *input.PolicyName + policyDoc := *input.PolicyDocument + userKVKey := accountID + "." + userName + + if err := validatePolicyName(policyName); err != nil { + return nil, errors.New(awserrors.ErrorIAMInvalidInput) + } + if _, err := ValidatePolicyDocument(policyDoc); err != nil { + return nil, errors.New(awserrors.ErrorIAMMalformedPolicyDocument) + } + + user, err := s.getUser(accountID, userName) + if err != nil { + return nil, err + } + + if user.InlinePolicies == nil { + user.InlinePolicies = map[string]string{} + } + user.InlinePolicies[policyName] = policyDoc + + 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 inline policy put on user", "accountID", accountID, "userName", userName, "policyName", policyName) + return &iam.PutUserPolicyOutput{}, nil +} + +// GetUserPolicy returns a user's inline policy document by name as a raw JSON +// string, matching the in-repo convention used by GetGroupPolicy. +func (s *IAMServiceImpl) GetUserPolicy(accountID string, input *iam.GetUserPolicyInput) (*iam.GetUserPolicyOutput, error) { + userName := *input.UserName + policyName := *input.PolicyName + + user, err := s.getUser(accountID, userName) + if err != nil { + return nil, err + } + + doc, ok := user.InlinePolicies[policyName] + if !ok { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + + return &iam.GetUserPolicyOutput{ + UserName: aws.String(userName), + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(doc), + }, nil +} + +// DeleteUserPolicy removes a user's inline policy by name. A missing name +// yields NoSuchEntity, matching AWS. Blind Put like the other user writers. +func (s *IAMServiceImpl) DeleteUserPolicy(accountID string, input *iam.DeleteUserPolicyInput) (*iam.DeleteUserPolicyOutput, error) { + userName := *input.UserName + policyName := *input.PolicyName + userKVKey := accountID + "." + userName + + user, err := s.getUser(accountID, userName) + if err != nil { + return nil, err + } + + if _, ok := user.InlinePolicies[policyName]; !ok { + return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) + } + delete(user.InlinePolicies, policyName) + + 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 inline policy deleted from user", "accountID", accountID, "userName", userName, "policyName", policyName) + return &iam.DeleteUserPolicyOutput{}, nil +} + +// ListUserPolicies returns the names of a user's inline policies, sorted for +// deterministic output. Pagination is not implemented: IsTruncated is always false. +func (s *IAMServiceImpl) ListUserPolicies(accountID string, input *iam.ListUserPoliciesInput) (*iam.ListUserPoliciesOutput, error) { + user, err := s.getUser(accountID, *input.UserName) + if err != nil { + return nil, err + } + + rawNames := slices.Sorted(maps.Keys(user.InlinePolicies)) + + names := make([]*string, 0, len(rawNames)) + for _, name := range rawNames { + names = append(names, aws.String(name)) + } + + return &iam.ListUserPoliciesOutput{ + PolicyNames: names, + IsTruncated: aws.Bool(false), + }, nil +} + // GetUserPolicies resolves all policy documents attached to a user. // Used internally by the gateway for policy evaluation. func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDocument, error) { @@ -1295,6 +1416,17 @@ func (s *IAMServiceImpl) GetUserPolicies(accountID, userName string) ([]PolicyDo } } + // Direct user-inline policies. A malformed document fails closed so a broken + // inline grant denies rather than silently permitting, mirroring the + // group-inline handling below. + for name, raw := range user.InlinePolicies { + var doc PolicyDocument + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return nil, fmt.Errorf("parse user inline policy %s/%s: %w", userName, name, err) + } + docs = append(docs, doc) + } + // 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 diff --git a/spinifex/handlers/iam/types.go b/spinifex/handlers/iam/types.go index 8e450108e..d7c0d780c 100644 --- a/spinifex/handlers/iam/types.go +++ b/spinifex/handlers/iam/types.go @@ -28,16 +28,17 @@ type ( // User represents an IAM user stored in JetStream KV. type User struct { - UserName string `json:"user_name"` - UserID string `json:"user_id"` - AccountID string `json:"account_id"` - ARN string `json:"arn"` - Path string `json:"path"` - CreatedAt string `json:"created_at"` - 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) + UserName string `json:"user_name"` + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + ARN string `json:"arn"` + Path string `json:"path"` + CreatedAt string `json:"created_at"` + 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) + InlinePolicies map[string]string `json:"inline_policies,omitempty"` // policyName → document JSON } // Group is a permission-grouping IAM identity stored in JetStream KV. diff --git a/spinifex/handlers/iam/user_inline_test.go b/spinifex/handlers/iam/user_inline_test.go new file mode 100644 index 000000000..dd1c626d7 --- /dev/null +++ b/spinifex/handlers/iam/user_inline_test.go @@ -0,0 +1,397 @@ +package handlers_iam + +import ( + "encoding/json" + "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" +) + +// ============================================================================ +// User Inline Policy Tests (Put / Get / Delete / List) +// ============================================================================ + +func TestPutUserPolicy_RoundTrip(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "inline-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("inline-user"), + PolicyName: aws.String("AllowDescribe"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + + out, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{ + UserName: aws.String("inline-user"), + PolicyName: aws.String("AllowDescribe"), + }) + require.NoError(t, err) + assert.Equal(t, "inline-user", *out.UserName) + assert.Equal(t, "AllowDescribe", *out.PolicyName) + assert.Equal(t, validPolicyDocument(), *out.PolicyDocument) +} + +func TestPutUserPolicy_IdempotentOverwrite(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "overwrite-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("overwrite-user"), + PolicyName: aws.String("Policy"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + + _, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("overwrite-user"), + PolicyName: aws.String("Policy"), + PolicyDocument: aws.String(inlineDenyDocument()), + }) + require.NoError(t, err) + + out, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{ + UserName: aws.String("overwrite-user"), + PolicyName: aws.String("Policy"), + }) + require.NoError(t, err) + assert.Equal(t, inlineDenyDocument(), *out.PolicyDocument) + + list, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{ + UserName: aws.String("overwrite-user"), + }) + require.NoError(t, err) + assert.Len(t, list.PolicyNames, 1, "overwrite must not duplicate the name") +} + +func TestPutUserPolicy_InvalidName(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "badname-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("badname-user"), + PolicyName: aws.String("bad name"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMInvalidInput) +} + +func TestPutUserPolicy_MalformedDocument(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "malformed-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("malformed-user"), + PolicyName: aws.String("Bad"), + PolicyDocument: aws.String(`{not valid json`), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument) +} + +func TestPutUserPolicy_OversizedDocument(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "oversized-user") + + huge := strings.Repeat("a", maxPolicyDocumentSize+1) + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("oversized-user"), + PolicyName: aws.String("Huge"), + PolicyDocument: aws.String(huge), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMMalformedPolicyDocument) +} + +func TestPutUserPolicy_UserNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("ghost"), + PolicyName: aws.String("Policy"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestGetUserPolicy_UnknownName(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "get-unknown") + + _, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{ + UserName: aws.String("get-unknown"), + PolicyName: aws.String("missing"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestGetUserPolicy_UserNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{ + UserName: aws.String("ghost"), + PolicyName: aws.String("Policy"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestListUserPolicies_Sorted(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "list-user") + + for _, name := range []string{"Charlie", "Alpha", "Bravo"} { + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("list-user"), + PolicyName: aws.String(name), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + } + + out, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{ + UserName: aws.String("list-user"), + }) + require.NoError(t, err) + require.False(t, *out.IsTruncated) + got := make([]string, 0, len(out.PolicyNames)) + for _, n := range out.PolicyNames { + got = append(got, *n) + } + assert.Equal(t, []string{"Alpha", "Bravo", "Charlie"}, got) +} + +func TestListUserPolicies_Empty(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "empty-inline-user") + + out, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{ + UserName: aws.String("empty-inline-user"), + }) + require.NoError(t, err) + assert.NotNil(t, out.PolicyNames) + assert.Len(t, out.PolicyNames, 0) + assert.False(t, *out.IsTruncated) +} + +func TestListUserPolicies_UserNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{ + UserName: aws.String("ghost"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestDeleteUserPolicy(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "del-inline-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("del-inline-user"), + PolicyName: aws.String("Doomed"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + + _, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{ + UserName: aws.String("del-inline-user"), + PolicyName: aws.String("Doomed"), + }) + require.NoError(t, err) + + _, err = svc.GetUserPolicy(testAccountID, &iam.GetUserPolicyInput{ + UserName: aws.String("del-inline-user"), + PolicyName: aws.String("Doomed"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) + + list, err := svc.ListUserPolicies(testAccountID, &iam.ListUserPoliciesInput{ + UserName: aws.String("del-inline-user"), + }) + require.NoError(t, err) + assert.Len(t, list.PolicyNames, 0) +} + +func TestDeleteUserPolicy_DoubleDelete(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "double-del-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("double-del-user"), + PolicyName: aws.String("Once"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + + _, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{ + UserName: aws.String("double-del-user"), + PolicyName: aws.String("Once"), + }) + require.NoError(t, err) + + _, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{ + UserName: aws.String("double-del-user"), + PolicyName: aws.String("Once"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +func TestDeleteUserPolicy_UserNotFound(t *testing.T) { + svc := setupTestIAMService(t) + + _, err := svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{ + UserName: aws.String("ghost"), + PolicyName: aws.String("Policy"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMNoSuchEntity) +} + +// ============================================================================ +// Authorization Integration — the linchpin (GetUserPolicies resolves user inline) +// ============================================================================ + +// putRawUserInlinePolicy writes an inline policy document into a user record +// directly via the bucket, bypassing PutUserPolicy validation. Used to plant a +// corrupt stored document that the write path would otherwise reject. +func putRawUserInlinePolicy(t *testing.T, svc *IAMServiceImpl, userName, policyName, raw string) { + t.Helper() + user, err := svc.getUser(testAccountID, userName) + require.NoError(t, err) + if user.InlinePolicies == nil { + user.InlinePolicies = map[string]string{} + } + user.InlinePolicies[policyName] = raw + data, err := json.Marshal(user) + require.NoError(t, err) + _, err = svc.usersBucket.Put(testAccountID+"."+userName, data) + require.NoError(t, err) +} + +// TestGetUserPolicies_UserInlineEnforced is the linchpin: a user's own inline +// policy must contribute its grant to the user's effective permission set, and +// stop contributing once the inline policy is removed. Without this, put-user-policy +// appears to succeed while granting nothing — a silent no-op. +func TestGetUserPolicies_UserInlineEnforced(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "quinn") + + // Before the inline policy exists: the grant is absent. + docs, err := svc.GetUserPolicies(testAccountID, "quinn") + require.NoError(t, err) + assert.False(t, policiesGrant(docs, "ec2:DescribeInstances"), "grant must be absent before the inline policy") + + _, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("quinn"), + PolicyName: aws.String("InlineGrant"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + + // After the inline policy is added: the grant flows through. + docs, err = svc.GetUserPolicies(testAccountID, "quinn") + require.NoError(t, err) + require.Len(t, docs, 1) + assert.True(t, policiesGrant(docs, "ec2:DescribeInstances"), "user inline grant must be enforced") + + // After removing the inline policy: the grant disappears again. + _, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{ + UserName: aws.String("quinn"), + PolicyName: aws.String("InlineGrant"), + }) + require.NoError(t, err) + + docs, err = svc.GetUserPolicies(testAccountID, "quinn") + require.NoError(t, err) + assert.False(t, policiesGrant(docs, "ec2:DescribeInstances"), "grant must vanish after the inline policy is deleted") +} + +// TestGetUserPolicies_UserManagedAndInline proves a user's direct managed +// attachment and its own inline document both surface in one combined effective +// set — inline grants supplement managed grants rather than replacing them, so a +// deny-wins evaluator can honour an inline Deny alongside a managed Allow. +func TestGetUserPolicies_UserManagedAndInline(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "rita") + managed := createTestPolicy(t, svc, "ManagedGrant") + + _, err := svc.AttachUserPolicy(testAccountID, &iam.AttachUserPolicyInput{ + UserName: aws.String("rita"), + PolicyArn: managed.Arn, + }) + require.NoError(t, err) + + _, err = svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("rita"), + PolicyName: aws.String("InlineDeny"), + PolicyDocument: aws.String(inlineDenyDocument()), + }) + require.NoError(t, err) + + docs, err := svc.GetUserPolicies(testAccountID, "rita") + require.NoError(t, err) + require.Len(t, docs, 2, "both the managed attachment and the inline document must resolve") + assert.True(t, policiesGrant(docs, "ec2:DescribeInstances"), "managed Allow surfaced") + assert.True(t, policiesGrant(docs, "s3:DeleteObject"), "inline doc surfaced") +} + +// TestGetUserPolicies_UserInlineMalformedFailsClosed proves a corrupt inline +// document on a user fails the whole resolution closed rather than silently +// dropping the grant source, mirroring the group-inline handling. +func TestGetUserPolicies_UserInlineMalformedFailsClosed(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "sam") + + putRawUserInlinePolicy(t, svc, "sam", "Bad", `{not valid json`) + + _, err := svc.GetUserPolicies(testAccountID, "sam") + assert.Error(t, err, "a malformed user inline doc must fail closed") +} + +// ============================================================================ +// DeleteUser inline-policy guard +// ============================================================================ + +// TestDeleteUser_WithInlinePolicy proves a user carrying an inline policy refuses +// deletion until the inline policy is removed, mirroring DeleteGroup. +func TestDeleteUser_WithInlinePolicy(t *testing.T) { + svc := setupTestIAMService(t) + createTestUser(t, svc, "inline-conflict-user") + + _, err := svc.PutUserPolicy(testAccountID, &iam.PutUserPolicyInput{ + UserName: aws.String("inline-conflict-user"), + PolicyName: aws.String("Blocker"), + PolicyDocument: aws.String(validPolicyDocument()), + }) + require.NoError(t, err) + + _, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{ + UserName: aws.String("inline-conflict-user"), + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorIAMDeleteConflict) + + // Succeeds once the inline policy is removed. + _, err = svc.DeleteUserPolicy(testAccountID, &iam.DeleteUserPolicyInput{ + UserName: aws.String("inline-conflict-user"), + PolicyName: aws.String("Blocker"), + }) + require.NoError(t, err) + + _, err = svc.DeleteUser(testAccountID, &iam.DeleteUserInput{ + UserName: aws.String("inline-conflict-user"), + }) + require.NoError(t, err) +} diff --git a/tests/e2e/iam/iam_test.go b/tests/e2e/iam/iam_test.go index f1b2c124a..35d33a18e 100644 --- a/tests/e2e/iam/iam_test.go +++ b/tests/e2e/iam/iam_test.go @@ -776,6 +776,18 @@ func iamDeleteUserBestEffort(fix *Fixture, user string) { }) } } + // Delete any inline policies so the inline-policy guard can't block the final + // DeleteUser after a partial run. + if inline, err := fix.AWS.IAM.ListUserPolicies(&iam.ListUserPoliciesInput{ + UserName: aws.String(user), + }); err == nil { + for _, name := range inline.PolicyNames { + _, _ = fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{ + UserName: aws.String(user), + PolicyName: name, + }) + } + } // Delete every access key for this user. keys, err := fix.AWS.IAM.ListAccessKeys(&iam.ListAccessKeysInput{ UserName: aws.String(user), diff --git a/tests/e2e/iam/iam_user_inline_test.go b/tests/e2e/iam/iam_user_inline_test.go new file mode 100644 index 000000000..cf8003dd2 --- /dev/null +++ b/tests/e2e/iam/iam_user_inline_test.go @@ -0,0 +1,88 @@ +//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" +) + +const ( + usrInlineEnforceUser = "usr-e2e-inl-enf-user" + usrInlineEnforceName = "usr-e2e-inl-enf-describe-regions" + + // A single-action grant so the positive case proves the user's own inline + // document was resolved and evaluated — not a blanket allow. + usrInlinePolicyDoc = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeRegions","Resource":"*"}]}` +) + +// runIAMUserInlineEnforcement proves the user-inline linchpin: a policy embedded +// directly in a user record grants that user its permission with no group or +// managed attachment involved. A user with its own access key is denied a guarded +// action; once an inline policy granting that action is put on the user, the same +// live credentials are allowed (policies resolved per request); after the inline +// policy is deleted the action is denied again, isolating the user's own inline +// document as the sole grant source. Mirrors runIAMGroupInlineEnforcement without +// the group indirection. +func runIAMUserInlineEnforcement(t *testing.T, fix *Fixture) { + harness.Phase(t, "Single — IAM User inline-policy authorization enforcement") + + // No group or managed policy — the inline doc lives on the user record, so the + // best-effort sweep clears it via ListUserPolicies/DeleteUserPolicy. + sweep := func() { + iamDeleteUserBestEffort(fix, usrInlineEnforceUser) + } + sweep() + fix.Harness.RegisterCleanup(sweep) + + // User with its own static credentials. + harness.Step(t, "create-user %q + access key", usrInlineEnforceUser) + _, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(usrInlineEnforceUser)}) + require.NoError(t, err, "create-user") + key, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(usrInlineEnforceUser)}) + require.NoError(t, err, "create-access-key") + userCli := harness.NewAWSClientWithCreds(t, fix.Env, + aws.StringValue(key.AccessKey.AccessKeyId), + aws.StringValue(key.AccessKey.SecretAccessKey)) + + // No grant 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 := userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + return e + }) + + // Put an inline policy granting ec2:DescribeRegions directly on the user. + harness.Step(t, "put-user-policy %q (grants ec2:DescribeRegions)", usrInlineEnforceName) + _, err = fix.AWS.IAM.PutUserPolicy(&iam.PutUserPolicyInput{ + UserName: aws.String(usrInlineEnforceUser), + PolicyName: aws.String(usrInlineEnforceName), + PolicyDocument: aws.String(usrInlinePolicyDoc), + }) + require.NoError(t, err, "put-user-policy") + + // Same live credentials are now allowed — the user's own inline grant applies. + harness.Step(t, "describe-regions after inline user grant (expect success)") + _, err = userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + require.NoError(t, err, "describe-regions must be allowed once the user inline policy grants it") + + // Delete the inline policy — the grant must evaporate, isolating the inline + // document as the sole grant source. + harness.Step(t, "delete-user-policy %s", usrInlineEnforceName) + _, err = fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{ + UserName: aws.String(usrInlineEnforceUser), + PolicyName: aws.String(usrInlineEnforceName), + }) + require.NoError(t, err, "delete-user-policy") + + harness.Step(t, "describe-regions after inline policy removed (expect AccessDenied)") + harness.ExpectError(t, "AccessDenied", func() error { + _, e := userCli.EC2.DescribeRegions(&ec2.DescribeRegionsInput{}) + return e + }) +} diff --git a/tests/e2e/iam/tests_test.go b/tests/e2e/iam/tests_test.go index 30283152a..5abb94d37 100644 --- a/tests/e2e/iam/tests_test.go +++ b/tests/e2e/iam/tests_test.go @@ -86,6 +86,14 @@ func TestIAMGroupInlineEnforcement(t *testing.T) { runIAMGroupInlineEnforcement(t, requireIAMFixture(t)) } +// TestIAMUserInlineEnforcement proves a user's own inline policy grants its +// permission with no group or managed attachment: denied with no grant, allowed +// once the inline policy is put, denied again after it is deleted. Sequential to +// avoid racing the mid-test grant. +func TestIAMUserInlineEnforcement(t *testing.T) { + runIAMUserInlineEnforcement(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