Files
seaweedfs/weed/s3api/iam_list_prefix_regression_test.go
Chris Lu 160e68dd65 fix(s3api): keep ListBucket resource ARN at bucket level (#9792)
* fix(s3api): keep ListBucket resource ARN at bucket level

ListObjects with ?prefix= was denied for IAM users granted s3:ListBucket
on the bucket ARN. authRequestWithAuthType promotes the prefix into object
so the legacy CanDo path can honor prefix-scoped Action strings, and that
promoted object leaked into the policy resource ARN, producing
arn:aws:s3:::bucket/<prefix> which never matches a bucket-level statement.

Keep the resource bucket-level for List in the bucket-policy and
IAM-attached-policy evaluators; prefix scoping stays in the s3:prefix
Condition. The CanDo path is untouched.

* fix(s3api): resolve List action at bucket level when prefix is promoted

The IAM evaluator built a bucket-level resource ARN but still passed the
prefix-promoted object to ResolveS3Action, so listing with a prefix made
hasObject true and misresolved ListBucketVersions/ListBucketMultipartUploads
to ListBucket. Resolve the action against the same zeroed object, and trim
the resource-ARN comments.
2026-06-02 14:45:45 -07:00

115 lines
4.3 KiB
Go

package s3api
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/stretchr/testify/require"
)
// ListObjects requests carry their key scope in the ?prefix= query parameter,
// which authRequestWithAuthType promotes into object so the legacy CanDo path
// can honor prefix-scoped Action strings. That promoted object must not reach
// the policy resource ARN: s3:ListBucket is a bucket-level action, so the
// resource stays arn:aws:s3:::bucket and any prefix scoping is expressed via
// the s3:prefix Condition.
func TestEvaluateIAMPolicies_ListBucketWithPrefix(t *testing.T) {
const bucket = "test-bucket"
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("list-bucket", mustPolicy(t, map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::" + bucket,
}},
})))
identity := &Identity{
Name: "alice",
Account: &AccountAdmin,
PolicyNames: []string{"list-bucket"},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
// authRequestWithAuthType promotes prefix into object before reaching the
// IAM evaluator; pass the post-promotion value to mirror that flow.
withPrefix := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=foo/", nil)
require.True(t, iam.evaluateIAMPolicies(withPrefix, identity, s3_constants.ACTION_LIST, bucket, "foo/"),
"s3:ListBucket on the bucket ARN must allow listing with a prefix")
noPrefix := httptest.NewRequest("GET", "/"+bucket+"?list-type=2", nil)
require.True(t, iam.evaluateIAMPolicies(noPrefix, identity, s3_constants.ACTION_LIST, bucket, ""),
"s3:ListBucket on the bucket ARN must allow listing without a prefix")
}
// Prefix scoping still works once it moves to the Condition: a policy that
// grants s3:ListBucket on the bucket only under an s3:prefix StringLike must
// allow a matching prefix and deny a non-matching one.
func TestEvaluateIAMPolicies_ListBucketPrefixCondition(t *testing.T) {
const bucket = "test-bucket"
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("list-scoped", mustPolicy(t, map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::" + bucket,
"Condition": map[string]any{"StringLike": map[string]any{"s3:prefix": "warehouse/*"}},
}},
})))
identity := &Identity{
Name: "bob",
Account: &AccountAdmin,
PolicyNames: []string{"list-scoped"},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
matching := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=warehouse/data", nil)
require.True(t, iam.evaluateIAMPolicies(matching, identity, s3_constants.ACTION_LIST, bucket, "warehouse/data"),
"prefix matching the s3:prefix condition must be allowed")
nonMatching := httptest.NewRequest("GET", "/"+bucket+"?list-type=2&prefix=secrets/", nil)
require.False(t, iam.evaluateIAMPolicies(nonMatching, identity, s3_constants.ACTION_LIST, bucket, "secrets/"),
"prefix outside the s3:prefix condition must be denied")
}
// Listing variants keep their specific action even when a prefix is promoted
// into object: ?versions resolves to s3:ListBucketVersions, not s3:ListBucket.
func TestEvaluateIAMPolicies_ListBucketVersionsWithPrefix(t *testing.T) {
const bucket = "test-bucket"
iam := &IdentityAccessManagement{}
require.NoError(t, iam.PutPolicy("list-versions", mustPolicy(t, map[string]any{
"Version": "2012-10-17",
"Statement": []map[string]any{{
"Effect": "Allow",
"Action": "s3:ListBucketVersions",
"Resource": "arn:aws:s3:::" + bucket,
}},
})))
identity := &Identity{
Name: "carol",
Account: &AccountAdmin,
PolicyNames: []string{"list-versions"},
Credentials: []*Credential{{AccessKey: "AKIAEXAMPLE", SecretKey: "secret"}},
}
r := httptest.NewRequest("GET", "/"+bucket+"?versions&prefix=foo/", nil)
require.True(t, iam.evaluateIAMPolicies(r, identity, s3_constants.ACTION_LIST, bucket, "foo/"),
"s3:ListBucketVersions must still resolve when listing with a prefix")
}
func mustPolicy(t *testing.T, doc map[string]any) string {
t.Helper()
b, err := json.Marshal(doc)
require.NoError(t, err)
return string(b)
}