Files
seaweedfs/weed/s3api/policy_engine/engine_test.go
T
fb3fb9dcb8 s3api: support s3:RequestObjectTagKeys in bucket policies (#11993)
* s3api: support s3:RequestObjectTagKeys in bucket policies

Condition operators accept the ForAllValues: and ForAnyValue: qualifiers,
and s3:RequestObjectTagKeys holds the keys of the tags taken from
X-Amz-Tagging wherever s3:RequestObjectTag/<key> is set.

CopyObject no longer stores an empty-key tag for an empty segment in
X-Amz-Tagging, so it keeps the key set a policy has checked.

Fixes #7679

* s3: parse tagging header like the tag handlers, not url.ParseQuery

url.ParseQuery stops at 10,000 parameters, so an X-Amz-Tagging header
padded with empty & segments made requestObjectTags return nil: the copy
handler still stored the denied tag while every s3:RequestObjectTag*
condition saw nothing. Scan non-empty segments the way parseTagsHeader
does so the policy sees the same tags the object gets.

* test: gofmt s3tables test files after #11997

The format check fails on these files on master as merged, so every
PR's S3 Tables Format Check goes red until they are re-formatted.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: user.email <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 02:04:39 -07:00

1432 lines
40 KiB
Go

package policy_engine
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// tagsToEntry converts a map of tag key-value pairs to the entry.Extended format
// used for s3:ExistingObjectTag/<key> condition evaluation
func tagsToEntry(tags map[string]string) map[string][]byte {
if tags == nil {
return nil
}
entry := make(map[string][]byte)
for k, v := range tags {
entry[s3_constants.AmzObjectTaggingPrefix+k] = []byte(v)
}
return entry
}
func TestPolicyEngine(t *testing.T) {
engine := NewPolicyEngine()
// Test policy JSON
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": ["arn:aws:s3:::test-bucket/*"]
},
{
"Effect": "Deny",
"Action": ["s3:DeleteObject"],
"Resource": ["arn:aws:s3:::test-bucket/*"],
"Condition": {
"StringEquals": {
"s3:RequestMethod": ["DELETE"]
}
}
}
]
}`
// Set bucket policy
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Test Allow case
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "user1",
Conditions: map[string][]string{},
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow, got %v", result)
}
// Test Deny case
args = &PolicyEvaluationArgs{
Action: "s3:DeleteObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "user1",
Conditions: map[string][]string{
"s3:RequestMethod": {"DELETE"},
},
}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultDeny {
t.Errorf("Expected Deny, got %v", result)
}
// Test non-matching action
args = &PolicyEvaluationArgs{
Action: "s3:ListBucket",
Resource: "arn:aws:s3:::test-bucket",
Principal: "user1",
Conditions: map[string][]string{},
}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate for non-matching action (should fall through to IAM), got %v", result)
}
// Test GetBucketPolicy
policy, err := engine.GetBucketPolicy("test-bucket")
if err != nil {
t.Fatalf("Failed to get bucket policy: %v", err)
}
if policy.Version != "2012-10-17" {
t.Errorf("Expected version 2012-10-17, got %s", policy.Version)
}
// Test DeleteBucketPolicy
err = engine.DeleteBucketPolicy("test-bucket")
if err != nil {
t.Fatalf("Failed to delete bucket policy: %v", err)
}
// Test policy is gone
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate after policy deletion, got %v", result)
}
}
func TestConditionEvaluators(t *testing.T) {
tests := []struct {
name string
operator string
conditionValue interface{}
contextValues []string
expected bool
}{
{
name: "StringEquals - match",
operator: "StringEquals",
conditionValue: "test-value",
contextValues: []string{"test-value"},
expected: true,
},
{
name: "StringEquals - no match",
operator: "StringEquals",
conditionValue: "test-value",
contextValues: []string{"other-value"},
expected: false,
},
{
name: "StringLike - wildcard match",
operator: "StringLike",
conditionValue: "test-*",
contextValues: []string{"test-value"},
expected: true,
},
{
name: "StringLike - wildcard no match",
operator: "StringLike",
conditionValue: "test-*",
contextValues: []string{"other-value"},
expected: false,
},
{
name: "NumericEquals - match",
operator: "NumericEquals",
conditionValue: "42",
contextValues: []string{"42"},
expected: true,
},
{
name: "NumericLessThan - match",
operator: "NumericLessThan",
conditionValue: "100",
contextValues: []string{"50"},
expected: true,
},
{
name: "NumericLessThan - no match",
operator: "NumericLessThan",
conditionValue: "100",
contextValues: []string{"150"},
expected: false,
},
{
name: "IpAddress - CIDR match",
operator: "IpAddress",
conditionValue: "192.168.1.0/24",
contextValues: []string{"192.168.1.100"},
expected: true,
},
{
name: "IpAddress - CIDR no match",
operator: "IpAddress",
conditionValue: "192.168.1.0/24",
contextValues: []string{"10.0.0.1"},
expected: false,
},
{
name: "Bool - true match",
operator: "Bool",
conditionValue: "true",
contextValues: []string{"true"},
expected: true,
},
{
name: "Bool - false match",
operator: "Bool",
conditionValue: "false",
contextValues: []string{"false"},
expected: true,
},
{
name: "Bool - no match",
operator: "Bool",
conditionValue: "true",
contextValues: []string{"false"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
evaluator, err := GetConditionEvaluator(tt.operator)
if err != nil {
t.Fatalf("Failed to get condition evaluator: %v", err)
}
result := evaluator.Evaluate(tt.conditionValue, tt.contextValues)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestPolicyValidation(t *testing.T) {
tests := []struct {
name string
policyJSON string
expectError bool
}{
{
name: "Valid policy",
policyJSON: `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*"
}
]
}`,
expectError: false,
},
{
name: "Valid policy with single statement object",
policyJSON: `{
"Version": "2012-10-17",
"Statement": {
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*"
}
}`,
expectError: false,
},
{
name: "Invalid version",
policyJSON: `{
"Version": "2008-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*"
}
]
}`,
expectError: true,
},
{
name: "Missing action",
policyJSON: `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Resource": "arn:aws:s3:::test-bucket/*"
}
]
}`,
expectError: true,
},
{
name: "Invalid JSON",
policyJSON: `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*"
}
]
}extra`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParsePolicy(tt.policyJSON)
if (err != nil) != tt.expectError {
t.Errorf("Expected error: %v, got error: %v", tt.expectError, err)
}
})
}
}
func TestPatternMatching(t *testing.T) {
tests := []struct {
name string
pattern string
value string
expected bool
}{
{
name: "Exact match",
pattern: "s3:GetObject",
value: "s3:GetObject",
expected: true,
},
{
name: "Wildcard match",
pattern: "s3:Get*",
value: "s3:GetObject",
expected: true,
},
{
name: "Wildcard no match",
pattern: "s3:Put*",
value: "s3:GetObject",
expected: false,
},
{
name: "Full wildcard",
pattern: "*",
value: "anything",
expected: true,
},
{
name: "Question mark wildcard",
pattern: "s3:GetObjec?",
value: "s3:GetObject",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiled, err := compilePattern(tt.pattern)
if err != nil {
t.Fatalf("Failed to compile pattern %s: %v", tt.pattern, err)
}
result := compiled.MatchString(tt.value)
if result != tt.expected {
t.Errorf("Pattern %s against %s: expected %v, got %v", tt.pattern, tt.value, tt.expected, result)
}
})
}
}
func TestExtractConditionValuesFromRequest(t *testing.T) {
// Create a test request
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/test-bucket/test-object",
RawQuery: "prefix=test&delimiter=/",
},
Header: map[string][]string{
"User-Agent": {"test-agent"},
"X-Amz-Copy-Source": {"source-bucket/source-object"},
},
RemoteAddr: "192.168.1.100:12345",
}
values := NewPolicyEngine().ExtractConditionValuesFromRequest(req)
// Check extracted values
if len(values["aws:SourceIp"]) != 1 || values["aws:SourceIp"][0] != "192.168.1.100" {
t.Errorf("Expected SourceIp to be 192.168.1.100, got %v", values["aws:SourceIp"])
}
if len(values["aws:UserAgent"]) != 1 || values["aws:UserAgent"][0] != "test-agent" {
t.Errorf("Expected UserAgent to be test-agent, got %v", values["aws:UserAgent"])
}
if len(values["s3:prefix"]) != 1 || values["s3:prefix"][0] != "test" {
t.Errorf("Expected prefix to be test, got %v", values["s3:prefix"])
}
if len(values["s3:delimiter"]) != 1 || values["s3:delimiter"][0] != "/" {
t.Errorf("Expected delimiter to be /, got %v", values["s3:delimiter"])
}
if len(values["s3:RequestMethod"]) != 1 || values["s3:RequestMethod"][0] != "GET" {
t.Errorf("Expected RequestMethod to be GET, got %v", values["s3:RequestMethod"])
}
if len(values["s3:x-amz-copy-source"]) != 1 || values["s3:x-amz-copy-source"][0] != "source-bucket/source-object" {
t.Errorf("Expected X-Amz-Copy-Source header to be extracted with s3: prefix, got %v", values["s3:x-amz-copy-source"])
}
// Check that aws:CurrentTime is properly set
if len(values["aws:CurrentTime"]) != 1 {
t.Errorf("Expected aws:CurrentTime to be set, got %v", values["aws:CurrentTime"])
}
// Check that aws:RequestTime is still available for backward compatibility
if len(values["aws:RequestTime"]) != 1 {
t.Errorf("Expected aws:RequestTime to be set for backward compatibility, got %v", values["aws:RequestTime"])
}
}
func TestExtractConditionValuesFromRequestSourceIPPrecedence(t *testing.T) {
tests := []struct {
name string
header map[string][]string
remoteAddr string
expectedIP string
}{
{
name: "ignores X-Forwarded-For and uses RemoteAddr",
header: map[string][]string{
"X-Forwarded-For": {"bad-ip, 203.0.113.10, 198.51.100.5"},
},
remoteAddr: "192.168.1.100:12345",
expectedIP: "192.168.1.100",
},
{
name: "ignores X-Real-Ip and uses RemoteAddr",
header: map[string][]string{
"X-Forwarded-For": {"bad-ip"},
"X-Real-Ip": {"198.51.100.7"},
},
remoteAddr: "192.168.1.100:12345",
expectedIP: "192.168.1.100",
},
{
name: "uses RemoteAddr ip when no forwarding headers",
header: map[string][]string{},
remoteAddr: "192.168.1.100:12345",
expectedIP: "192.168.1.100",
},
{
name: "keeps unix socket marker when RemoteAddr is not an ip",
header: map[string][]string{},
remoteAddr: "@",
expectedIP: "@",
},
{
name: "ignores IPv6 X-Forwarded-For entry and uses RemoteAddr",
header: map[string][]string{
"X-Forwarded-For": {"2001:db8::8, 198.51.100.7"},
},
remoteAddr: "192.168.1.100:12345",
expectedIP: "192.168.1.100",
},
{
name: "ignores spoofed X-Forwarded-For behind private peer",
header: map[string][]string{
"X-Forwarded-For": {"8.8.8.8, 203.0.113.10, 10.0.0.1"},
},
remoteAddr: "192.168.1.100:12345",
expectedIP: "192.168.1.100",
},
{
name: "handles bracketed IPv6 remote address",
header: map[string][]string{},
remoteAddr: "[2001:db8::1]:12345",
expectedIP: "2001:db8::1",
},
{
name: "avoids returning DNS host names",
header: map[string][]string{},
remoteAddr: "example.com:9000",
expectedIP: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &http.Request{
Method: "GET",
URL: &url.URL{Path: "/"},
Header: tt.header,
RemoteAddr: tt.remoteAddr,
}
values := NewPolicyEngine().ExtractConditionValuesFromRequest(req)
if len(values["aws:SourceIp"]) != 1 || values["aws:SourceIp"][0] != tt.expectedIP {
t.Errorf("Expected SourceIp %q, got %v", tt.expectedIP, values["aws:SourceIp"])
}
})
}
}
func TestExtractSourceIP_IgnoresForwardedHeaders(t *testing.T) {
req := &http.Request{
Method: "GET",
URL: &url.URL{Path: "/"},
Header: map[string][]string{
"X-Forwarded-For": {"203.0.113.99"},
"X-Real-Ip": {"198.51.100.1"},
},
RemoteAddr: "10.0.0.5:54321",
}
values := NewPolicyEngine().ExtractConditionValuesFromRequest(req)
if got := values["aws:SourceIp"]; len(got) != 1 || got[0] != "10.0.0.5" {
t.Errorf("Expected SourceIp to be the direct peer 10.0.0.5, got %v", got)
}
}
func TestExtractSourceIP_EnforcesIPRestrictionPolicy(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Principal": "*", "Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::secret-bucket/*"},
{"Effect": "Deny", "Principal": "*", "Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::secret-bucket/*",
"Condition": {"NotIpAddress": {"aws:SourceIp": ["10.0.0.0/24"]}}}
]
}`
if err := engine.SetBucketPolicy("secret-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
r := httptest.NewRequest(http.MethodGet, "/secret-bucket/secret-key", nil)
r.RemoteAddr = "10.0.50.5:54321"
r.Header.Set("X-Forwarded-For", "10.0.0.5")
result := engine.EvaluatePolicy("secret-bucket", &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::secret-bucket/secret-key",
Principal: "*",
Conditions: engine.ExtractConditionValuesFromRequest(r),
})
if result != PolicyResultDeny {
t.Errorf("Expected Deny for peer outside 10.0.0.0/24 despite spoofed X-Forwarded-For, got %v", result)
}
}
func TestExtractSourceIP_TrustedProxyHonorsForwardedHeader(t *testing.T) {
engine := NewPolicyEngine()
engine.SetTrustedProxies(NewTrustedProxies([]string{"10.0.0.0/24"}))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = "10.0.0.1:54321"
r.Header.Set("X-Forwarded-For", "203.0.113.99")
values := engine.ExtractConditionValuesFromRequest(r)
if got := values["aws:SourceIp"]; len(got) != 1 || got[0] != "203.0.113.99" {
t.Errorf("Expected trusted proxy to honor X-Forwarded-For 203.0.113.99, got %v", got)
}
}
func TestPolicyEvaluationWithConditions(t *testing.T) {
engine := NewPolicyEngine()
// Policy with IP condition
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "192.168.1.0/24"
}
}
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Test matching IP
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "user1",
Conditions: map[string][]string{
"aws:SourceIp": {"192.168.1.100"},
},
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultAllow {
t.Errorf("Expected Allow for matching IP, got %v", result)
}
// Test non-matching IP
args.Conditions["aws:SourceIp"] = []string{"10.0.0.1"}
result = engine.EvaluatePolicy("test-bucket", args)
if result != PolicyResultIndeterminate {
t.Errorf("Expected Indeterminate for non-matching IP (should fall through to IAM), got %v", result)
}
}
func TestResourceArn(t *testing.T) {
tests := []struct {
name string
bucketName string
objectName string
expected string
}{
{
name: "Bucket only",
bucketName: "test-bucket",
objectName: "",
expected: "arn:aws:s3:::test-bucket",
},
{
name: "Bucket and object",
bucketName: "test-bucket",
objectName: "test-object",
expected: "arn:aws:s3:::test-bucket/test-object",
},
{
name: "Bucket and nested object",
bucketName: "test-bucket",
objectName: "folder/subfolder/test-object",
expected: "arn:aws:s3:::test-bucket/folder/subfolder/test-object",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildResourceArn(tt.bucketName, tt.objectName)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestActionConversion(t *testing.T) {
tests := []struct {
name string
action string
expected string
}{
{
name: "Already has s3 prefix",
action: "s3:GetObject",
expected: "s3:GetObject",
},
{
name: "Add s3 prefix",
action: "GetObject",
expected: "s3:GetObject",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildActionName(tt.action)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
func TestPolicyEngineForRequest(t *testing.T) {
engine := NewPolicyEngine()
// Set up a policy
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:RequestMethod": "GET"
}
}
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
// Create test request
req := &http.Request{
Method: "GET",
URL: &url.URL{
Path: "/test-bucket/test-object",
},
Header: make(map[string][]string),
RemoteAddr: "192.168.1.100:12345",
}
// Test the request
result := engine.EvaluatePolicyForRequest("test-bucket", "test-object", "GetObject", "user1", req)
if result != PolicyResultAllow {
t.Errorf("Expected Allow for matching request, got %v", result)
}
}
func TestWildcardMatching(t *testing.T) {
tests := []struct {
name string
pattern string
str string
expected bool
}{
{
name: "Exact match",
pattern: "test",
str: "test",
expected: true,
},
{
name: "Single wildcard",
pattern: "*",
str: "anything",
expected: true,
},
{
name: "Prefix wildcard",
pattern: "test*",
str: "test123",
expected: true,
},
{
name: "Suffix wildcard",
pattern: "*test",
str: "123test",
expected: true,
},
{
name: "Middle wildcard",
pattern: "test*123",
str: "testABC123",
expected: true,
},
{
name: "No match",
pattern: "test*",
str: "other",
expected: false,
},
{
name: "Multiple wildcards",
pattern: "test*abc*123",
str: "testXYZabcDEF123",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := wildcard.MatchesWildcard(tt.pattern, tt.str)
if result != tt.expected {
t.Errorf("Pattern %s against %s: expected %v, got %v", tt.pattern, tt.str, tt.expected, result)
}
})
}
}
func TestCompilePolicy(t *testing.T) {
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::test-bucket/*"
}
]
}`
policy, err := ParsePolicy(policyJSON)
if err != nil {
t.Fatalf("Failed to parse policy: %v", err)
}
compiled, err := CompilePolicy(policy)
if err != nil {
t.Fatalf("Failed to compile policy: %v", err)
}
if len(compiled.Statements) != 1 {
t.Errorf("Expected 1 compiled statement, got %d", len(compiled.Statements))
}
stmt := compiled.Statements[0]
if len(stmt.ActionPatterns) != 2 {
t.Errorf("Expected 2 action patterns, got %d", len(stmt.ActionPatterns))
}
if len(stmt.ResourcePatterns) != 1 {
t.Errorf("Expected 1 resource pattern, got %d", len(stmt.ResourcePatterns))
}
}
// TestExistingObjectTagCondition tests s3:ExistingObjectTag/<tag-key> condition support
func TestExistingObjectTagCondition(t *testing.T) {
engine := NewPolicyEngine()
// Policy that allows GetObject only for objects with specific tag
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/status": ["public"]
}
}
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
tests := []struct {
name string
objectTags map[string]string
expected PolicyEvaluationResult
}{
{
name: "Matching tag value - should allow",
objectTags: map[string]string{"status": "public"},
expected: PolicyResultAllow,
},
{
name: "Non-matching tag value - should be indeterminate",
objectTags: map[string]string{"status": "private"},
expected: PolicyResultIndeterminate,
},
{
name: "Missing tag - should be indeterminate",
objectTags: map[string]string{"other": "value"},
expected: PolicyResultIndeterminate,
},
{
name: "No tags - should be indeterminate",
objectTags: nil,
expected: PolicyResultIndeterminate,
},
{
name: "Empty tags - should be indeterminate",
objectTags: map[string]string{},
expected: PolicyResultIndeterminate,
},
{
name: "Multiple tags with matching one - should allow",
objectTags: map[string]string{"status": "public", "owner": "admin"},
expected: PolicyResultAllow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
ObjectEntry: tagsToEntry(tt.objectTags),
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestRequestObjectTagCondition(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:RequestObjectTag/department": ["engineering"]
}
}
}
]
}`
if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
copyHeaders := map[string]string{"X-Amz-Copy-Source": "/src-bucket/src-object"}
replaceHeaders := map[string]string{
"X-Amz-Copy-Source": "/src-bucket/src-object",
s3_constants.AmzObjectTaggingDirective: "REPLACE",
}
tests := []struct {
name string
method string
target string
headers map[string]string
tagging string
expected PolicyEvaluationResult
}{
{"matching tag allows", "", "", nil, "department=engineering", PolicyResultAllow},
{"matching tag among others allows", "", "", nil, "project=alpha&department=engineering", PolicyResultAllow},
{"url-encoded value is decoded", "", "", nil, "department=engin%65ering", PolicyResultAllow},
{"other value is not allowed", "", "", nil, "department=sales", PolicyResultIndeterminate},
{"missing tag is not allowed", "", "", nil, "project=alpha", PolicyResultIndeterminate},
{"no tagging header is not allowed", "", "", nil, "", PolicyResultIndeterminate},
{"malformed header is not allowed", "", "", nil, "department=engineering&%zz", PolicyResultIndeterminate},
{"last value of a repeated key counts", "", "", nil, "department=sales&department=engineering", PolicyResultAllow},
{"earlier value of a repeated key is ignored", "", "", nil, "department=engineering&department=sales", PolicyResultIndeterminate},
{"header is ignored on PutObjectTagging", "", "?tagging", nil, "department=engineering", PolicyResultIndeterminate},
{"header counts with an unrelated query parameter", "", "?foo=bar", nil, "department=engineering", PolicyResultAllow},
{"header counts on a Signature V2 presigned PUT", "", "?AWSAccessKeyId=k&Signature=s&Expires=1", nil, "department=engineering", PolicyResultAllow},
{"header is ignored on a renameObject subresource", "", "?renameObject", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on a copy that keeps source tags", "", "", copyHeaders, "department=engineering", PolicyResultIndeterminate},
{"header counts on a copy that replaces tags", "", "", replaceHeaders, "department=engineering", PolicyResultAllow},
{"header is ignored on a multipart copy that replaces tags", "", "?partNumber=1&uploadId=x", replaceHeaders, "department=engineering", PolicyResultIndeterminate},
{"header counts on a copy with a lone partNumber", "", "?partNumber=1", replaceHeaders, "department=engineering", PolicyResultAllow},
{"header counts on a multipart create", "POST", "?uploads", nil, "department=engineering", PolicyResultAllow},
{"header is ignored on a part upload", "", "?partNumber=1&uploadId=x", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on multipart completion", "POST", "?uploadId=x", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on a directory marker", "", "/", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on an acl subresource", "", "?acl", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on a GET", "GET", "", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on a DELETE", "DELETE", "", nil, "department=engineering", PolicyResultIndeterminate},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
method := tt.method
if method == "" {
method = http.MethodPut
}
req := httptest.NewRequest(method, "/test-bucket/test-object"+tt.target, nil)
for k, v := range tt.headers {
req.Header.Set(k, v)
}
if tt.tagging != "" {
req.Header.Set(s3_constants.AmzObjectTagging, tt.tagging)
}
args := &PolicyEvaluationArgs{
Action: "s3:PutObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
Conditions: engine.ExtractConditionValuesFromRequest(req),
}
if result := engine.EvaluatePolicy("test-bucket", args); result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestRequestObjectTagConditionDeny(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*"
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringNotEquals": {
"s3:RequestObjectTag/department": ["engineering"]
}
}
}
]
}`
if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
tests := []struct {
name string
target string
tagging string
expected PolicyEvaluationResult
}{
{"required tag passes", "", "department=engineering", PolicyResultAllow},
{"other value is denied", "", "department=sales", PolicyResultDeny},
{"missing tag is denied", "", "project=alpha", PolicyResultDeny},
{"repeated key ending in another value is denied", "", "department=engineering&department=sales", PolicyResultDeny},
{"malformed header is denied", "", "department=engineering&%zz", PolicyResultDeny},
{"unrelated query parameter does not hide the tag", "?foo=bar", "department=sales", PolicyResultDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/test-bucket/test-object"+tt.target, nil)
req.Header.Set(s3_constants.AmzObjectTagging, tt.tagging)
args := &PolicyEvaluationArgs{
Action: "s3:PutObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
Conditions: engine.ExtractConditionValuesFromRequest(req),
}
if result := engine.EvaluatePolicy("test-bucket", args); result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func evaluateTaggedRequest(engine *PolicyEngine, method, target string, headers map[string]string, tagging string) PolicyEvaluationResult {
if method == "" {
method = http.MethodPut
}
req := httptest.NewRequest(method, "/test-bucket/test-object"+target, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
if tagging != "" {
req.Header.Set(s3_constants.AmzObjectTagging, tagging)
}
return engine.EvaluatePolicy("test-bucket", &PolicyEvaluationArgs{
Action: "s3:PutObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
Conditions: engine.ExtractConditionValuesFromRequest(req),
})
}
func TestRequestObjectTagKeysCondition(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"ForAllValues:StringEquals": {
"s3:RequestObjectTagKeys": ["department", "project"]
},
"Null": {
"s3:RequestObjectTagKeys": "false"
}
}
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"ForAnyValue:StringLike": {
"s3:RequestObjectTagKeys": ["secret*"]
}
}
}
]
}`
if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
copyHeaders := map[string]string{"X-Amz-Copy-Source": "/src-bucket/src-object"}
replaceHeaders := map[string]string{
"X-Amz-Copy-Source": "/src-bucket/src-object",
s3_constants.AmzObjectTaggingDirective: "REPLACE",
}
tests := []struct {
name string
method string
target string
headers map[string]string
tagging string
expected PolicyEvaluationResult
}{
{"listed key allows", "", "", nil, "department=engineering", PolicyResultAllow},
{"all listed keys allow", "", "", nil, "department=engineering&project=alpha", PolicyResultAllow},
{"unlisted key is not allowed", "", "", nil, "department=engineering&owner=bob", PolicyResultIndeterminate},
{"no tagging header is not allowed", "", "", nil, "", PolicyResultIndeterminate},
{"repeated key counts once", "", "", nil, "department=a&department=b", PolicyResultAllow},
{"url-encoded key is decoded", "", "", nil, "depart%6Dent=engineering", PolicyResultAllow},
{"empty segments add no key", "", "", nil, "&department=engineering&", PolicyResultAllow},
{"empty key is not allowed", "", "", nil, "department=engineering&=x", PolicyResultIndeterminate},
{"key case is preserved", "", "", nil, "Department=engineering", PolicyResultIndeterminate},
{"malformed header is not allowed", "", "", nil, "department=engineering&%zz", PolicyResultIndeterminate},
{"secret key is denied", "", "", nil, "secret=1", PolicyResultDeny},
{"secret key behind a padded header is denied", "", "", nil, "secret=1" + strings.Repeat("&", 10001), PolicyResultDeny},
{"secret key among listed keys is denied", "", "", nil, "department=engineering&secret-level=2", PolicyResultDeny},
{"url-encoded secret key is denied", "", "", nil, "secr%65t=1", PolicyResultDeny},
{"secret key on a copy that replaces tags is denied", "", "", replaceHeaders, "secret=1", PolicyResultDeny},
{"secret key on a multipart create is denied", "POST", "?uploads", nil, "secret=1", PolicyResultDeny},
{"header is ignored on a copy that keeps source tags", "", "", copyHeaders, "secret=1", PolicyResultIndeterminate},
{"header is ignored on PutObjectTagging", "", "?tagging", nil, "department=engineering", PolicyResultIndeterminate},
{"header is ignored on a part upload", "", "?partNumber=1&uploadId=x", nil, "secret=1", PolicyResultIndeterminate},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if result := evaluateTaggedRequest(engine, tt.method, tt.target, tt.headers, tt.tagging); result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestRequestObjectTagKeysRequiredTagDeny(t *testing.T) {
engine := NewPolicyEngine()
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*"
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"ForAllValues:StringNotEquals": {
"s3:RequestObjectTagKeys": ["department", "project"]
}
}
}
]
}`
if err := engine.SetBucketPolicy("test-bucket", policyJSON); err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
tests := []struct {
name string
tagging string
expected PolicyEvaluationResult
}{
{"required key passes", "department=engineering", PolicyResultAllow},
{"required key with another key passes", "project=alpha&owner=bob", PolicyResultAllow},
{"only other keys are denied", "owner=bob", PolicyResultDeny},
{"no tagging header is denied", "", PolicyResultDeny},
{"malformed header is denied", "department=engineering&%zz", PolicyResultDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if result := evaluateTaggedRequest(engine, "", "", nil, tt.tagging); result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestExistingObjectTagConditionMultipleTags tests policies with multiple tag conditions
func TestExistingObjectTagConditionMultipleTags(t *testing.T) {
engine := NewPolicyEngine()
// Policy that requires multiple tag conditions
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/status": ["public"],
"s3:ExistingObjectTag/tier": ["free", "premium"]
}
}
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
tests := []struct {
name string
objectTags map[string]string
expected PolicyEvaluationResult
}{
{
name: "Both tags match - should allow",
objectTags: map[string]string{"status": "public", "tier": "free"},
expected: PolicyResultAllow,
},
{
name: "Both tags match (premium tier) - should allow",
objectTags: map[string]string{"status": "public", "tier": "premium"},
expected: PolicyResultAllow,
},
{
name: "Only status matches - should be indeterminate",
objectTags: map[string]string{"status": "public"},
expected: PolicyResultIndeterminate,
},
{
name: "Only tier matches - should be indeterminate",
objectTags: map[string]string{"tier": "free"},
expected: PolicyResultIndeterminate,
},
{
name: "Neither tag matches - should be indeterminate",
objectTags: map[string]string{"status": "private", "tier": "basic"},
expected: PolicyResultIndeterminate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
ObjectEntry: tagsToEntry(tt.objectTags),
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestExistingObjectTagDenyPolicy tests deny policies with tag conditions
func TestExistingObjectTagDenyPolicy(t *testing.T) {
engine := NewPolicyEngine()
// Policy that denies access to objects with confidential tag
policyJSON := `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*"
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::test-bucket/*",
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/classification": ["confidential"]
}
}
}
]
}`
err := engine.SetBucketPolicy("test-bucket", policyJSON)
if err != nil {
t.Fatalf("Failed to set bucket policy: %v", err)
}
tests := []struct {
name string
objectTags map[string]string
expected PolicyEvaluationResult
}{
{
name: "No tags - allow by default statement",
objectTags: nil,
expected: PolicyResultAllow,
},
{
name: "Non-confidential tag - allow",
objectTags: map[string]string{"classification": "public"},
expected: PolicyResultAllow,
},
{
name: "Confidential tag - deny",
objectTags: map[string]string{"classification": "confidential"},
expected: PolicyResultDeny,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := &PolicyEvaluationArgs{
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/test-object",
Principal: "*",
ObjectEntry: tagsToEntry(tt.objectTags),
}
result := engine.EvaluatePolicy("test-bucket", args)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// 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")
}
})
}