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.
This commit is contained in:
Chris Lu
2026-07-27 16:42:01 -07:00
committed by GitHub
parent 5536d88fbb
commit 62c4333074
6 changed files with 332 additions and 31 deletions
+30 -1
View File
@@ -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