diff --git a/test/s3/policy/policy_test.go b/test/s3/policy/policy_test.go index 4d46d4cc7..07092e04f 100644 --- a/test/s3/policy/policy_test.go +++ b/test/s3/policy/policy_test.go @@ -422,6 +422,218 @@ func TestS3MultipartOperationsInheritPutObjectPermissions(t *testing.T) { require.Equal(t, 0, len(listUploadsOut.Uploads)) } +// TestS3IAMManagedPolicyLifecycle is an end-to-end integration test covering the +// user-reported use case in https://github.com/seaweedfs/seaweedfs/issues/8506 +// where managed policy operations (GetPolicy, ListPolicies, DeletePolicy, +// AttachUserPolicy, DetachUserPolicy) returned 500 errors. +func TestS3IAMManagedPolicyLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + cluster, err := startMiniCluster(t) + require.NoError(t, err) + defer cluster.Stop() + + iamClient := newIAMClient(t, cluster.s3Endpoint) + + // Step 1: Create a user (this already worked per the issue) + userName := uniqueName("lifecycle-user") + _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) + require.NoError(t, err, "CreateUser should succeed") + + // Step 2: Create a managed policy via IAM API + policyName := uniqueName("lifecycle-policy") + policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName) + policyDoc := `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject"], + "Resource": "arn:aws:s3:::*" + }] + }` + createOut, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err, "CreatePolicy should succeed") + require.NotNil(t, createOut.Policy) + require.Equal(t, policyName, *createOut.Policy.PolicyName) + + // Step 3: ListPolicies — should include the created policy (was returning 500) + listOut, err := iamClient.ListPolicies(&iam.ListPoliciesInput{}) + require.NoError(t, err, "ListPolicies should succeed (was returning 500)") + require.True(t, managedPolicyContains(listOut.Policies, policyName), + "ListPolicies should contain the newly created policy") + + // Step 4: GetPolicy by ARN — should return the policy (was returning 500) + getOut, err := iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(policyArn)}) + require.NoError(t, err, "GetPolicy should succeed (was returning 500)") + require.NotNil(t, getOut.Policy) + require.Equal(t, policyName, *getOut.Policy.PolicyName) + require.Equal(t, policyArn, *getOut.Policy.Arn) + + // Step 5: AttachUserPolicy — should succeed (was returning 500) + _, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(userName), + PolicyArn: aws.String(policyArn), + }) + require.NoError(t, err, "AttachUserPolicy should succeed (was returning 500)") + + // Step 6: ListAttachedUserPolicies — verify the policy is attached + attachedOut, err := iamClient.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err, "ListAttachedUserPolicies should succeed") + require.True(t, attachedPolicyContains(attachedOut.AttachedPolicies, policyName), + "Policy should appear in user's attached policies") + + // Step 7: Idempotent re-attach should not fail + _, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(userName), + PolicyArn: aws.String(policyArn), + }) + require.NoError(t, err, "Re-attaching same policy should be idempotent") + + // Step 8: DeletePolicy while attached — should fail with DeleteConflict (AWS behavior) + _, err = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyArn)}) + require.Error(t, err, "DeletePolicy should fail while policy is attached") + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeDeleteConflictException, awsErr.Code(), + "Should return DeleteConflict when deleting attached policy") + + // Step 9: DetachUserPolicy + _, err = iamClient.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(userName), + PolicyArn: aws.String(policyArn), + }) + require.NoError(t, err, "DetachUserPolicy should succeed") + + // Verify detached + attachedOut, err = iamClient.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + require.False(t, attachedPolicyContains(attachedOut.AttachedPolicies, policyName), + "Policy should no longer appear in user's attached policies after detach") + + // Step 10: DeletePolicy — should now succeed (was returning XML parsing error) + _, err = iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyArn)}) + require.NoError(t, err, "DeletePolicy should succeed after detach (was returning XML parsing error)") + + // Step 11: Verify the policy is gone + listOut, err = iamClient.ListPolicies(&iam.ListPoliciesInput{}) + require.NoError(t, err) + require.False(t, managedPolicyContains(listOut.Policies, policyName), + "Deleted policy should not appear in ListPolicies") + + _, err = iamClient.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(policyArn)}) + require.Error(t, err, "GetPolicy should fail for deleted policy") + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) +} + +// TestS3IAMManagedPolicyErrorCases covers error cases from the user-reported issue: +// invalid ARNs, missing policies, and missing users. +func TestS3IAMManagedPolicyErrorCases(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + cluster, err := startMiniCluster(t) + require.NoError(t, err) + defer cluster.Stop() + + iamClient := newIAMClient(t, cluster.s3Endpoint) + + t.Run("GetPolicy with nonexistent ARN returns NoSuchEntity", func(t *testing.T) { + _, err := iamClient.GetPolicy(&iam.GetPolicyInput{ + PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"), + }) + require.Error(t, err) + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) + + t.Run("DeletePolicy with nonexistent ARN returns NoSuchEntity", func(t *testing.T) { + _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{ + PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"), + }) + require.Error(t, err) + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) + + t.Run("AttachUserPolicy with nonexistent policy returns NoSuchEntity", func(t *testing.T) { + userName := uniqueName("err-user") + _, err := iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) + require.NoError(t, err) + + _, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String(userName), + PolicyArn: aws.String("arn:aws:iam:::policy/does-not-exist"), + }) + require.Error(t, err) + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) + + t.Run("AttachUserPolicy with nonexistent user returns NoSuchEntity", func(t *testing.T) { + policyName := uniqueName("err-policy") + _, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), + }) + require.NoError(t, err) + + _, err = iamClient.AttachUserPolicy(&iam.AttachUserPolicyInput{ + UserName: aws.String("nonexistent-user"), + PolicyArn: aws.String(fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)), + }) + require.Error(t, err) + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) + + t.Run("DetachUserPolicy that is not attached returns NoSuchEntity", func(t *testing.T) { + userName := uniqueName("detach-user") + _, err := iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) + require.NoError(t, err) + + policyName := uniqueName("detach-policy") + _, err = iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`), + }) + require.NoError(t, err) + + _, err = iamClient.DetachUserPolicy(&iam.DetachUserPolicyInput{ + UserName: aws.String(userName), + PolicyArn: aws.String(fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)), + }) + require.Error(t, err) + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) + + t.Run("ListAttachedUserPolicies for nonexistent user returns NoSuchEntity", func(t *testing.T) { + _, err := iamClient.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{ + UserName: aws.String("nonexistent-user"), + }) + require.Error(t, err) + var awsErr awserr.Error + require.True(t, errors.As(err, &awsErr)) + require.Equal(t, iam.ErrCodeNoSuchEntityException, awsErr.Code()) + }) +} + func execShell(t *testing.T, weedCmd, master, filer, shellCmd string) string { // weed shell -master=... -filer=... args := []string{"shell", "-master=" + master, "-filer=" + filer} diff --git a/weed/iamapi/iamapi_handlers.go b/weed/iamapi/iamapi_handlers.go index ae63310d9..f959a8b5c 100644 --- a/weed/iamapi/iamapi_handlers.go +++ b/weed/iamapi/iamapi_handlers.go @@ -36,6 +36,8 @@ func writeIamErrorResponse(w http.ResponseWriter, r *http.Request, iamError *Iam s3err.WriteXMLResponse(w, r, http.StatusNotFound, errorResp) case iam.ErrCodeMalformedPolicyDocumentException, iam.ErrCodeInvalidInputException: s3err.WriteXMLResponse(w, r, http.StatusBadRequest, errorResp) + case iam.ErrCodeDeleteConflictException: + s3err.WriteXMLResponse(w, r, http.StatusConflict, errorResp) case iam.ErrCodeServiceFailureException: // We do not want to expose internal server error to the client s3err.WriteXMLResponse(w, r, http.StatusInternalServerError, internalErrorResponse) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 418a266fe..c5f1f20ae 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -41,6 +41,20 @@ const ( var policyLock = sync.RWMutex{} +const policyArnPrefix = "arn:aws:iam:::policy/" + +// parsePolicyArn validates an IAM policy ARN and extracts the policy name. +func parsePolicyArn(policyArn string) (string, *IamError) { + if !strings.HasPrefix(policyArn, policyArnPrefix) { + return "", &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("invalid policy ARN: %s", policyArn)} + } + policyName := strings.TrimPrefix(policyArn, policyArnPrefix) + if policyName == "" { + return "", &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("invalid policy ARN: %s", policyArn)} + } + return policyName, nil +} + // userPolicyKey returns a namespaced key for inline user policies to prevent collision with managed policies. // getOrCreateUserPolicies returns the policy map for a user, creating it if needed. // Returns a pointer to the user's policy map from Policies.InlinePolicies. @@ -234,16 +248,19 @@ func (iama *IamApiServer) CreatePolicy(s3cfg *iam_pb.S3ApiConfiguration, values if err != nil { return CreatePolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} } - policyId := Hash(&policyDocumentString) + policyId := Hash(&policyName) arn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName) resp.CreatePolicyResult.Policy.PolicyName = &policyName resp.CreatePolicyResult.Policy.Arn = &arn resp.CreatePolicyResult.Policy.PolicyId = &policyId policies := Policies{} // Note: Lock is already held by DoActions, no need to acquire here - if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil { + if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} } + if policies.Policies == nil { + policies.Policies = make(map[string]policy_engine.PolicyDocument) + } policies.Policies[policyName] = policyDocument if err = iama.s3ApiConfig.PutPolicies(&policies); err != nil { return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} @@ -265,43 +282,43 @@ func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values if err != nil { return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} } - actions, err := GetActions(&policyDocument) - if err != nil { + if _, err := GetActions(&policyDocument); err != nil { return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} } + // Verify the user exists before persisting the policy + var targetIdent *iam_pb.Identity + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + targetIdent = ident + break + } + } + if targetIdent == nil { + return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)} + } + // Persist inline policy to storage using per-user indexed structure policies := Policies{} if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} } - // Get or create user's policy map userPolicies := policies.getOrCreateUserPolicies(userName) userPolicies[policyName] = policyDocument - // policies.InlinePolicies[userName] now contains the updated map if err = iama.s3ApiConfig.PutPolicies(&policies); err != nil { return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} } - // Compute aggregated actions from all user's inline policies, passing the local policies - // to avoid redundant I/O (reuses the just-written Policies map) - aggregatedActions, computeErr := computeAggregatedActionsForUser(iama, userName, &policies) + // Recompute aggregated actions (inline + managed) + aggregatedActions, computeErr := computeAllActionsForUser(iama, userName, &policies, targetIdent) if computeErr != nil { - glog.Warningf("Failed to compute aggregated actions for user %s: %v", userName, computeErr) - aggregatedActions = actions // Fall back to current policy's actions - } - - glog.V(3).Infof("PutUserPolicy: aggregated actions=%v", aggregatedActions) - for _, ident := range s3cfg.Identities { - if userName != ident.Name { - continue - } - ident.Actions = aggregatedActions - return resp, nil + glog.Warningf("Failed to compute aggregated actions for user %s: %v; keeping existing actions", userName, computeErr) + } else { + targetIdent.Actions = aggregatedActions } - return PutUserPolicyResponse{}, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)} + return resp, nil } func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp GetUserPolicyResponse, err *IamError) { @@ -419,17 +436,247 @@ func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val } } - // Recompute aggregated actions from remaining inline policies (passing policies to avoid redundant GetPolicies) - aggregatedActions, computeErr := computeAggregatedActionsForUser(iama, userName, &policies) + // Recompute aggregated actions from remaining inline + managed policies + aggregatedActions, computeErr := computeAllActionsForUser(iama, userName, &policies, targetIdent) if computeErr != nil { - glog.Warningf("Failed to recompute aggregated actions for user %s: %v", userName, computeErr) + glog.Warningf("Failed to recompute aggregated actions for user %s: %v; keeping existing actions", userName, computeErr) + } else { + targetIdent.Actions = aggregatedActions + } + 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) { + policyArn := values.Get("PolicyArn") + policyName, iamError := parsePolicyArn(policyArn) + if iamError != nil { + return resp, iamError + } + + 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 _, exists := policies.Policies[policyName]; !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + + policyId := Hash(&policyName) + resp.GetPolicyResult.Policy.PolicyName = &policyName + resp.GetPolicyResult.Policy.Arn = &policyArn + resp.GetPolicyResult.Policy.PolicyId = &policyId + return resp, nil +} + +// DeletePolicy removes a managed policy. Rejects deletion if the policy is still attached to any user +// (matching AWS IAM behavior: must detach before deleting). +func (iama *IamApiServer) DeletePolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeletePolicyResponse, iamError *IamError) { + policyArn := values.Get("PolicyArn") + policyName, iamError := parsePolicyArn(policyArn) + if iamError != nil { + return resp, iamError + } + + 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 _, exists := policies.Policies[policyName]; !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + + // Reject deletion if the policy is still attached to any user + for _, ident := range s3cfg.Identities { + for _, name := range ident.PolicyNames { + if name == policyName { + return resp, &IamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("policy %s is still attached to user %s", policyName, ident.Name), + } + } + } + } + + delete(policies.Policies, policyName) + if err := iama.s3ApiConfig.PutPolicies(&policies); err != nil { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} } - // Update the found identity's actions - targetIdent.Actions = aggregatedActions return resp, nil } +// ListPolicies lists all managed policies. +func (iama *IamApiServer) ListPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListPoliciesResponse, iamError *IamError) { + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + + for policyName := range policies.Policies { + name := policyName + arn := fmt.Sprintf("arn:aws:iam:::policy/%s", name) + policyId := Hash(&name) + resp.ListPoliciesResult.Policies = append(resp.ListPoliciesResult.Policies, &iam.Policy{ + PolicyName: &name, + Arn: &arn, + PolicyId: &policyId, + }) + } + return resp, nil +} + +// AttachUserPolicy attaches a managed policy to a user. +func (iama *IamApiServer) AttachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp AttachUserPolicyResponse, iamError *IamError) { + userName := values.Get("UserName") + policyArn := values.Get("PolicyArn") + policyName, iamError := parsePolicyArn(policyArn) + if iamError != nil { + return resp, iamError + } + + // Verify managed policy exists + 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 _, exists := policies.Policies[policyName]; !exists { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + + // Find user and attach policy + for _, ident := range s3cfg.Identities { + if ident.Name != userName { + continue + } + // Check if already attached + for _, name := range ident.PolicyNames { + if name == policyName { + return resp, nil // Already attached, idempotent + } + } + prevPolicyNames := ident.PolicyNames + ident.PolicyNames = append(ident.PolicyNames, policyName) + + // Recompute aggregated actions (inline + managed) + aggregatedActions, err := computeAllActionsForUser(iama, userName, &policies, ident) + if err != nil { + // Roll back PolicyNames to keep identity consistent + ident.PolicyNames = prevPolicyNames + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to compute actions after attaching policy: %w", err)} + } + ident.Actions = aggregatedActions + return resp, nil + } + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)} +} + +// DetachUserPolicy detaches a managed policy from a user. +func (iama *IamApiServer) DetachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DetachUserPolicyResponse, iamError *IamError) { + userName := values.Get("UserName") + policyArn := values.Get("PolicyArn") + policyName, iamError := parsePolicyArn(policyArn) + if iamError != nil { + return resp, iamError + } + + for _, ident := range s3cfg.Identities { + if ident.Name != userName { + continue + } + // Find and remove policy name from the list + prevPolicyNames := make([]string, len(ident.PolicyNames)) + copy(prevPolicyNames, ident.PolicyNames) + + found := false + for i, name := range ident.PolicyNames { + if name == policyName { + ident.PolicyNames = append(ident.PolicyNames[:i], ident.PolicyNames[i+1:]...) + found = true + break + } + } + if !found { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s is not attached to user %s", policyName, userName)} + } + + // Recompute aggregated actions (inline + managed) + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + // Roll back PolicyNames on storage error + ident.PolicyNames = prevPolicyNames + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + aggregatedActions, err := computeAllActionsForUser(iama, userName, &policies, ident) + if err != nil { + // Roll back PolicyNames to keep identity consistent + ident.PolicyNames = prevPolicyNames + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to compute actions after detaching policy: %w", err)} + } + ident.Actions = aggregatedActions + return resp, nil + } + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)} +} + +// ListAttachedUserPolicies lists the managed policies attached to a user. +func (iama *IamApiServer) ListAttachedUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListAttachedUserPoliciesResponse, iamError *IamError) { + userName := values.Get("UserName") + for _, ident := range s3cfg.Identities { + if ident.Name != userName { + continue + } + for _, policyName := range ident.PolicyNames { + name := policyName + arn := fmt.Sprintf("arn:aws:iam:::policy/%s", name) + resp.ListAttachedUserPoliciesResult.AttachedPolicies = append( + resp.ListAttachedUserPoliciesResult.AttachedPolicies, + &iam.AttachedPolicy{PolicyName: &name, PolicyArn: &arn}, + ) + } + return resp, nil + } + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)} +} + +// computeAllActionsForUser computes the union of actions from both inline and managed policies. +func computeAllActionsForUser(iama *IamApiServer, userName string, policies *Policies, ident *iam_pb.Identity) ([]string, error) { + actionSet := make(map[string]bool) + var aggregatedActions []string + + addUniqueActions := func(actions []string) { + for _, action := range actions { + if !actionSet[action] { + actionSet[action] = true + aggregatedActions = append(aggregatedActions, action) + } + } + } + + // Include inline policy actions + inlineActions, err := computeAggregatedActionsForUser(iama, userName, policies) + if err != nil { + return nil, err + } + addUniqueActions(inlineActions) + + // Include managed policy actions + for _, policyName := range ident.PolicyNames { + if policyDoc, exists := policies.Policies[policyName]; exists { + actions, err := GetActions(&policyDoc) + if err != nil { + glog.Warningf("Failed to get actions from managed policy '%s' for user %s: %v", policyName, userName, err) + continue + } + addUniqueActions(actions) + } + } + + return aggregatedActions, nil +} + func GetActions(policy *policy_engine.PolicyDocument) ([]string, error) { var actions []string @@ -708,6 +955,46 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) { writeIamErrorResponse(w, r, iamError) return } + case "GetPolicy": + response, iamError = iama.GetPolicy(s3cfg, values) + if iamError != nil { + writeIamErrorResponse(w, r, iamError) + return + } + changed = false + case "DeletePolicy": + response, iamError = iama.DeletePolicy(s3cfg, values) + if iamError != nil { + writeIamErrorResponse(w, r, iamError) + return + } + changed = false + case "ListPolicies": + response, iamError = iama.ListPolicies(s3cfg, values) + if iamError != nil { + writeIamErrorResponse(w, r, iamError) + return + } + changed = false + case "AttachUserPolicy": + response, iamError = iama.AttachUserPolicy(s3cfg, values) + if iamError != nil { + writeIamErrorResponse(w, r, iamError) + return + } + case "DetachUserPolicy": + response, iamError = iama.DetachUserPolicy(s3cfg, values) + if iamError != nil { + writeIamErrorResponse(w, r, iamError) + return + } + case "ListAttachedUserPolicies": + response, iamError = iama.ListAttachedUserPolicies(s3cfg, values) + if iamError != nil { + writeIamErrorResponse(w, r, iamError) + return + } + changed = false default: errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented) errorResponse := ErrorResponse{} diff --git a/weed/iamapi/iamapi_management_handlers_test.go b/weed/iamapi/iamapi_management_handlers_test.go index 2a814a0e8..c135ded6d 100644 --- a/weed/iamapi/iamapi_management_handlers_test.go +++ b/weed/iamapi/iamapi_management_handlers_test.go @@ -5,6 +5,7 @@ import ( "net/url" "testing" + "github.com/aws/aws-sdk-go/service/iam" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" @@ -261,3 +262,299 @@ func TestMultipleInlinePoliciesAggregateActions(t *testing.T) { assert.True(t, actionSet[expectedAction], "Expected action '%s' not found in aggregated actions. Got: %v", expectedAction, aliceIdent.Actions) } } + +// newTestIamApiServer creates a minimal IamApiServer for unit testing with only s3ApiConfig set. +// Other fields (iam, masterClient, etc.) are left nil — tests must not call code paths that use them. +func newTestIamApiServer(policies Policies) *IamApiServer { + return &IamApiServer{ + s3ApiConfig: &mockIamS3ApiConfig{policies: policies}, + } +} + +func TestGetPolicy(t *testing.T) { + policyDoc := policy_engine.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy_engine.PolicyStatement{ + { + Effect: policy_engine.PolicyEffectAllow, + Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), + Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::*"), + }, + }, + } + iama := newTestIamApiServer(Policies{ + Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc}, + }) + s3cfg := &iam_pb.S3ApiConfiguration{} + + // Success case + values := url.Values{"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}} + resp, iamErr := iama.GetPolicy(s3cfg, values) + assert.Nil(t, iamErr) + assert.Equal(t, "my-policy", *resp.GetPolicyResult.Policy.PolicyName) + assert.Equal(t, "arn:aws:iam:::policy/my-policy", *resp.GetPolicyResult.Policy.Arn) + policyName := "my-policy" + expectedId := Hash(&policyName) + assert.Equal(t, expectedId, *resp.GetPolicyResult.Policy.PolicyId) + + // Not found case + values = url.Values{"PolicyArn": []string{"arn:aws:iam:::policy/nonexistent"}} + _, iamErr = iama.GetPolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) + + // Invalid ARN + values = url.Values{"PolicyArn": []string{"invalid-arn"}} + _, iamErr = iama.GetPolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code) + + // Empty ARN + values = url.Values{"PolicyArn": []string{""}} + _, iamErr = iama.GetPolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeInvalidInputException, iamErr.Code) +} + +func TestDeletePolicy(t *testing.T) { + policyDoc := policy_engine.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy_engine.PolicyStatement{ + { + Effect: policy_engine.PolicyEffectAllow, + Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), + Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::*"), + }, + }, + } + mock := &mockIamS3ApiConfig{policies: Policies{ + Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc}, + }} + iama := &IamApiServer{s3ApiConfig: mock} + + // Reject deletion when policy is attached to a user (AWS-compatible behavior) + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{ + Name: "alice", + PolicyNames: []string{"my-policy"}, + }}, + } + values := url.Values{"PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}} + _, iamErr := iama.DeletePolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeDeleteConflictException, iamErr.Code) + + // Succeed when no users are attached + s3cfgEmpty := &iam_pb.S3ApiConfiguration{} + _, iamErr = iama.DeletePolicy(s3cfgEmpty, values) + assert.Nil(t, iamErr) + + // Verify deleted + _, iamErr = iama.GetPolicy(s3cfgEmpty, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) +} + +func TestListPolicies(t *testing.T) { + iama := newTestIamApiServer(Policies{}) + s3cfg := &iam_pb.S3ApiConfiguration{} + + // Empty case + resp, iamErr := iama.ListPolicies(s3cfg, url.Values{}) + assert.Nil(t, iamErr) + assert.Empty(t, resp.ListPoliciesResult.Policies) + + // Populated case + policyDoc := policy_engine.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy_engine.PolicyStatement{ + { + Effect: policy_engine.PolicyEffectAllow, + Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), + Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::*"), + }, + }, + } + iama = newTestIamApiServer(Policies{ + Policies: map[string]policy_engine.PolicyDocument{ + "policy-a": policyDoc, + "policy-b": policyDoc, + }, + }) + + resp, iamErr = iama.ListPolicies(s3cfg, url.Values{}) + assert.Nil(t, iamErr) + assert.Equal(t, 2, len(resp.ListPoliciesResult.Policies)) + for _, p := range resp.ListPoliciesResult.Policies { + name := *p.PolicyName + expectedId := Hash(&name) + assert.Equal(t, expectedId, *p.PolicyId, "PolicyId should be Hash(policyName) for %s", name) + } +} + +func TestAttachUserPolicy(t *testing.T) { + policyDoc := policy_engine.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy_engine.PolicyStatement{ + { + Effect: policy_engine.PolicyEffectAllow, + Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), + Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::*"), + }, + }, + } + iama := newTestIamApiServer(Policies{ + Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc}, + }) + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{Name: "alice"}}, + } + + // Success case + values := url.Values{ + "UserName": []string{"alice"}, + "PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}, + } + _, iamErr := iama.AttachUserPolicy(s3cfg, values) + assert.Nil(t, iamErr) + assert.Contains(t, s3cfg.Identities[0].PolicyNames, "my-policy") + // Verify actions were computed from the managed policy + assert.Greater(t, len(s3cfg.Identities[0].Actions), 0) + + // Idempotent re-attach + _, iamErr = iama.AttachUserPolicy(s3cfg, values) + assert.Nil(t, iamErr) + // Should still have exactly one entry + count := 0 + for _, name := range s3cfg.Identities[0].PolicyNames { + if name == "my-policy" { + count++ + } + } + assert.Equal(t, 1, count) + + // Policy not found + values = url.Values{ + "UserName": []string{"alice"}, + "PolicyArn": []string{"arn:aws:iam:::policy/nonexistent"}, + } + _, iamErr = iama.AttachUserPolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) + + // User not found + values = url.Values{ + "UserName": []string{"bob"}, + "PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}, + } + _, iamErr = iama.AttachUserPolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) +} + +func TestManagedPolicyActionsPreservedAcrossInlineMutations(t *testing.T) { + managedPolicyDoc := policy_engine.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy_engine.PolicyStatement{ + { + Effect: policy_engine.PolicyEffectAllow, + Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), + Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::*"), + }, + }, + } + iama := newTestIamApiServer(Policies{ + Policies: map[string]policy_engine.PolicyDocument{"my-policy": managedPolicyDoc}, + }) + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{Name: "alice"}}, + } + + // Attach managed policy + _, iamErr := iama.AttachUserPolicy(s3cfg, url.Values{ + "UserName": []string{"alice"}, + "PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}, + }) + assert.Nil(t, iamErr) + assert.Contains(t, s3cfg.Identities[0].Actions, "Read", "Managed policy should grant Read action") + + // Add an inline policy + inlinePolicyJSON := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"arn:aws:s3:::bucket-x/*"}]}` + _, iamErr = iama.PutUserPolicy(s3cfg, url.Values{ + "UserName": []string{"alice"}, + "PolicyName": []string{"inline-write"}, + "PolicyDocument": []string{inlinePolicyJSON}, + }) + assert.Nil(t, iamErr) + + // Should have both managed (Read) and inline (Write:bucket-x/*) actions + actionSet := make(map[string]bool) + for _, a := range s3cfg.Identities[0].Actions { + actionSet[a] = true + } + assert.True(t, actionSet["Read"], "Managed policy Read action should persist after PutUserPolicy") + assert.True(t, actionSet["Write:bucket-x/*"], "Inline policy Write action should be present") + + // Delete the inline policy + _, iamErr = iama.DeleteUserPolicy(s3cfg, url.Values{ + "UserName": []string{"alice"}, + "PolicyName": []string{"inline-write"}, + }) + assert.Nil(t, iamErr) + + // Managed policy actions should still be present + assert.Contains(t, s3cfg.Identities[0].PolicyNames, "my-policy", "Managed policy should still be attached") + assert.Contains(t, s3cfg.Identities[0].Actions, "Read", "Managed policy Read action should persist after DeleteUserPolicy") +} + +func TestDetachUserPolicy(t *testing.T) { + policyDoc := policy_engine.PolicyDocument{ + Version: "2012-10-17", + Statement: []policy_engine.PolicyStatement{ + { + Effect: policy_engine.PolicyEffectAllow, + Action: policy_engine.NewStringOrStringSlice("s3:GetObject"), + Resource: policy_engine.NewStringOrStringSlice("arn:aws:s3:::*"), + }, + }, + } + iama := newTestIamApiServer(Policies{ + Policies: map[string]policy_engine.PolicyDocument{"my-policy": policyDoc}, + }) + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{Name: "alice", PolicyNames: []string{"my-policy"}}}, + } + + values := url.Values{ + "UserName": []string{"alice"}, + "PolicyArn": []string{"arn:aws:iam:::policy/my-policy"}, + } + _, iamErr := iama.DetachUserPolicy(s3cfg, values) + assert.Nil(t, iamErr) + assert.Empty(t, s3cfg.Identities[0].PolicyNames) + + // Detach again should fail (not attached) + _, iamErr = iama.DetachUserPolicy(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) +} + +func TestListAttachedUserPolicies(t *testing.T) { + iama := newTestIamApiServer(Policies{}) + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{Name: "alice", PolicyNames: []string{"policy-a", "policy-b"}}}, + } + + values := url.Values{"UserName": []string{"alice"}} + resp, iamErr := iama.ListAttachedUserPolicies(s3cfg, values) + assert.Nil(t, iamErr) + assert.Equal(t, 2, len(resp.ListAttachedUserPoliciesResult.AttachedPolicies)) + assert.Equal(t, "policy-a", *resp.ListAttachedUserPoliciesResult.AttachedPolicies[0].PolicyName) + assert.Equal(t, "arn:aws:iam:::policy/policy-a", *resp.ListAttachedUserPoliciesResult.AttachedPolicies[0].PolicyArn) + + // User not found + values = url.Values{"UserName": []string{"bob"}} + _, iamErr = iama.ListAttachedUserPolicies(s3cfg, values) + assert.NotNil(t, iamErr) + assert.Equal(t, iam.ErrCodeNoSuchEntityException, iamErr.Code) +} diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index d2c1df996..ea596655d 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -22,8 +22,14 @@ type ( UpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse PutUserPolicyResponse = iamlib.PutUserPolicyResponse DeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse - GetUserPolicyResponse = iamlib.GetUserPolicyResponse - ErrorResponse = iamlib.ErrorResponse + GetUserPolicyResponse = iamlib.GetUserPolicyResponse + GetPolicyResponse = iamlib.GetPolicyResponse + DeletePolicyResponse = iamlib.DeletePolicyResponse + ListPoliciesResponse = iamlib.ListPoliciesResponse + AttachUserPolicyResponse = iamlib.AttachUserPolicyResponse + DetachUserPolicyResponse = iamlib.DetachUserPolicyResponse + ListAttachedUserPoliciesResponse = iamlib.ListAttachedUserPoliciesResponse + ErrorResponse = iamlib.ErrorResponse ServiceAccountInfo = iamlib.ServiceAccountInfo CreateServiceAccountResponse = iamlib.CreateServiceAccountResponse DeleteServiceAccountResponse = iamlib.DeleteServiceAccountResponse