mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 06:20:48 +02:00
* s3api: support s3:RequestObjectTag/<key> in bucket policies The tags come from X-Amz-Tagging only where the handler stores them from there: not on PutObjectTagging, which reads the body, and not on a copy that keeps the source's tags. For a repeated key the last value counts, since that is what CopyObject stores. * s3api: extract RequestObjectTag only where the handler stores it The header was honored on any request carrying it, so a tag attached to a GET, DELETE, part upload, multipart completion, directory marker or a PUT subresource satisfied a tag-conditioned policy the object never receives. Extraction now follows the handlers: PutObject, a copy under X-Amz-Tagging-Directive: REPLACE, and CreateMultipartUpload store the header; a plain object PUT is identified by carrying only X-Amz-* signing parameters. * s3api: exclude multipart part PUTs before the copy-source check A PUT carrying X-Amz-Copy-Source plus a numeric partNumber and uploadId routes to CopyObjectPartHandler, which stores a part and never reads X-Amz-Tagging. Checking the copy source first let such a request satisfy an s3:RequestObjectTag condition under REPLACE with tags that are never stored. Mirror the router: the part handlers match only when both partNumber (digits only) and uploadId are present, so a lone or non-numeric partNumber still falls through to the storing handlers. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3api: gate tag extraction on the routed subresource, not query key shape Rejecting every PUT whose query held a key outside X-Amz- dropped X-Amz-Tagging from ordinary object PUTs that carry arbitrary parameters — Signature V2 presigns (AWSAccessKeyId/Signature/Expires) among them — so a tag-conditioned Deny could be bypassed by appending an unrelated query parameter while putToFiler still stored the tags. Exclude the object PUT subresources the router actually dispatches (tagging, renameObject, acl, retention, legal-hold) ahead of the copy-source check instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * s3api: cover the routed-subresource and part-copy tag extraction cases Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Chris Lu <chris.lu@gmail.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1279 lines
34 KiB
Go
1279 lines
34 KiB
Go
package policy_engine
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
})
|
|
}
|