diff --git a/weed/s3api/policy_engine/engine.go b/weed/s3api/policy_engine/engine.go index d39b4b2ce..a8d37c5ab 100644 --- a/weed/s3api/policy_engine/engine.go +++ b/weed/s3api/policy_engine/engine.go @@ -162,6 +162,16 @@ func (engine *PolicyEngine) evaluateStatement(stmt *CompiledStatement, args *Pol if !matchedAction { matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, args.Action, args) } + // Multipart upload actions (CreateMultipartUpload, UploadPart, CompleteMultipartUpload, etc.) + // are implicitly allowed by s3:PutObject, since multipart upload is an implementation + // detail of putting objects. Check if this is a multipart action and the statement + // grants s3:PutObject. + if !matchedAction && multipartActionSet[args.Action] { + matchedAction = engine.matchesPatterns(stmt.ActionPatterns, "s3:PutObject") + if !matchedAction { + matchedAction = engine.matchesDynamicPatterns(stmt.DynamicActionPatterns, "s3:PutObject", args) + } + } if !matchedAction { return false } diff --git a/weed/s3api/policy_engine/engine_test.go b/weed/s3api/policy_engine/engine_test.go index 452c01775..0e4098544 100644 --- a/weed/s3api/policy_engine/engine_test.go +++ b/weed/s3api/policy_engine/engine_test.go @@ -981,3 +981,80 @@ func TestExistingObjectTagDenyPolicy(t *testing.T) { }) } } + +// TestMultipartUploadInheritsPutObjectPermission verifies that granting s3:PutObject +// in a bucket policy implicitly allows multipart upload operations. +// See https://github.com/seaweedfs/seaweedfs/discussions/8751 +func TestMultipartUploadInheritsPutObjectPermission(t *testing.T) { + engine := NewPolicyEngine() + + policyJSON := `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::test-bucket/*" + } + ] + }` + + if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil { + t.Fatalf("Failed to set policy: %v", err) + } + + multipartActions := []string{ + "s3:CreateMultipartUpload", + "s3:UploadPart", + "s3:UploadPartCopy", + "s3:CompleteMultipartUpload", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + "s3:ListBucketMultipartUploads", + } + + for _, action := range multipartActions { + t.Run(action, func(t *testing.T) { + args := &PolicyEvaluationArgs{ + Action: action, + Resource: "arn:aws:s3:::test-bucket/myfile.dat", + Principal: "*", + Conditions: map[string][]string{ + "aws:SourceIp": {"10.0.0.1"}, + }, + } + result := engine.EvaluatePolicy("test-bucket", args) + if result != PolicyResultAllow { + t.Errorf("Expected s3:PutObject to implicitly allow %s, got %v", action, result) + } + }) + } + + // ListBucketMultipartUploads is a bucket-level action; the object-only + // resource "arn:aws:s3:::test-bucket/*" should NOT match the bucket ARN. + t.Run("s3:ListBucketMultipartUploads bucket ARN", func(t *testing.T) { + args := &PolicyEvaluationArgs{ + Action: "s3:ListBucketMultipartUploads", + Resource: "arn:aws:s3:::test-bucket", + Principal: "*", + } + result := engine.EvaluatePolicy("test-bucket", args) + if result == PolicyResultAllow { + t.Error("Object-only resource should not match bucket ARN for ListBucketMultipartUploads") + } + }) + + // s3:PutObject must NOT implicitly grant unrelated actions + t.Run("s3:DeleteObject not inherited", func(t *testing.T) { + args := &PolicyEvaluationArgs{ + Action: "s3:DeleteObject", + Resource: "arn:aws:s3:::test-bucket/myfile.dat", + Principal: "*", + } + result := engine.EvaluatePolicy("test-bucket", args) + if result == PolicyResultAllow { + t.Error("s3:PutObject should NOT implicitly allow s3:DeleteObject") + } + }) +} diff --git a/weed/s3api/policy_engine/types.go b/weed/s3api/policy_engine/types.go index f1623ff15..9f2aa4598 100644 --- a/weed/s3api/policy_engine/types.go +++ b/weed/s3api/policy_engine/types.go @@ -43,6 +43,7 @@ var ( multipartActionSet = map[string]bool{ s3const.S3_ACTION_CREATE_MULTIPART: true, s3const.S3_ACTION_UPLOAD_PART: true, + s3const.S3_ACTION_UPLOAD_PART_COPY: true, s3const.S3_ACTION_COMPLETE_MULTIPART: true, s3const.S3_ACTION_ABORT_MULTIPART: true, s3const.S3_ACTION_LIST_PARTS: true, diff --git a/weed/s3api/s3_constants/s3_action_strings.go b/weed/s3api/s3_constants/s3_action_strings.go index 20f41e8c2..5d96ba2c8 100644 --- a/weed/s3api/s3_constants/s3_action_strings.go +++ b/weed/s3api/s3_constants/s3_action_strings.go @@ -32,6 +32,7 @@ const ( S3_ACTION_UPLOAD_PART = "s3:UploadPart" S3_ACTION_COMPLETE_MULTIPART = "s3:CompleteMultipartUpload" S3_ACTION_ABORT_MULTIPART = "s3:AbortMultipartUpload" + S3_ACTION_UPLOAD_PART_COPY = "s3:UploadPartCopy" S3_ACTION_LIST_PARTS = "s3:ListMultipartUploadParts" S3_ACTION_LIST_MULTIPART_UPLOADS = "s3:ListBucketMultipartUploads"