From 535be3096bdc2987b2a983dd6b7375eaa20882ab Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 23 Jan 2026 16:41:51 -0800 Subject: [PATCH] Add AWS IAM integration tests and refactor admin authorization (#8098) * Add AWS IAM integration tests and refactor admin authorization - Added AWS IAM management integration tests (User, AccessKey, Policy) - Updated test framework to support IAM client creation with JWT/OIDC - Refactored s3api authorization to be policy-driven for IAM actions - Removed hardcoded role name checks for admin privileges - Added new tests to GitHub Actions basic test matrix * test(s3/iam): add UpdateUser and UpdateAccessKey tests and fix nil pointer dereference * feat(s3api): add DeletePolicy and update tests with cleanup logic * test(s3/iam): use t.Cleanup for managed policy deletion in CreatePolicy test --- .github/workflows/s3-iam-tests.yml | 2 +- test/s3/iam/iam_config.json | 8 +- test/s3/iam/iam_config.local.json | 8 +- test/s3/iam/s3_iam_admin_test.go | 241 +++++++++++++++++++++++++++++ test/s3/iam/s3_iam_framework.go | 51 +++++- test/s3/iam/test_config.json | 107 +++++++++---- weed/s3api/s3_action_resolver.go | 4 +- weed/s3api/s3_iam_middleware.go | 5 + weed/s3api/s3api_embedded_iam.go | 22 ++- 9 files changed, 396 insertions(+), 52 deletions(-) create mode 100644 test/s3/iam/s3_iam_admin_test.go diff --git a/.github/workflows/s3-iam-tests.yml b/.github/workflows/s3-iam-tests.yml index fcaebf956..52d644e43 100644 --- a/.github/workflows/s3-iam-tests.yml +++ b/.github/workflows/s3-iam-tests.yml @@ -117,7 +117,7 @@ jobs: "basic") echo "Running basic IAM functionality tests..." make clean setup start-services wait-for-services - go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation" ./... + go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAM" ./... ;; "advanced") echo "Running advanced IAM feature tests..." diff --git a/test/s3/iam/iam_config.json b/test/s3/iam/iam_config.json index ed1f0df47..59359636b 100644 --- a/test/s3/iam/iam_config.json +++ b/test/s3/iam/iam_config.json @@ -230,10 +230,12 @@ { "Effect": "Allow", "Action": [ - "s3:*" + "s3:*", + "iam:*" ], "Resource": [ - "*" + "*", + "arn:aws:iam:::*" ] }, { @@ -342,4 +344,4 @@ } } ] -} +} \ No newline at end of file diff --git a/test/s3/iam/iam_config.local.json b/test/s3/iam/iam_config.local.json index ed1f0df47..59359636b 100644 --- a/test/s3/iam/iam_config.local.json +++ b/test/s3/iam/iam_config.local.json @@ -230,10 +230,12 @@ { "Effect": "Allow", "Action": [ - "s3:*" + "s3:*", + "iam:*" ], "Resource": [ - "*" + "*", + "arn:aws:iam:::*" ] }, { @@ -342,4 +344,4 @@ } } ] -} +} \ No newline at end of file diff --git a/test/s3/iam/s3_iam_admin_test.go b/test/s3/iam/s3_iam_admin_test.go new file mode 100644 index 000000000..fd1d0da7f --- /dev/null +++ b/test/s3/iam/s3_iam_admin_test.go @@ -0,0 +1,241 @@ +package iam + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIAMUserManagement tests user management operations +func TestIAMUserManagement(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + // Create IAM client with admin privileges + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + t.Run("create_and_get_user", func(t *testing.T) { + userName := "test-user-mgm" + + // Create user + createResp, err := iamClient.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.Equal(t, userName, *createResp.User.UserName) + + // Get user + getResp, err := iamClient.GetUser(&iam.GetUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.Equal(t, userName, *getResp.User.UserName) + + // List users to verify existence + listResp, err := iamClient.ListUsers(&iam.ListUsersInput{}) + require.NoError(t, err) + found := false + for _, user := range listResp.Users { + if *user.UserName == userName { + found = true + break + } + } + assert.True(t, found, "Created user should be listed") + + // Clean up + _, err = iamClient.DeleteUser(&iam.DeleteUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + }) + + t.Run("update_user", func(t *testing.T) { + userName := "user-to-update" + newUserName := "user-updated" + + // Create user + _, err := iamClient.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + defer func() { + // Try to delete both just in case + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(newUserName)}) + }() + + // Update user name + _, err = iamClient.UpdateUser(&iam.UpdateUserInput{ + UserName: aws.String(userName), + NewUserName: aws.String(newUserName), + }) + require.NoError(t, err) + + // Verify update (GetUser with NEW name should work) + getResp, err := iamClient.GetUser(&iam.GetUserInput{ + UserName: aws.String(newUserName), + }) + require.NoError(t, err) + assert.Equal(t, newUserName, *getResp.User.UserName) + + // GetUser with OLD name should fail + _, err = iamClient.GetUser(&iam.GetUserInput{ + UserName: aws.String(userName), + }) + require.Error(t, err) + }) +} + +// TestIAMAccessKeyManagement tests access key operations +func TestIAMAccessKeyManagement(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + userName := "test-user-keys" + _, err = iamClient.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + + t.Run("create_list_delete_access_key", func(t *testing.T) { + // Create access key + createResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.NotEmpty(t, *createResp.AccessKey.AccessKeyId) + assert.NotEmpty(t, *createResp.AccessKey.SecretAccessKey) + assert.Equal(t, "Active", *createResp.AccessKey.Status) + + // List access keys + listResp, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.Equal(t, 1, len(listResp.AccessKeyMetadata)) + assert.Equal(t, *createResp.AccessKey.AccessKeyId, *listResp.AccessKeyMetadata[0].AccessKeyId) + + // Delete access key + _, err = iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), + AccessKeyId: createResp.AccessKey.AccessKeyId, + }) + require.NoError(t, err) + + // Verify deletion + listResp, err = iamClient.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.Equal(t, 0, len(listResp.AccessKeyMetadata)) + }) + + t.Run("update_access_key_status", func(t *testing.T) { + // Create access key + createResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), + AccessKeyId: createResp.AccessKey.AccessKeyId, + }) + + // Update to Inactive + _, err = iamClient.UpdateAccessKey(&iam.UpdateAccessKeyInput{ + UserName: aws.String(userName), + AccessKeyId: createResp.AccessKey.AccessKeyId, + Status: aws.String("Inactive"), + }) + require.NoError(t, err) + + // Verify update in ListAccessKeys + listResp, err := iamClient.ListAccessKeys(&iam.ListAccessKeysInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + found := false + for _, key := range listResp.AccessKeyMetadata { + if *key.AccessKeyId == *createResp.AccessKey.AccessKeyId { + assert.Equal(t, "Inactive", *key.Status) + found = true + break + } + } + assert.True(t, found) + }) +} + +// TestIAMPolicyManagement tests policy operations +func TestIAMPolicyManagement(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + t.Run("create_managed_policy", func(t *testing.T) { + policyName := "test-managed-policy" + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}` + + createResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + assert.Equal(t, policyName, *createResp.Policy.PolicyName) + assert.NotEmpty(t, *createResp.Policy.Arn) + + t.Cleanup(func() { + _, _ = iamClient.DeletePolicy(&iam.DeletePolicyInput{ + PolicyArn: createResp.Policy.Arn, + }) + }) + }) + + t.Run("user_inline_policy", func(t *testing.T) { + userName := "test-user-policy" + _, err := iamClient.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + + policyName := "test-inline-policy" + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}` + + // Put user policy + _, err = iamClient.PutUserPolicy(&iam.PutUserPolicyInput{ + UserName: aws.String(userName), + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + + // Get user policy + getResp, err := iamClient.GetUserPolicy(&iam.GetUserPolicyInput{ + UserName: aws.String(userName), + PolicyName: aws.String(policyName), + }) + require.NoError(t, err) + assert.Equal(t, userName, *getResp.UserName) + assert.Equal(t, policyName, *getResp.PolicyName) + assert.Contains(t, *getResp.PolicyDocument, "s3:Get") + + // Delete user policy + _, err = iamClient.DeleteUserPolicy(&iam.DeleteUserPolicyInput{ + UserName: aws.String(userName), + PolicyName: aws.String(policyName), + }) + require.NoError(t, err) + }) +} diff --git a/test/s3/iam/s3_iam_framework.go b/test/s3/iam/s3_iam_framework.go index 6e5545ab5..026d98373 100644 --- a/test/s3/iam/s3_iam_framework.go +++ b/test/s3/iam/s3_iam_framework.go @@ -21,6 +21,7 @@ import ( "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/s3" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" @@ -683,13 +684,13 @@ func (f *S3IAMTestFramework) GenerateUniqueBucketName(prefix string) string { randomSuffix := mathrand.Intn(10000) bucketName := fmt.Sprintf("%s-%s-%d", prefix, testName, randomSuffix) - + // Ensure final name is valid if len(bucketName) > 63 { // Truncate further if necessary bucketName = bucketName[:63] } - + return bucketName } @@ -904,3 +905,49 @@ func (f *S3IAMTestFramework) WaitForS3ServiceSimple() error { // The full implementation would be in the Makefile's wait-for-services target return nil } + +// CreateIAMClientWithJWT creates an IAM client authenticated with a JWT token for the specified role +func (f *S3IAMTestFramework) CreateIAMClientWithJWT(username, roleName string) (*iam.IAM, error) { + return f.CreateIAMClientWithCustomClaims(username, roleName, "", nil) +} + +// CreateIAMClientWithCustomClaims creates an IAM client with specific account ID and custom claims +func (f *S3IAMTestFramework) CreateIAMClientWithCustomClaims(username, roleName, account string, claims map[string]interface{}) (*iam.IAM, error) { + var token string + var err error + + if f.useKeycloak && claims == nil && account == "" { + // Use real Keycloak authentication if no custom requirements + token, err = f.getKeycloakToken(username) + if err != nil { + return nil, fmt.Errorf("failed to get Keycloak token: %v", err) + } + } else { + // Generate STS session token (mock mode or custom requirements) + token, err = f.generateSTSSessionToken(username, roleName, time.Hour, account, claims) + if err != nil { + return nil, fmt.Errorf("failed to generate STS session token: %v", err) + } + } + + // Create custom HTTP client with Bearer token transport + httpClient := &http.Client{ + Transport: &BearerTokenTransport{ + Token: token, + }, + } + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String(TestRegion), + Endpoint: aws.String(TestS3Endpoint), + HTTPClient: httpClient, + // Use anonymous credentials to avoid AWS signature generation + Credentials: credentials.AnonymousCredentials, + DisableSSL: aws.Bool(true), + }) + if err != nil { + return nil, fmt.Errorf("failed to create AWS session: %v", err) + } + + return iam.New(sess), nil +} diff --git a/test/s3/iam/test_config.json b/test/s3/iam/test_config.json index 018b0ef90..8063c4059 100644 --- a/test/s3/iam/test_config.json +++ b/test/s3/iam/test_config.json @@ -8,27 +8,33 @@ "secretKey": "test-secret-key" } ], - "actions": ["Admin"] + "actions": [ + "Admin" + ] }, { - "name": "readonlyuser", + "name": "readonlyuser", "credentials": [ { "accessKey": "readonly-access-key", "secretKey": "readonly-secret-key" } ], - "actions": ["Read"] + "actions": [ + "Read" + ] }, { "name": "writeonlyuser", "credentials": [ { - "accessKey": "writeonly-access-key", + "accessKey": "writeonly-access-key", "secretKey": "writeonly-secret-key" } ], - "actions": ["Write"] + "actions": [ + "Write" + ] } ], "iam": { @@ -52,7 +58,7 @@ "rules": [ { "claim": "groups", - "claimValue": "admins", + "claimValue": "admins", "roleName": "S3AdminRole" }, { @@ -78,13 +84,13 @@ "test-ldap": { "server": "ldap://localhost:389", "baseDN": "dc=example,dc=com", - "bindDN": "cn=admin,dc=example,dc=com", + "bindDN": "cn=admin,dc=example,dc=com", "bindPassword": "admin-password", "userFilter": "(uid=%s)", "groupFilter": "(memberUid=%s)", "attributes": { "email": "mail", - "displayName": "cn", + "displayName": "cn", "groups": "memberOf" }, "roleMapping": { @@ -95,7 +101,7 @@ "roleName": "S3AdminRole" }, { - "claim": "groups", + "claim": "groups", "claimValue": "cn=users,ou=groups,dc=example,dc=com", "roleName": "S3ReadOnlyRole" } @@ -114,13 +120,18 @@ { "Effect": "Allow", "Principal": { - "Federated": ["test-oidc", "test-ldap"] + "Federated": [ + "test-oidc", + "test-ldap" + ] }, "Action": "sts:AssumeRoleWithWebIdentity" } ] }, - "attachedPolicies": ["S3AdminPolicy"], + "attachedPolicies": [ + "S3AdminPolicy" + ], "description": "Full administrative access to S3 resources" }, "S3ReadOnlyRole": { @@ -128,15 +139,20 @@ "Version": "2012-10-17", "Statement": [ { - "Effect": "Allow", + "Effect": "Allow", "Principal": { - "Federated": ["test-oidc", "test-ldap"] + "Federated": [ + "test-oidc", + "test-ldap" + ] }, "Action": "sts:AssumeRoleWithWebIdentity" } ] }, - "attachedPolicies": ["S3ReadOnlyPolicy"], + "attachedPolicies": [ + "S3ReadOnlyPolicy" + ], "description": "Read-only access to S3 resources" }, "S3WriteOnlyRole": { @@ -146,13 +162,18 @@ { "Effect": "Allow", "Principal": { - "Federated": ["test-oidc", "test-ldap"] + "Federated": [ + "test-oidc", + "test-ldap" + ] }, "Action": "sts:AssumeRoleWithWebIdentity" } ] }, - "attachedPolicies": ["S3WriteOnlyPolicy"], + "attachedPolicies": [ + "S3WriteOnlyPolicy" + ], "description": "Write-only access to S3 resources" } }, @@ -162,22 +183,26 @@ "Statement": [ { "Effect": "Allow", - "Action": ["s3:*"], + "Action": [ + "s3:*", + "iam:*" + ], "Resource": [ "arn:aws:s3:::*", - "arn:aws:s3:::*/*" + "arn:aws:s3:::*/*", + "arn:aws:iam:::*" ] } ] }, "S3ReadOnlyPolicy": { - "Version": "2012-10-17", + "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", - "s3:GetObjectVersion", + "s3:GetObjectVersion", "s3:ListBucket", "s3:ListBucketVersions", "s3:GetBucketLocation", @@ -201,7 +226,7 @@ "s3:DeleteObject", "s3:DeleteObjectVersion", "s3:InitiateMultipartUpload", - "s3:UploadPart", + "s3:UploadPart", "s3:CompleteMultipartUpload", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts" @@ -219,7 +244,7 @@ "Effect": "Allow", "Action": [ "s3:CreateBucket", - "s3:DeleteBucket", + "s3:DeleteBucket", "s3:GetBucketPolicy", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy", @@ -237,14 +262,19 @@ "Statement": [ { "Effect": "Allow", - "Action": ["s3:*"], + "Action": [ + "s3:*" + ], "Resource": [ "arn:aws:s3:::*", "arn:aws:s3:::*/*" ], "Condition": { "IpAddress": { - "aws:SourceIp": ["192.168.1.0/24", "10.0.0.0/8"] + "aws:SourceIp": [ + "192.168.1.0/24", + "10.0.0.0/8" + ] } } } @@ -254,8 +284,11 @@ "Version": "2012-10-17", "Statement": [ { - "Effect": "Allow", - "Action": ["s3:GetObject", "s3:ListBucket"], + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:ListBucket" + ], "Resource": [ "arn:aws:s3:::*", "arn:aws:s3:::*/*" @@ -265,7 +298,7 @@ "aws:CurrentTime": "2023-01-01T00:00:00Z" }, "DateLessThan": { - "aws:CurrentTime": "2025-12-31T23:59:59Z" + "aws:CurrentTime": "2025-12-31T23:59:59Z" } } } @@ -280,7 +313,7 @@ "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", - "Action": "s3:GetObject", + "Action": "s3:GetObject", "Resource": "arn:aws:s3:::example-bucket/*" } ] @@ -292,7 +325,10 @@ "Sid": "DenyDeleteOperations", "Effect": "Deny", "Principal": "*", - "Action": ["s3:DeleteObject", "s3:DeleteBucket"], + "Action": [ + "s3:DeleteObject", + "s3:DeleteBucket" + ], "Resource": [ "arn:aws:s3:::example-bucket", "arn:aws:s3:::example-bucket/*" @@ -305,17 +341,22 @@ "Statement": [ { "Sid": "IPRestrictedAccess", - "Effect": "Allow", + "Effect": "Allow", "Principal": "*", - "Action": ["s3:GetObject", "s3:PutObject"], + "Action": [ + "s3:GetObject", + "s3:PutObject" + ], "Resource": "arn:aws:s3:::example-bucket/*", "Condition": { "IpAddress": { - "aws:SourceIp": ["203.0.113.0/24"] + "aws:SourceIp": [ + "203.0.113.0/24" + ] } } } ] } } -} +} \ No newline at end of file diff --git a/weed/s3api/s3_action_resolver.go b/weed/s3api/s3_action_resolver.go index 83431424c..f7acd88ae 100644 --- a/weed/s3api/s3_action_resolver.go +++ b/weed/s3api/s3_action_resolver.go @@ -289,8 +289,8 @@ func resolveBucketLevelAction(method string, baseAction string) string { // mapBaseActionToS3Format converts coarse-grained base actions to S3 format // This is the fallback when no specific resolution is found func mapBaseActionToS3Format(baseAction string) string { - // Handle actions that already have s3: prefix - if strings.HasPrefix(baseAction, "s3:") { + // Handle actions that already have s3: or iam: prefix + if strings.HasPrefix(baseAction, "s3:") || strings.HasPrefix(baseAction, "iam:") { return baseAction } diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 3982ed2af..fb0cbaa41 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -341,6 +341,11 @@ type MockAssumedRoleUser struct { // buildS3ResourceArn builds an S3 resource ARN from bucket and object func buildS3ResourceArn(bucket string, objectKey string) string { + // If bucket is already an ARN, return it as-is + if strings.HasPrefix(bucket, "arn:") { + return bucket + } + if bucket == "" { return "arn:aws:s3:::*" } diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 08e808f6c..ebdd54c72 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -1003,16 +1003,20 @@ func (e *EmbeddedIamApi) AuthIam(f http.HandlerFunc, _ Action) http.HandlerFunc f(w, r) return } - // Operating on another user: require admin + // Operating on another user: require admin or permission if !identity.isAdmin() { - s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) - return + if e.iam.VerifyActionPermission(r, identity, Action("iam:"+action), "arn:aws:iam:::*", "") != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } } } else { - // All other IAM actions require admin (CreateUser, DeleteUser, PutUserPolicy, etc.) + // All other IAM actions require admin or permission if !identity.isAdmin() { - s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) - return + if e.iam.VerifyActionPermission(r, identity, Action("iam:"+action), "arn:aws:iam:::*", "") != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } } } @@ -1094,8 +1098,10 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) { s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) return } - // CreatePolicy only validates the policy document and returns metadata. - // Policies are not stored separately; they are attached inline via PutUserPolicy. + case "DeletePolicy": + // Managed policies are not stored separately, so deletion is a no-op. + // Returns success for AWS compatibility. + response = struct{}{} changed = false case "PutUserPolicy": response, iamErr = e.PutUserPolicy(s3cfg, values)