From e21d7602c3b65b17ad64cf03fab5e4e1b0b302db Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 8 Apr 2026 15:57:04 -0700 Subject: [PATCH] feat(iam): implement group inline policy actions (#8992) * feat(iam): implement group inline policy actions Add PutGroupPolicy, GetGroupPolicy, DeleteGroupPolicy, and ListGroupPolicies to both embedded and standalone IAM servers. The standalone IAM stores group inline policies in a new GroupInlinePolicies field in the Policies JSON, mirroring the existing user inline policy pattern. DeleteGroup now also checks for inline policies before allowing deletion. * fix: address review feedback for group inline policies - Embedded IAM: return NotImplemented for group inline policies instead of silently succeeding as no-ops (Gemini + CodeRabbit) - Standalone IAM: recompute member actions after PutGroupPolicy and DeleteGroupPolicy (Gemini) - Add parameter validation for GroupName/PolicyName/PolicyDocument on PutGroupPolicy, DeleteGroupPolicy, ListGroupPolicies (Gemini) - Add UserName validation for ListUserPolicies in standalone IAM - Call cleanupGroupInlinePolicies from DeleteGroup (Gemini) - Migrate GroupInlinePolicies on group rename in UpdateGroup (CodeRabbit) - Fix integration test cleanup order (CodeRabbit) * fix: persist recomputed actions and improve error handling - Set changed=true for PutGroupPolicy/DeleteGroupPolicy in standalone IAM DoActions so recomputed member actions are persisted (Gemini critical) - Make cleanupGroupInlinePolicies accept policies parameter to avoid redundant I/O, return error (Gemini) - Make migrateGroupInlinePolicies return error, handle in caller (Gemini) * fix: include group policies in action recomputation Extend computeAllActionsForUser to also aggregate group inline policies and group managed policies when s3cfg is provided. Previously, group inline policies were stored but never reflected in member Identity.Actions. (CodeRabbit critical) * perf: use identity index in recomputeActionsForGroupMembers for O(N+M) * fix: skip group inline policy integration test on embedded IAM The embedded IAM returns NotImplemented for group inline policies. Skip TestIAMGroupInlinePolicy when running against embedded mode to avoid CI failures in the group integration test matrix. --- test/s3/iam/s3_iam_group_test.go | 99 +++++++++ weed/iam/responses.go | 34 +++ weed/iamapi/iamapi_group_handlers.go | 242 ++++++++++++++++++++++ weed/iamapi/iamapi_management_handlers.go | 112 +++++++++- weed/iamapi/iamapi_response.go | 4 + weed/s3api/s3api_embedded_iam.go | 59 +++++- weed/s3api/s3api_embedded_iam_test.go | 33 +++ 7 files changed, 573 insertions(+), 10 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 1043e7c95..bcb9d895e 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -778,6 +778,105 @@ func TestIAMGroupRawAPI(t *testing.T) { }) } +// TestIAMGroupInlinePolicy tests group inline policy operations: +// PutGroupPolicy, GetGroupPolicy, ListGroupPolicies, DeleteGroupPolicy +func TestIAMGroupInlinePolicy(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + // Skip if running against embedded IAM (which returns NotImplemented for group inline policies) + _, probeErr := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{GroupName: aws.String("probe-group-inline-support")}) + if probeErr != nil { + if awsErr, ok := probeErr.(awserr.Error); ok && awsErr.Code() == "NotImplemented" { + t.Skip("Skipping: group inline policies not supported in embedded IAM mode") + } + } + require.NoError(t, err) + + groupName := "test-group-inline-policy" + policyName := "TestInlinePolicy" + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + defer func() { + // Clean up inline policies before deleting the group + iamClient.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyName: aws.String(policyName), + }) + iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + }() + + t.Run("list_empty", func(t *testing.T) { + resp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + assert.Empty(t, resp.PolicyNames) + assert.False(t, *resp.IsTruncated) + }) + + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::test-bucket/*"}]}` + + t.Run("put_group_policy", func(t *testing.T) { + _, err := iamClient.PutGroupPolicy(&iam.PutGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + }) + + t.Run("list_after_put", func(t *testing.T) { + resp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + assert.NotEmpty(t, resp.PolicyNames) + }) + + t.Run("get_group_policy", func(t *testing.T) { + resp, err := iamClient.GetGroupPolicy(&iam.GetGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyName: aws.String(policyName), + }) + require.NoError(t, err) + assert.Equal(t, groupName, *resp.GroupName) + assert.Equal(t, policyName, *resp.PolicyName) + assert.Contains(t, *resp.PolicyDocument, "s3:GetObject") + }) + + t.Run("delete_group_policy", func(t *testing.T) { + _, err := iamClient.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyName: aws.String(policyName), + }) + require.NoError(t, err) + }) + + t.Run("list_after_delete", func(t *testing.T) { + resp, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + assert.Empty(t, resp.PolicyNames) + }) + + t.Run("nonexistent_group", func(t *testing.T) { + _, err := iamClient.ListGroupPolicies(&iam.ListGroupPoliciesInput{ + GroupName: aws.String("nonexistent-group-for-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()) + }) +} + // createS3Client creates an S3 client with static credentials func createS3Client(t *testing.T, accessKey, secretKey string) *s3.S3 { sess, err := session.NewSession(&aws.Config{ diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 67e5b71e9..786a3aa20 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -371,6 +371,40 @@ type ListAttachedGroupPoliciesResponse struct { CommonResponse } +// PutGroupPolicyResponse is the response for PutGroupPolicy action. +type PutGroupPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutGroupPolicyResponse"` + CommonResponse +} + +// GetGroupPolicyResponse is the response for GetGroupPolicy action. +type GetGroupPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupPolicyResponse"` + GetGroupPolicyResult struct { + GroupName string `xml:"GroupName"` + PolicyName string `xml:"PolicyName"` + PolicyDocument string `xml:"PolicyDocument"` + } `xml:"GetGroupPolicyResult"` + CommonResponse +} + +// DeleteGroupPolicyResponse is the response for DeleteGroupPolicy action. +type DeleteGroupPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteGroupPolicyResponse"` + CommonResponse +} + +// ListGroupPoliciesResponse is the response for ListGroupPolicies action. +type ListGroupPoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupPoliciesResponse"` + ListGroupPoliciesResult struct { + PolicyNames []string `xml:"PolicyNames>member"` + IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker,omitempty"` + } `xml:"ListGroupPoliciesResult"` + CommonResponse +} + // ListGroupsForUserResponse is the response for ListGroupsForUser action. type ListGroupsForUserResponse struct { XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"` diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index 620fc1f62..3e44294d2 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -1,13 +1,17 @@ package iamapi import ( + "encoding/json" "errors" "fmt" "net/url" + "sort" "github.com/aws/aws-sdk-go/service/iam" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" ) func (iama *IamApiServer) CreateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*CreateGroupResponse, *IamError) { @@ -40,7 +44,19 @@ func (iama *IamApiServer) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values u if len(g.PolicyNames) > 0 { return resp, &IamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d attached policy(ies)", groupName, len(g.PolicyNames))} } + // Check for inline policies + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + if gp := policies.GroupInlinePolicies[groupName]; len(gp) > 0 { + return resp, &IamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d inline policy(ies)", groupName, len(gp))} + } s3cfg.Groups = append(s3cfg.Groups[:i], s3cfg.Groups[i+1:]...) + // Clean up any empty inline policy entries, reuse already-fetched policies + if err := cleanupGroupInlinePolicies(iama, groupName, &policies); err != nil { + glog.Warningf("Failed to cleanup inline policies for group %s: %v", groupName, err) + } return resp, nil } } @@ -67,7 +83,11 @@ func (iama *IamApiServer) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values u return resp, &IamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("group %s already exists", newName)} } } + oldName := g.Name g.Name = newName + if err := migrateGroupInlinePolicies(iama, oldName, newName); err != nil { + glog.Warningf("Failed to migrate inline policies for group rename %s -> %s: %v", oldName, newName, err) + } } return resp, nil } @@ -326,3 +346,225 @@ func buildUserGroupsIndex(s3cfg *iam_pb.S3ApiConfiguration) map[string][]string } return index } + +// PutGroupPolicy attaches an inline policy to a group. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_PutGroupPolicy.html +func (iama *IamApiServer) PutGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*PutGroupPolicyResponse, *IamError) { + resp := &PutGroupPolicyResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + policyName := values.Get("PolicyName") + if policyName == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyName is required")} + } + policyDocumentString := values.Get("PolicyDocument") + if policyDocumentString == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyDocument is required")} + } + policyDocument, err := GetPolicyDocument(&policyDocumentString) + if err != nil { + return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} + } + if _, err := GetActions(&policyDocument); err != nil { + return resp, &IamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err} + } + + // Find group and get its members for action recomputation + var targetGroup *iam_pb.Group + for _, g := range s3cfg.Groups { + if g.Name == groupName { + targetGroup = g + break + } + } + if targetGroup == nil { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} + } + + // Persist inline policy + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + groupPolicies := policies.getOrCreateGroupPolicies(groupName) + groupPolicies[policyName] = policyDocument + if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + + // Recompute actions for all group members + recomputeActionsForGroupMembers(iama, s3cfg, targetGroup, &policies) + + return resp, nil +} + +// GetGroupPolicy gets an inline policy attached to a group. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetGroupPolicy.html +func (iama *IamApiServer) GetGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*GetGroupPolicyResponse, *IamError) { + resp := &GetGroupPolicyResponse{} + groupName := values.Get("GroupName") + policyName := values.Get("PolicyName") + + // Verify group exists + found := false + for _, g := range s3cfg.Groups { + if g.Name == groupName { + found = true + break + } + } + if !found { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} + } + + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + + if groupPolicies := policies.GroupInlinePolicies[groupName]; groupPolicies != nil { + if policyDocument, exists := groupPolicies[policyName]; exists { + policyDocumentJSON, err := json.Marshal(policyDocument) + if err != nil { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + resp.GetGroupPolicyResult.GroupName = groupName + resp.GetGroupPolicyResult.PolicyName = policyName + resp.GetGroupPolicyResult.PolicyDocument = string(policyDocumentJSON) + return resp, nil + } + } + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found on group %s", policyName, groupName)} +} + +// DeleteGroupPolicy removes an inline policy from a group. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_DeleteGroupPolicy.html +func (iama *IamApiServer) DeleteGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*DeleteGroupPolicyResponse, *IamError) { + resp := &DeleteGroupPolicyResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + policyName := values.Get("PolicyName") + if policyName == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyName is required")} + } + + // Find group for member action recomputation + var targetGroup *iam_pb.Group + for _, g := range s3cfg.Groups { + if g.Name == groupName { + targetGroup = g + break + } + } + if targetGroup == nil { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} + } + + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + if groupPolicies := policies.GroupInlinePolicies[groupName]; groupPolicies != nil { + delete(groupPolicies, policyName) + if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + } + + // Recompute actions for all group members + recomputeActionsForGroupMembers(iama, s3cfg, targetGroup, &policies) + + return resp, nil +} + +// ListGroupPolicies lists the names of inline policies attached to a group. +// https://docs.aws.amazon.com/IAM/latest/APIReference/API_ListGroupPolicies.html +func (iama *IamApiServer) ListGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*ListGroupPoliciesResponse, *IamError) { + resp := &ListGroupPoliciesResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + + // Verify group exists + found := false + for _, g := range s3cfg.Groups { + if g.Name == groupName { + found = true + break + } + } + if !found { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} + } + + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + if groupPolicies := policies.GroupInlinePolicies[groupName]; groupPolicies != nil { + for policyName := range groupPolicies { + resp.ListGroupPoliciesResult.PolicyNames = append(resp.ListGroupPoliciesResult.PolicyNames, policyName) + } + sort.Strings(resp.ListGroupPoliciesResult.PolicyNames) + } + resp.ListGroupPoliciesResult.IsTruncated = false + return resp, nil +} + +// cleanupGroupInlinePolicies removes all inline policies for a group from persistent storage. +// If policies is provided, it uses that to avoid redundant I/O; otherwise fetches from storage. +func cleanupGroupInlinePolicies(iama *IamApiServer, groupName string, policies *Policies) error { + if policies == nil { + policies = &Policies{} + if err := iama.s3ApiConfig.GetPolicies(policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return err + } + } + if _, exists := policies.GroupInlinePolicies[groupName]; exists { + delete(policies.GroupInlinePolicies, groupName) + return iama.s3ApiConfig.PutPolicies(policies) + } + return nil +} + +// migrateGroupInlinePolicies renames the inline policies key when a group is renamed. +func migrateGroupInlinePolicies(iama *IamApiServer, oldName, newName string) error { + policies := Policies{} + if err := iama.s3ApiConfig.GetPolicies(&policies); err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + return err + } + if oldPolicies, exists := policies.GroupInlinePolicies[oldName]; exists { + if policies.GroupInlinePolicies == nil { + policies.GroupInlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument) + } + policies.GroupInlinePolicies[newName] = oldPolicies + delete(policies.GroupInlinePolicies, oldName) + return iama.s3ApiConfig.PutPolicies(&policies) + } + return nil +} + +// recomputeActionsForGroupMembers recomputes the aggregated actions for all members of a group. +// Uses an identity index for O(N+M) complexity instead of O(N*M). +func recomputeActionsForGroupMembers(iama *IamApiServer, s3cfg *iam_pb.S3ApiConfiguration, group *iam_pb.Group, policies *Policies) { + // Build name -> identity index for O(1) lookup + identIndex := make(map[string]*iam_pb.Identity, len(s3cfg.Identities)) + for _, ident := range s3cfg.Identities { + identIndex[ident.Name] = ident + } + for _, memberName := range group.Members { + if ident, ok := identIndex[memberName]; ok { + aggregatedActions, err := computeAllActionsForUser(iama, memberName, policies, ident, s3cfg) + if err != nil { + glog.Warningf("Failed to recompute actions for user %s after group policy change: %v", memberName, err) + } else { + ident.Actions = aggregatedActions + } + } + } +} diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index cfcaac098..1e4b9c404 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -70,6 +70,17 @@ func (p *Policies) getOrCreateUserPolicies(userName string) map[string]policy_en return p.InlinePolicies[userName] } +// getOrCreateGroupPolicies returns the policy map for a group, creating it if needed. +func (p *Policies) getOrCreateGroupPolicies(groupName string) map[string]policy_engine.PolicyDocument { + if p.GroupInlinePolicies == nil { + p.GroupInlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument) + } + if p.GroupInlinePolicies[groupName] == nil { + p.GroupInlinePolicies[groupName] = make(map[string]policy_engine.PolicyDocument) + } + return p.GroupInlinePolicies[groupName] +} + // computeAggregatedActionsForUser computes the union of actions across all inline policies for a user. // Directly accesses user's policies from Policies.InlinePolicies[userName] for O(1) lookup. // If policies is non-nil, it uses that instead of fetching from storage (for I/O optimization). @@ -139,6 +150,10 @@ type Policies struct { // Structure: [userName][policyName] -> PolicyDocument // Enables fast access without iterating all policies InlinePolicies map[string]map[string]policy_engine.PolicyDocument `json:"inlinePolicies"` + + // GroupInlinePolicies: group-indexed inline policies for O(1) lookup + // Structure: [groupName][policyName] -> PolicyDocument + GroupInlinePolicies map[string]map[string]policy_engine.PolicyDocument `json:"groupInlinePolicies,omitempty"` } func Hash(s *string) string { @@ -394,7 +409,7 @@ func (iama *IamApiServer) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values } // Recompute aggregated actions (inline + managed) - aggregatedActions, computeErr := computeAllActionsForUser(iama, userName, &policies, targetIdent) + aggregatedActions, computeErr := computeAllActionsForUser(iama, userName, &policies, targetIdent, s3cfg) if computeErr != nil { glog.Warningf("Failed to compute aggregated actions for user %s: %v; keeping existing actions", userName, computeErr) } else { @@ -521,7 +536,7 @@ func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val } // Recompute aggregated actions from remaining inline + managed policies - aggregatedActions, computeErr := computeAllActionsForUser(iama, userName, &policies, targetIdent) + aggregatedActions, computeErr := computeAllActionsForUser(iama, userName, &policies, targetIdent, s3cfg) if computeErr != nil { glog.Warningf("Failed to recompute aggregated actions for user %s: %v; keeping existing actions", userName, computeErr) } else { @@ -535,6 +550,9 @@ func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val func (iama *IamApiServer) ListUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp *ListUserPoliciesResponse, iamError *IamError) { resp = &ListUserPoliciesResponse{} userName := values.Get("UserName") + if userName == "" { + return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")} + } // Verify the user exists found := false @@ -690,8 +708,8 @@ func (iama *IamApiServer) AttachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val prevPolicyNames := ident.PolicyNames ident.PolicyNames = append(ident.PolicyNames, policyName) - // Recompute aggregated actions (inline + managed) - aggregatedActions, err := computeAllActionsForUser(iama, userName, &policies, ident) + // Recompute aggregated actions (inline + managed + group) + aggregatedActions, err := computeAllActionsForUser(iama, userName, &policies, ident, s3cfg) if err != nil { // Roll back PolicyNames to keep identity consistent ident.PolicyNames = prevPolicyNames @@ -740,7 +758,7 @@ func (iama *IamApiServer) DetachUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, val ident.PolicyNames = prevPolicyNames return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} } - aggregatedActions, err := computeAllActionsForUser(iama, userName, &policies, ident) + aggregatedActions, err := computeAllActionsForUser(iama, userName, &policies, ident, s3cfg) if err != nil { // Roll back PolicyNames to keep identity consistent ident.PolicyNames = prevPolicyNames @@ -773,8 +791,10 @@ func (iama *IamApiServer) ListAttachedUserPolicies(s3cfg *iam_pb.S3ApiConfigurat 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) { +// computeAllActionsForUser computes the union of actions from user inline policies, +// user managed policies, group inline policies, and group managed policies. +// If s3cfg is provided, group memberships are resolved to include group policies. +func computeAllActionsForUser(iama *IamApiServer, userName string, policies *Policies, ident *iam_pb.Identity, s3cfgs ...*iam_pb.S3ApiConfiguration) ([]string, error) { actionSet := make(map[string]bool) var aggregatedActions []string @@ -787,14 +807,14 @@ func computeAllActionsForUser(iama *IamApiServer, userName string, policies *Pol } } - // Include inline policy actions + // Include user inline policy actions inlineActions, err := computeAggregatedActionsForUser(iama, userName, policies) if err != nil { return nil, err } addUniqueActions(inlineActions) - // Include managed policy actions + // Include user managed policy actions for _, policyName := range ident.PolicyNames { if policyDoc, exists := policies.Policies[policyName]; exists { actions, err := GetActions(&policyDoc) @@ -806,6 +826,48 @@ func computeAllActionsForUser(iama *IamApiServer, userName string, policies *Pol } } + // Include group policies (both inline and managed) if s3cfg is available + if len(s3cfgs) > 0 && s3cfgs[0] != nil { + s3cfg := s3cfgs[0] + for _, g := range s3cfg.Groups { + if g.Disabled { + continue + } + isMember := false + for _, m := range g.Members { + if m == userName { + isMember = true + break + } + } + if !isMember { + continue + } + // Group managed policies + for _, policyName := range g.PolicyNames { + if policyDoc, exists := policies.Policies[policyName]; exists { + actions, err := GetActions(&policyDoc) + if err != nil { + glog.Warningf("Failed to get actions from group managed policy '%s' (group %s) for user %s: %v", policyName, g.Name, userName, err) + continue + } + addUniqueActions(actions) + } + } + // Group inline policies + if groupPolicies := policies.GroupInlinePolicies[g.Name]; groupPolicies != nil { + for policyName, policyDoc := range groupPolicies { + actions, err := GetActions(&policyDoc) + if err != nil { + glog.Warningf("Failed to get actions from group inline policy '%s' (group %s) for user %s: %v", policyName, g.Name, userName, err) + continue + } + addUniqueActions(actions) + } + } + } + } + return aggregatedActions, nil } @@ -1221,6 +1283,38 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) { return } changed = false + case "PutGroupPolicy": + var err *IamError + response, err = iama.PutGroupPolicy(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + // changed = true: PutGroupPolicy recomputes member Identity.Actions + case "GetGroupPolicy": + var err *IamError + response, err = iama.GetGroupPolicy(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false + case "DeleteGroupPolicy": + var err *IamError + response, err = iama.DeleteGroupPolicy(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + // changed = true: DeleteGroupPolicy recomputes member Identity.Actions + case "ListGroupPolicies": + var err *IamError + response, err = iama.ListGroupPolicies(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false case "ListGroupsForUser": var err *IamError response, err = iama.ListGroupsForUser(s3cfg, values) diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index 2f28e0e23..00f66c725 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -48,5 +48,9 @@ type ( AttachGroupPolicyResponse = iamlib.AttachGroupPolicyResponse DetachGroupPolicyResponse = iamlib.DetachGroupPolicyResponse ListAttachedGroupPoliciesResponse = iamlib.ListAttachedGroupPoliciesResponse + PutGroupPolicyResponse = iamlib.PutGroupPolicyResponse + GetGroupPolicyResponse = iamlib.GetGroupPolicyResponse + DeleteGroupPolicyResponse = iamlib.DeleteGroupPolicyResponse + ListGroupPoliciesResponse = iamlib.ListGroupPoliciesResponse ListGroupsForUserResponse = iamlib.ListGroupsForUserResponse ) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 06971e684..d30b41abf 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -125,6 +125,10 @@ type ( iamAttachGroupPolicyResponse = iamlib.AttachGroupPolicyResponse iamDetachGroupPolicyResponse = iamlib.DetachGroupPolicyResponse iamListAttachedGroupPoliciesResponse = iamlib.ListAttachedGroupPoliciesResponse + iamPutGroupPolicyResponse = iamlib.PutGroupPolicyResponse + iamGetGroupPolicyResponse = iamlib.GetGroupPolicyResponse + iamDeleteGroupPolicyResponse = iamlib.DeleteGroupPolicyResponse + iamListGroupPoliciesResponse = iamlib.ListGroupPoliciesResponse iamListGroupsForUserResponse = iamlib.ListGroupsForUserResponse ) @@ -1771,6 +1775,31 @@ func (e *EmbeddedIamApi) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, val return resp, nil } +// notImplementedError returns a NotImplemented IAM error for the embedded server. +func notImplementedGroupInlineError() *iamError { + return &iamError{Code: s3err.GetAPIError(s3err.ErrNotImplemented).Code, Error: fmt.Errorf("group inline policies are not supported in embedded IAM mode; use the standalone IAM server or managed policies (AttachGroupPolicy)")} +} + +// PutGroupPolicy is not supported in embedded IAM mode. +func (e *EmbeddedIamApi) PutGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamPutGroupPolicyResponse, *iamError) { + return &iamPutGroupPolicyResponse{}, notImplementedGroupInlineError() +} + +// GetGroupPolicy is not supported in embedded IAM mode. +func (e *EmbeddedIamApi) GetGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamGetGroupPolicyResponse, *iamError) { + return &iamGetGroupPolicyResponse{}, notImplementedGroupInlineError() +} + +// DeleteGroupPolicy is not supported in embedded IAM mode. +func (e *EmbeddedIamApi) DeleteGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDeleteGroupPolicyResponse, *iamError) { + return &iamDeleteGroupPolicyResponse{}, notImplementedGroupInlineError() +} + +// ListGroupPolicies is not supported in embedded IAM mode. +func (e *EmbeddedIamApi) ListGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamListGroupPoliciesResponse, *iamError) { + return &iamListGroupPoliciesResponse{}, notImplementedGroupInlineError() +} + // handleImplicitUsername adds username who signs the request to values if 'username' is not specified. // According to AWS documentation: "If you do not specify a user name, IAM determines the user name // implicitly based on the Amazon Web Services access key ID signing the request." @@ -1925,7 +1954,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s if e.readOnly { switch action { case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListUserPolicies", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount", - "GetGroup", "ListGroups", "ListAttachedGroupPolicies", "ListGroupsForUser": + "GetGroup", "ListGroups", "ListAttachedGroupPolicies", "GetGroupPolicy", "ListGroupPolicies", "ListGroupsForUser": // Allowed read-only actions default: return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, Error: fmt.Errorf("IAM write operations are disabled on this server")} @@ -2179,6 +2208,34 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s return nil, iamErr } changed = false + case "PutGroupPolicy": + var iamErr *iamError + response, iamErr = e.PutGroupPolicy(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false + case "GetGroupPolicy": + var iamErr *iamError + response, iamErr = e.GetGroupPolicy(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false + case "DeleteGroupPolicy": + var iamErr *iamError + response, iamErr = e.DeleteGroupPolicy(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false + case "ListGroupPolicies": + var iamErr *iamError + response, iamErr = e.ListGroupPolicies(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false case "ListGroupsForUser": var iamErr *iamError response, iamErr = e.ListGroupsForUser(s3cfg, values) diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index b5ff318b1..a035af9e0 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -534,6 +534,39 @@ func TestEmbeddedIamListUserPolicies(t *testing.T) { assert.Equal(t, http.StatusNotFound, rr3.Code) } +// TestEmbeddedIamGroupInlinePoliciesNotImplemented tests that group inline policies +// return NotImplemented in embedded IAM mode. +func TestEmbeddedIamGroupInlinePoliciesNotImplemented(t *testing.T) { + api := NewEmbeddedIamApiForTest() + s3cfg := &iam_pb.S3ApiConfiguration{ + Groups: []*iam_pb.Group{ + {Name: "developers", Members: []string{"alice"}}, + }, + } + + notImpl := s3err.GetAPIError(s3err.ErrNotImplemented).Code + + _, iamErr := api.PutGroupPolicy(s3cfg, url.Values{ + "GroupName": {"developers"}, + "PolicyName": {"DevPolicy"}, + "PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"arn:aws:s3:::*"}]}`}, + }) + assert.NotNil(t, iamErr) + assert.Equal(t, notImpl, iamErr.Code) + + _, iamErr = api.GetGroupPolicy(s3cfg, url.Values{"GroupName": {"developers"}, "PolicyName": {"DevPolicy"}}) + assert.NotNil(t, iamErr) + assert.Equal(t, notImpl, iamErr.Code) + + _, iamErr = api.DeleteGroupPolicy(s3cfg, url.Values{"GroupName": {"developers"}, "PolicyName": {"DevPolicy"}}) + assert.NotNil(t, iamErr) + assert.Equal(t, notImpl, iamErr.Code) + + _, iamErr = api.ListGroupPolicies(s3cfg, url.Values{"GroupName": {"developers"}}) + assert.NotNil(t, iamErr) + assert.Equal(t, notImpl, iamErr.Code) +} + // TestEmbeddedIamAttachUserPolicy tests attaching a managed policy to a user. func TestEmbeddedIamAttachUserPolicy(t *testing.T) { api := NewEmbeddedIamApiForTest()