9 changed files with 722 additions and 127 deletions
-
3k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml
-
4k8s/charts/seaweedfs/values.yaml
-
104weed/iam/helpers.go
-
17weed/iam/helpers_test.go
-
24weed/s3api/policy_engine/engine_test.go
-
2weed/s3api/policy_engine/examples.go
-
314weed/s3api/policy_engine/integration.go
-
373weed/s3api/policy_engine/integration_test.go
-
8weed/s3api/s3api_object_handlers.go
@ -0,0 +1,373 @@ |
|||||
|
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) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue