package policy_engine import ( "testing" "github.com/stretchr/testify/assert" ) // TestConvertSingleActionDeleteObject tests support for s3:DeleteObject action (Issue #7864) func TestConvertSingleActionDeleteObject(t *testing.T) { // Test that Write action includes DeleteObject S3 action stmt, err := convertSingleAction("Write:bucket") assert.NoError(t, err) assert.NotNil(t, stmt) // Check that s3:DeleteObject is included in the actions actions := stmt.Action.Strings() assert.Contains(t, actions, "s3:DeleteObject", "Write action should include s3:DeleteObject") assert.Contains(t, actions, "s3:PutObject", "Write action should include s3:PutObject") } // TestConvertSingleActionSubpath tests subpath handling for legacy actions (Issue #7864) func TestConvertSingleActionSubpath(t *testing.T) { testCases := []struct { name string action string expectedActions []string expectedResources []string description string }{ { name: "Write_on_bucket", action: "Write:mybucket", expectedActions: []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors"}, expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, description: "Write permission on bucket should include bucket and object ARNs", }, { name: "Write_on_bucket_with_wildcard", action: "Write:mybucket/*", expectedActions: []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors"}, expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, description: "Write permission with /* should include bucket and object ARNs", }, { name: "Write_on_subpath", action: "Write:mybucket/sub_path/*", expectedActions: []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors"}, expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/sub_path/*"}, description: "Write permission on subpath should include bucket and subpath objects ARNs", }, { name: "Read_on_subpath", action: "Read:mybucket/documents/*", expectedActions: []string{"s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket", "s3:ListBucketVersions", "s3:GetObjectAcl", "s3:GetObjectVersionAcl", "s3:GetObjectTagging", "s3:GetObjectVersionTagging", "s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetBucketAcl", "s3:GetBucketCors", "s3:GetBucketTagging", "s3:GetBucketNotification"}, expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/documents/*"}, description: "Read permission on subpath should include bucket ARN and subpath objects", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { stmt, err := convertSingleAction(tc.action) assert.NoError(t, err, tc.description) assert.NotNil(t, stmt) // Check actions actions := stmt.Action.Strings() for _, expectedAction := range tc.expectedActions { assert.Contains(t, actions, expectedAction, "Action %s should be included for %s", expectedAction, tc.action) } // Check resources - verify all expected resources are present resources := stmt.Resource.Strings() assert.ElementsMatch(t, resources, tc.expectedResources, "Resources should match exactly for %s. Got %v, expected %v", tc.action, resources, tc.expectedResources) }) } } // TestConvertSingleActionSubpathDeleteAllowed tests that DeleteObject works on subpaths func TestConvertSingleActionSubpathDeleteAllowed(t *testing.T) { // This test specifically addresses Issue #7864 part 1: // "when a user is granted permission to a subpath, eg s3.configure -user someuser // -actions Write -buckets some_bucket/sub_path/* -apply // the user will only be able to put, but not delete object under somebucket/sub_path" stmt, err := convertSingleAction("Write:some_bucket/sub_path/*") assert.NoError(t, err) // The fix: s3:DeleteObject should be in the allowed actions actions := stmt.Action.Strings() assert.Contains(t, actions, "s3:DeleteObject", "Write permission on subpath should allow deletion of objects in that path") // The resource should be restricted to the subpath resources := stmt.Resource.Strings() assert.Contains(t, resources, "arn:aws:s3:::some_bucket/sub_path/*", "Delete permission should apply to objects under the subpath") } // TestConvertSingleActionNestedPaths tests deeply nested paths func TestConvertSingleActionNestedPaths(t *testing.T) { testCases := []struct { action string expectedResources []string }{ { action: "Write:bucket/a/b/c/*", expectedResources: []string{"arn:aws:s3:::bucket", "arn:aws:s3:::bucket/a/b/c/*"}, }, { action: "Read:bucket/data/documents/2024/*", expectedResources: []string{"arn:aws:s3:::bucket", "arn:aws:s3:::bucket/data/documents/2024/*"}, }, } for _, tc := range testCases { stmt, err := convertSingleAction(tc.action) assert.NoError(t, err) resources := stmt.Resource.Strings() assert.ElementsMatch(t, resources, tc.expectedResources) } } // TestGetResourcesFromLegacyAction tests that GetResourcesFromLegacyAction generates // action-appropriate resources consistent with convertSingleAction func TestGetResourcesFromLegacyAction(t *testing.T) { testCases := []struct { name string action string expectedResources []string description string }{ // List actions - bucket-only (no object ARNs) { name: "List_on_bucket", action: "List:mybucket", expectedResources: []string{"arn:aws:s3:::mybucket"}, description: "List action should only have bucket ARN", }, { name: "List_on_bucket_with_wildcard", action: "List:mybucket/*", expectedResources: []string{"arn:aws:s3:::mybucket"}, description: "List action should only have bucket ARN regardless of wildcard", }, // Read actions - bucket and object-level ARNs (includes List* and Get* operations) { name: "Read_on_bucket", action: "Read:mybucket", expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, description: "Read action should have both bucket and object ARNs", }, { name: "Read_on_subpath", action: "Read:mybucket/documents/*", expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/documents/*"}, description: "Read action on subpath should have bucket ARN and object ARN for subpath", }, // Write actions - bucket and object ARNs (includes bucket-level operations) { name: "Write_on_subpath", action: "Write:mybucket/sub_path/*", expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/sub_path/*"}, description: "Write action should have bucket and object ARNs", }, // Admin actions - both bucket and object ARNs { name: "Admin_on_bucket", action: "Admin:mybucket", expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, description: "Admin action should have both bucket and object ARNs", }, { name: "Admin_on_subpath", action: "Admin:mybucket/admin/section/*", expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/admin/section/*"}, description: "Admin action on subpath should restrict to subpath, preventing privilege escalation", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { resources, err := GetResourcesFromLegacyAction(tc.action) assert.NoError(t, err, tc.description) assert.ElementsMatch(t, resources, tc.expectedResources, "Resources should match expected. Got %v, expected %v", resources, tc.expectedResources) // Also verify consistency with convertSingleAction where applicable stmt, err := convertSingleAction(tc.action) assert.NoError(t, err) stmtResources := stmt.Resource.Strings() assert.ElementsMatch(t, resources, stmtResources, "GetResourcesFromLegacyAction should match convertSingleAction resources for %s", tc.action) }) } } // TestExtractBucketAndPrefixEdgeCases validates edge case handling in extractBucketAndPrefix func TestExtractBucketAndPrefixEdgeCases(t *testing.T) { testCases := []struct { name string pattern string expectedBucket string expectedPrefix string description string }{ { name: "Empty string", pattern: "", expectedBucket: "", expectedPrefix: "", description: "Empty pattern should return empty strings", }, { name: "Whitespace only", pattern: " ", expectedBucket: "", expectedPrefix: "", description: "Whitespace-only pattern should return empty strings", }, { name: "Slash only", pattern: "/", expectedBucket: "", expectedPrefix: "", description: "Slash-only pattern should return empty strings", }, { name: "Double slash prefix", pattern: "bucket//prefix/*", expectedBucket: "bucket", expectedPrefix: "prefix", description: "Double slash should be normalized (trailing slashes removed)", }, { name: "Normal bucket", pattern: "mybucket", expectedBucket: "mybucket", expectedPrefix: "", description: "Bucket-only pattern should work correctly", }, { name: "Bucket with prefix", pattern: "mybucket/myprefix/*", expectedBucket: "mybucket", expectedPrefix: "myprefix", description: "Bucket with prefix should be parsed correctly", }, { name: "Nested prefix", pattern: "mybucket/a/b/c/*", expectedBucket: "mybucket", expectedPrefix: "a/b/c", description: "Nested prefix should be preserved", }, { name: "Bucket with trailing slash", pattern: "mybucket/", expectedBucket: "mybucket", expectedPrefix: "", description: "Trailing slash on bucket should be normalized", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { bucket, prefix := extractBucketAndPrefix(tc.pattern) assert.Equal(t, tc.expectedBucket, bucket, tc.description) assert.Equal(t, tc.expectedPrefix, prefix, tc.description) }) } } // TestCreatePolicyFromLegacyIdentityMultipleActions validates correct resource ARN aggregation // when multiple action types target the same resource pattern func TestCreatePolicyFromLegacyIdentityMultipleActions(t *testing.T) { testCases := []struct { name string identityName string actions []string expectedStatements int expectedActionsInStmt1 []string expectedResourcesInStmt1 []string description string }{ { name: "List_and_Write_on_subpath", identityName: "data-manager", actions: []string{"List:mybucket/data/*", "Write:mybucket/data/*"}, expectedStatements: 1, expectedActionsInStmt1: []string{ "s3:ListBucket", "s3:ListBucketVersions", "s3:ListAllMyBuckets", "s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors", }, expectedResourcesInStmt1: []string{ "arn:aws:s3:::mybucket", // From List and Write actions "arn:aws:s3:::mybucket/data/*", // From Write action }, description: "List + Write on same subpath should aggregate all actions and both bucket and object ARNs", }, { name: "Read_and_Tagging_on_bucket", identityName: "tag-reader", actions: []string{"Read:mybucket", "Tagging:mybucket"}, expectedStatements: 1, expectedActionsInStmt1: []string{ "s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket", "s3:ListBucketVersions", "s3:GetObjectAcl", "s3:GetObjectVersionAcl", "s3:GetObjectTagging", "s3:GetObjectVersionTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetBucketAcl", "s3:GetBucketCors", "s3:GetBucketTagging", "s3:GetBucketNotification", "s3:PutBucketTagging", "s3:DeleteBucketTagging", }, expectedResourcesInStmt1: []string{ "arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*", }, description: "Read + Tagging on same bucket should aggregate all bucket and object-level actions and ARNs", }, { name: "Admin_with_other_actions", identityName: "admin-user", actions: []string{"Admin:mybucket/admin/*", "Write:mybucket/admin/*"}, expectedStatements: 1, expectedActionsInStmt1: []string{"s3:*"}, expectedResourcesInStmt1: []string{ "arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/admin/*", }, description: "Admin action should dominate and set s3:*, other actions still processed for resources", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { policy, err := CreatePolicyFromLegacyIdentity(tc.identityName, tc.actions) assert.NoError(t, err, tc.description) assert.NotNil(t, policy) // Check statement count assert.Equal(t, tc.expectedStatements, len(policy.Statement), "Expected %d statement(s), got %d", tc.expectedStatements, len(policy.Statement)) if tc.expectedStatements > 0 { stmt := policy.Statement[0] // Check actions actualActions := stmt.Action.Strings() for _, expectedAction := range tc.expectedActionsInStmt1 { assert.Contains(t, actualActions, expectedAction, "Action %s should be included in statement", expectedAction) } // Check resources - all expected resources should be present actualResources := stmt.Resource.Strings() assert.ElementsMatch(t, tc.expectedResourcesInStmt1, actualResources, "Statement should aggregate all required resource ARNs. Got %v, expected %v", actualResources, tc.expectedResourcesInStmt1) } }) } }