mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 23:10:41 +02:00
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:
1 parent
5536d88fbb
commit
62c4333074
6 files changed
+332
-31
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user