diff --git a/test/s3/normal/s3_list_buckets_test.go b/test/s3/normal/s3_list_buckets_test.go index 4f85f538d..6608ca944 100644 --- a/test/s3/normal/s3_list_buckets_test.go +++ b/test/s3/normal/s3_list_buckets_test.go @@ -35,7 +35,15 @@ func TestListBucketsPaginationAndOwnerIndex(t *testing.T) { {"name": "carol", "credentials": [{"accessKey": "carol", "secretKey": "carol_secret"}], "actions": ["List:alice-b1"]}, {"name": "bob", "credentials": [{"accessKey": "bob", "secretKey": "bob_secret"}], - "actions": ["Read:alice-b1"]} + "actions": ["Read:alice-b1"]}, + {"name": "dana", "credentials": [{"accessKey": "dana", "secretKey": "dana_secret"}], + "policyNames": ["ReadAliceB1"]}, + {"name": "erin", "credentials": [{"accessKey": "erin", "secretKey": "erin_secret"}], + "policyNames": ["ListEveryBucket"]} + ], + "policies": [ + {"name": "ReadAliceB1", "content": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetBucketLocation\",\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::alice-b1\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::alice-b1/*\"]},{\"Effect\":\"Allow\",\"Action\":[\"s3:ListAllMyBuckets\"],\"Resource\":\"*\"}]}"}, + {"name": "ListEveryBucket", "content": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:ListBucket\"],\"Resource\":[\"arn:aws:s3:::*\"]}]}"} ] }` configPath := filepath.Join(t.TempDir(), "s3.json") @@ -61,6 +69,8 @@ func TestListBucketsPaginationAndOwnerIndex(t *testing.T) { alice := newClient("alice", "alice_secret") carol := newClient("carol", "carol_secret") bob := newClient("bob", "bob_secret") + dana := newClient("dana", "dana_secret") + erin := newClient("erin", "erin_secret") // Non-admin listings use the owner index once the backfill marker exists. markerURL := fmt.Sprintf("http://127.0.0.1:%d/buckets/.system/owners/.complete", cluster.filerPort) @@ -126,6 +136,25 @@ func TestListBucketsPaginationAndOwnerIndex(t *testing.T) { assert.Empty(t, bucketNames(out.Buckets)) }) + // An attached IAM policy grants listing on a bucket the identity never + // created, so ListBuckets has to report it. + t.Run("PolicyGrantVisible", func(t *testing.T) { + out, err := dana.ListBuckets(ctx, &s3v2.ListBucketsInput{}) + require.NoError(t, err) + assert.Equal(t, []string{"alice-b1"}, bucketNames(out.Buckets)) + + _, err = dana.ListObjectsV2(ctx, &s3v2.ListObjectsV2Input{Bucket: aws.String("alice-b1")}) + assert.NoError(t, err, "the same policy authorizes reads") + _, err = dana.ListObjectsV2(ctx, &s3v2.ListObjectsV2Input{Bucket: aws.String("alice-b3")}) + assert.Error(t, err, "and grants nothing beyond the bucket it names") + }) + + t.Run("PolicyWildcardResourceVisible", func(t *testing.T) { + out, err := erin.ListBuckets(ctx, &s3v2.ListBucketsInput{}) + require.NoError(t, err) + assert.Equal(t, append(append([]string{}, adminBuckets...), aliceBuckets...), bucketNames(out.Buckets)) + }) + t.Run("AdminPaginatesAll", func(t *testing.T) { var all []string var token *string diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index c593a4e36..bf7eca7f9 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -2450,15 +2450,37 @@ const ( authorizeDenied ) +// attachedPolicyNames returns the identity's own policy names plus the ones it +// inherits from its enabled groups. The copy keeps callers from mutating the +// shared identity. +func (iam *IdentityAccessManagement) attachedPolicyNames(identity *Identity) []string { + iam.m.RLock() + defer iam.m.RUnlock() + + names := slices.Clone(identity.PolicyNames) + for _, groupName := range iam.userGroups[identity.Name] { + if g, exists := iam.groups[groupName]; exists && !g.Disabled { + names = append(names, g.PolicyNames...) + } + } + return names +} + +// hasSessionToken reports whether the request carries an STS session token, +// whose session policies are known only to the IAM integration. +func hasSessionToken(r *http.Request) bool { + return r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader) != "" || + r.Header.Get("X-Amz-Security-Token") != "" || + r.URL.Query().Get("X-Amz-Security-Token") != "" +} + // authorizationRoute picks the mechanism, so every caller routes identically. // Traditional identities (with Actions from -s3.config) use legacy auth, // JWT/STS identities (no Actions or having a session token) use IAM // authorization. A request with a session token must go through the IAM // integration so session policies are enforced. func (iam *IdentityAccessManagement) authorizationRoute(r *http.Request, identity *Identity) authorizationRoute { - hasSessionToken := r.Header.Get(s3_constants.SeaweedFSSessionTokenHeader) != "" || - r.Header.Get("X-Amz-Security-Token") != "" || - r.URL.Query().Get("X-Amz-Security-Token") != "" + sessionToken := hasSessionToken(r) iam.m.RLock() groupsHavePolicies := false for _, gn := range iam.userGroups[identity.Name] { @@ -2470,7 +2492,7 @@ func (iam *IdentityAccessManagement) authorizationRoute(r *http.Request, identit iam.m.RUnlock() hasAttachedPolicies := len(identity.PolicyNames) > 0 || groupsHavePolicies - if (len(identity.Actions) == 0 || hasSessionToken || hasAttachedPolicies) && iam.iamIntegration != nil { + if (len(identity.Actions) == 0 || sessionToken || hasAttachedPolicies) && iam.iamIntegration != nil { return authorizeViaIAMIntegration } if hasAttachedPolicies { @@ -2515,12 +2537,12 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide // canListBucketsFromOwnerIndex reports whether ListBuckets for this identity // can be served from the bucket owner index instead of scanning /buckets, and -// if so which bucket names its legacy actions may grant beyond ownership. +// if so which bucket names its permissions may grant beyond ownership. // -// Admins and identities whose legacy actions can match arbitrary buckets (a -// bare "List" grant, or any wildcard pattern) need the full scan. Identities -// authorized through IAM policies get their owned buckets only, matching the -// AWS behavior of ListBuckets returning the account's buckets. +// Only an identity whose grants name every bucket they can reach can be served +// from the index. Admins, a bare "List" grant, a wildcard action pattern and a +// wildcard policy resource all match buckets the identity does not name, so +// their visible set only comes out of the full scan. func (iam *IdentityAccessManagement) canListBucketsFromOwnerIndex(r *http.Request, identity *Identity) (ok bool, granted []string) { // Fail closed on a nil identity: the scan path filters every bucket out // without dereferencing it, while the index path would need its name. @@ -2528,10 +2550,8 @@ func (iam *IdentityAccessManagement) canListBucketsFromOwnerIndex(r *http.Reques return false, nil } - // Identities authorized by IAM policies (or by nothing) cannot have their - // visible set enumerated; they get their owned buckets only. if iam.authorizationRoute(r, identity) != authorizeViaLegacyActions { - return true, nil + return iam.bucketsNamedByAttachedPolicies(r, identity) } for _, a := range identity.Actions { @@ -2555,6 +2575,39 @@ func (iam *IdentityAccessManagement) canListBucketsFromOwnerIndex(r *http.Reques return true, granted } +// bucketsNamedByAttachedPolicies collects the buckets the identity's own and +// group policies name in a statement allowing s3:ListBucket, the permission +// bucketVisibleToIdentity checks. The names are candidates that the caller +// re-checks against the full policy evaluation. Enumeration fails on a policy +// this gateway does not hold, such as an STS session policy. +func (iam *IdentityAccessManagement) bucketsNamedByAttachedPolicies(r *http.Request, identity *Identity) (ok bool, granted []string) { + if hasSessionToken(r) { + return false, nil + } + + policyNames := iam.attachedPolicyNames(identity) + // Nothing attached: the identity reaches only what it owns. + if len(policyNames) == 0 { + return true, nil + } + + iam.m.RLock() + engine := iam.iamPolicyEngine + iam.m.RUnlock() + if engine == nil { + return false, nil + } + + for _, policyName := range policyNames { + names, complete := engine.BucketsAllowedForAction(policyName, s3_constants.S3_ACTION_LIST_BUCKET) + if !complete { + return false, nil + } + granted = append(granted, names...) + } + return true, granted +} + // AuthorizeCopySource verifies the caller is allowed to read the CopyObject / // UploadPartCopy source. The Auth middleware only checks the destination // (s3:PutObject) because routing keys on the request URL; without this call, @@ -2713,20 +2766,7 @@ func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity } } - // Create IAMIdentity for authorization — copy PolicyNames to avoid mutating shared identity - policyNames := make([]string, len(identity.PolicyNames)) - copy(policyNames, identity.PolicyNames) - - // Include policies inherited from user's groups - iam.m.RLock() - if groupNames, ok := iam.userGroups[identity.Name]; ok { - for _, gn := range groupNames { - if g, exists := iam.groups[gn]; exists && !g.Disabled { - policyNames = append(policyNames, g.PolicyNames...) - } - } - } - iam.m.RUnlock() + policyNames := iam.attachedPolicyNames(identity) iamIdentity := &IAMIdentity{ Name: identity.Name, diff --git a/weed/s3api/policy_engine/engine.go b/weed/s3api/policy_engine/engine.go index 2e0a53351..b0a98d6b7 100644 --- a/weed/s3api/policy_engine/engine.go +++ b/weed/s3api/policy_engine/engine.go @@ -644,6 +644,46 @@ func (engine *PolicyEngine) GetPolicyStatements(bucketName string) []PolicyState return context.policy.Document.Statement } +// BucketsAllowedForAction returns the buckets a policy names in the Allow +// statements that can match the action, and whether those names cover every +// bucket the policy can allow it on. A statement reaching buckets it does not +// name -- a wildcard resource, a policy variable, a NotResource -- leaves the +// set incomplete, as does an unknown policy name. +func (engine *PolicyEngine) BucketsAllowedForAction(policyName string, action string) (buckets []string, complete bool) { + engine.mutex.RLock() + context, exists := engine.contexts[policyName] + engine.mutex.RUnlock() + + if !exists { + return nil, false + } + + for _, statement := range context.policy.Document.Statement { + // A Deny only narrows what an Allow named. + if statement.Effect != PolicyEffectAllow || !statementMayAllowAction(statement.Action.Strings(), action) { + continue + } + if statement.Resource == nil || statement.NotResource != nil { + return nil, false + } + resources := statement.Resource.Strings() + if len(resources) == 0 { + return nil, false + } + for _, resource := range resources { + if PolicyVariableRegex.MatchString(resource) { + return nil, false + } + bucket := GetBucketFromResource(resource) + if bucket == "" || strings.ContainsAny(bucket, "*?") { + return nil, false + } + buckets = append(buckets, bucket) + } + } + return buckets, true +} + // ValidatePolicyForBucket validates if a policy is valid for a bucket func (engine *PolicyEngine) ValidatePolicyForBucket(bucketName string, policyJSON string) error { policy, err := ParsePolicy(policyJSON) diff --git a/weed/s3api/policy_engine/engine_buckets_for_action_test.go b/weed/s3api/policy_engine/engine_buckets_for_action_test.go new file mode 100644 index 000000000..1e1175fd9 --- /dev/null +++ b/weed/s3api/policy_engine/engine_buckets_for_action_test.go @@ -0,0 +1,52 @@ +package policy_engine + +import ( + "strings" + "testing" +) + +// BucketsAllowedForAction has to read a grant at least as loosely as the +// authorizer applies it, since a bucket it misses disappears from ListBuckets. +func TestBucketsAllowedForAction(t *testing.T) { + engine := NewPolicyEngine() + policies := map[string]string{ + "put": `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::b1/*"]}]}`, + "mixed-case": `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["S3:LISTBUCKET"],"Resource":["arn:aws:s3:::b2"]}]}`, + "any-bucket": `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::*"]}]}`, + } + for name, document := range policies { + if err := engine.SetBucketPolicy(name, document); err != nil { + t.Fatalf("load policy %s: %v", name, err) + } + } + + tests := []struct { + name string + policy string + action string + wantBuckets string + wantComplete bool + }{ + // Multipart uploads ride on s3:PutObject, in whatever case they are asked for. + {name: "multipart inherits put", policy: "put", action: "s3:UploadPart", wantBuckets: "b1", wantComplete: true}, + {name: "multipart inherits put in any case", policy: "put", action: "S3:UPLOADPART", wantBuckets: "b1", wantComplete: true}, + {name: "unrelated action names nothing", policy: "put", action: "s3:ListBucket", wantComplete: true}, + {name: "mixed case grant is read", policy: "mixed-case", action: "s3:ListBucket", wantBuckets: "b2", wantComplete: true}, + {name: "wildcard bucket is incomplete", policy: "any-bucket", action: "s3:ListBucket"}, + {name: "unknown policy is incomplete", policy: "absent", action: "s3:ListBucket"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buckets, complete := engine.BucketsAllowedForAction(tt.policy, tt.action) + if complete != tt.wantComplete { + t.Fatalf("complete = %v, want %v", complete, tt.wantComplete) + } + if got := strings.Join(buckets, ","); got != tt.wantBuckets { + t.Errorf("buckets = %q, want %q", got, tt.wantBuckets) + } + }) + } +} diff --git a/weed/s3api/policy_engine/types.go b/weed/s3api/policy_engine/types.go index 203390131..b9df7ab36 100644 --- a/weed/s3api/policy_engine/types.go +++ b/weed/s3api/policy_engine/types.go @@ -50,6 +50,15 @@ var ( s3const.S3_ACTION_LIST_PARTS: true, s3const.S3_ACTION_LIST_MULTIPART_UPLOADS: true, } + + // lowerMultipartActionSet keys the same actions for case-insensitive lookup. + lowerMultipartActionSet = func() map[string]bool { + lowered := make(map[string]bool, len(multipartActionSet)) + for action := range multipartActionSet { + lowered[strings.ToLower(action)] = true + } + return lowered + }() ) // StringOrStringSlice represents a value that can be either a string or []string @@ -683,6 +692,38 @@ func (cs *CompiledStatement) MatchesAction(action string) bool { return false } +// statementMayAllowAction reports whether a statement's actions can match the +// action, including the multipart operations that ride on s3:PutObject. It is +// looser than either evaluator on purpose: the IAM authorizer matches action +// names case-insensitively and a policy variable resolves per request, so a +// classifier that reads them strictly would miss a grant that is really there. +func statementMayAllowAction(actions []string, action string) bool { + for _, pattern := range actions { + if actionPatternMayMatch(pattern, action) { + return true + } + } + if !lowerMultipartActionSet[strings.ToLower(action)] { + return false + } + for _, pattern := range actions { + if actionPatternMayMatch(pattern, s3const.S3_ACTION_PUT_OBJECT) { + return true + } + } + return false +} + +func actionPatternMayMatch(pattern, action string) bool { + if PolicyVariableRegex.MatchString(pattern) { + return true + } + if strings.EqualFold(pattern, action) { + return true + } + return wildcard.MatchesWildcard(strings.ToLower(pattern), strings.ToLower(action)) +} + // MatchesResource checks if a resource matches any of the compiled resource matchers func (cs *CompiledStatement) MatchesResource(resource string) bool { for _, matcher := range cs.ResourceMatchers { diff --git a/weed/s3api/s3api_bucket_handlers_list_test.go b/weed/s3api/s3api_bucket_handlers_list_test.go index aa6f43c6f..e037fe425 100644 --- a/weed/s3api/s3api_bucket_handlers_list_test.go +++ b/weed/s3api/s3api_bucket_handlers_list_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) @@ -74,7 +76,7 @@ func TestCanListBucketsFromOwnerIndex(t *testing.T) { {name: "bare List scans", identity: &Identity{Name: "u", Actions: []Action{"List"}}, wantOk: false}, {name: "wildcard scans", identity: &Identity{Name: "u", Actions: []Action{"List:team-*"}}, wantOk: false}, {name: "no actions is owned-only", identity: &Identity{Name: "u"}, wantOk: true}, - {name: "attached policies is owned-only", identity: &Identity{Name: "u", Actions: []Action{"List:b1"}, PolicyNames: []string{"p"}}, wantOk: true}, + {name: "attached policy with no loaded document scans", identity: &Identity{Name: "u", Actions: []Action{"List:b1"}, PolicyNames: []string{"p"}}, wantOk: false}, {name: "named grants enumerate", identity: &Identity{Name: "u", Actions: []Action{"Read:b1", "List:b2", "Admin:b3/prefix", "Write"}}, wantOk: true, wantGranted: []string{"b1", "b2", "b3"}}, // grants come back in action order; resolveGrantedBuckets sorts and dedups @@ -93,17 +95,114 @@ func TestCanListBucketsFromOwnerIndex(t *testing.T) { }) } - t.Run("session token routes to policies when integrated", func(t *testing.T) { + // A session token carries policies only the IAM integration can see. + t.Run("session token scans", func(t *testing.T) { integrated := &IdentityAccessManagement{iamIntegration: &S3IAMIntegration{}} req := r() req.Header.Set("X-Amz-Security-Token", "tok") ok, granted := integrated.canListBucketsFromOwnerIndex(req, &Identity{Name: "u", Actions: []Action{"List:b1"}}) - if !ok || granted != nil { - t.Errorf("ok = %v granted = %v, want owned-only", ok, granted) + if ok || granted != nil { + t.Errorf("ok = %v granted = %v, want scan", ok, granted) } }) } +// An identity authorized by an attached IAM policy is listed from the buckets +// its policy names, and scanned for whenever the policy can reach a bucket it +// does not name. +func TestCanListBucketsFromOwnerIndexAttachedPolicy(t *testing.T) { + // The policy from the issue report: List on one named bucket, plus the + // account-wide ListAllMyBuckets that names no bucket at all. + const namedBucket = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::example-bucket"]}, + {"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::example-bucket/*"]}, + {"Effect":"Allow","Action":["s3:ListAllMyBuckets"],"Resource":"*"}]}` + const wildcardResource = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::team-*"]}]}` + const notResource = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:ListBucket"],"NotResource":["arn:aws:s3:::secret"]}]}` + const allActionsOneBucket = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::b1","arn:aws:s3:::b1/*"]}]}` + const denyOne = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::b1","arn:aws:s3:::b2"]}, + {"Effect":"Deny","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::b2"]}]}` + const groupPolicy = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::shared"]}]}` + // A variable resolves per request, naming a bucket this cannot predict. + const resourceVariable = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::home-${aws:username}"]}]}` + // A variable action may resolve to ListBucket, so the bucket it names counts. + const actionVariable = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["s3:${aws:username}"],"Resource":["arn:aws:s3:::b9"]}]}` + // The IAM authorizer matches action names case-insensitively, so these + // grant List and their buckets have to be listed. + const mixedCaseAction = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["S3:LISTBUCKET"],"Resource":["arn:aws:s3:::b7"]}]}` + const mixedCaseWildcardAction = `{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["S3:*"],"Resource":["arn:aws:s3:::b8"]}]}` + + engine := policy_engine.NewPolicyEngine() + for name, document := range map[string]string{ + "named": namedBucket, "wildcard": wildcardResource, "not-resource": notResource, + "all-actions": allActionsOneBucket, "deny-one": denyOne, "group": groupPolicy, + "resource-variable": resourceVariable, "action-variable": actionVariable, + "mixed-case": mixedCaseAction, "mixed-case-wildcard": mixedCaseWildcardAction, + } { + if err := engine.SetBucketPolicy(name, document); err != nil { + t.Fatalf("load policy %s: %v", name, err) + } + } + iam := &IdentityAccessManagement{ + iamPolicyEngine: engine, + groups: map[string]*iam_pb.Group{ + "team": {Name: "team", PolicyNames: []string{"group"}}, + "disabled": {Name: "disabled", PolicyNames: []string{"wildcard"}, Disabled: true}, + }, + userGroups: map[string][]string{"grouped": {"team"}, "in-disabled-group": {"disabled"}}, + } + + tests := []struct { + name string + identity *Identity + wantOk bool + wantGranted []string + }{ + {name: "named bucket enumerates", identity: &Identity{Name: "u", PolicyNames: []string{"named"}}, + wantOk: true, wantGranted: []string{"example-bucket"}}, + {name: "wildcard resource scans", identity: &Identity{Name: "u", PolicyNames: []string{"wildcard"}}}, + {name: "not-resource scans", identity: &Identity{Name: "u", PolicyNames: []string{"not-resource"}}}, + {name: "unknown policy scans", identity: &Identity{Name: "u", PolicyNames: []string{"missing"}}}, + {name: "one wildcard policy scans", identity: &Identity{Name: "u", PolicyNames: []string{"named", "wildcard"}}}, + {name: "action wildcard on a named bucket enumerates", identity: &Identity{Name: "u", PolicyNames: []string{"all-actions"}}, + wantOk: true, wantGranted: []string{"b1", "b1"}}, + // Deny narrows nothing here; resolveGrantedBuckets re-checks each name. + {name: "denied bucket stays a candidate", identity: &Identity{Name: "u", PolicyNames: []string{"deny-one"}}, + wantOk: true, wantGranted: []string{"b1", "b2"}}, + {name: "group policy contributes", identity: &Identity{Name: "grouped", PolicyNames: []string{"named"}}, + wantOk: true, wantGranted: []string{"example-bucket", "shared"}}, + {name: "disabled group is ignored", identity: &Identity{Name: "in-disabled-group"}, wantOk: true}, + {name: "resource variable scans", identity: &Identity{Name: "u", PolicyNames: []string{"resource-variable"}}}, + {name: "action variable keeps its bucket", identity: &Identity{Name: "u", PolicyNames: []string{"action-variable"}}, + wantOk: true, wantGranted: []string{"b9"}}, + {name: "mixed case action enumerates", identity: &Identity{Name: "u", PolicyNames: []string{"mixed-case"}}, + wantOk: true, wantGranted: []string{"b7"}}, + {name: "mixed case wildcard action enumerates", identity: &Identity{Name: "u", PolicyNames: []string{"mixed-case-wildcard"}}, + wantOk: true, wantGranted: []string{"b8"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "/", nil) + ok, granted := iam.canListBucketsFromOwnerIndex(req, tt.identity) + if ok != tt.wantOk { + t.Fatalf("ok = %v, want %v", ok, tt.wantOk) + } + if strings.Join(granted, ",") != strings.Join(tt.wantGranted, ",") { + t.Errorf("granted = %v, want %v", granted, tt.wantGranted) + } + }) + } +} + func TestContinuationTokenRoundTrip(t *testing.T) { for _, name := range []string{"a", "bucket-42", "with.dots-and-dashes", "0123456789"} { decoded, err := decodeContinuationToken(encodeContinuationToken(name))