diff --git a/weed/s3api/s3_granular_action_security_test.go b/weed/s3api/s3_granular_action_security_test.go new file mode 100644 index 000000000..29f1f20db --- /dev/null +++ b/weed/s3api/s3_granular_action_security_test.go @@ -0,0 +1,307 @@ +package s3api + +import ( + "net/http" + "net/url" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/stretchr/testify/assert" +) + +// 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:ListMultipartUploads", + 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:ListMultipartUploads", + }, + { + 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 new granular action determination + result := determineGranularS3Action(req, 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 := determineGranularS3Action(req, 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 := determineGranularS3Action(req, 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) + }) + } +} diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 50fa00db3..111c63e04 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -203,10 +203,13 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA // Extract request context for policy conditions requestContext := extractRequestContext(r) + // Determine the specific S3 action based on the HTTP request details + specificAction := determineGranularS3Action(r, action, bucket, objectKey) + // Create action request actionRequest := &integration.ActionRequest{ Principal: identity.Principal, - Action: mapS3ActionToIAMAction(action), + Action: specificAction, Resource: resourceArn, SessionToken: identity.SessionToken, RequestContext: requestContext, @@ -270,26 +273,200 @@ func buildS3ResourceArn(bucket string, objectKey string) string { return "arn:seaweed:s3:::" + bucket + "/" + objectKey } -// mapS3ActionToIAMAction maps S3 API actions to IAM policy actions -func mapS3ActionToIAMAction(s3Action Action) string { - // Map S3 actions to standard IAM policy actions - actionMap := map[Action]string{ - s3_constants.ACTION_READ: "s3:GetObject", - s3_constants.ACTION_WRITE: "s3:PutObject", - s3_constants.ACTION_LIST: "s3:ListBucket", - s3_constants.ACTION_TAGGING: "s3:GetObjectTagging", - s3_constants.ACTION_READ_ACP: "s3:GetObjectAcl", - s3_constants.ACTION_WRITE_ACP: "s3:PutObjectAcl", - s3_constants.ACTION_DELETE_BUCKET: "s3:DeleteBucket", - s3_constants.ACTION_ADMIN: "s3:*", +// determineGranularS3Action determines the specific S3 IAM action based on HTTP request details +// This provides granular, operation-specific actions for accurate IAM policy enforcement +func determineGranularS3Action(r *http.Request, fallbackAction Action, bucket string, objectKey string) string { + method := r.Method + query := r.URL.Query() + + // Handle object-level operations + if objectKey != "" && objectKey != "/" { + switch method { + case "GET", "HEAD": + // Object read operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:GetObjectAcl" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:GetObjectTagging" + } + if _, hasRetention := query["retention"]; hasRetention { + return "s3:GetObjectRetention" + } + if _, hasLegalHold := query["legal-hold"]; hasLegalHold { + return "s3:GetObjectLegalHold" + } + if _, hasVersions := query["versions"]; hasVersions { + return "s3:GetObjectVersion" + } + // Default object read + return "s3:GetObject" + + case "PUT", "POST": + // Object write operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:PutObjectAcl" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:PutObjectTagging" + } + if _, hasRetention := query["retention"]; hasRetention { + return "s3:PutObjectRetention" + } + if _, hasLegalHold := query["legal-hold"]; hasLegalHold { + return "s3:PutObjectLegalHold" + } + // Check for multipart upload operations + if _, hasUploads := query["uploads"]; hasUploads { + return "s3:CreateMultipartUpload" + } + if _, hasUploadId := query["uploadId"]; hasUploadId { + if _, hasPartNumber := query["partNumber"]; hasPartNumber { + return "s3:UploadPart" + } + return "s3:CompleteMultipartUpload" // Complete multipart upload + } + // Default object write + return "s3:PutObject" + + case "DELETE": + // Object delete operations + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:DeleteObjectTagging" + } + if _, hasUploadId := query["uploadId"]; hasUploadId { + return "s3:AbortMultipartUpload" + } + // Default object delete + return "s3:DeleteObject" + } } - if iamAction, exists := actionMap[s3Action]; exists { - return iamAction + // Handle bucket-level operations + if bucket != "" { + switch method { + case "GET", "HEAD": + // Bucket read operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:GetBucketAcl" + } + if _, hasPolicy := query["policy"]; hasPolicy { + return "s3:GetBucketPolicy" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:GetBucketTagging" + } + if _, hasCors := query["cors"]; hasCors { + return "s3:GetBucketCors" + } + if _, hasVersioning := query["versioning"]; hasVersioning { + return "s3:GetBucketVersioning" + } + if _, hasNotification := query["notification"]; hasNotification { + return "s3:GetBucketNotification" + } + if _, hasObjectLock := query["object-lock"]; hasObjectLock { + return "s3:GetBucketObjectLockConfiguration" + } + if _, hasUploads := query["uploads"]; hasUploads { + return "s3:ListMultipartUploads" + } + if _, hasVersions := query["versions"]; hasVersions { + return "s3:ListBucketVersions" + } + // Default bucket read/list + return "s3:ListBucket" + + case "PUT": + // Bucket write operations - check for specific query parameters + if _, hasAcl := query["acl"]; hasAcl { + return "s3:PutBucketAcl" + } + if _, hasPolicy := query["policy"]; hasPolicy { + return "s3:PutBucketPolicy" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:PutBucketTagging" + } + if _, hasCors := query["cors"]; hasCors { + return "s3:PutBucketCors" + } + if _, hasVersioning := query["versioning"]; hasVersioning { + return "s3:PutBucketVersioning" + } + if _, hasNotification := query["notification"]; hasNotification { + return "s3:PutBucketNotification" + } + if _, hasObjectLock := query["object-lock"]; hasObjectLock { + return "s3:PutBucketObjectLockConfiguration" + } + // Default bucket creation + return "s3:CreateBucket" + + case "DELETE": + // Bucket delete operations - check for specific query parameters + if _, hasPolicy := query["policy"]; hasPolicy { + return "s3:DeleteBucketPolicy" + } + if _, hasTagging := query["tagging"]; hasTagging { + return "s3:DeleteBucketTagging" + } + if _, hasCors := query["cors"]; hasCors { + return "s3:DeleteBucketCors" + } + // Default bucket delete + return "s3:DeleteBucket" + } } - // Default to the string representation of the action - return string(s3Action) + // Fallback to legacy mapping for specific known actions + return mapLegacyActionToIAM(fallbackAction) +} + +// mapLegacyActionToIAM provides fallback mapping for legacy actions +// This ensures backward compatibility while the system transitions to granular actions +func mapLegacyActionToIAM(legacyAction Action) string { + switch legacyAction { + case s3_constants.ACTION_READ: + return "s3:GetObject" // Fallback for unmapped read operations + case s3_constants.ACTION_WRITE: + return "s3:PutObject" // Fallback for unmapped write operations + case s3_constants.ACTION_LIST: + return "s3:ListBucket" // Fallback for unmapped list operations + case s3_constants.ACTION_TAGGING: + return "s3:GetObjectTagging" // Fallback for unmapped tagging operations + case s3_constants.ACTION_READ_ACP: + return "s3:GetObjectAcl" // Fallback for unmapped ACL read operations + case s3_constants.ACTION_WRITE_ACP: + return "s3:PutObjectAcl" // Fallback for unmapped ACL write operations + case s3_constants.ACTION_DELETE_BUCKET: + return "s3:DeleteBucket" // Fallback for unmapped bucket delete operations + case s3_constants.ACTION_ADMIN: + return "s3:*" // Fallback for unmapped admin operations + + // Handle granular multipart actions (already correctly mapped) + case s3_constants.ACTION_CREATE_MULTIPART_UPLOAD: + return "s3:CreateMultipartUpload" + case s3_constants.ACTION_UPLOAD_PART: + return "s3:UploadPart" + case s3_constants.ACTION_COMPLETE_MULTIPART: + return "s3:CompleteMultipartUpload" + case s3_constants.ACTION_ABORT_MULTIPART: + return "s3:AbortMultipartUpload" + case s3_constants.ACTION_LIST_MULTIPART_UPLOADS: + return "s3:ListMultipartUploads" + case s3_constants.ACTION_LIST_PARTS: + return "s3:ListParts" + + default: + // If it's already a properly formatted S3 action, return as-is + actionStr := string(legacyAction) + if strings.HasPrefix(actionStr, "s3:") { + return actionStr + } + // Fallback: convert to S3 action format + return "s3:" + actionStr + } } // extractRequestContext extracts request context for policy conditions diff --git a/weed/s3api/s3_iam_simple_test.go b/weed/s3api/s3_iam_simple_test.go index 7bf403017..ced26c398 100644 --- a/weed/s3api/s3_iam_simple_test.go +++ b/weed/s3api/s3_iam_simple_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -11,6 +12,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/iam/policy" "github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/iam/utils" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -116,33 +118,274 @@ func TestBuildS3ResourceArn(t *testing.T) { } } -// TestMapS3ActionToIAMAction tests S3 to IAM action mapping -func TestMapS3ActionToIAMAction(t *testing.T) { +// TestDetermineGranularS3Action tests granular S3 action determination from HTTP requests +func TestDetermineGranularS3Action(t *testing.T) { tests := []struct { - name string - s3Action Action - expected string + name string + method string + bucket string + objectKey string + queryParams map[string]string + fallbackAction Action + expected string + description string + }{ + // Object-level operations + { + name: "get_object", + method: "GET", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{}, + fallbackAction: s3_constants.ACTION_READ, + expected: "s3:GetObject", + description: "Basic object retrieval", + }, + { + name: "get_object_acl", + method: "GET", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{"acl": ""}, + fallbackAction: s3_constants.ACTION_READ_ACP, + expected: "s3:GetObjectAcl", + description: "Object ACL retrieval", + }, + { + name: "get_object_tagging", + method: "GET", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{"tagging": ""}, + fallbackAction: s3_constants.ACTION_TAGGING, + expected: "s3:GetObjectTagging", + description: "Object tagging retrieval", + }, + { + name: "put_object", + method: "PUT", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{}, + fallbackAction: s3_constants.ACTION_WRITE, + expected: "s3:PutObject", + description: "Basic object upload", + }, + { + name: "put_object_acl", + method: "PUT", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{"acl": ""}, + fallbackAction: s3_constants.ACTION_WRITE_ACP, + expected: "s3:PutObjectAcl", + description: "Object ACL modification", + }, + { + name: "delete_object", + method: "DELETE", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{}, + fallbackAction: s3_constants.ACTION_WRITE, // DELETE object uses WRITE fallback + expected: "s3:DeleteObject", + description: "Object deletion - correctly mapped to DeleteObject (not PutObject)", + }, + { + name: "delete_object_tagging", + method: "DELETE", + bucket: "test-bucket", + objectKey: "test-object.txt", + queryParams: map[string]string{"tagging": ""}, + fallbackAction: s3_constants.ACTION_TAGGING, + expected: "s3:DeleteObjectTagging", + description: "Object tag deletion", + }, + + // Multipart upload operations + { + name: "create_multipart_upload", + method: "POST", + bucket: "test-bucket", + objectKey: "large-file.txt", + queryParams: map[string]string{"uploads": ""}, + fallbackAction: s3_constants.ACTION_WRITE, + expected: "s3:CreateMultipartUpload", + description: "Multipart upload initiation", + }, + { + name: "upload_part", + method: "PUT", + bucket: "test-bucket", + objectKey: "large-file.txt", + queryParams: map[string]string{"uploadId": "12345", "partNumber": "1"}, + fallbackAction: s3_constants.ACTION_WRITE, + expected: "s3:UploadPart", + description: "Multipart part upload", + }, + { + name: "complete_multipart_upload", + method: "POST", + bucket: "test-bucket", + objectKey: "large-file.txt", + queryParams: map[string]string{"uploadId": "12345"}, + fallbackAction: s3_constants.ACTION_WRITE, + expected: "s3:CompleteMultipartUpload", + description: "Multipart upload completion", + }, + { + name: "abort_multipart_upload", + method: "DELETE", + bucket: "test-bucket", + objectKey: "large-file.txt", + queryParams: map[string]string{"uploadId": "12345"}, + fallbackAction: s3_constants.ACTION_WRITE, + expected: "s3:AbortMultipartUpload", + description: "Multipart upload abort", + }, + + // Bucket-level operations + { + name: "list_bucket", + method: "GET", + bucket: "test-bucket", + objectKey: "", + queryParams: map[string]string{}, + fallbackAction: s3_constants.ACTION_LIST, + expected: "s3:ListBucket", + description: "Bucket listing", + }, + { + name: "get_bucket_acl", + method: "GET", + bucket: "test-bucket", + objectKey: "", + queryParams: map[string]string{"acl": ""}, + fallbackAction: s3_constants.ACTION_READ_ACP, + expected: "s3:GetBucketAcl", + description: "Bucket ACL retrieval", + }, + { + name: "put_bucket_policy", + method: "PUT", + bucket: "test-bucket", + objectKey: "", + queryParams: map[string]string{"policy": ""}, + fallbackAction: s3_constants.ACTION_WRITE, + expected: "s3:PutBucketPolicy", + description: "Bucket policy modification", + }, + { + name: "delete_bucket", + method: "DELETE", + bucket: "test-bucket", + objectKey: "", + queryParams: map[string]string{}, + fallbackAction: s3_constants.ACTION_DELETE_BUCKET, + expected: "s3:DeleteBucket", + description: "Bucket deletion", + }, + { + name: "list_multipart_uploads", + method: "GET", + bucket: "test-bucket", + objectKey: "", + queryParams: map[string]string{"uploads": ""}, + fallbackAction: s3_constants.ACTION_LIST, + expected: "s3:ListMultipartUploads", + description: "List multipart uploads in bucket", + }, + + // Fallback scenarios + { + name: "legacy_read_fallback", + method: "GET", + bucket: "", + objectKey: "", + queryParams: map[string]string{}, + fallbackAction: s3_constants.ACTION_READ, + expected: "s3:GetObject", + description: "Legacy read action fallback", + }, + { + name: "already_granular_action", + method: "GET", + bucket: "", + objectKey: "", + queryParams: map[string]string{}, + fallbackAction: "s3:GetBucketLocation", // Already granular + expected: "s3:GetBucketLocation", + description: "Already granular action passed through", + }, + } + + 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 := determineGranularS3Action(req, tt.fallbackAction, tt.bucket, tt.objectKey) + + assert.Equal(t, tt.expected, result, + "Test %s failed: %s. Expected %s but got %s", + tt.name, tt.description, tt.expected, result) + }) + } +} + +// TestMapLegacyActionToIAM tests the legacy action fallback mapping +func TestMapLegacyActionToIAM(t *testing.T) { + tests := []struct { + name string + legacyAction Action + expected string }{ { - name: "read action", - s3Action: "READ", // Assuming this is defined in s3_constants - expected: "READ", // Will fallback to string representation + name: "read_action_fallback", + legacyAction: s3_constants.ACTION_READ, + expected: "s3:GetObject", + }, + { + name: "write_action_fallback", + legacyAction: s3_constants.ACTION_WRITE, + expected: "s3:PutObject", + }, + { + name: "admin_action_fallback", + legacyAction: s3_constants.ACTION_ADMIN, + expected: "s3:*", + }, + { + name: "granular_multipart_action", + legacyAction: s3_constants.ACTION_CREATE_MULTIPART_UPLOAD, + expected: "s3:CreateMultipartUpload", }, { - name: "write action", - s3Action: "WRITE", - expected: "WRITE", + name: "unknown_action_with_s3_prefix", + legacyAction: "s3:CustomAction", + expected: "s3:CustomAction", }, { - name: "list action", - s3Action: "LIST", - expected: "LIST", + name: "unknown_action_without_prefix", + legacyAction: "CustomAction", + expected: "s3:CustomAction", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := mapS3ActionToIAMAction(tt.s3Action) + result := mapLegacyActionToIAM(tt.legacyAction) assert.Equal(t, tt.expected, result) }) }