mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: IAM user inline policies (#462)
This commit is contained in:
+4
-4
@@ -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** |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user