From 85c706e0156d09a188c8d3eca4287527572c7eb8 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 6 Oct 2026 08:54:10 -0700 Subject: [PATCH] iam: refuse DeleteUser while access keys remain Deleting a user with live credentials orphaned its access keys; AWS answers DeleteConflict until they are removed first. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- weed/iamapi/iamapi_management_handlers.go | 13 ++++++++++ weed/iamapi/iamapi_test.go | 23 ++++++++++++++++++ weed/s3api/s3api_embedded_iam.go | 11 +++++++-- weed/s3api/s3api_embedded_iam_test.go | 29 ++++++++++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 592c886e3c..e6250d9fbe 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -228,6 +228,19 @@ func (iama *IamApiServer) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName resp = &DeleteUserResponse{} for i, ident := range s3cfg.Identities { if userName == ident.Name { + // AWS IAM behavior: prevent deletion if user has dependent resources. + if len(ident.ServiceAccountIds) > 0 { + return resp, &IamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("cannot delete user %s: user has %d service account(s). Delete service accounts first", userName, len(ident.ServiceAccountIds)), + } + } + if len(ident.Credentials) > 0 { + return resp, &IamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("cannot delete user %s: user has %d access key(s). Delete access keys first", userName, len(ident.Credentials)), + } + } // Clean up any inline policies stored for this user policies := Policies{} if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { diff --git a/weed/iamapi/iamapi_test.go b/weed/iamapi/iamapi_test.go index 6268e448be..e44c0832d2 100644 --- a/weed/iamapi/iamapi_test.go +++ b/weed/iamapi/iamapi_test.go @@ -488,6 +488,29 @@ func TestDeleteUser(t *testing.T) { out := DeleteUserResponse{} response, err := executeRequest(req.HTTPRequest, out) assert.Equal(t, nil, err) + assert.Equal(t, http.StatusConflict, response.Code) + + listReq, _ := iam.New(session.New()).ListAccessKeysRequest(&iam.ListAccessKeysInput{UserName: userName}) + _ = listReq.Build() + listOut := ListAccessKeysResponse{} + listResp, err := executeRequest(listReq.HTTPRequest, listOut) + assert.Equal(t, nil, err) + var listResult ListAccessKeysResponse + assert.Equal(t, nil, xml.Unmarshal(listResp.Body.Bytes(), &listResult)) + for _, key := range listResult.ListAccessKeysResult.AccessKeyMetadata { + delKeyReq, _ := iam.New(session.New()).DeleteAccessKeyRequest(&iam.DeleteAccessKeyInput{ + UserName: userName, + AccessKeyId: key.AccessKeyId, + }) + _ = delKeyReq.Build() + _, err := executeRequest(delKeyReq.HTTPRequest, out) + assert.Equal(t, nil, err) + } + + req, _ = iam.New(session.New()).DeleteUserRequest(params) + _ = req.Build() + response, err = executeRequest(req.HTTPRequest, out) + assert.Equal(t, nil, err) assert.Equal(t, http.StatusOK, response.Code) } diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index c7f395a400..685d575bca 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -355,8 +355,8 @@ func (e *EmbeddedIamApi) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName s resp := &iamDeleteUserResponse{} for i, ident := range s3cfg.Identities { if userName == ident.Name { - // AWS IAM behavior: prevent deletion if user has service accounts - // This ensures explicit cleanup and prevents orphaned resources + // AWS IAM behavior: prevent deletion if user has dependent resources. + // This ensures explicit cleanup and prevents orphaned resources. if len(ident.ServiceAccountIds) > 0 { return resp, &iamError{ Code: iam.ErrCodeDeleteConflictException, @@ -364,6 +364,13 @@ func (e *EmbeddedIamApi) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName s userName, len(ident.ServiceAccountIds)), } } + if len(ident.Credentials) > 0 { + return resp, &iamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("cannot delete user %s: user has %d access key(s). Delete access keys first", + userName, len(ident.Credentials)), + } + } s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...) // Remove user from all groups for _, g := range s3cfg.Groups { diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index 091d562be7..5500a0e0b6 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -1692,7 +1692,34 @@ func TestEmbeddedIamFullWorkflow(t *testing.T) { assert.Equal(t, http.StatusOK, response.Code) }) - // 5. Delete user + var accessKeyId string + for _, ident := range api.mockConfig.Identities { + if ident.Name == "WorkflowUser" && len(ident.Credentials) > 0 { + accessKeyId = ident.Credentials[0].AccessKey + } + } + assert.NotEmpty(t, accessKeyId) + + // 5. Deleting a user that still has an access key is refused + t.Run("DeleteUserConflict", func(t *testing.T) { + params := &iam.DeleteUserInput{UserName: aws.String("WorkflowUser")} + req, _ := iam.New(session.New()).DeleteUserRequest(params) + _ = req.Build() + response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusConflict, response.Code) + }) + + // 6. Delete the access key, then the user + t.Run("DeleteAccessKey", func(t *testing.T) { + params := &iam.DeleteAccessKeyInput{UserName: aws.String("WorkflowUser"), AccessKeyId: aws.String(accessKeyId)} + req, _ := iam.New(session.New()).DeleteAccessKeyRequest(params) + _ = req.Build() + response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code) + }) + t.Run("DeleteUser", func(t *testing.T) { params := &iam.DeleteUserInput{UserName: aws.String("WorkflowUser")} req, _ := iam.New(session.New()).DeleteUserRequest(params)