From 45ee2ab4b9d894f2291961b992e98a4b000d962d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 8 Apr 2026 12:27:03 -0700 Subject: [PATCH] feat(iam): implement ListUserPolicies API action (#8991) * feat(iam): implement ListUserPolicies API action (#8987) Add ListUserPolicies support to both embedded and standalone IAM servers, resolving the NotImplemented error when calling `aws iam list-user-policies`. * fix: address review feedback for ListUserPolicies - Add handleImplicitUsername for ListUserPolicies in both IAM servers so omitting UserName defaults to the calling user (Gemini review) - Assert synthetic policy name in unit test (CodeRabbit) - Use require.True for error type assertion in integration test (CodeRabbit) --- test/s3/iam/s3_iam_admin_test.go | 32 ++++++++++ weed/iam/responses.go | 11 ++++ weed/iamapi/iamapi_management_handlers.go | 44 ++++++++++++++ weed/iamapi/iamapi_response.go | 1 + weed/s3api/s3api_embedded_iam.go | 29 ++++++++- weed/s3api/s3api_embedded_iam_test.go | 73 +++++++++++++++++++++++ 6 files changed, 188 insertions(+), 2 deletions(-) diff --git a/test/s3/iam/s3_iam_admin_test.go b/test/s3/iam/s3_iam_admin_test.go index d9ce1a3d4..75556b43f 100644 --- a/test/s3/iam/s3_iam_admin_test.go +++ b/test/s3/iam/s3_iam_admin_test.go @@ -321,6 +321,13 @@ func TestIAMPolicyManagement(t *testing.T) { require.NoError(t, err) defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + // List user policies before any inline policy is attached + listResp, err := iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.Empty(t, listResp.PolicyNames, "New user should have no inline policies") + policyName := "test-inline-policy" policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}` @@ -332,6 +339,14 @@ func TestIAMPolicyManagement(t *testing.T) { }) require.NoError(t, err) + // List user policies after attaching inline policy + listResp, err = iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.NotEmpty(t, listResp.PolicyNames, "User should have inline policies after PutUserPolicy") + assert.False(t, *listResp.IsTruncated) + // Get user policy getResp, err := iamClient.GetUserPolicy(&iam.GetUserPolicyInput{ UserName: aws.String(userName), @@ -348,5 +363,22 @@ func TestIAMPolicyManagement(t *testing.T) { PolicyName: aws.String(policyName), }) require.NoError(t, err) + + // List user policies after deletion + listResp, err = iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + assert.Empty(t, listResp.PolicyNames, "User should have no inline policies after DeleteUserPolicy") + }) + + t.Run("list_user_policies_nonexistent_user", func(t *testing.T) { + _, err := iamClient.ListUserPolicies(&iam.ListUserPoliciesInput{ + UserName: aws.String("nonexistent-user-for-list-policies"), + }) + require.Error(t, err) + awsErr, ok := err.(awserr.Error) + require.True(t, ok, "Expected AWS error type") + assert.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) }) } diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 6ce19cbd6..67e5b71e9 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -178,6 +178,17 @@ type ListAttachedUserPoliciesResponse struct { CommonResponse } +// ListUserPoliciesResponse is the response for ListUserPolicies action. +type ListUserPoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUserPoliciesResponse"` + ListUserPoliciesResult struct { + PolicyNames []string `xml:"PolicyNames>member"` + IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker,omitempty"` + } `xml:"ListUserPoliciesResult"` + CommonResponse +} + // GetUserPolicyResponse is the response for GetUserPolicy action. type GetUserPolicyResponse struct { XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"` diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 28654c0ca..cfcaac098 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -9,6 +9,7 @@ import ( "fmt" "net/http" "net/url" + "sort" "strings" "sync" @@ -529,6 +530,40 @@ func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val return resp, nil } +// ListUserPolicies lists the names of inline policies attached to a user. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListUserPolicies.html +func (iama *IamApiServer) ListUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListUserPoliciesResponse, iamError *IamError) { + resp = &ListUserPoliciesResponse{} + userName := values.Get("UserName") + + // Verify the user exists + found := false + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + found = true + break + } + } + if !found { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)} + } + + // List inline policy names from persistent storage + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + + if userPolicies := policies.InlinePolicies[userName]; userPolicies != nil { + for policyName := range userPolicies { + resp.ListUserPoliciesResult.PolicyNames = append(resp.ListUserPoliciesResult.PolicyNames, policyName) + } + sort.Strings(resp.ListUserPoliciesResult.PolicyNames) + } + resp.ListUserPoliciesResult.IsTruncated = false + return resp, nil +} + // GetPolicy retrieves a managed policy by ARN. func (iama *IamApiServer) GetPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *GetPolicyResponse, iamError *IamError) { resp = &GetPolicyResponse{} @@ -1062,6 +1097,15 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) { writeIamErrorResponse(w, r, reqID, err) return } + case "ListUserPolicies": + iama.handleImplicitUsername(r, values) + var err *IamError + response, err = iama.ListUserPolicies(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false case "GetPolicy": var err *IamError response, err = iama.GetPolicy(s3cfg, values) diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index 021e4a0eb..2f28e0e23 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -23,6 +23,7 @@ type ( PutUserPolicyResponse = iamlib.PutUserPolicyResponse DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse GetUserPolicyResponse = iamlib.GetUserPolicyResponse + ListUserPoliciesResponse = iamlib.ListUserPoliciesResponse GetPolicyResponse = iamlib.GetPolicyResponse DeletePolicyResponse = iamlib.DeletePolicyResponse ListPoliciesResponse = iamlib.ListPoliciesResponse diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 4a367a1fb..06971e684 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -99,6 +99,7 @@ type ( iamPutUserPolicyResponse = iamlib.PutUserPolicyResponse iamDeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse iamGetUserPolicyResponse = iamlib.GetUserPolicyResponse + iamListUserPoliciesResponse = iamlib.ListUserPoliciesResponse iamAttachUserPolicyResponse = iamlib.AttachUserPolicyResponse iamDetachUserPolicyResponse = iamlib.DetachUserPolicyResponse iamListAttachedUserPoliciesResponse = iamlib.ListAttachedUserPoliciesResponse @@ -939,6 +940,23 @@ func (e *EmbeddedIamApi) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, valu return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} } +// ListUserPolicies lists the names of inline policies attached to a user. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListUserPolicies.html +func (e *EmbeddedIamApi) ListUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamListUserPoliciesResponse, *iamError) { + resp := &iamListUserPoliciesResponse{} + userName := values.Get("UserName") + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + if len(ident.Actions) > 0 { + resp.ListUserPoliciesResult.PolicyNames = []string{userName + "_policy"} + } + resp.ListUserPoliciesResult.IsTruncated = false + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} +} + // AttachUserPolicy attaches a managed policy to a user. func (e *EmbeddedIamApi) AttachUserPolicy(ctx context.Context, values url.Values) (*iamAttachUserPolicyResponse, *iamError) { resp := &iamAttachUserPolicyResponse{} @@ -1906,7 +1924,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s action := values.Get("Action") if e.readOnly { switch action { - case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount", + case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListUserPolicies", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount", "GetGroup", "ListGroups", "ListAttachedGroupPolicies", "ListGroupsForUser": // Allowed read-only actions default: @@ -2003,6 +2021,13 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s if iamErr != nil { return nil, iamErr } + case "ListUserPolicies": + var iamErr *iamError + response, iamErr = e.ListUserPolicies(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false case "AttachUserPolicy": var iamErr *iamError response, iamErr = e.AttachUserPolicy(ctx, values) @@ -2198,7 +2223,7 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) { // Handle implicit username for HTTP requests switch r.Form.Get("Action") { - case "ListAccessKeys", "CreateAccessKey", "DeleteAccessKey", "UpdateAccessKey": + case "ListAccessKeys", "CreateAccessKey", "DeleteAccessKey", "UpdateAccessKey", "ListUserPolicies": e.handleImplicitUsername(r, values) case "CreateServiceAccount": createdBy := s3_constants.GetIdentityNameFromContext(r) diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index 817e6e285..b5ff318b1 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -461,6 +461,79 @@ func TestEmbeddedIamDeleteUserPolicyUserNotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, rr.Code) } +// TestEmbeddedIamListUserPolicies tests listing inline policies for a user. +func TestEmbeddedIamListUserPolicies(t *testing.T) { + api := NewEmbeddedIamApiForTest() + api.mockConfig = &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{ + { + Name: "UserWithPolicy", + Actions: []string{"Read", "Write"}, + Credentials: []*iam_pb.Credential{ + {AccessKey: UserAccessKeyPrefix + "TEST12345", SecretKey: "secret"}, + }, + }, + { + Name: "UserWithoutPolicy", + Credentials: []*iam_pb.Credential{ + {AccessKey: UserAccessKeyPrefix + "TEST67890", SecretKey: "secret"}, + }, + }, + }, + } + + // List policies for user with actions + form := url.Values{} + form.Set("Action", "ListUserPolicies") + form.Set("UserName", "UserWithPolicy") + + req, _ := http.NewRequest("POST", "/", nil) + req.PostForm = form + req.Form = form + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr := httptest.NewRecorder() + apiRouter := mux.NewRouter().SkipClean(true) + apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions) + apiRouter.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "ListUserPoliciesResponse") + assert.Contains(t, rr.Body.String(), "PolicyNames") + assert.Contains(t, rr.Body.String(), "UserWithPolicy_policy") + + // List policies for user without actions + form2 := url.Values{} + form2.Set("Action", "ListUserPolicies") + form2.Set("UserName", "UserWithoutPolicy") + + req2, _ := http.NewRequest("POST", "/", nil) + req2.PostForm = form2 + req2.Form = form2 + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr2 := httptest.NewRecorder() + apiRouter.ServeHTTP(rr2, req2) + + assert.Equal(t, http.StatusOK, rr2.Code) + assert.Contains(t, rr2.Body.String(), "ListUserPoliciesResponse") + + // List policies for nonexistent user + form3 := url.Values{} + form3.Set("Action", "ListUserPolicies") + form3.Set("UserName", "NonExistentUser") + + req3, _ := http.NewRequest("POST", "/", nil) + req3.PostForm = form3 + req3.Form = form3 + req3.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr3 := httptest.NewRecorder() + apiRouter.ServeHTTP(rr3, req3) + + assert.Equal(t, http.StatusNotFound, rr3.Code) +} + // TestEmbeddedIamAttachUserPolicy tests attaching a managed policy to a user. func TestEmbeddedIamAttachUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest()