diff --git a/weed/credential/credential_manager.go b/weed/credential/credential_manager.go index a53015c2a..190260b3e 100644 --- a/weed/credential/credential_manager.go +++ b/weed/credential/credential_manager.go @@ -271,6 +271,40 @@ func (cm *CredentialManager) UpdatePolicy(ctx context.Context, name string, docu return cm.Store.PutPolicy(ctx, name, document) } +// PutUserInlinePolicy stores a per-user inline policy document. +// Returns nil without error if the underlying store does not support inline policies. +func (cm *CredentialManager) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error { + if store, ok := cm.Store.(InlinePolicyStore); ok { + return store.PutUserInlinePolicy(ctx, userName, policyName, document) + } + return nil +} + +// GetUserInlinePolicy retrieves a per-user inline policy document. +// Returns nil without error if the underlying store does not support inline policies. +func (cm *CredentialManager) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) { + if store, ok := cm.Store.(InlinePolicyStore); ok { + return store.GetUserInlinePolicy(ctx, userName, policyName) + } + return nil, nil +} + +// DeleteUserInlinePolicy removes a per-user inline policy document. +func (cm *CredentialManager) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error { + if store, ok := cm.Store.(InlinePolicyStore); ok { + return store.DeleteUserInlinePolicy(ctx, userName, policyName) + } + return nil +} + +// ListUserInlinePolicies returns the names of all inline policies for a user. +func (cm *CredentialManager) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) { + if store, ok := cm.Store.(InlinePolicyStore); ok { + return store.ListUserInlinePolicies(ctx, userName) + } + return nil, nil +} + // LoadS3ConfigFile reads a static S3 identity config file and registers // the identities so they appear in LoadConfiguration and listing results. func (cm *CredentialManager) LoadS3ConfigFile(path string) error { diff --git a/weed/credential/credential_store.go b/weed/credential/credential_store.go index f7972e78b..9c8e44461 100644 --- a/weed/credential/credential_store.go +++ b/weed/credential/credential_store.go @@ -137,5 +137,16 @@ type PolicyManager interface { GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error) } +// InlinePolicyStore is an optional interface for credential stores that support +// per-user inline policy storage. Stores that implement this interface preserve +// the exact policy document submitted via PutUserPolicy, enabling lossless +// round-trips through GetUserPolicy. +type InlinePolicyStore interface { + PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error + GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) + DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error + ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) +} + // Stores holds all available credential store implementations var Stores []CredentialStore diff --git a/weed/credential/filer_etc/filer_etc_policy.go b/weed/credential/filer_etc/filer_etc_policy.go index 98cf1e721..2c632ea42 100644 --- a/weed/credential/filer_etc/filer_etc_policy.go +++ b/weed/credential/filer_etc/filer_etc_policy.go @@ -316,6 +316,69 @@ func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*policy return nil, nil // Policy not found } +// PutUserInlinePolicy stores a per-user inline policy document. +func (store *FilerEtcStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error { + store.policyMu.Lock() + defer store.policyMu.Unlock() + + policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx) + if err != nil { + return err + } + + if policiesCollection.InlinePolicies[userName] == nil { + policiesCollection.InlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument) + } + policiesCollection.InlinePolicies[userName][policyName] = document + return store.saveLegacyPoliciesCollection(ctx, policiesCollection) +} + +// GetUserInlinePolicy retrieves a per-user inline policy document. +func (store *FilerEtcStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) { + policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx) + if err != nil { + return nil, err + } + if userPolicies := policiesCollection.InlinePolicies[userName]; userPolicies != nil { + if doc, exists := userPolicies[policyName]; exists { + return &doc, nil + } + } + return nil, nil +} + +// DeleteUserInlinePolicy removes a per-user inline policy document. +func (store *FilerEtcStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error { + store.policyMu.Lock() + defer store.policyMu.Unlock() + + policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx) + if err != nil { + return err + } + if userPolicies := policiesCollection.InlinePolicies[userName]; userPolicies != nil { + delete(userPolicies, policyName) + if len(userPolicies) == 0 { + delete(policiesCollection.InlinePolicies, userName) + } + } + return store.saveLegacyPoliciesCollection(ctx, policiesCollection) +} + +// ListUserInlinePolicies returns the names of all inline policies for a user. +func (store *FilerEtcStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) { + policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx) + if err != nil { + return nil, err + } + userPolicies := policiesCollection.InlinePolicies[userName] + names := make([]string, 0, len(userPolicies)) + for name := range userPolicies { + names = append(names, name) + } + return names, nil +} + // ListPolicyNames returns all managed policy names stored in the filer. func (store *FilerEtcStore) ListPolicyNames(ctx context.Context) ([]string, error) { names := make([]string, 0) diff --git a/weed/credential/memory/memory_policy.go b/weed/credential/memory/memory_policy.go index a86cb0af6..228b09844 100644 --- a/weed/credential/memory/memory_policy.go +++ b/weed/credential/memory/memory_policy.go @@ -105,3 +105,71 @@ func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error { delete(store.policies, name) return nil } + +// PutUserInlinePolicy stores a per-user inline policy document. +func (store *MemoryStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + if store.inlinePolicies[userName] == nil { + store.inlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument) + } + store.inlinePolicies[userName][policyName] = document + return nil +} + +// GetUserInlinePolicy retrieves a per-user inline policy document. +func (store *MemoryStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + if userPolicies := store.inlinePolicies[userName]; userPolicies != nil { + if doc, exists := userPolicies[policyName]; exists { + return &doc, nil + } + } + return nil, nil +} + +// DeleteUserInlinePolicy removes a per-user inline policy document. +func (store *MemoryStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error { + store.mu.Lock() + defer store.mu.Unlock() + + if !store.initialized { + return fmt.Errorf("store not initialized") + } + + if userPolicies := store.inlinePolicies[userName]; userPolicies != nil { + delete(userPolicies, policyName) + if len(userPolicies) == 0 { + delete(store.inlinePolicies, userName) + } + } + return nil +} + +// ListUserInlinePolicies returns the names of all inline policies for a user. +func (store *MemoryStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) { + store.mu.RLock() + defer store.mu.RUnlock() + + if !store.initialized { + return nil, fmt.Errorf("store not initialized") + } + + userPolicies := store.inlinePolicies[userName] + names := make([]string, 0, len(userPolicies)) + for name := range userPolicies { + names = append(names, name) + } + return names, nil +} diff --git a/weed/credential/memory/memory_store.go b/weed/credential/memory/memory_store.go index baca350a8..3dbc3aedf 100644 --- a/weed/credential/memory/memory_store.go +++ b/weed/credential/memory/memory_store.go @@ -18,12 +18,13 @@ func init() { // This is primarily intended for testing purposes type MemoryStore struct { mu sync.RWMutex - users map[string]*iam_pb.Identity // username -> identity - accessKeys map[string]string // access_key -> username - serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account - serviceAccountAccessKeys map[string]string // access_key -> id - policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document - groups map[string]*iam_pb.Group // group_name -> group + users map[string]*iam_pb.Identity // username -> identity + accessKeys map[string]string // access_key -> username + serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account + serviceAccountAccessKeys map[string]string // access_key -> id + policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document + inlinePolicies map[string]map[string]policy_engine.PolicyDocument // username -> policy_name -> document + groups map[string]*iam_pb.Group // group_name -> group initialized bool } @@ -44,6 +45,7 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount) store.serviceAccountAccessKeys = make(map[string]string) store.policies = make(map[string]policy_engine.PolicyDocument) + store.inlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument) store.groups = make(map[string]*iam_pb.Group) store.initialized = true @@ -59,6 +61,7 @@ func (store *MemoryStore) Shutdown() { store.serviceAccounts = nil store.serviceAccountAccessKeys = nil store.policies = nil + store.inlinePolicies = nil store.groups = nil store.initialized = false } @@ -74,6 +77,7 @@ func (store *MemoryStore) Reset() { store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount) store.serviceAccountAccessKeys = make(map[string]string) store.policies = make(map[string]policy_engine.PolicyDocument) + store.inlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument) store.groups = make(map[string]*iam_pb.Group) } } diff --git a/weed/credential/propagating_store.go b/weed/credential/propagating_store.go index f4facdf8f..68ded0a6e 100644 --- a/weed/credential/propagating_store.go +++ b/weed/credential/propagating_store.go @@ -280,6 +280,34 @@ func (s *PropagatingCredentialStore) LoadInlinePolicies(ctx context.Context) (ma return nil, nil } +func (s *PropagatingCredentialStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error { + if store, ok := s.CredentialStore.(InlinePolicyStore); ok { + return store.PutUserInlinePolicy(ctx, userName, policyName, document) + } + return nil +} + +func (s *PropagatingCredentialStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) { + if store, ok := s.CredentialStore.(InlinePolicyStore); ok { + return store.GetUserInlinePolicy(ctx, userName, policyName) + } + return nil, nil +} + +func (s *PropagatingCredentialStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error { + if store, ok := s.CredentialStore.(InlinePolicyStore); ok { + return store.DeleteUserInlinePolicy(ctx, userName, policyName) + } + return nil +} + +func (s *PropagatingCredentialStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) { + if store, ok := s.CredentialStore.(InlinePolicyStore); ok { + return store.ListUserInlinePolicies(ctx, userName) + } + return nil, nil +} + func (s *PropagatingCredentialStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error { if pm, ok := s.CredentialStore.(PolicyManager); ok { if err := pm.CreatePolicy(ctx, name, document); err != nil { diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index d30b41abf..ef67258d1 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -840,10 +840,54 @@ func (e *EmbeddedIamApi) getActions(policy *policy_engine.PolicyDocument) ([]str return actions, nil } +// recomputeActions aggregates ident.Actions from all stored inline policies +// for a user. Returns (nil, nil) when the credential manager is unavailable +// (caller should keep existing actions). Returns a non-nil error on store +// failures so callers can abort the mutation. +func (e *EmbeddedIamApi) recomputeActions(ctx context.Context, userName string) ([]string, error) { + if e.credentialManager == nil { + return nil, nil + } + policyNames, err := e.credentialManager.ListUserInlinePolicies(ctx, userName) + if err != nil { + return nil, fmt.Errorf("list inline policies for user %s: %w", userName, err) + } + if len(policyNames) == 0 { + return nil, nil + } + actionSet := make(map[string]bool) + var aggregated []string + for _, name := range policyNames { + doc, err := e.credentialManager.GetUserInlinePolicy(ctx, userName, name) + if err != nil { + return nil, fmt.Errorf("read inline policy %q for user %s: %w", name, userName, err) + } + if doc == nil { + continue + } + actions, err := e.getActions(doc) + if err != nil { + glog.Warningf("recomputeActions: failed to parse inline policy %q for user %s: %v", name, userName, err) + continue + } + for _, a := range actions { + if !actionSet[a] { + actionSet[a] = true + aggregated = append(aggregated, a) + } + } + } + return aggregated, nil +} + // PutUserPolicy attaches a policy to a user. func (e *EmbeddedIamApi) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamPutUserPolicyResponse, *iamError) { resp := &iamPutUserPolicyResponse{} userName := values.Get("UserName") + policyName := values.Get("PolicyName") + if policyName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyName is required")} + } policyDocumentString := values.Get("PolicyDocument") policyDocument, err := e.GetPolicyDocument(&policyDocumentString) if err != nil { @@ -858,7 +902,27 @@ func (e *EmbeddedIamApi) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values if userName != ident.Name { continue } - ident.Actions = actions + + // Persist the original policy document for lossless round-trip via GetUserPolicy. + // This must succeed before updating ident.Actions to keep both in sync. + ctx := context.Background() + if e.credentialManager != nil { + if storeErr := e.credentialManager.PutUserInlinePolicy(ctx, userName, policyName, policyDocument); storeErr != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: storeErr} + } + } + + // Recompute ident.Actions from ALL stored inline policies so that + // multiple policies are properly aggregated for enforcement. + aggregated, recomputeErr := e.recomputeActions(ctx, userName) + if recomputeErr != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: recomputeErr} + } + if aggregated != nil { + ident.Actions = aggregated + } else { + ident.Actions = actions + } return resp, nil } return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)} @@ -876,34 +940,58 @@ func (e *EmbeddedIamApi) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values resp.GetUserPolicyResult.UserName = userName resp.GetUserPolicyResult.PolicyName = policyName + + // Try to retrieve the stored inline policy document for a lossless round-trip + if e.credentialManager != nil { + ctx := context.Background() + storedDoc, storeErr := e.credentialManager.GetUserInlinePolicy(ctx, userName, policyName) + if storeErr != nil { + glog.Warningf("GetUserPolicy: failed to read stored inline policy %q for user %s: %v; falling back to reconstruction", policyName, userName, storeErr) + } + if storeErr == nil && storedDoc != nil { + policyDocumentJSON, err := json.Marshal(storedDoc) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + resp.GetUserPolicyResult.PolicyDocument = string(policyDocumentJSON) + return resp, nil + } + } + + // Fallback: reconstruct from ident.Actions (lossy - fine-grained actions + // collapse to wildcards like s3:Get*, s3:Put*, s3:List*) if len(ident.Actions) == 0 { return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: errors.New("no actions found")} } policyDocument := policy_engine.PolicyDocument{Version: iamPolicyDocumentVersion} statements := make(map[string][]string) + seenAction := make(map[string]map[string]bool) for _, action := range ident.Actions { // Action format: "ActionType" (global) or "ActionType:bucket" or "ActionType:bucket/path" - actionType, bucketPath, hasPath := strings.Cut(action, ":") - var resource string - if !hasPath { - // Global action (no bucket specified) - resource = "*" - } else if strings.Contains(bucketPath, "/") { - // Path-specific: bucket/path -> arn:aws:s3:::bucket/path/* - resource = fmt.Sprintf("arn:aws:s3:::%s/*", bucketPath) - } else { - // Bucket-level: bucket -> arn:aws:s3:::bucket/* - resource = fmt.Sprintf("arn:aws:s3:::%s/*", bucketPath) + // Use SplitN so the path component (which may contain ':') is preserved intact. + act := strings.SplitN(action, ":", 2) + + resource := "*" + if len(act) == 2 { + // Preserve the stored path verbatim so bucket-level and + // object-level resources remain distinguishable. + resource = fmt.Sprintf("arn:aws:s3:::%s", act[1]) } - statements[resource] = append(statements[resource], - fmt.Sprintf("s3:%s", iamMapToIdentitiesAction(actionType)), - ) + s3Action := fmt.Sprintf("s3:%s", iamMapToIdentitiesAction(act[0])) + // Dedupe actions per resource + if seenAction[resource] == nil { + seenAction[resource] = make(map[string]bool) + } + if seenAction[resource][s3Action] { + continue + } + seenAction[resource][s3Action] = true + statements[resource] = append(statements[resource], s3Action) } for resource, actions := range statements { isEqAction := false for i, statement := range policyDocument.Statement { - // Use order-independent comparison to avoid duplicates from different action orderings if iamStringSlicesEqual(statement.Action.Strings(), actions) { policyDocument.Statement[i].Resource = policy_engine.NewStringOrStringSlicePtr(append( policyDocument.Statement[i].Resource.Strings(), resource)...) @@ -931,13 +1019,36 @@ func (e *EmbeddedIamApi) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} } -// DeleteUserPolicy removes the inline policy from a user (clears their actions). +// DeleteUserPolicy removes the inline policy from a user. func (e *EmbeddedIamApi) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDeleteUserPolicyResponse, *iamError) { resp := &iamDeleteUserPolicyResponse{} userName := values.Get("UserName") + policyName := values.Get("PolicyName") + if policyName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("PolicyName is required")} + } for _, ident := range s3cfg.Identities { if ident.Name == userName { - ident.Actions = nil + ctx := context.Background() + + // Remove the stored inline policy document. + // Must succeed before updating ident.Actions to keep both in sync. + if e.credentialManager != nil { + if err := e.credentialManager.DeleteUserInlinePolicy(ctx, userName, policyName); err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + } + + // Recompute ident.Actions from remaining inline policies + aggregated, recomputeErr := e.recomputeActions(ctx, userName) + if recomputeErr != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: recomputeErr} + } + if aggregated != nil { + ident.Actions = aggregated + } else { + ident.Actions = nil + } return resp, nil } } @@ -951,6 +1062,16 @@ func (e *EmbeddedIamApi) ListUserPolicies(s3cfg *iam_pb.S3ApiConfiguration, valu userName := values.Get("UserName") for _, ident := range s3cfg.Identities { if ident.Name == userName { + // Try to list stored inline policy names + if e.credentialManager != nil { + ctx := context.Background() + if names, err := e.credentialManager.ListUserInlinePolicies(ctx, userName); err == nil && len(names) > 0 { + resp.ListUserPoliciesResult.PolicyNames = names + resp.ListUserPoliciesResult.IsTruncated = false + return resp, nil + } + } + // Fallback: infer a single policy name from actions if len(ident.Actions) > 0 { resp.ListUserPoliciesResult.PolicyNames = []string{userName + "_policy"} } diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go index a035af9e0..60f38b8fa 100644 --- a/weed/s3api/s3api_embedded_iam_test.go +++ b/weed/s3api/s3api_embedded_iam_test.go @@ -1259,6 +1259,82 @@ func TestEmbeddedIamCreateAccessKeyForExistingUser(t *testing.T) { assert.Len(t, api.mockConfig.Identities[0].Credentials, 1) } +// TestEmbeddedIamPutGetUserPolicyRoundTrip is a regression test for +// https://github.com/seaweedfs/seaweedfs/issues/9008: put-user-policy followed +// by get-user-policy must return the same policy document, with Action and Resource +// lists intact (no wildcard expansion, no duplication, no collapsing). +func TestEmbeddedIamPutGetUserPolicyRoundTrip(t *testing.T) { + api := NewEmbeddedIamApiForTest() + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{Name: "steward"}}, + } + api.mockConfig = s3cfg + + policyJSON := `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"], + "Resource": ["arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"] + }] + }` + + // PutUserPolicy + _, iamErr := api.PutUserPolicy(s3cfg, url.Values{ + "UserName": []string{"steward"}, + "PolicyName": []string{"steward_policy"}, + "PolicyDocument": []string{policyJSON}, + }) + assert.Nil(t, iamErr) + + // GetUserPolicy should return the exact document + resp, iamErr := api.GetUserPolicy(s3cfg, url.Values{ + "UserName": []string{"steward"}, + "PolicyName": []string{"steward_policy"}, + }) + assert.Nil(t, iamErr) + + var got policy_engine.PolicyDocument + assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got)) + + assert.Equal(t, "2012-10-17", got.Version) + require.Equal(t, 1, len(got.Statement)) + stmt := got.Statement[0] + assert.Equal(t, policy_engine.PolicyEffectAllow, stmt.Effect) + assert.ElementsMatch(t, []string{"s3:GetObject", "s3:PutObject", "s3:ListBucket"}, stmt.Action.Strings()) + assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings()) +} + +// TestEmbeddedIamGetUserPolicyFallback tests the lossy fallback reconstruction +// when no stored inline policy is available (pre-existing ident.Actions only). +func TestEmbeddedIamGetUserPolicyFallback(t *testing.T) { + api := NewEmbeddedIamApiForTest() + s3cfg := &iam_pb.S3ApiConfiguration{ + Identities: []*iam_pb.Identity{{ + Name: "steward", + Actions: []string{"Read:b-le*", "Write:b-le*", "List:b-le*", "Read:b-le*/*", "Write:b-le*/*", "List:b-le*/*"}, + }}, + } + api.mockConfig = s3cfg + + // No stored inline policy — GetUserPolicy must reconstruct from ident.Actions + resp, iamErr := api.GetUserPolicy(s3cfg, url.Values{ + "UserName": []string{"steward"}, + "PolicyName": []string{"steward_policy"}, + }) + assert.Nil(t, iamErr) + + var got policy_engine.PolicyDocument + assert.NoError(t, json.Unmarshal([]byte(resp.GetUserPolicyResult.PolicyDocument), &got)) + + // Fallback is lossy but must not duplicate actions + require.Equal(t, 1, len(got.Statement), "fallback should merge equal-action statements") + stmt := got.Statement[0] + assert.ElementsMatch(t, []string{"s3:Get*", "s3:Put*", "s3:List*"}, stmt.Action.Strings()) + // Bucket-level and object-level resources stay distinct + assert.ElementsMatch(t, []string{"arn:aws:s3:::b-le*", "arn:aws:s3:::b-le*/*"}, stmt.Resource.Strings()) +} + // TestEmbeddedIamGetUserPolicyUserNotFound tests GetUserPolicy with non-existent user func TestEmbeddedIamGetUserPolicyUserNotFound(t *testing.T) { api := NewEmbeddedIamApiForTest()