S3: Enforce bucket policy (#7471)
* evaluate policies during authorization * cache bucket policy * refactor * matching with regex special characters * Case Sensitivity, pattern cache, Dead Code Removal * Fixed Typo, Restored []string Case, Added Cache Size Limit * hook up with policy engine * remove old implementation * action mapping * validate * if not specified, fall through to IAM checks * fmt * Fail-close on policy evaluation errors * Explicit `Allow` bypasses IAM checks * fix error message * arn:seaweed => arn:aws * remove legacy support * fix tests * Clean up bucket policy after this test * fix for tests * address comments * security fixes * fix tests * temp comment out
This commit is contained in:
@@ -53,6 +53,9 @@ type IdentityAccessManagement struct {
|
||||
|
||||
// IAM Integration for advanced features
|
||||
iamIntegration *S3IAMIntegration
|
||||
|
||||
// Link to S3ApiServer for bucket policy evaluation
|
||||
s3ApiServer *S3ApiServer
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
@@ -60,7 +63,7 @@ type Identity struct {
|
||||
Account *Account
|
||||
Credentials []*Credential
|
||||
Actions []Action
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:seaweed:iam::user/username")
|
||||
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
|
||||
}
|
||||
|
||||
// Account represents a system user, a system user can
|
||||
@@ -381,11 +384,11 @@ func generatePrincipalArn(identityName string) string {
|
||||
// Handle special cases
|
||||
switch identityName {
|
||||
case AccountAnonymous.Id:
|
||||
return "arn:seaweed:iam::user/anonymous"
|
||||
return "arn:aws:iam::user/anonymous"
|
||||
case AccountAdmin.Id:
|
||||
return "arn:seaweed:iam::user/admin"
|
||||
return "arn:aws:iam::user/admin"
|
||||
default:
|
||||
return fmt.Sprintf("arn:seaweed:iam::user/%s", identityName)
|
||||
return fmt.Sprintf("arn:aws:iam::user/%s", identityName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,19 +500,57 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action)
|
||||
|
||||
// For ListBuckets, authorization is performed in the handler by iterating
|
||||
// through buckets and checking permissions for each. Skip the global check here.
|
||||
policyAllows := false
|
||||
|
||||
if action == s3_constants.ACTION_LIST && bucket == "" {
|
||||
// ListBuckets operation - authorization handled per-bucket in the handler
|
||||
} else {
|
||||
// Use enhanced IAM authorization if available, otherwise fall back to legacy authorization
|
||||
if iam.iamIntegration != nil {
|
||||
// Always use IAM when available for unified authorization
|
||||
if errCode := iam.authorizeWithIAM(r, identity, action, bucket, object); errCode != s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
} else {
|
||||
// Fall back to existing authorization when IAM is not configured
|
||||
if !identity.canDo(action, bucket, object) {
|
||||
// First check bucket policy if one exists
|
||||
// Bucket policies can grant or deny access to specific users/principals
|
||||
// Following AWS semantics:
|
||||
// - Explicit DENY in bucket policy → immediate rejection
|
||||
// - Explicit ALLOW in bucket policy → grant access (bypass IAM checks)
|
||||
// - No policy or indeterminate → fall through to IAM checks
|
||||
if iam.s3ApiServer != nil && iam.s3ApiServer.policyEngine != nil && bucket != "" {
|
||||
principal := buildPrincipalARN(identity)
|
||||
allowed, evaluated, err := iam.s3ApiServer.policyEngine.EvaluatePolicy(bucket, object, string(action), principal)
|
||||
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
// If we can't evaluate the policy, deny access rather than falling through to IAM
|
||||
glog.Errorf("Error evaluating bucket policy for %s/%s: %v - denying access", bucket, object, err)
|
||||
return identity, s3err.ErrAccessDenied
|
||||
} else if evaluated {
|
||||
// A bucket policy exists and was evaluated with a matching statement
|
||||
if allowed {
|
||||
// Policy explicitly allows this action - grant access immediately
|
||||
// This bypasses IAM checks to support cross-account access and policy-only principals
|
||||
glog.V(3).Infof("Bucket policy allows %s to %s on %s/%s (bypassing IAM)", identity.Name, action, bucket, object)
|
||||
policyAllows = true
|
||||
} else {
|
||||
// Policy explicitly denies this action - deny access immediately
|
||||
// Note: Explicit Deny in bucket policy overrides all other permissions
|
||||
glog.V(3).Infof("Bucket policy explicitly denies %s to %s on %s/%s", identity.Name, action, bucket, object)
|
||||
return identity, s3err.ErrAccessDenied
|
||||
}
|
||||
}
|
||||
// If not evaluated (no policy or no matching statements), fall through to IAM/identity checks
|
||||
}
|
||||
|
||||
// Only check IAM if bucket policy didn't explicitly allow
|
||||
// This ensures bucket policies can independently grant access (AWS semantics)
|
||||
if !policyAllows {
|
||||
// Use enhanced IAM authorization if available, otherwise fall back to legacy authorization
|
||||
if iam.iamIntegration != nil {
|
||||
// Always use IAM when available for unified authorization
|
||||
if errCode := iam.authorizeWithIAM(r, identity, action, bucket, object); errCode != s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
} else {
|
||||
// Fall back to existing authorization when IAM is not configured
|
||||
if !identity.canDo(action, bucket, object) {
|
||||
return identity, s3err.ErrAccessDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,6 +611,34 @@ func (identity *Identity) isAdmin() bool {
|
||||
return slices.Contains(identity.Actions, s3_constants.ACTION_ADMIN)
|
||||
}
|
||||
|
||||
// buildPrincipalARN builds an ARN for an identity to use in bucket policy evaluation
|
||||
func buildPrincipalARN(identity *Identity) string {
|
||||
if identity == nil {
|
||||
return "*" // Anonymous
|
||||
}
|
||||
|
||||
// Check if this is the anonymous user identity (authenticated as anonymous)
|
||||
// S3 policies expect Principal: "*" for anonymous access
|
||||
if identity.Name == s3_constants.AccountAnonymousId ||
|
||||
(identity.Account != nil && identity.Account.Id == s3_constants.AccountAnonymousId) {
|
||||
return "*" // Anonymous user
|
||||
}
|
||||
|
||||
// Build an AWS-compatible principal ARN
|
||||
// Format: arn:aws:iam::account-id:user/user-name
|
||||
accountId := identity.Account.Id
|
||||
if accountId == "" {
|
||||
accountId = "000000000000" // Default account ID
|
||||
}
|
||||
|
||||
userName := identity.Name
|
||||
if userName == "" {
|
||||
userName = "unknown"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("arn:aws:iam::%s:user/%s", accountId, userName)
|
||||
}
|
||||
|
||||
// GetCredentialManager returns the credential manager instance
|
||||
func (iam *IdentityAccessManagement) GetCredentialManager() *credential.CredentialManager {
|
||||
return iam.credentialManager
|
||||
|
||||
@@ -145,8 +145,14 @@ func (s3a *S3ApiServer) updateBucketConfigCacheFromEntry(entry *filer_pb.Entry)
|
||||
} else {
|
||||
glog.V(3).Infof("updateBucketConfigCacheFromEntry: no Object Lock configuration found for bucket %s", bucket)
|
||||
}
|
||||
|
||||
// Load bucket policy if present (for performance optimization)
|
||||
config.BucketPolicy = loadBucketPolicyFromExtended(entry, bucket)
|
||||
}
|
||||
|
||||
// Sync bucket policy to the policy engine for evaluation
|
||||
s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy)
|
||||
|
||||
// Load CORS configuration from bucket directory content
|
||||
if corsConfig, err := s3a.loadCORSFromBucketContent(bucket); err != nil {
|
||||
if !errors.Is(err, filer_pb.ErrNotFound) {
|
||||
|
||||
@@ -194,7 +194,7 @@ func TestLoadS3ApiConfiguration(t *testing.T) {
|
||||
expectIdent: &Identity{
|
||||
Name: "notSpecifyAccountId",
|
||||
Account: &AccountAdmin,
|
||||
PrincipalArn: "arn:seaweed:iam::user/notSpecifyAccountId",
|
||||
PrincipalArn: "arn:aws:iam::user/notSpecifyAccountId",
|
||||
Actions: []Action{
|
||||
"Read",
|
||||
"Write",
|
||||
@@ -220,7 +220,7 @@ func TestLoadS3ApiConfiguration(t *testing.T) {
|
||||
expectIdent: &Identity{
|
||||
Name: "specifiedAccountID",
|
||||
Account: &specifiedAccount,
|
||||
PrincipalArn: "arn:seaweed:iam::user/specifiedAccountID",
|
||||
PrincipalArn: "arn:aws:iam::user/specifiedAccountID",
|
||||
Actions: []Action{
|
||||
"Read",
|
||||
"Write",
|
||||
@@ -238,7 +238,7 @@ func TestLoadS3ApiConfiguration(t *testing.T) {
|
||||
expectIdent: &Identity{
|
||||
Name: "anonymous",
|
||||
Account: &AccountAnonymous,
|
||||
PrincipalArn: "arn:seaweed:iam::user/anonymous",
|
||||
PrincipalArn: "arn:aws:iam::user/anonymous",
|
||||
Actions: []Action{
|
||||
"Read",
|
||||
"Write",
|
||||
|
||||
@@ -109,7 +109,7 @@ func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args
|
||||
// AWS Policy evaluation logic:
|
||||
// 1. Check for explicit Deny - if found, return Deny
|
||||
// 2. Check for explicit Allow - if found, return Allow
|
||||
// 3. If no explicit Allow is found, return Deny (default deny)
|
||||
// 3. If no matching statements, return Indeterminate (fall through to IAM)
|
||||
|
||||
hasExplicitAllow := false
|
||||
|
||||
@@ -128,7 +128,9 @@ func (engine *PolicyEngine) evaluateCompiledPolicy(policy *CompiledPolicy, args
|
||||
return PolicyResultAllow
|
||||
}
|
||||
|
||||
return PolicyResultDeny // Default deny
|
||||
// No matching statements - return Indeterminate to fall through to IAM
|
||||
// This allows IAM policies to grant access even when bucket policy doesn't mention the action
|
||||
return PolicyResultIndeterminate
|
||||
}
|
||||
|
||||
// evaluateStatement evaluates a single policy statement
|
||||
|
||||
@@ -76,8 +76,8 @@ func TestPolicyEngine(t *testing.T) {
|
||||
}
|
||||
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Expected Deny for non-matching action, got %v", result)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for non-matching action (should fall through to IAM), got %v", result)
|
||||
}
|
||||
|
||||
// Test GetBucketPolicy
|
||||
@@ -471,8 +471,8 @@ func TestPolicyEvaluationWithConditions(t *testing.T) {
|
||||
// Test non-matching IP
|
||||
args.Conditions["aws:SourceIp"] = []string{"10.0.0.1"}
|
||||
result = engine.EvaluatePolicy("test-bucket", args)
|
||||
if result != PolicyResultDeny {
|
||||
t.Errorf("Expected Deny for non-matching IP, got %v", result)
|
||||
if result != PolicyResultIndeterminate {
|
||||
t.Errorf("Expected Indeterminate for non-matching IP (should fall through to IAM), got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBucketPolicyValidationBasics tests the core validation logic
|
||||
func TestBucketPolicyValidationBasics(t *testing.T) {
|
||||
s3Server := &S3ApiServer{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policy *policy.PolicyDocument
|
||||
bucket string
|
||||
expectedValid bool
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "Valid bucket policy",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "TestStatement",
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::test-bucket/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "test-bucket",
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Policy without Principal (invalid)",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:seaweed:s3:::test-bucket/*"},
|
||||
// Principal is missing
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "test-bucket",
|
||||
expectedValid: false,
|
||||
expectedError: "bucket policies must specify a Principal",
|
||||
},
|
||||
{
|
||||
name: "Invalid version",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2008-10-17", // Wrong version
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:seaweed:s3:::test-bucket/*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "test-bucket",
|
||||
expectedValid: false,
|
||||
expectedError: "unsupported policy version",
|
||||
},
|
||||
{
|
||||
name: "Resource not matching bucket",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:seaweed:s3:::other-bucket/*"}, // Wrong bucket
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "test-bucket",
|
||||
expectedValid: false,
|
||||
expectedError: "does not match bucket",
|
||||
},
|
||||
{
|
||||
name: "Non-S3 action",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"iam:GetUser"}, // Non-S3 action
|
||||
Resource: []string{"arn:seaweed:s3:::test-bucket/*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "test-bucket",
|
||||
expectedValid: false,
|
||||
expectedError: "bucket policies only support S3 actions",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := s3Server.validateBucketPolicy(tt.policy, tt.bucket)
|
||||
|
||||
if tt.expectedValid {
|
||||
assert.NoError(t, err, "Policy should be valid")
|
||||
} else {
|
||||
assert.Error(t, err, "Policy should be invalid")
|
||||
if tt.expectedError != "" {
|
||||
assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketResourceValidation tests the resource ARN validation
|
||||
func TestBucketResourceValidation(t *testing.T) {
|
||||
s3Server := &S3ApiServer{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
bucket string
|
||||
valid bool
|
||||
}{
|
||||
// SeaweedFS ARN format
|
||||
{
|
||||
name: "Exact bucket ARN (SeaweedFS)",
|
||||
resource: "arn:seaweed:s3:::test-bucket",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Bucket wildcard ARN (SeaweedFS)",
|
||||
resource: "arn:seaweed:s3:::test-bucket/*",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Specific object ARN (SeaweedFS)",
|
||||
resource: "arn:seaweed:s3:::test-bucket/path/to/object.txt",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
// AWS ARN format (compatibility)
|
||||
{
|
||||
name: "Exact bucket ARN (AWS)",
|
||||
resource: "arn:aws:s3:::test-bucket",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Bucket wildcard ARN (AWS)",
|
||||
resource: "arn:aws:s3:::test-bucket/*",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Specific object ARN (AWS)",
|
||||
resource: "arn:aws:s3:::test-bucket/path/to/object.txt",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
// Simplified format (without ARN prefix)
|
||||
{
|
||||
name: "Simplified bucket name",
|
||||
resource: "test-bucket",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Simplified bucket wildcard",
|
||||
resource: "test-bucket/*",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Simplified specific object",
|
||||
resource: "test-bucket/path/to/object.txt",
|
||||
bucket: "test-bucket",
|
||||
valid: true,
|
||||
},
|
||||
// Invalid cases
|
||||
{
|
||||
name: "Different bucket ARN (SeaweedFS)",
|
||||
resource: "arn:seaweed:s3:::other-bucket/*",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Different bucket ARN (AWS)",
|
||||
resource: "arn:aws:s3:::other-bucket/*",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Different bucket simplified",
|
||||
resource: "other-bucket/*",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Global S3 wildcard (SeaweedFS)",
|
||||
resource: "arn:seaweed:s3:::*",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Global S3 wildcard (AWS)",
|
||||
resource: "arn:aws:s3:::*",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid ARN format",
|
||||
resource: "invalid-arn",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Bucket name prefix match but different bucket",
|
||||
resource: "test-bucket-different/*",
|
||||
bucket: "test-bucket",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := s3Server.validateResourceForBucket(tt.resource, tt.bucket)
|
||||
assert.Equal(t, tt.valid, result, "Resource validation result should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBucketPolicyJSONSerialization tests policy JSON handling
|
||||
func TestBucketPolicyJSONSerialization(t *testing.T) {
|
||||
policy := &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "PublicReadGetObject",
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::public-bucket/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test that policy can be marshaled and unmarshaled correctly
|
||||
jsonData := marshalPolicy(t, policy)
|
||||
assert.NotEmpty(t, jsonData, "JSON data should not be empty")
|
||||
|
||||
// Verify the JSON contains expected elements
|
||||
jsonStr := string(jsonData)
|
||||
assert.Contains(t, jsonStr, "2012-10-17", "JSON should contain version")
|
||||
assert.Contains(t, jsonStr, "s3:GetObject", "JSON should contain action")
|
||||
assert.Contains(t, jsonStr, "arn:seaweed:s3:::public-bucket/*", "JSON should contain resource")
|
||||
assert.Contains(t, jsonStr, "PublicReadGetObject", "JSON should contain statement ID")
|
||||
}
|
||||
|
||||
// Helper function for marshaling policies
|
||||
func marshalPolicy(t *testing.T, policyDoc *policy.PolicyDocument) []byte {
|
||||
data, err := json.Marshal(policyDoc)
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
// TestIssue7252Examples tests the specific examples from GitHub issue #7252
|
||||
func TestIssue7252Examples(t *testing.T) {
|
||||
s3Server := &S3ApiServer{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policy *policy.PolicyDocument
|
||||
bucket string
|
||||
expectedValid bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Issue #7252 - Standard ARN with wildcard",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::main-bucket/*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "main-bucket",
|
||||
expectedValid: true,
|
||||
description: "AWS ARN format should be accepted",
|
||||
},
|
||||
{
|
||||
name: "Issue #7252 - Simplified resource with wildcard",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"main-bucket/*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "main-bucket",
|
||||
expectedValid: true,
|
||||
description: "Simplified format with wildcard should be accepted",
|
||||
},
|
||||
{
|
||||
name: "Issue #7252 - Resource as exact bucket name",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"main-bucket"},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "main-bucket",
|
||||
expectedValid: true,
|
||||
description: "Exact bucket name should be accepted",
|
||||
},
|
||||
{
|
||||
name: "Public read policy with AWS ARN",
|
||||
policy: &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{
|
||||
{
|
||||
Sid: "PublicReadGetObject",
|
||||
Effect: "Allow",
|
||||
Principal: map[string]interface{}{
|
||||
"AWS": "*",
|
||||
},
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::my-public-bucket/*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
bucket: "my-public-bucket",
|
||||
expectedValid: true,
|
||||
description: "Standard public read policy with AWS ARN should work",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := s3Server.validateBucketPolicy(tt.policy, tt.bucket)
|
||||
|
||||
if tt.expectedValid {
|
||||
assert.NoError(t, err, "Policy should be valid: %s", tt.description)
|
||||
} else {
|
||||
assert.Error(t, err, "Policy should be invalid: %s", tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestS3EndToEndWithJWT(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "S3 Read-Only Role Complete Workflow",
|
||||
roleArn: "arn:seaweed:iam::role/S3ReadOnlyRole",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
sessionName: "readonly-test-session",
|
||||
setupRole: setupS3ReadOnlyRole,
|
||||
s3Operations: []S3Operation{
|
||||
@@ -69,7 +69,7 @@ func TestS3EndToEndWithJWT(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "S3 Admin Role Complete Workflow",
|
||||
roleArn: "arn:seaweed:iam::role/S3AdminRole",
|
||||
roleArn: "arn:aws:iam::role/S3AdminRole",
|
||||
sessionName: "admin-test-session",
|
||||
setupRole: setupS3AdminRole,
|
||||
s3Operations: []S3Operation{
|
||||
@@ -83,7 +83,7 @@ func TestS3EndToEndWithJWT(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "S3 IP-Restricted Role",
|
||||
roleArn: "arn:seaweed:iam::role/S3IPRestrictedRole",
|
||||
roleArn: "arn:aws:iam::role/S3IPRestrictedRole",
|
||||
sessionName: "ip-restricted-session",
|
||||
setupRole: setupS3IPRestrictedRole,
|
||||
s3Operations: []S3Operation{
|
||||
@@ -145,7 +145,7 @@ func TestS3MultipartUploadWithJWT(t *testing.T) {
|
||||
|
||||
// Assume role
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:seaweed:iam::role/S3WriteRole",
|
||||
RoleArn: "arn:aws:iam::role/S3WriteRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "multipart-test-session",
|
||||
})
|
||||
@@ -255,7 +255,7 @@ func TestS3PerformanceWithIAM(t *testing.T) {
|
||||
|
||||
// Assume role
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:seaweed:iam::role/S3ReadOnlyRole",
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "performance-test-session",
|
||||
})
|
||||
@@ -452,8 +452,8 @@ func setupS3ReadOnlyRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket", "s3:HeadObject"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -496,8 +496,8 @@ func setupS3AdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -540,8 +540,8 @@ func setupS3WriteRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:PutObject", "s3:GetObject", "s3:ListBucket", "s3:DeleteObject"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -584,8 +584,8 @@ func setupS3IPRestrictedRole(ctx context.Context, manager *integration.IAMManage
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"IpAddress": {
|
||||
|
||||
@@ -139,7 +139,7 @@ func (s3iam *S3IAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Requ
|
||||
parts := strings.Split(roleName, "/")
|
||||
roleNameOnly = parts[len(parts)-1]
|
||||
}
|
||||
principalArn = fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
principalArn = fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
}
|
||||
|
||||
// Validate the JWT token directly using STS service (avoid circular dependency)
|
||||
@@ -238,11 +238,11 @@ type MockAssumedRoleUser struct {
|
||||
// buildS3ResourceArn builds an S3 resource ARN from bucket and object
|
||||
func buildS3ResourceArn(bucket string, objectKey string) string {
|
||||
if bucket == "" {
|
||||
return "arn:seaweed:s3:::*"
|
||||
return "arn:aws:s3:::*"
|
||||
}
|
||||
|
||||
if objectKey == "" || objectKey == "/" {
|
||||
return "arn:seaweed:s3:::" + bucket
|
||||
return "arn:aws:s3:::" + bucket
|
||||
}
|
||||
|
||||
// Remove leading slash from object key if present
|
||||
@@ -250,7 +250,7 @@ func buildS3ResourceArn(bucket string, objectKey string) string {
|
||||
objectKey = objectKey[1:]
|
||||
}
|
||||
|
||||
return "arn:seaweed:s3:::" + bucket + "/" + objectKey
|
||||
return "arn:aws:s3:::" + bucket + "/" + objectKey
|
||||
}
|
||||
|
||||
// determineGranularS3Action determines the specific S3 IAM action based on HTTP request details
|
||||
|
||||
@@ -84,31 +84,31 @@ func TestBuildS3ResourceArn(t *testing.T) {
|
||||
name: "empty bucket and object",
|
||||
bucket: "",
|
||||
object: "",
|
||||
expected: "arn:seaweed:s3:::*",
|
||||
expected: "arn:aws:s3:::*",
|
||||
},
|
||||
{
|
||||
name: "bucket only",
|
||||
bucket: "test-bucket",
|
||||
object: "",
|
||||
expected: "arn:seaweed:s3:::test-bucket",
|
||||
expected: "arn:aws:s3:::test-bucket",
|
||||
},
|
||||
{
|
||||
name: "bucket and object",
|
||||
bucket: "test-bucket",
|
||||
object: "test-object.txt",
|
||||
expected: "arn:seaweed:s3:::test-bucket/test-object.txt",
|
||||
expected: "arn:aws:s3:::test-bucket/test-object.txt",
|
||||
},
|
||||
{
|
||||
name: "bucket and object with leading slash",
|
||||
bucket: "test-bucket",
|
||||
object: "/test-object.txt",
|
||||
expected: "arn:seaweed:s3:::test-bucket/test-object.txt",
|
||||
expected: "arn:aws:s3:::test-bucket/test-object.txt",
|
||||
},
|
||||
{
|
||||
name: "bucket and nested object",
|
||||
bucket: "test-bucket",
|
||||
object: "folder/subfolder/test-object.txt",
|
||||
expected: "arn:seaweed:s3:::test-bucket/folder/subfolder/test-object.txt",
|
||||
expected: "arn:aws:s3:::test-bucket/folder/subfolder/test-object.txt",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ func TestExtractRoleNameFromPrincipal(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "valid assumed role ARN",
|
||||
principal: "arn:seaweed:sts::assumed-role/S3ReadOnlyRole/session-123",
|
||||
principal: "arn:aws:sts::assumed-role/S3ReadOnlyRole/session-123",
|
||||
expected: "S3ReadOnlyRole",
|
||||
},
|
||||
{
|
||||
@@ -457,7 +457,7 @@ func TestExtractRoleNameFromPrincipal(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "missing session name",
|
||||
principal: "arn:seaweed:sts::assumed-role/TestRole",
|
||||
principal: "arn:aws:sts::assumed-role/TestRole",
|
||||
expected: "TestRole", // Extracts role name even without session name
|
||||
},
|
||||
{
|
||||
@@ -479,7 +479,7 @@ func TestExtractRoleNameFromPrincipal(t *testing.T) {
|
||||
func TestIAMIdentityIsAdmin(t *testing.T) {
|
||||
identity := &IAMIdentity{
|
||||
Name: "test-identity",
|
||||
Principal: "arn:seaweed:sts::assumed-role/TestRole/session",
|
||||
Principal: "arn:aws:sts::assumed-role/TestRole/session",
|
||||
SessionToken: "test-token",
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestJWTAuthenticationFlow(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Read-Only JWT Authentication",
|
||||
roleArn: "arn:seaweed:iam::role/S3ReadOnlyRole",
|
||||
roleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
setupRole: setupTestReadOnlyRole,
|
||||
testOperations: []JWTTestOperation{
|
||||
{Action: s3_constants.ACTION_READ, Bucket: "test-bucket", Object: "test-file.txt", ExpectedAllow: true},
|
||||
@@ -66,7 +66,7 @@ func TestJWTAuthenticationFlow(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Admin JWT Authentication",
|
||||
roleArn: "arn:seaweed:iam::role/S3AdminRole",
|
||||
roleArn: "arn:aws:iam::role/S3AdminRole",
|
||||
setupRole: setupTestAdminRole,
|
||||
testOperations: []JWTTestOperation{
|
||||
{Action: s3_constants.ACTION_READ, Bucket: "admin-bucket", Object: "admin-file.txt", ExpectedAllow: true},
|
||||
@@ -221,7 +221,7 @@ func TestIPBasedPolicyEnforcement(t *testing.T) {
|
||||
|
||||
// Assume role
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:seaweed:iam::role/S3IPRestrictedRole",
|
||||
RoleArn: "arn:aws:iam::role/S3IPRestrictedRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "ip-test-session",
|
||||
})
|
||||
@@ -363,8 +363,8 @@ func setupTestReadOnlyRole(ctx context.Context, manager *integration.IAMManager)
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -425,8 +425,8 @@ func setupTestAdminRole(ctx context.Context, manager *integration.IAMManager) {
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -487,8 +487,8 @@ func setupTestIPRestrictedRole(ctx context.Context, manager *integration.IAMMana
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"IpAddress": {
|
||||
@@ -544,7 +544,7 @@ func testJWTAuthorizationWithRole(t *testing.T, iam *IdentityAccessManagement, i
|
||||
req.Header.Set("X-SeaweedFS-Session-Token", token)
|
||||
|
||||
// Use a proper principal ARN format that matches what STS would generate
|
||||
principalArn := "arn:seaweed:sts::assumed-role/" + roleName + "/test-session"
|
||||
principalArn := "arn:aws:sts::assumed-role/" + roleName + "/test-session"
|
||||
req.Header.Set("X-SeaweedFS-Principal", principalArn)
|
||||
|
||||
// Test authorization
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestMultipartIAMValidation(t *testing.T) {
|
||||
|
||||
// Get session token
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:seaweed:iam::role/S3WriteRole",
|
||||
RoleArn: "arn:aws:iam::role/S3WriteRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "multipart-test-session",
|
||||
})
|
||||
@@ -443,8 +443,8 @@ func TestMultipartUploadSession(t *testing.T) {
|
||||
UploadID: "test-upload-123",
|
||||
Bucket: "test-bucket",
|
||||
ObjectKey: "test-file.txt",
|
||||
Initiator: "arn:seaweed:iam::user/testuser",
|
||||
Owner: "arn:seaweed:iam::user/testuser",
|
||||
Initiator: "arn:aws:iam::user/testuser",
|
||||
Owner: "arn:aws:iam::user/testuser",
|
||||
CreatedAt: time.Now(),
|
||||
Parts: []MultipartUploadPart{
|
||||
{
|
||||
@@ -550,8 +550,8 @@ func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMMan
|
||||
"s3:ListParts",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -603,8 +603,8 @@ func createMultipartRequest(t *testing.T, method, path, sessionToken string) *ht
|
||||
if sessionToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
||||
// Set the principal ARN header that matches the assumed role from the test setup
|
||||
// This corresponds to the role "arn:seaweed:iam::role/S3WriteRole" with session name "multipart-test-session"
|
||||
req.Header.Set("X-SeaweedFS-Principal", "arn:seaweed:sts::assumed-role/S3WriteRole/multipart-test-session")
|
||||
// This corresponds to the role "arn:aws:iam::role/S3WriteRole" with session name "multipart-test-session"
|
||||
req.Header.Set("X-SeaweedFS-Principal", "arn:aws:sts::assumed-role/S3WriteRole/multipart-test-session")
|
||||
}
|
||||
|
||||
// Add common headers
|
||||
|
||||
@@ -32,8 +32,8 @@ func (t *S3PolicyTemplates) GetS3ReadOnlyPolicy() *policy.PolicyDocument {
|
||||
"s3:ListAllMyBuckets",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -59,8 +59,8 @@ func (t *S3PolicyTemplates) GetS3WriteOnlyPolicy() *policy.PolicyDocument {
|
||||
"s3:ListParts",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -79,8 +79,8 @@ func (t *S3PolicyTemplates) GetS3AdminPolicy() *policy.PolicyDocument {
|
||||
"s3:*",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -103,8 +103,8 @@ func (t *S3PolicyTemplates) GetBucketSpecificReadPolicy(bucketName string) *poli
|
||||
"s3:GetBucketLocation",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName,
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName,
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -130,8 +130,8 @@ func (t *S3PolicyTemplates) GetBucketSpecificWritePolicy(bucketName string) *pol
|
||||
"s3:ListParts",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName,
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName,
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -150,7 +150,7 @@ func (t *S3PolicyTemplates) GetPathBasedAccessPolicy(bucketName, pathPrefix stri
|
||||
"s3:ListBucket",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName,
|
||||
"arn:aws:s3:::" + bucketName,
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringLike": map[string]interface{}{
|
||||
@@ -171,7 +171,7 @@ func (t *S3PolicyTemplates) GetPathBasedAccessPolicy(bucketName, pathPrefix stri
|
||||
"s3:AbortMultipartUpload",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName + "/" + pathPrefix + "/*",
|
||||
"arn:aws:s3:::" + bucketName + "/" + pathPrefix + "/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -190,8 +190,8 @@ func (t *S3PolicyTemplates) GetIPRestrictedPolicy(allowedCIDRs []string) *policy
|
||||
"s3:*",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"IpAddress": map[string]interface{}{
|
||||
@@ -217,8 +217,8 @@ func (t *S3PolicyTemplates) GetTimeBasedAccessPolicy(startHour, endHour int) *po
|
||||
"s3:ListBucket",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"DateGreaterThan": map[string]interface{}{
|
||||
@@ -252,7 +252,7 @@ func (t *S3PolicyTemplates) GetMultipartUploadPolicy(bucketName string) *policy.
|
||||
"s3:ListParts",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -262,7 +262,7 @@ func (t *S3PolicyTemplates) GetMultipartUploadPolicy(bucketName string) *policy.
|
||||
"s3:ListBucket",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName,
|
||||
"arn:aws:s3:::" + bucketName,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -282,7 +282,7 @@ func (t *S3PolicyTemplates) GetPresignedURLPolicy(bucketName string) *policy.Pol
|
||||
"s3:PutObject",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": map[string]interface{}{
|
||||
@@ -310,8 +310,8 @@ func (t *S3PolicyTemplates) GetTemporaryAccessPolicy(bucketName string, expirati
|
||||
"s3:ListBucket",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName,
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName,
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"DateLessThan": map[string]interface{}{
|
||||
@@ -338,7 +338,7 @@ func (t *S3PolicyTemplates) GetContentTypeRestrictedPolicy(bucketName string, al
|
||||
"s3:CompleteMultipartUpload",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
Condition: map[string]map[string]interface{}{
|
||||
"StringEquals": map[string]interface{}{
|
||||
@@ -354,8 +354,8 @@ func (t *S3PolicyTemplates) GetContentTypeRestrictedPolicy(bucketName string, al
|
||||
"s3:ListBucket",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::" + bucketName,
|
||||
"arn:seaweed:s3:::" + bucketName + "/*",
|
||||
"arn:aws:s3:::" + bucketName,
|
||||
"arn:aws:s3:::" + bucketName + "/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -385,8 +385,8 @@ func (t *S3PolicyTemplates) GetDenyDeletePolicy() *policy.PolicyDocument {
|
||||
"s3:ListParts",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -398,8 +398,8 @@ func (t *S3PolicyTemplates) GetDenyDeletePolicy() *policy.PolicyDocument {
|
||||
"s3:DeleteBucket",
|
||||
},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,8 +26,8 @@ func TestS3PolicyTemplates(t *testing.T) {
|
||||
assert.NotContains(t, stmt.Action, "s3:PutObject")
|
||||
assert.NotContains(t, stmt.Action, "s3:DeleteObject")
|
||||
|
||||
assert.Contains(t, stmt.Resource, "arn:seaweed:s3:::*")
|
||||
assert.Contains(t, stmt.Resource, "arn:seaweed:s3:::*/*")
|
||||
assert.Contains(t, stmt.Resource, "arn:aws:s3:::*")
|
||||
assert.Contains(t, stmt.Resource, "arn:aws:s3:::*/*")
|
||||
})
|
||||
|
||||
t.Run("S3WriteOnlyPolicy", func(t *testing.T) {
|
||||
@@ -45,8 +45,8 @@ func TestS3PolicyTemplates(t *testing.T) {
|
||||
assert.NotContains(t, stmt.Action, "s3:GetObject")
|
||||
assert.NotContains(t, stmt.Action, "s3:DeleteObject")
|
||||
|
||||
assert.Contains(t, stmt.Resource, "arn:seaweed:s3:::*")
|
||||
assert.Contains(t, stmt.Resource, "arn:seaweed:s3:::*/*")
|
||||
assert.Contains(t, stmt.Resource, "arn:aws:s3:::*")
|
||||
assert.Contains(t, stmt.Resource, "arn:aws:s3:::*/*")
|
||||
})
|
||||
|
||||
t.Run("S3AdminPolicy", func(t *testing.T) {
|
||||
@@ -61,8 +61,8 @@ func TestS3PolicyTemplates(t *testing.T) {
|
||||
assert.Equal(t, "S3FullAccess", stmt.Sid)
|
||||
assert.Contains(t, stmt.Action, "s3:*")
|
||||
|
||||
assert.Contains(t, stmt.Resource, "arn:seaweed:s3:::*")
|
||||
assert.Contains(t, stmt.Resource, "arn:seaweed:s3:::*/*")
|
||||
assert.Contains(t, stmt.Resource, "arn:aws:s3:::*")
|
||||
assert.Contains(t, stmt.Resource, "arn:aws:s3:::*/*")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ func TestBucketSpecificPolicies(t *testing.T) {
|
||||
assert.Contains(t, stmt.Action, "s3:ListBucket")
|
||||
assert.NotContains(t, stmt.Action, "s3:PutObject")
|
||||
|
||||
expectedBucketArn := "arn:seaweed:s3:::" + bucketName
|
||||
expectedObjectArn := "arn:seaweed:s3:::" + bucketName + "/*"
|
||||
expectedBucketArn := "arn:aws:s3:::" + bucketName
|
||||
expectedObjectArn := "arn:aws:s3:::" + bucketName + "/*"
|
||||
assert.Contains(t, stmt.Resource, expectedBucketArn)
|
||||
assert.Contains(t, stmt.Resource, expectedObjectArn)
|
||||
})
|
||||
@@ -104,8 +104,8 @@ func TestBucketSpecificPolicies(t *testing.T) {
|
||||
assert.Contains(t, stmt.Action, "s3:CreateMultipartUpload")
|
||||
assert.NotContains(t, stmt.Action, "s3:GetObject")
|
||||
|
||||
expectedBucketArn := "arn:seaweed:s3:::" + bucketName
|
||||
expectedObjectArn := "arn:seaweed:s3:::" + bucketName + "/*"
|
||||
expectedBucketArn := "arn:aws:s3:::" + bucketName
|
||||
expectedObjectArn := "arn:aws:s3:::" + bucketName + "/*"
|
||||
assert.Contains(t, stmt.Resource, expectedBucketArn)
|
||||
assert.Contains(t, stmt.Resource, expectedObjectArn)
|
||||
})
|
||||
@@ -127,7 +127,7 @@ func TestPathBasedAccessPolicy(t *testing.T) {
|
||||
assert.Equal(t, "Allow", listStmt.Effect)
|
||||
assert.Equal(t, "ListBucketPermission", listStmt.Sid)
|
||||
assert.Contains(t, listStmt.Action, "s3:ListBucket")
|
||||
assert.Contains(t, listStmt.Resource, "arn:seaweed:s3:::"+bucketName)
|
||||
assert.Contains(t, listStmt.Resource, "arn:aws:s3:::"+bucketName)
|
||||
assert.NotNil(t, listStmt.Condition)
|
||||
|
||||
// Second statement: Object operations on path
|
||||
@@ -138,7 +138,7 @@ func TestPathBasedAccessPolicy(t *testing.T) {
|
||||
assert.Contains(t, objectStmt.Action, "s3:PutObject")
|
||||
assert.Contains(t, objectStmt.Action, "s3:DeleteObject")
|
||||
|
||||
expectedObjectArn := "arn:seaweed:s3:::" + bucketName + "/" + pathPrefix + "/*"
|
||||
expectedObjectArn := "arn:aws:s3:::" + bucketName + "/" + pathPrefix + "/*"
|
||||
assert.Contains(t, objectStmt.Resource, expectedObjectArn)
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestMultipartUploadPolicyTemplate(t *testing.T) {
|
||||
assert.Contains(t, multipartStmt.Action, "s3:ListMultipartUploads")
|
||||
assert.Contains(t, multipartStmt.Action, "s3:ListParts")
|
||||
|
||||
expectedObjectArn := "arn:seaweed:s3:::" + bucketName + "/*"
|
||||
expectedObjectArn := "arn:aws:s3:::" + bucketName + "/*"
|
||||
assert.Contains(t, multipartStmt.Resource, expectedObjectArn)
|
||||
|
||||
// Second statement: List bucket
|
||||
@@ -225,7 +225,7 @@ func TestMultipartUploadPolicyTemplate(t *testing.T) {
|
||||
assert.Equal(t, "ListBucketForMultipart", listStmt.Sid)
|
||||
assert.Contains(t, listStmt.Action, "s3:ListBucket")
|
||||
|
||||
expectedBucketArn := "arn:seaweed:s3:::" + bucketName
|
||||
expectedBucketArn := "arn:aws:s3:::" + bucketName
|
||||
assert.Contains(t, listStmt.Resource, expectedBucketArn)
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ func TestPresignedURLPolicy(t *testing.T) {
|
||||
assert.Contains(t, stmt.Action, "s3:PutObject")
|
||||
assert.NotNil(t, stmt.Condition)
|
||||
|
||||
expectedObjectArn := "arn:seaweed:s3:::" + bucketName + "/*"
|
||||
expectedObjectArn := "arn:aws:s3:::" + bucketName + "/*"
|
||||
assert.Contains(t, stmt.Resource, expectedObjectArn)
|
||||
|
||||
// Check signature version condition
|
||||
@@ -495,7 +495,7 @@ func TestPolicyValidation(t *testing.T) {
|
||||
// Check resource format
|
||||
for _, resource := range stmt.Resource {
|
||||
if resource != "*" {
|
||||
assert.Contains(t, resource, "arn:seaweed:s3:::", "Resource should be valid SeaweedFS S3 ARN: %s", resource)
|
||||
assert.Contains(t, resource, "arn:aws:s3:::", "Resource should be valid AWS S3 ARN: %s", resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request
|
||||
parts := strings.Split(roleName, "/")
|
||||
roleNameOnly = parts[len(parts)-1]
|
||||
}
|
||||
principalArn = fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
principalArn = fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
|
||||
}
|
||||
|
||||
// Create IAM identity for authorization using extracted information
|
||||
@@ -130,7 +130,7 @@ func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context
|
||||
|
||||
// Validate session token and get identity
|
||||
// Use a proper ARN format for the principal
|
||||
principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/PresignedUser/presigned-session")
|
||||
principalArn := fmt.Sprintf("arn:aws:sts::assumed-role/PresignedUser/presigned-session")
|
||||
iamIdentity := &IAMIdentity{
|
||||
SessionToken: req.SessionToken,
|
||||
Principal: principalArn,
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestPresignedURLIAMValidation(t *testing.T) {
|
||||
|
||||
// Get session token
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:seaweed:iam::role/S3ReadOnlyRole",
|
||||
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "presigned-test-session",
|
||||
})
|
||||
@@ -136,7 +136,7 @@ func TestPresignedURLGeneration(t *testing.T) {
|
||||
|
||||
// Get session token
|
||||
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
|
||||
RoleArn: "arn:seaweed:iam::role/S3AdminRole",
|
||||
RoleArn: "arn:aws:iam::role/S3AdminRole",
|
||||
WebIdentityToken: validJWTToken,
|
||||
RoleSessionName: "presigned-gen-test-session",
|
||||
})
|
||||
@@ -503,8 +503,8 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject", "s3:ListBucket", "s3:HeadObject"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -539,8 +539,8 @@ func setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMMan
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:*"},
|
||||
Resource: []string{
|
||||
"arn:seaweed:s3:::*",
|
||||
"arn:seaweed:s3:::*/*",
|
||||
"arn:aws:s3:::*",
|
||||
"arn:aws:s3:::*/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/kms"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
@@ -32,6 +33,7 @@ type BucketConfig struct {
|
||||
IsPublicRead bool // Cached flag to avoid JSON parsing on every request
|
||||
CORS *cors.CORSConfiguration
|
||||
ObjectLockConfig *ObjectLockConfiguration // Cached parsed Object Lock configuration
|
||||
BucketPolicy *policy.PolicyDocument // Cached bucket policy for performance
|
||||
KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations
|
||||
LastModified time.Time
|
||||
Entry *filer_pb.Entry
|
||||
@@ -318,6 +320,28 @@ func (bcc *BucketConfigCache) RemoveNegativeCache(bucket string) {
|
||||
delete(bcc.negativeCache, bucket)
|
||||
}
|
||||
|
||||
// loadBucketPolicyFromExtended loads and parses bucket policy from entry extended attributes
|
||||
func loadBucketPolicyFromExtended(entry *filer_pb.Entry, bucket string) *policy.PolicyDocument {
|
||||
if entry.Extended == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
policyJSON, exists := entry.Extended[BUCKET_POLICY_METADATA_KEY]
|
||||
if !exists || len(policyJSON) == 0 {
|
||||
glog.V(4).Infof("loadBucketPolicyFromExtended: no bucket policy found for bucket %s", bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
var policyDoc policy.PolicyDocument
|
||||
if err := json.Unmarshal(policyJSON, &policyDoc); err != nil {
|
||||
glog.Errorf("loadBucketPolicyFromExtended: failed to parse bucket policy for %s: %v", bucket, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
glog.V(3).Infof("loadBucketPolicyFromExtended: loaded bucket policy for bucket %s", bucket)
|
||||
return &policyDoc
|
||||
}
|
||||
|
||||
// getBucketConfig retrieves bucket configuration with caching
|
||||
func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.ErrorCode) {
|
||||
// Check negative cache first
|
||||
@@ -376,8 +400,14 @@ func (s3a *S3ApiServer) getBucketConfig(bucket string) (*BucketConfig, s3err.Err
|
||||
} else {
|
||||
glog.V(3).Infof("getBucketConfig: no Object Lock config found in extended attributes for bucket %s", bucket)
|
||||
}
|
||||
|
||||
// Load bucket policy if present (for performance optimization)
|
||||
config.BucketPolicy = loadBucketPolicyFromExtended(entry, bucket)
|
||||
}
|
||||
|
||||
// Sync bucket policy to the policy engine for evaluation
|
||||
s3a.syncBucketPolicyToEngine(bucket, config.BucketPolicy)
|
||||
|
||||
// Load CORS configuration from bucket directory content
|
||||
if corsConfig, err := s3a.loadCORSFromBucketContent(bucket); err != nil {
|
||||
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||
|
||||
@@ -577,25 +577,62 @@ func isPublicReadGrants(grants []*s3.Grant) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// buildResourceARN builds a resource ARN from bucket and object
|
||||
// Used by the policy engine wrapper
|
||||
func buildResourceARN(bucket, object string) string {
|
||||
if object == "" || object == "/" {
|
||||
return fmt.Sprintf("arn:aws:s3:::%s", bucket)
|
||||
}
|
||||
// Remove leading slash if present
|
||||
object = strings.TrimPrefix(object, "/")
|
||||
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucket, object)
|
||||
}
|
||||
|
||||
// AuthWithPublicRead creates an auth wrapper that allows anonymous access for public-read buckets
|
||||
func (s3a *S3ApiServer) AuthWithPublicRead(handler http.HandlerFunc, action Action) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
bucket, _ := s3_constants.GetBucketAndObject(r)
|
||||
bucket, object := s3_constants.GetBucketAndObject(r)
|
||||
authType := getRequestAuthType(r)
|
||||
isAnonymous := authType == authTypeAnonymous
|
||||
|
||||
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, authType=%v, isAnonymous=%v", bucket, authType, isAnonymous)
|
||||
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, object=%s, authType=%v, isAnonymous=%v", bucket, object, authType, isAnonymous)
|
||||
|
||||
// For anonymous requests, check if bucket allows public read
|
||||
// For anonymous requests, check if bucket allows public read via ACLs or bucket policies
|
||||
if isAnonymous {
|
||||
// First check ACL-based public access
|
||||
isPublic := s3a.isBucketPublicRead(bucket)
|
||||
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, isPublic=%v", bucket, isPublic)
|
||||
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, isPublicACL=%v", bucket, isPublic)
|
||||
if isPublic {
|
||||
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to public-read bucket %s", bucket)
|
||||
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to public-read bucket %s (ACL)", bucket)
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
glog.V(3).Infof("AuthWithPublicRead: bucket %s is not public-read, falling back to IAM auth", bucket)
|
||||
|
||||
// Check bucket policy for anonymous access using the policy engine
|
||||
principal := "*" // Anonymous principal
|
||||
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal)
|
||||
if err != nil {
|
||||
// SECURITY: Fail-close on policy evaluation errors
|
||||
// If we can't evaluate the policy, deny access rather than falling through to IAM
|
||||
glog.Errorf("AuthWithPublicRead: error evaluating bucket policy for %s/%s: %v - denying access", bucket, object, err)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
||||
return
|
||||
} else if evaluated {
|
||||
// A bucket policy exists and was evaluated with a matching statement
|
||||
if allowed {
|
||||
// Policy explicitly allows anonymous access
|
||||
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to bucket %s (bucket policy)", bucket)
|
||||
handler(w, r)
|
||||
return
|
||||
} else {
|
||||
// Policy explicitly denies anonymous access
|
||||
glog.V(3).Infof("AuthWithPublicRead: bucket policy explicitly denies anonymous access to %s/%s", bucket, object)
|
||||
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
||||
return
|
||||
}
|
||||
}
|
||||
// No matching policy statement - fall through to check ACLs and then IAM auth
|
||||
glog.V(3).Infof("AuthWithPublicRead: no bucket policy match for %s, checking ACLs", bucket)
|
||||
}
|
||||
|
||||
// For all authenticated requests and anonymous requests to non-public buckets,
|
||||
|
||||
126
weed/s3api/s3api_bucket_policy_arn_test.go
Normal file
126
weed/s3api/s3api_bucket_policy_arn_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// TestBuildResourceARN verifies that resource ARNs use the AWS-compatible format
|
||||
func TestBuildResourceARN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bucket string
|
||||
object string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "bucket only",
|
||||
bucket: "my-bucket",
|
||||
object: "",
|
||||
expected: "arn:aws:s3:::my-bucket",
|
||||
},
|
||||
{
|
||||
name: "bucket with slash",
|
||||
bucket: "my-bucket",
|
||||
object: "/",
|
||||
expected: "arn:aws:s3:::my-bucket",
|
||||
},
|
||||
{
|
||||
name: "bucket and object",
|
||||
bucket: "my-bucket",
|
||||
object: "path/to/object.txt",
|
||||
expected: "arn:aws:s3:::my-bucket/path/to/object.txt",
|
||||
},
|
||||
{
|
||||
name: "bucket and object with leading slash",
|
||||
bucket: "my-bucket",
|
||||
object: "/path/to/object.txt",
|
||||
expected: "arn:aws:s3:::my-bucket/path/to/object.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildResourceARN(tt.bucket, tt.object)
|
||||
if result != tt.expected {
|
||||
t.Errorf("buildResourceARN(%q, %q) = %q, want %q", tt.bucket, tt.object, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPrincipalARN verifies that principal ARNs use the AWS-compatible format
|
||||
func TestBuildPrincipalARN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity *Identity
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "nil identity (anonymous)",
|
||||
identity: nil,
|
||||
expected: "*",
|
||||
},
|
||||
{
|
||||
name: "anonymous user by name",
|
||||
identity: &Identity{
|
||||
Name: s3_constants.AccountAnonymousId,
|
||||
Account: &Account{
|
||||
Id: "123456789012",
|
||||
},
|
||||
},
|
||||
expected: "*",
|
||||
},
|
||||
{
|
||||
name: "anonymous user by account ID",
|
||||
identity: &Identity{
|
||||
Name: "test-user",
|
||||
Account: &Account{
|
||||
Id: s3_constants.AccountAnonymousId,
|
||||
},
|
||||
},
|
||||
expected: "*",
|
||||
},
|
||||
{
|
||||
name: "identity with account and name",
|
||||
identity: &Identity{
|
||||
Name: "test-user",
|
||||
Account: &Account{
|
||||
Id: "123456789012",
|
||||
},
|
||||
},
|
||||
expected: "arn:aws:iam::123456789012:user/test-user",
|
||||
},
|
||||
{
|
||||
name: "identity without account ID",
|
||||
identity: &Identity{
|
||||
Name: "test-user",
|
||||
Account: &Account{
|
||||
Id: "",
|
||||
},
|
||||
},
|
||||
expected: "arn:aws:iam::000000000000:user/test-user",
|
||||
},
|
||||
{
|
||||
name: "identity without name",
|
||||
identity: &Identity{
|
||||
Name: "",
|
||||
Account: &Account{
|
||||
Id: "123456789012",
|
||||
},
|
||||
},
|
||||
expected: "arn:aws:iam::123456789012:user/unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildPrincipalARN(tt.identity)
|
||||
if result != tt.expected {
|
||||
t.Errorf("buildPrincipalARN() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
203
weed/s3api/s3api_bucket_policy_engine.go
Normal file
203
weed/s3api/s3api_bucket_policy_engine.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
// BucketPolicyEngine wraps the policy_engine to provide bucket policy evaluation
|
||||
type BucketPolicyEngine struct {
|
||||
engine *policy_engine.PolicyEngine
|
||||
}
|
||||
|
||||
// NewBucketPolicyEngine creates a new bucket policy engine
|
||||
func NewBucketPolicyEngine() *BucketPolicyEngine {
|
||||
return &BucketPolicyEngine{
|
||||
engine: policy_engine.NewPolicyEngine(),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadBucketPolicy loads a bucket policy into the engine from the filer entry
|
||||
func (bpe *BucketPolicyEngine) LoadBucketPolicy(bucket string, entry *filer_pb.Entry) error {
|
||||
if entry == nil || entry.Extended == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
policyJSON, exists := entry.Extended[BUCKET_POLICY_METADATA_KEY]
|
||||
if !exists || len(policyJSON) == 0 {
|
||||
// No policy for this bucket - remove it if it exists
|
||||
bpe.engine.DeleteBucketPolicy(bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set the policy in the engine
|
||||
if err := bpe.engine.SetBucketPolicy(bucket, string(policyJSON)); err != nil {
|
||||
glog.Errorf("Failed to load bucket policy for %s: %v", bucket, err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.V(3).Infof("Loaded bucket policy for %s into policy engine", bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadBucketPolicyFromCache loads a bucket policy from a cached BucketConfig
|
||||
//
|
||||
// NOTE: This function uses JSON marshaling/unmarshaling to convert between
|
||||
// policy.PolicyDocument and policy_engine.PolicyDocument. This is inefficient
|
||||
// but necessary because the two types are defined in different packages and
|
||||
// have subtle differences. A future improvement would be to unify these types
|
||||
// or create a direct conversion function for better performance and type safety.
|
||||
func (bpe *BucketPolicyEngine) LoadBucketPolicyFromCache(bucket string, policyDoc *policy.PolicyDocument) error {
|
||||
if policyDoc == nil {
|
||||
// No policy for this bucket - remove it if it exists
|
||||
bpe.engine.DeleteBucketPolicy(bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert policy.PolicyDocument to policy_engine.PolicyDocument
|
||||
// We use JSON marshaling as an intermediate format since both types
|
||||
// follow the same AWS S3 policy structure
|
||||
policyJSON, err := json.Marshal(policyDoc)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to marshal bucket policy for %s: %v", bucket, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the policy in the engine
|
||||
if err := bpe.engine.SetBucketPolicy(bucket, string(policyJSON)); err != nil {
|
||||
glog.Errorf("Failed to load bucket policy for %s: %v", bucket, err)
|
||||
return err
|
||||
}
|
||||
|
||||
glog.V(4).Infof("Loaded bucket policy for %s into policy engine from cache", bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBucketPolicy removes a bucket policy from the engine
|
||||
func (bpe *BucketPolicyEngine) DeleteBucketPolicy(bucket string) error {
|
||||
return bpe.engine.DeleteBucketPolicy(bucket)
|
||||
}
|
||||
|
||||
// EvaluatePolicy evaluates whether an action is allowed by bucket policy
|
||||
// Returns: (allowed bool, evaluated bool, error)
|
||||
// - allowed: whether the policy allows the action
|
||||
// - evaluated: whether a policy was found and evaluated (false = no policy exists)
|
||||
// - error: any error during evaluation
|
||||
func (bpe *BucketPolicyEngine) EvaluatePolicy(bucket, object, action, principal string) (allowed bool, evaluated bool, err error) {
|
||||
// Validate required parameters
|
||||
if bucket == "" {
|
||||
return false, false, fmt.Errorf("bucket cannot be empty")
|
||||
}
|
||||
if action == "" {
|
||||
return false, false, fmt.Errorf("action cannot be empty")
|
||||
}
|
||||
|
||||
// Convert action to S3 action format
|
||||
s3Action := convertActionToS3Format(action)
|
||||
|
||||
// Build resource ARN
|
||||
resource := buildResourceARN(bucket, object)
|
||||
|
||||
glog.V(4).Infof("EvaluatePolicy: bucket=%s, resource=%s, action=%s, principal=%s", bucket, resource, s3Action, principal)
|
||||
|
||||
// Evaluate using the policy engine
|
||||
args := &policy_engine.PolicyEvaluationArgs{
|
||||
Action: s3Action,
|
||||
Resource: resource,
|
||||
Principal: principal,
|
||||
}
|
||||
|
||||
result := bpe.engine.EvaluatePolicy(bucket, args)
|
||||
|
||||
switch result {
|
||||
case policy_engine.PolicyResultAllow:
|
||||
glog.V(3).Infof("EvaluatePolicy: ALLOW - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
return true, true, nil
|
||||
case policy_engine.PolicyResultDeny:
|
||||
glog.V(3).Infof("EvaluatePolicy: DENY - bucket=%s, action=%s, principal=%s", bucket, s3Action, principal)
|
||||
return false, true, nil
|
||||
case policy_engine.PolicyResultIndeterminate:
|
||||
// No policy exists for this bucket
|
||||
glog.V(4).Infof("EvaluatePolicy: INDETERMINATE (no policy) - bucket=%s", bucket)
|
||||
return false, false, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("unknown policy result: %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// convertActionToS3Format converts internal action strings to S3 action format
|
||||
//
|
||||
// KNOWN LIMITATION: The current Action type uses coarse-grained constants
|
||||
// (ACTION_READ, ACTION_WRITE, etc.) that map to specific S3 actions, but these
|
||||
// are used for multiple operations. For example, ACTION_WRITE is used for both
|
||||
// PutObject and DeleteObject, but this function maps it to only s3:PutObject.
|
||||
// This means bucket policies requiring fine-grained permissions (e.g., allowing
|
||||
// s3:DeleteObject but not s3:PutObject) will not work correctly.
|
||||
//
|
||||
// TODO: Refactor to use specific S3 action strings throughout the S3 API handlers
|
||||
// instead of coarse-grained Action constants. This is a major architectural change
|
||||
// that should be done in a separate PR.
|
||||
//
|
||||
// This function explicitly maps all known actions to prevent security issues from
|
||||
// overly permissive default behavior.
|
||||
func convertActionToS3Format(action string) string {
|
||||
// Handle multipart actions that already have s3: prefix
|
||||
if strings.HasPrefix(action, "s3:") {
|
||||
return action
|
||||
}
|
||||
|
||||
// Explicit mapping for all known actions
|
||||
switch action {
|
||||
// Basic operations
|
||||
case s3_constants.ACTION_READ:
|
||||
return "s3:GetObject"
|
||||
case s3_constants.ACTION_WRITE:
|
||||
return "s3:PutObject"
|
||||
case s3_constants.ACTION_LIST:
|
||||
return "s3:ListBucket"
|
||||
case s3_constants.ACTION_TAGGING:
|
||||
return "s3:PutObjectTagging"
|
||||
case s3_constants.ACTION_ADMIN:
|
||||
return "s3:*"
|
||||
|
||||
// ACL operations
|
||||
case s3_constants.ACTION_READ_ACP:
|
||||
return "s3:GetObjectAcl"
|
||||
case s3_constants.ACTION_WRITE_ACP:
|
||||
return "s3:PutObjectAcl"
|
||||
|
||||
// Bucket operations
|
||||
case s3_constants.ACTION_DELETE_BUCKET:
|
||||
return "s3:DeleteBucket"
|
||||
|
||||
// Object Lock operations
|
||||
case s3_constants.ACTION_BYPASS_GOVERNANCE_RETENTION:
|
||||
return "s3:BypassGovernanceRetention"
|
||||
case s3_constants.ACTION_GET_OBJECT_RETENTION:
|
||||
return "s3:GetObjectRetention"
|
||||
case s3_constants.ACTION_PUT_OBJECT_RETENTION:
|
||||
return "s3:PutObjectRetention"
|
||||
case s3_constants.ACTION_GET_OBJECT_LEGAL_HOLD:
|
||||
return "s3:GetObjectLegalHold"
|
||||
case s3_constants.ACTION_PUT_OBJECT_LEGAL_HOLD:
|
||||
return "s3:PutObjectLegalHold"
|
||||
case s3_constants.ACTION_GET_BUCKET_OBJECT_LOCK_CONFIG:
|
||||
return "s3:GetBucketObjectLockConfiguration"
|
||||
case s3_constants.ACTION_PUT_BUCKET_OBJECT_LOCK_CONFIG:
|
||||
return "s3:PutBucketObjectLockConfiguration"
|
||||
|
||||
default:
|
||||
// Log warning for unmapped actions to help catch issues
|
||||
glog.Warningf("convertActionToS3Format: unmapped action '%s', prefixing with 's3:'", action)
|
||||
// For unknown actions, prefix with s3: to maintain format consistency
|
||||
// This maintains backward compatibility while alerting developers
|
||||
return "s3:" + action
|
||||
}
|
||||
}
|
||||
@@ -275,14 +275,10 @@ func (s3a *S3ApiServer) validateBucketPolicy(policyDoc *policy.PolicyDocument, b
|
||||
// validateResourceForBucket checks if a resource ARN is valid for the given bucket
|
||||
func (s3a *S3ApiServer) validateResourceForBucket(resource, bucket string) bool {
|
||||
// Accepted formats for S3 bucket policies:
|
||||
// AWS-style ARNs:
|
||||
// AWS-style ARNs (standard):
|
||||
// arn:aws:s3:::bucket-name
|
||||
// arn:aws:s3:::bucket-name/*
|
||||
// arn:aws:s3:::bucket-name/path/to/object
|
||||
// SeaweedFS ARNs:
|
||||
// arn:seaweed:s3:::bucket-name
|
||||
// arn:seaweed:s3:::bucket-name/*
|
||||
// arn:seaweed:s3:::bucket-name/path/to/object
|
||||
// Simplified formats (for convenience):
|
||||
// bucket-name
|
||||
// bucket-name/*
|
||||
@@ -290,13 +286,10 @@ func (s3a *S3ApiServer) validateResourceForBucket(resource, bucket string) bool
|
||||
|
||||
var resourcePath string
|
||||
const awsPrefix = "arn:aws:s3:::"
|
||||
const seaweedPrefix = "arn:seaweed:s3:::"
|
||||
|
||||
// Strip the optional ARN prefix to get the resource path
|
||||
if path, ok := strings.CutPrefix(resource, awsPrefix); ok {
|
||||
resourcePath = path
|
||||
} else if path, ok := strings.CutPrefix(resource, seaweedPrefix); ok {
|
||||
resourcePath = path
|
||||
} else {
|
||||
resourcePath = resource
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ type S3ApiServer struct {
|
||||
bucketRegistry *BucketRegistry
|
||||
credentialManager *credential.CredentialManager
|
||||
bucketConfigCache *BucketConfigCache
|
||||
policyEngine *BucketPolicyEngine // Engine for evaluating bucket policies
|
||||
}
|
||||
|
||||
func NewS3ApiServer(router *mux.Router, option *S3ApiServerOption) (s3ApiServer *S3ApiServer, err error) {
|
||||
@@ -97,8 +98,12 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
cb: NewCircuitBreaker(option),
|
||||
credentialManager: iam.credentialManager,
|
||||
bucketConfigCache: NewBucketConfigCache(60 * time.Minute), // Increased TTL since cache is now event-driven
|
||||
policyEngine: NewBucketPolicyEngine(), // Initialize bucket policy engine
|
||||
}
|
||||
|
||||
// Link IAM back to server for bucket policy evaluation
|
||||
iam.s3ApiServer = s3ApiServer
|
||||
|
||||
// Initialize advanced IAM system if config is provided
|
||||
if option.IamConfig != "" {
|
||||
glog.V(0).Infof("Loading advanced IAM configuration from: %s", option.IamConfig)
|
||||
@@ -157,6 +162,20 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
|
||||
return s3ApiServer, nil
|
||||
}
|
||||
|
||||
// syncBucketPolicyToEngine syncs a bucket policy to the policy engine
|
||||
// This helper method centralizes the logic for loading bucket policies into the engine
|
||||
// to avoid duplication and ensure consistent error handling
|
||||
func (s3a *S3ApiServer) syncBucketPolicyToEngine(bucket string, policyDoc *policy.PolicyDocument) {
|
||||
if policyDoc != nil {
|
||||
if err := s3a.policyEngine.LoadBucketPolicyFromCache(bucket, policyDoc); err != nil {
|
||||
glog.Errorf("Failed to sync bucket policy for %s to policy engine: %v", bucket, err)
|
||||
}
|
||||
} else {
|
||||
// No policy - ensure it's removed from engine if it was there
|
||||
s3a.policyEngine.DeleteBucketPolicy(bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyDomainNames classifies domains into path-style and virtual-host style domains.
|
||||
// A domain is considered path-style if:
|
||||
// 1. It contains a dot (has subdomains)
|
||||
|
||||
Reference in New Issue
Block a user