package s3api import ( "net/http" "net/url" "strings" "testing" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/stretchr/testify/assert" ) // createTestRequestWithQueryParams creates a test HTTP request with query parameters // and extracts bucket/object from the path. This helper reduces duplication in test setup. func createTestRequestWithQueryParams(method, path string, queryParams map[string]string) (*http.Request, string, string, error) { // Parse the URL u, err := url.Parse(path) if err != nil { return nil, "", "", err } // Add query parameters q := u.Query() for k, v := range queryParams { q.Add(k, v) } u.RawQuery = q.Encode() // Create HTTP request req, err := http.NewRequest(method, u.String(), nil) if err != nil { return nil, "", "", err } // Parse path to extract bucket and object parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") bucket := "" object := "" if len(parts) > 0 { bucket = parts[0] } if len(parts) > 1 { object = "/" + strings.Join(parts[1:], "/") } return req, bucket, object, nil } // TestGranularActionMappingSecurity demonstrates how the new granular action mapping // fixes critical security issues that existed with the previous coarse mapping func TestGranularActionMappingSecurity(t *testing.T) { tests := []struct { name string method string bucket string objectKey string queryParams map[string]string description string problemWithOldMapping string granularActionResult string }{ { name: "delete_object_security_fix", method: "DELETE", bucket: "sensitive-bucket", objectKey: "confidential-file.txt", queryParams: map[string]string{}, description: "DELETE object operations should map to s3:DeleteObject, not s3:PutObject", problemWithOldMapping: "Old mapping incorrectly mapped DELETE object to s3:PutObject, " + "allowing users with only PUT permissions to delete objects - a critical security flaw", granularActionResult: "s3:DeleteObject", }, { name: "get_object_acl_precision", method: "GET", bucket: "secure-bucket", objectKey: "private-file.pdf", queryParams: map[string]string{"acl": ""}, description: "GET object ACL should map to s3:GetObjectAcl, not generic s3:GetObject", problemWithOldMapping: "Old mapping would allow users with s3:GetObject permission to " + "read ACLs, potentially exposing sensitive permission information", granularActionResult: "s3:GetObjectAcl", }, { name: "put_object_tagging_precision", method: "PUT", bucket: "data-bucket", objectKey: "business-document.xlsx", queryParams: map[string]string{"tagging": ""}, description: "PUT object tagging should map to s3:PutObjectTagging, not generic s3:PutObject", problemWithOldMapping: "Old mapping couldn't distinguish between actual object uploads and " + "metadata operations like tagging, making fine-grained permissions impossible", granularActionResult: "s3:PutObjectTagging", }, { name: "multipart_upload_precision", method: "POST", bucket: "large-files", objectKey: "video.mp4", queryParams: map[string]string{"uploads": ""}, description: "Multipart upload initiation should map to s3:CreateMultipartUpload", problemWithOldMapping: "Old mapping would treat multipart operations as generic s3:PutObject, " + "preventing policies that allow regular uploads but restrict large multipart operations", granularActionResult: "s3:CreateMultipartUpload", }, { name: "bucket_policy_vs_bucket_creation", method: "PUT", bucket: "corporate-bucket", objectKey: "", queryParams: map[string]string{"policy": ""}, description: "Bucket policy modifications should map to s3:PutBucketPolicy, not s3:CreateBucket", problemWithOldMapping: "Old mapping couldn't distinguish between creating buckets and " + "modifying bucket policies, potentially allowing unauthorized policy changes", granularActionResult: "s3:PutBucketPolicy", }, { name: "list_vs_read_distinction", method: "GET", bucket: "inventory-bucket", objectKey: "", queryParams: map[string]string{"uploads": ""}, description: "Listing multipart uploads should map to s3:ListBucketMultipartUploads", problemWithOldMapping: "Old mapping would use generic s3:ListBucket for all bucket operations, " + "preventing fine-grained control over who can see ongoing multipart operations", granularActionResult: "s3:ListBucketMultipartUploads", }, { name: "delete_object_tagging_precision", method: "DELETE", bucket: "metadata-bucket", objectKey: "tagged-file.json", queryParams: map[string]string{"tagging": ""}, description: "Delete object tagging should map to s3:DeleteObjectTagging, not s3:DeleteObject", problemWithOldMapping: "Old mapping couldn't distinguish between deleting objects and " + "deleting tags, preventing policies that allow tag management but not object deletion", granularActionResult: "s3:DeleteObjectTagging", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create HTTP request with query parameters req := &http.Request{ Method: tt.method, URL: &url.URL{Path: "/" + tt.bucket + "/" + tt.objectKey}, } // Add query parameters query := req.URL.Query() for key, value := range tt.queryParams { query.Set(key, value) } req.URL.RawQuery = query.Encode() // Test the granular action determination result := ResolveS3Action(req, string(s3_constants.ACTION_WRITE), tt.bucket, tt.objectKey) assert.Equal(t, tt.granularActionResult, result, "Security Fix Test: %s\n"+ "Description: %s\n"+ "Problem with old mapping: %s\n"+ "Expected: %s, Got: %s", tt.name, tt.description, tt.problemWithOldMapping, tt.granularActionResult, result) // Log the security improvement t.Logf("SECURITY IMPROVEMENT: %s", tt.description) t.Logf(" Problem Fixed: %s", tt.problemWithOldMapping) t.Logf(" Granular Action: %s", result) }) } } // TestBackwardCompatibilityFallback tests that the new system maintains backward compatibility // with existing generic actions while providing enhanced granularity func TestBackwardCompatibilityFallback(t *testing.T) { tests := []struct { name string method string bucket string objectKey string fallbackAction Action expectedResult string description string }{ { name: "generic_read_fallback", method: "GET", // Generic method without specific query params bucket: "", // Edge case: no bucket specified objectKey: "", // Edge case: no object specified fallbackAction: s3_constants.ACTION_READ, expectedResult: "s3:GetObject", description: "Generic read operations should fall back to s3:GetObject for compatibility", }, { name: "generic_write_fallback", method: "PUT", // Generic method without specific query params bucket: "", // Edge case: no bucket specified objectKey: "", // Edge case: no object specified fallbackAction: s3_constants.ACTION_WRITE, expectedResult: "s3:PutObject", description: "Generic write operations should fall back to s3:PutObject for compatibility", }, { name: "already_granular_passthrough", method: "GET", bucket: "", objectKey: "", fallbackAction: "s3:GetBucketLocation", // Already specific expectedResult: "s3:GetBucketLocation", description: "Already granular actions should pass through unchanged", }, { name: "unknown_action_conversion", method: "GET", bucket: "", objectKey: "", fallbackAction: "CustomAction", // Not S3-prefixed expectedResult: "s3:CustomAction", description: "Unknown actions should be converted to S3 format for consistency", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := &http.Request{ Method: tt.method, URL: &url.URL{Path: "/" + tt.bucket + "/" + tt.objectKey}, } result := ResolveS3Action(req, string(tt.fallbackAction), tt.bucket, tt.objectKey) assert.Equal(t, tt.expectedResult, result, "Backward Compatibility Test: %s\nDescription: %s\nExpected: %s, Got: %s", tt.name, tt.description, tt.expectedResult, result) t.Logf("COMPATIBILITY: %s - %s", tt.description, result) }) } } // TestPolicyEnforcementScenarios demonstrates how granular actions enable // more precise and secure IAM policy enforcement func TestPolicyEnforcementScenarios(t *testing.T) { scenarios := []struct { name string policyExample string method string bucket string objectKey string queryParams map[string]string expectedAction string securityBenefit string }{ { name: "allow_read_deny_acl_access", policyExample: `{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::sensitive-bucket/*" } ] }`, method: "GET", bucket: "sensitive-bucket", objectKey: "document.pdf", queryParams: map[string]string{"acl": ""}, expectedAction: "s3:GetObjectAcl", securityBenefit: "Policy allows reading objects but denies ACL access - granular actions enable this distinction", }, { name: "allow_tagging_deny_object_modification", policyExample: `{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:PutObjectTagging", "s3:DeleteObjectTagging"], "Resource": "arn:aws:s3:::data-bucket/*" } ] }`, method: "PUT", bucket: "data-bucket", objectKey: "metadata-file.json", queryParams: map[string]string{"tagging": ""}, expectedAction: "s3:PutObjectTagging", securityBenefit: "Policy allows tag management but prevents actual object uploads - critical for metadata-only roles", }, { name: "restrict_multipart_uploads", policyExample: `{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::uploads/*" }, { "Effect": "Deny", "Action": ["s3:CreateMultipartUpload", "s3:UploadPart"], "Resource": "arn:aws:s3:::uploads/*" } ] }`, method: "POST", bucket: "uploads", objectKey: "large-file.zip", queryParams: map[string]string{"uploads": ""}, expectedAction: "s3:CreateMultipartUpload", securityBenefit: "Policy allows regular uploads but blocks large multipart uploads - prevents resource abuse", }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { req := &http.Request{ Method: scenario.method, URL: &url.URL{Path: "/" + scenario.bucket + "/" + scenario.objectKey}, } query := req.URL.Query() for key, value := range scenario.queryParams { query.Set(key, value) } req.URL.RawQuery = query.Encode() result := ResolveS3Action(req, string(s3_constants.ACTION_WRITE), scenario.bucket, scenario.objectKey) assert.Equal(t, scenario.expectedAction, result, "Policy Enforcement Scenario: %s\nExpected Action: %s, Got: %s", scenario.name, scenario.expectedAction, result) t.Logf("SECURITY SCENARIO: %s", scenario.name) t.Logf(" Expected Action: %s", result) t.Logf(" Security Benefit: %s", scenario.securityBenefit) t.Logf(" Policy Example:\n%s", scenario.policyExample) }) } } // TestDeleteObjectPolicyEnforcement demonstrates that the architectural limitation has been fixed // Previously, DeleteObject operations were mapped to s3:PutObject, preventing fine-grained policies from working func TestDeleteObjectPolicyEnforcement(t *testing.T) { tests := []struct { name string method string bucket string objectKey string baseAction Action expectedS3Action string policyScenario string }{ { name: "delete_object_maps_to_correct_action", method: http.MethodDelete, bucket: "test-bucket", objectKey: "test-object.txt", baseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:DeleteObject", policyScenario: "Policy that denies s3:DeleteObject but allows s3:PutObject should now work correctly", }, { name: "put_object_maps_to_correct_action", method: http.MethodPut, bucket: "test-bucket", objectKey: "test-object.txt", baseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:PutObject", policyScenario: "Policy that allows s3:PutObject but denies s3:DeleteObject should allow uploads", }, { name: "batch_delete_maps_to_delete_action", method: http.MethodPost, bucket: "test-bucket", objectKey: "", baseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:DeleteObject", policyScenario: "Batch delete operations should also map to s3:DeleteObject", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create HTTP request req := &http.Request{ Method: tt.method, URL: &url.URL{Path: "/" + tt.bucket + "/" + tt.objectKey}, Header: http.Header{}, } // For batch delete, add the delete query parameter if tt.method == http.MethodPost && tt.expectedS3Action == "s3:DeleteObject" { query := req.URL.Query() query.Set("delete", "") req.URL.RawQuery = query.Encode() } // Test the action resolution result := ResolveS3Action(req, string(tt.baseAction), tt.bucket, tt.objectKey) assert.Equal(t, tt.expectedS3Action, result, "Action Resolution Test: %s\n"+ "HTTP Method: %s\n"+ "Base Action: %s\n"+ "Policy Scenario: %s\n"+ "Expected: %s, Got: %s", tt.name, tt.method, tt.baseAction, tt.policyScenario, tt.expectedS3Action, result) t.Logf("ARCHITECTURAL FIX VERIFIED: %s", tt.name) t.Logf(" Method: %s -> S3 Action: %s", tt.method, result) t.Logf(" Policy Scenario: %s", tt.policyScenario) }) } } // TestFineGrainedPolicyExample demonstrates a real-world use case that now works // This test verifies the exact scenario described in the original TODO comment func TestFineGrainedPolicyExample(t *testing.T) { // Example policy: Allow PutObject but Deny DeleteObject // This is a common pattern for "append-only" buckets or write-once scenarios policyExample := `{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowObjectUploads", "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::test-bucket/*" }, { "Sid": "DenyObjectDeletion", "Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "arn:aws:s3:::test-bucket/*" } ] }` testCases := []struct { operation string method string objectKey string queryParams map[string]string baseAction Action expectedAction string shouldBeAllowed bool rationale string }{ { operation: "PUT object", method: http.MethodPut, objectKey: "document.txt", queryParams: map[string]string{}, baseAction: s3_constants.ACTION_WRITE, expectedAction: "s3:PutObject", shouldBeAllowed: true, rationale: "Policy explicitly allows s3:PutObject - upload should succeed", }, { operation: "DELETE object", method: http.MethodDelete, objectKey: "document.txt", queryParams: map[string]string{}, baseAction: s3_constants.ACTION_WRITE, expectedAction: "s3:DeleteObject", shouldBeAllowed: false, rationale: "Policy explicitly denies s3:DeleteObject - deletion should be blocked", }, { operation: "Batch DELETE", method: http.MethodPost, objectKey: "", queryParams: map[string]string{"delete": ""}, baseAction: s3_constants.ACTION_WRITE, expectedAction: "s3:DeleteObject", shouldBeAllowed: false, rationale: "Policy explicitly denies s3:DeleteObject - batch deletion should be blocked", }, } t.Logf("\nTesting Fine-Grained Policy:") t.Logf("%s\n", policyExample) for _, tc := range testCases { t.Run(tc.operation, func(t *testing.T) { // Create HTTP request req := &http.Request{ Method: tc.method, URL: &url.URL{Path: "/test-bucket/" + tc.objectKey}, Header: http.Header{}, } // Add query parameters query := req.URL.Query() for key, value := range tc.queryParams { query.Set(key, value) } req.URL.RawQuery = query.Encode() // Determine the S3 action actualAction := ResolveS3Action(req, string(tc.baseAction), "test-bucket", tc.objectKey) // Verify the action mapping is correct assert.Equal(t, tc.expectedAction, actualAction, "Operation: %s\nExpected Action: %s\nGot: %s", tc.operation, tc.expectedAction, actualAction) // Log the result allowStatus := "[DENIED]" if tc.shouldBeAllowed { allowStatus = "[ALLOWED]" } t.Logf("%s %s -> %s", allowStatus, tc.operation, actualAction) t.Logf(" Rationale: %s", tc.rationale) }) } t.Logf("\nARCHITECTURAL LIMITATION RESOLVED!") t.Logf(" Fine-grained policies like 'allow PUT but deny DELETE' now work correctly") t.Logf(" The policy engine can distinguish between s3:PutObject and s3:DeleteObject") } // TestCoarseActionResolution verifies that ResolveS3Action correctly maps // coarse-grained ACTION_WRITE to specific S3 actions based on HTTP context. // This demonstrates the fix for the architectural limitation where ACTION_WRITE // was always mapped to s3:PutObject, preventing fine-grained policies from working. func TestCoarseActionResolution(t *testing.T) { testCases := []struct { name string method string path string queryParams map[string]string coarseAction Action expectedS3Action string policyScenario string }{ { name: "PUT_with_ACTION_WRITE_resolves_to_PutObject", method: http.MethodPut, path: "/test-bucket/test-file.txt", queryParams: map[string]string{}, coarseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:PutObject", policyScenario: "Policy allowing s3:PutObject should match PUT requests", }, { name: "DELETE_with_ACTION_WRITE_resolves_to_DeleteObject", method: http.MethodDelete, path: "/test-bucket/test-file.txt", queryParams: map[string]string{}, coarseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:DeleteObject", policyScenario: "Policy denying s3:DeleteObject should block DELETE requests", }, { name: "batch_DELETE_with_ACTION_WRITE_resolves_to_DeleteObject", method: http.MethodPost, path: "/test-bucket", queryParams: map[string]string{"delete": ""}, coarseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:DeleteObject", policyScenario: "Policy denying s3:DeleteObject should block batch DELETE", }, { name: "POST_multipart_with_ACTION_WRITE_resolves_to_CreateMultipartUpload", method: http.MethodPost, path: "/test-bucket/large-file.mp4", queryParams: map[string]string{"uploads": ""}, coarseAction: s3_constants.ACTION_WRITE, expectedS3Action: "s3:CreateMultipartUpload", policyScenario: "Policy allowing s3:PutObject but denying s3:CreateMultipartUpload can now work", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create test request with query parameters and extract bucket/object req, bucket, object, err := createTestRequestWithQueryParams(tc.method, tc.path, tc.queryParams) assert.NoError(t, err) // Call ResolveS3Action with coarse action constant resolvedAction := ResolveS3Action(req, string(tc.coarseAction), bucket, object) // Verify correct S3 action is resolved assert.Equal(t, tc.expectedS3Action, resolvedAction, "Coarse action %s with method %s should resolve to %s", tc.coarseAction, tc.method, tc.expectedS3Action) t.Logf("SUCCESS: %s", tc.name) t.Logf(" Input: %s %s + ACTION_WRITE", tc.method, tc.path) t.Logf(" Output: %s", resolvedAction) t.Logf(" Policy impact: %s", tc.policyScenario) }) } t.Log("\n=== ARCHITECTURAL LIMITATION RESOLVED ===") t.Log("Handlers can use coarse ACTION_WRITE constant, and the context-aware") t.Log("resolver will map it to the correct specific S3 action (PutObject,") t.Log("DeleteObject, CreateMultipartUpload, etc.) based on HTTP method and") t.Log("query parameters. This enables fine-grained bucket policies like:") t.Log(" - Allow s3:PutObject but Deny s3:DeleteObject (append-only)") t.Log(" - Allow regular uploads but Deny multipart (size limits)") }