From 62c4333074009b40f7b678f72f53789a5adf85d2 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 27 Jul 2026 16:42:01 -0700 Subject: [PATCH] s3: list the buckets an attached IAM policy grants (#10458) * s3: list the buckets an attached IAM policy grants ListBuckets served an identity authorized by an attached IAM policy only the buckets it had created itself. A user granted s3:ListBucket on a bucket someone else provisioned could GetObject and ListObjectsV2 against it, but the bucket never showed up in the listing any S3 client uses to build its bucket picker. The owner-index fast path is only valid for an identity whose grants name every bucket it can reach, and the routing check assumed a policy could never be enumerated. Read the names out of the policy instead: statements that allow s3:ListBucket on a concrete bucket ARN become candidates, and the per-bucket permission re-check still decides what is listed. A policy that can reach a bucket it does not name -- a wildcard resource, a policy variable, a NotResource, an STS session policy -- falls back to the full scan, which evaluates the policy per bucket. * s3: share the attached policy name lookup authorizeWithIAM and the ListBuckets enumeration both built an identity's policy names the same way, its own plus the ones from its enabled groups. Pull that into one helper so group eligibility is decided in a single place. * s3: read policy actions the way the IAM authorizer matches them The IAM authorizer matches action names case-insensitively, so a policy granting "S3:LISTBUCKET" or "S3:*" authorizes a list. The ListBuckets classifier read those actions with the local case-sensitive matcher and found no grant, so once the owner index was ready the buckets that policy allows dropped out of the listing. Match the action the looser way in the classifier: case-insensitive, and true for any pattern holding a policy variable. Over-matching only costs a candidate the per-bucket permission check then rejects, while under-matching hides a bucket the caller can read. * s3: infer a multipart grant in any case The classifier matches action patterns case-insensitively but looked the requested action up in a canonical-cased set, so "S3:UPLOADPART" missed the s3:PutObject inference that the authorizer makes. Key the set for lookup in lower case, matching how the IAM authorizer holds it. --- test/s3/normal/s3_list_buckets_test.go | 31 ++++- weed/s3api/auth_credentials.go | 92 ++++++++++----- weed/s3api/policy_engine/engine.go | 40 +++++++ .../engine_buckets_for_action_test.go | 52 +++++++++ weed/s3api/policy_engine/types.go | 41 +++++++ weed/s3api/s3api_bucket_handlers_list_test.go | 107 +++++++++++++++++- 6 files changed, 332 insertions(+), 31 deletions(-) create mode 100644 weed/s3api/policy_engine/engine_buckets_for_action_test.go 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))