Merge branch 'master' of https://github.com/seaweedfs/seaweedfs
This commit is contained in:
@@ -176,6 +176,9 @@ spec:
|
|||||||
{{- if $volume.dataCenter }}
|
{{- if $volume.dataCenter }}
|
||||||
-dataCenter={{ $volume.dataCenter }} \
|
-dataCenter={{ $volume.dataCenter }} \
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if $volume.id }}
|
||||||
|
-id={{ $volume.id }} \
|
||||||
|
{{- end }}
|
||||||
-ip.bind={{ $volume.ipBind }} \
|
-ip.bind={{ $volume.ipBind }} \
|
||||||
-readMode={{ $volume.readMode }} \
|
-readMode={{ $volume.readMode }} \
|
||||||
{{- if $volume.whiteList }}
|
{{- if $volume.whiteList }}
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ volume:
|
|||||||
# Volume server's rack name
|
# Volume server's rack name
|
||||||
rack: null
|
rack: null
|
||||||
|
|
||||||
|
# Stable identifier for the volume server, independent of IP address
|
||||||
|
# Useful for Kubernetes environments with hostPath volumes to maintain stable identity
|
||||||
|
id: null
|
||||||
|
|
||||||
# Volume server's data center name
|
# Volume server's data center name
|
||||||
dataCenter: null
|
dataCenter: null
|
||||||
|
|
||||||
|
|||||||
@@ -68,30 +68,94 @@ func StringSlicesEqual(a, b []string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapToStatementAction converts a policy statement action to an S3 action constant.
|
// fineGrainedActionMap maps S3 IAM action names to internal S3 action constants.
|
||||||
func MapToStatementAction(action string) string {
|
// Supports both prefixed (e.g., "s3:DeleteObject") and unprefixed (e.g., "DeleteObject") formats.
|
||||||
switch action {
|
// Populated in init() to avoid duplication of prefixed/unprefixed variants.
|
||||||
case StatementActionAdmin:
|
var fineGrainedActionMap = map[string]string{
|
||||||
return s3_constants.ACTION_ADMIN
|
// Coarse-grained actions (populated statically)
|
||||||
case StatementActionWrite:
|
StatementActionAdmin: s3_constants.ACTION_ADMIN,
|
||||||
return s3_constants.ACTION_WRITE
|
StatementActionWrite: s3_constants.ACTION_WRITE,
|
||||||
case StatementActionWriteAcp:
|
StatementActionWriteAcp: s3_constants.ACTION_WRITE_ACP,
|
||||||
return s3_constants.ACTION_WRITE_ACP
|
StatementActionRead: s3_constants.ACTION_READ,
|
||||||
case StatementActionRead:
|
StatementActionReadAcp: s3_constants.ACTION_READ_ACP,
|
||||||
return s3_constants.ACTION_READ
|
StatementActionList: s3_constants.ACTION_LIST,
|
||||||
case StatementActionReadAcp:
|
StatementActionTagging: s3_constants.ACTION_TAGGING,
|
||||||
return s3_constants.ACTION_READ_ACP
|
StatementActionDelete: s3_constants.ACTION_DELETE_BUCKET,
|
||||||
case StatementActionList:
|
}
|
||||||
return s3_constants.ACTION_LIST
|
|
||||||
case StatementActionTagging:
|
// baseS3ActionMap defines the base S3 actions that will be populated with both
|
||||||
return s3_constants.ACTION_TAGGING
|
// prefixed (s3:Action) and unprefixed (Action) variants in init().
|
||||||
case StatementActionDelete:
|
var baseS3ActionMap = map[string]string{
|
||||||
return s3_constants.ACTION_DELETE_BUCKET
|
// Object operations
|
||||||
default:
|
"DeleteObject": s3_constants.ACTION_WRITE,
|
||||||
return ""
|
"PutObject": s3_constants.ACTION_WRITE,
|
||||||
|
"GetObject": s3_constants.ACTION_READ,
|
||||||
|
"DeleteObjectVersion": s3_constants.ACTION_WRITE,
|
||||||
|
"GetObjectVersion": s3_constants.ACTION_READ,
|
||||||
|
// Tagging operations
|
||||||
|
"GetObjectTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
"GetObjectVersionTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
"PutObjectTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
"DeleteObjectTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
"GetBucketTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
"PutBucketTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
"DeleteBucketTagging": s3_constants.ACTION_TAGGING,
|
||||||
|
// ACL operations
|
||||||
|
"PutObjectAcl": s3_constants.ACTION_WRITE_ACP,
|
||||||
|
"GetObjectAcl": s3_constants.ACTION_READ_ACP,
|
||||||
|
"GetObjectVersionAcl": s3_constants.ACTION_READ_ACP,
|
||||||
|
"PutBucketAcl": s3_constants.ACTION_WRITE_ACP,
|
||||||
|
"GetBucketAcl": s3_constants.ACTION_READ_ACP,
|
||||||
|
// Bucket operations
|
||||||
|
"DeleteBucket": s3_constants.ACTION_DELETE_BUCKET,
|
||||||
|
"DeleteBucketPolicy": s3_constants.ACTION_ADMIN,
|
||||||
|
"ListBucket": s3_constants.ACTION_LIST,
|
||||||
|
"ListBucketVersions": s3_constants.ACTION_LIST,
|
||||||
|
"ListAllMyBuckets": s3_constants.ACTION_LIST,
|
||||||
|
"GetBucketLocation": s3_constants.ACTION_READ,
|
||||||
|
"GetBucketVersioning": s3_constants.ACTION_READ,
|
||||||
|
"PutBucketVersioning": s3_constants.ACTION_WRITE,
|
||||||
|
"GetBucketCors": s3_constants.ACTION_READ,
|
||||||
|
"PutBucketCors": s3_constants.ACTION_WRITE,
|
||||||
|
"DeleteBucketCors": s3_constants.ACTION_WRITE,
|
||||||
|
"GetBucketNotification": s3_constants.ACTION_READ,
|
||||||
|
"PutBucketNotification": s3_constants.ACTION_WRITE,
|
||||||
|
"GetBucketObjectLockConfiguration": s3_constants.ACTION_READ,
|
||||||
|
"PutBucketObjectLockConfiguration": s3_constants.ACTION_WRITE,
|
||||||
|
// Multipart upload operations
|
||||||
|
"CreateMultipartUpload": s3_constants.ACTION_WRITE,
|
||||||
|
"UploadPart": s3_constants.ACTION_WRITE,
|
||||||
|
"CompleteMultipartUpload": s3_constants.ACTION_WRITE,
|
||||||
|
"AbortMultipartUpload": s3_constants.ACTION_WRITE,
|
||||||
|
"ListMultipartUploads": s3_constants.ACTION_WRITE,
|
||||||
|
"ListParts": s3_constants.ACTION_WRITE,
|
||||||
|
// Retention and legal hold operations
|
||||||
|
"GetObjectRetention": s3_constants.ACTION_READ,
|
||||||
|
"PutObjectRetention": s3_constants.ACTION_WRITE,
|
||||||
|
"GetObjectLegalHold": s3_constants.ACTION_READ,
|
||||||
|
"PutObjectLegalHold": s3_constants.ACTION_WRITE,
|
||||||
|
"BypassGovernanceRetention": s3_constants.ACTION_WRITE,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Populate both prefixed and unprefixed variants for all base S3 actions.
|
||||||
|
// This avoids duplication and makes it easy to add new actions in one place.
|
||||||
|
for action, constant := range baseS3ActionMap {
|
||||||
|
fineGrainedActionMap[action] = constant // unprefixed: "DeleteObject"
|
||||||
|
fineGrainedActionMap["s3:"+action] = constant // prefixed: "s3:DeleteObject"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MapToStatementAction converts a policy statement action to an S3 action constant.
|
||||||
|
// It handles both coarse-grained statement actions (e.g., "Put*", "Get*") and
|
||||||
|
// fine-grained S3 actions (e.g., "s3:DeleteObject", "s3:PutObject") via exact lookup.
|
||||||
|
func MapToStatementAction(action string) string {
|
||||||
|
if val, ok := fineGrainedActionMap[action]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// MapToIdentitiesAction converts an S3 action constant to a policy statement action.
|
// MapToIdentitiesAction converts an S3 action constant to a policy statement action.
|
||||||
func MapToIdentitiesAction(action string) string {
|
func MapToIdentitiesAction(action string) string {
|
||||||
switch action {
|
switch action {
|
||||||
@@ -123,5 +187,3 @@ func MaskAccessKey(accessKeyId string) string {
|
|||||||
}
|
}
|
||||||
return accessKeyId
|
return accessKeyId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,12 +88,25 @@ func TestMapToStatementAction(t *testing.T) {
|
|||||||
{StatementActionRead, s3_constants.ACTION_READ},
|
{StatementActionRead, s3_constants.ACTION_READ},
|
||||||
{StatementActionList, s3_constants.ACTION_LIST},
|
{StatementActionList, s3_constants.ACTION_LIST},
|
||||||
{StatementActionDelete, s3_constants.ACTION_DELETE_BUCKET},
|
{StatementActionDelete, s3_constants.ACTION_DELETE_BUCKET},
|
||||||
|
// Test fine-grained S3 action mappings (Issue #7864)
|
||||||
|
{"DeleteObject", s3_constants.ACTION_WRITE},
|
||||||
|
{"s3:DeleteObject", s3_constants.ACTION_WRITE},
|
||||||
|
{"PutObject", s3_constants.ACTION_WRITE},
|
||||||
|
{"s3:PutObject", s3_constants.ACTION_WRITE},
|
||||||
|
{"GetObject", s3_constants.ACTION_READ},
|
||||||
|
{"s3:GetObject", s3_constants.ACTION_READ},
|
||||||
|
{"ListBucket", s3_constants.ACTION_LIST},
|
||||||
|
{"s3:ListBucket", s3_constants.ACTION_LIST},
|
||||||
|
{"PutObjectAcl", s3_constants.ACTION_WRITE_ACP},
|
||||||
|
{"s3:PutObjectAcl", s3_constants.ACTION_WRITE_ACP},
|
||||||
|
{"GetObjectAcl", s3_constants.ACTION_READ_ACP},
|
||||||
|
{"s3:GetObjectAcl", s3_constants.ACTION_READ_ACP},
|
||||||
{"unknown", ""},
|
{"unknown", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
result := MapToStatementAction(test.input)
|
result := MapToStatementAction(test.input)
|
||||||
assert.Equal(t, test.expected, result)
|
assert.Equal(t, test.expected, result, "Failed for input: %s", test.input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,5 +145,3 @@ func TestMaskAccessKey(t *testing.T) {
|
|||||||
assert.Equal(t, test.expected, result)
|
assert.Equal(t, test.expected, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func TestConvertIdentityToPolicy(t *testing.T) {
|
|||||||
"Admin:bucket2",
|
"Admin:bucket2",
|
||||||
}
|
}
|
||||||
|
|
||||||
policy, err := ConvertIdentityToPolicy(identityActions, "bucket1")
|
policy, err := ConvertIdentityToPolicy(identityActions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to convert identity to policy: %v", err)
|
t.Fatalf("Failed to convert identity to policy: %v", err)
|
||||||
}
|
}
|
||||||
@@ -252,13 +252,17 @@ func TestConvertIdentityToPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actions := normalizeToStringSlice(stmt.Action)
|
actions := normalizeToStringSlice(stmt.Action)
|
||||||
if len(actions) != 3 {
|
// Read action now includes: GetObject, GetObjectVersion, ListBucket, ListBucketVersions,
|
||||||
t.Errorf("Expected 3 read actions, got %d", len(actions))
|
// GetObjectAcl, GetObjectVersionAcl, GetObjectTagging, GetObjectVersionTagging,
|
||||||
|
// GetBucketLocation, GetBucketVersioning, GetBucketAcl, GetBucketCors, GetBucketTagging, GetBucketNotification
|
||||||
|
if len(actions) != 14 {
|
||||||
|
t.Errorf("Expected 14 read actions, got %d: %v", len(actions), actions)
|
||||||
}
|
}
|
||||||
|
|
||||||
resources := normalizeToStringSlice(stmt.Resource)
|
resources := normalizeToStringSlice(stmt.Resource)
|
||||||
|
// Read action now includes both bucket ARN (for ListBucket*) and object ARN (for GetObject*)
|
||||||
if len(resources) != 2 {
|
if len(resources) != 2 {
|
||||||
t.Errorf("Expected 2 resources, got %d", len(resources))
|
t.Errorf("Expected 2 resources (bucket and bucket/*), got %d: %v", len(resources), resources)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,8 +801,8 @@ func TestExistingObjectTagCondition(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
args := &PolicyEvaluationArgs{
|
args := &PolicyEvaluationArgs{
|
||||||
Action: "s3:GetObject",
|
Action: "s3:GetObject",
|
||||||
Resource: "arn:aws:s3:::test-bucket/test-object",
|
Resource: "arn:aws:s3:::test-bucket/test-object",
|
||||||
Principal: "*",
|
Principal: "*",
|
||||||
ObjectEntry: tagsToEntry(tt.objectTags),
|
ObjectEntry: tagsToEntry(tt.objectTags),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,8 +878,8 @@ func TestExistingObjectTagConditionMultipleTags(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
args := &PolicyEvaluationArgs{
|
args := &PolicyEvaluationArgs{
|
||||||
Action: "s3:GetObject",
|
Action: "s3:GetObject",
|
||||||
Resource: "arn:aws:s3:::test-bucket/test-object",
|
Resource: "arn:aws:s3:::test-bucket/test-object",
|
||||||
Principal: "*",
|
Principal: "*",
|
||||||
ObjectEntry: tagsToEntry(tt.objectTags),
|
ObjectEntry: tagsToEntry(tt.objectTags),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,8 +950,8 @@ func TestExistingObjectTagDenyPolicy(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
args := &PolicyEvaluationArgs{
|
args := &PolicyEvaluationArgs{
|
||||||
Action: "s3:GetObject",
|
Action: "s3:GetObject",
|
||||||
Resource: "arn:aws:s3:::test-bucket/test-object",
|
Resource: "arn:aws:s3:::test-bucket/test-object",
|
||||||
Principal: "*",
|
Principal: "*",
|
||||||
ObjectEntry: tagsToEntry(tt.objectTags),
|
ObjectEntry: tagsToEntry(tt.objectTags),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func ExampleLegacyIntegration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert to policy
|
// Convert to policy
|
||||||
policy, err := ConvertIdentityToPolicy(legacyActions, "bucket1")
|
policy, err := ConvertIdentityToPolicy(legacyActions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error converting identity to policy: %v\n", err)
|
fmt.Printf("Error converting identity to policy: %v\n", err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -123,12 +123,70 @@ func (p *PolicyBackedIAM) evaluateUsingPolicyConversion(action, bucketName, obje
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractBucketAndPrefix extracts bucket name and prefix from a resource pattern.
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// "bucket" -> bucket="bucket", prefix=""
|
||||||
|
// "bucket/*" -> bucket="bucket", prefix=""
|
||||||
|
// "bucket/prefix/*" -> bucket="bucket", prefix="prefix"
|
||||||
|
// "bucket/a/b/c/*" -> bucket="bucket", prefix="a/b/c"
|
||||||
|
func extractBucketAndPrefix(pattern string) (string, string) {
|
||||||
|
// Validate input
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
if pattern == "" || pattern == "/" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing /* if present
|
||||||
|
pattern = strings.TrimSuffix(pattern, "/*")
|
||||||
|
|
||||||
|
// Remove a single trailing slash to avoid empty path segments
|
||||||
|
if strings.HasSuffix(pattern, "/") {
|
||||||
|
pattern = pattern[:len(pattern)-1]
|
||||||
|
}
|
||||||
|
if pattern == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on the first /
|
||||||
|
parts := strings.SplitN(pattern, "/", 2)
|
||||||
|
bucket := strings.TrimSpace(parts[0])
|
||||||
|
if bucket == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
// No slash, entire pattern is bucket
|
||||||
|
return bucket, ""
|
||||||
|
}
|
||||||
|
// Has slash, first part is bucket, rest is prefix
|
||||||
|
prefix := strings.Trim(parts[1], "/")
|
||||||
|
return bucket, prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildObjectResourceArn generates ARNs for object-level access.
|
||||||
|
// It properly handles both bucket-level (all objects) and prefix-level access.
|
||||||
|
// Returns empty slice if bucket is invalid to prevent generating malformed ARNs.
|
||||||
|
func buildObjectResourceArn(resourcePattern string) []string {
|
||||||
|
bucket, prefix := extractBucketAndPrefix(resourcePattern)
|
||||||
|
// If bucket is empty, the pattern is invalid; avoid generating malformed ARNs
|
||||||
|
if bucket == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
if prefix != "" {
|
||||||
|
// Prefix-based access: restrict to objects under this prefix
|
||||||
|
return []string{fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix)}
|
||||||
|
}
|
||||||
|
// Bucket-level access: all objects in bucket
|
||||||
|
return []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
||||||
|
}
|
||||||
|
|
||||||
// ConvertIdentityToPolicy converts a legacy identity action to an AWS policy
|
// ConvertIdentityToPolicy converts a legacy identity action to an AWS policy
|
||||||
func ConvertIdentityToPolicy(identityActions []string, bucketName string) (*PolicyDocument, error) {
|
func ConvertIdentityToPolicy(identityActions []string) (*PolicyDocument, error) {
|
||||||
statements := make([]PolicyStatement, 0)
|
statements := make([]PolicyStatement, 0)
|
||||||
|
|
||||||
for _, action := range identityActions {
|
for _, action := range identityActions {
|
||||||
stmt, err := convertSingleAction(action, bucketName)
|
stmt, err := convertSingleAction(action)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
glog.Warningf("Failed to convert action %s: %v", action, err)
|
glog.Warningf("Failed to convert action %s: %v", action, err)
|
||||||
continue
|
continue
|
||||||
@@ -148,8 +206,9 @@ func ConvertIdentityToPolicy(identityActions []string, bucketName string) (*Poli
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertSingleAction converts a single legacy action to a policy statement
|
// convertSingleAction converts a single legacy action to a policy statement.
|
||||||
func convertSingleAction(action, bucketName string) (*PolicyStatement, error) {
|
// action format: "ActionType:ResourcePattern" (e.g., "Write:bucket/prefix/*")
|
||||||
|
func convertSingleAction(action string) (*PolicyStatement, error) {
|
||||||
parts := strings.Split(action, ":")
|
parts := strings.Split(action, ":")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return nil, fmt.Errorf("invalid action format: %s", action)
|
return nil, fmt.Errorf("invalid action format: %s", action)
|
||||||
@@ -163,111 +222,158 @@ func convertSingleAction(action, bucketName string) (*PolicyStatement, error) {
|
|||||||
|
|
||||||
switch actionType {
|
switch actionType {
|
||||||
case "Read":
|
case "Read":
|
||||||
s3Actions = []string{"s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket"}
|
// Read includes both object-level (GetObject, GetObjectAcl, GetObjectTagging, GetObjectVersions)
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
// and bucket-level operations (ListBucket, GetBucketLocation, GetBucketVersioning, GetBucketCors, etc.)
|
||||||
// Object-level read access
|
s3Actions = []string{
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
"s3:GetObject",
|
||||||
resources = []string{
|
"s3:GetObjectVersion",
|
||||||
fmt.Sprintf("arn:aws:s3:::%s", bucket),
|
"s3:GetObjectAcl",
|
||||||
fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
"s3:GetObjectVersionAcl",
|
||||||
}
|
"s3:GetObjectTagging",
|
||||||
|
"s3:GetObjectVersionTagging",
|
||||||
|
"s3:ListBucket",
|
||||||
|
"s3:ListBucketVersions",
|
||||||
|
"s3:GetBucketLocation",
|
||||||
|
"s3:GetBucketVersioning",
|
||||||
|
"s3:GetBucketAcl",
|
||||||
|
"s3:GetBucketCors",
|
||||||
|
"s3:GetBucketTagging",
|
||||||
|
"s3:GetBucketNotification",
|
||||||
|
}
|
||||||
|
bucket, _ := extractBucketAndPrefix(resourcePattern)
|
||||||
|
objectResources := buildObjectResourceArn(resourcePattern)
|
||||||
|
// Include both bucket ARN (for ListBucket* and Get*Bucket operations) and object ARNs (for GetObject* operations)
|
||||||
|
if bucket != "" {
|
||||||
|
resources = append([]string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, objectResources...)
|
||||||
} else {
|
} else {
|
||||||
// Bucket-level read access
|
resources = objectResources
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Write":
|
case "Write":
|
||||||
s3Actions = []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl"}
|
// Write includes object-level writes (PutObject, DeleteObject, PutObjectAcl, DeleteObjectVersion, DeleteObjectTagging, PutObjectTagging)
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
// and bucket-level writes (PutBucketVersioning, PutBucketCors, DeleteBucketCors, PutBucketAcl, PutBucketTagging, DeleteBucketTagging, PutBucketNotification)
|
||||||
// Object-level write access
|
// and multipart upload operations (AbortMultipartUpload, ListMultipartUploads, ListParts).
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
// ListMultipartUploads and ListParts are included because they are part of the multipart upload workflow
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
// and require Write permissions to be meaningful (no point listing uploads if you can't abort/complete them).
|
||||||
|
s3Actions = []string{
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:PutObjectAcl",
|
||||||
|
"s3:PutObjectTagging",
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:DeleteObjectVersion",
|
||||||
|
"s3:DeleteObjectTagging",
|
||||||
|
"s3:AbortMultipartUpload",
|
||||||
|
"s3:ListMultipartUploads",
|
||||||
|
"s3:ListParts",
|
||||||
|
"s3:PutBucketAcl",
|
||||||
|
"s3:PutBucketCors",
|
||||||
|
"s3:PutBucketTagging",
|
||||||
|
"s3:PutBucketNotification",
|
||||||
|
"s3:PutBucketVersioning",
|
||||||
|
"s3:DeleteBucketTagging",
|
||||||
|
"s3:DeleteBucketCors",
|
||||||
|
}
|
||||||
|
bucket, _ := extractBucketAndPrefix(resourcePattern)
|
||||||
|
objectResources := buildObjectResourceArn(resourcePattern)
|
||||||
|
// Include bucket ARN so bucket-level write operations (e.g., PutBucketVersioning, PutBucketCors)
|
||||||
|
// have the correct resource, while still allowing object-level writes.
|
||||||
|
if bucket != "" {
|
||||||
|
resources = append([]string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, objectResources...)
|
||||||
} else {
|
} else {
|
||||||
// Bucket-level write access
|
resources = objectResources
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Admin":
|
case "Admin":
|
||||||
s3Actions = []string{"s3:*"}
|
s3Actions = []string{"s3:*"}
|
||||||
resources = []string{
|
bucket, prefix := extractBucketAndPrefix(resourcePattern)
|
||||||
fmt.Sprintf("arn:aws:s3:::%s", resourcePattern),
|
if bucket == "" {
|
||||||
fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern),
|
// Invalid pattern, return error
|
||||||
|
return nil, fmt.Errorf("Admin action requires a valid bucket name")
|
||||||
}
|
}
|
||||||
|
if prefix != "" {
|
||||||
case "List":
|
// Subpath admin access: restrict to objects under this prefix
|
||||||
s3Actions = []string{"s3:ListBucket", "s3:ListBucketVersions"}
|
resources = []string{
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
fmt.Sprintf("arn:aws:s3:::%s", bucket),
|
||||||
// Object-level list access - extract bucket from "bucket/prefix/*" pattern
|
fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix),
|
||||||
patternWithoutWildcard := strings.TrimSuffix(resourcePattern, "/*")
|
}
|
||||||
parts := strings.SplitN(patternWithoutWildcard, "/", 2)
|
} else {
|
||||||
bucket := parts[0]
|
// Bucket-level admin access: full bucket permissions
|
||||||
resources = []string{
|
resources = []string{
|
||||||
fmt.Sprintf("arn:aws:s3:::%s", bucket),
|
fmt.Sprintf("arn:aws:s3:::%s", bucket),
|
||||||
fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
fmt.Sprintf("arn:aws:s3:::%s/*", bucket),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "List":
|
||||||
|
// List includes bucket listing operations and also ListAllMyBuckets
|
||||||
|
s3Actions = []string{"s3:ListBucket", "s3:ListBucketVersions", "s3:ListAllMyBuckets"}
|
||||||
|
// ListBucket actions only require bucket ARN, not object-level ARNs
|
||||||
|
bucket, _ := extractBucketAndPrefix(resourcePattern)
|
||||||
|
if bucket != "" {
|
||||||
|
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}
|
||||||
} else {
|
} else {
|
||||||
// Bucket-level list access
|
// Invalid pattern, return empty resources to fail validation
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)}
|
resources = []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "Tagging":
|
case "Tagging":
|
||||||
s3Actions = []string{"s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging"}
|
// Tagging includes both object-level and bucket-level tagging operations
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)}
|
s3Actions = []string{
|
||||||
|
"s3:GetObjectTagging",
|
||||||
|
"s3:PutObjectTagging",
|
||||||
|
"s3:DeleteObjectTagging",
|
||||||
|
"s3:GetBucketTagging",
|
||||||
|
"s3:PutBucketTagging",
|
||||||
|
"s3:DeleteBucketTagging",
|
||||||
|
}
|
||||||
|
bucket, _ := extractBucketAndPrefix(resourcePattern)
|
||||||
|
objectResources := buildObjectResourceArn(resourcePattern)
|
||||||
|
// Include bucket ARN so bucket-level tagging operations have the correct resource
|
||||||
|
if bucket != "" {
|
||||||
|
resources = append([]string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, objectResources...)
|
||||||
|
} else {
|
||||||
|
resources = objectResources
|
||||||
|
}
|
||||||
|
|
||||||
case "BypassGovernanceRetention":
|
case "BypassGovernanceRetention":
|
||||||
s3Actions = []string{"s3:BypassGovernanceRetention"}
|
s3Actions = []string{"s3:BypassGovernanceRetention"}
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
resources = buildObjectResourceArn(resourcePattern)
|
||||||
// Object-level bypass governance access
|
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
|
||||||
} else {
|
|
||||||
// Bucket-level bypass governance access
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "GetObjectRetention":
|
case "GetObjectRetention":
|
||||||
s3Actions = []string{"s3:GetObjectRetention"}
|
s3Actions = []string{"s3:GetObjectRetention"}
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
resources = buildObjectResourceArn(resourcePattern)
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
|
||||||
} else {
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "PutObjectRetention":
|
case "PutObjectRetention":
|
||||||
s3Actions = []string{"s3:PutObjectRetention"}
|
s3Actions = []string{"s3:PutObjectRetention"}
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
resources = buildObjectResourceArn(resourcePattern)
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
|
||||||
} else {
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "GetObjectLegalHold":
|
case "GetObjectLegalHold":
|
||||||
s3Actions = []string{"s3:GetObjectLegalHold"}
|
s3Actions = []string{"s3:GetObjectLegalHold"}
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
resources = buildObjectResourceArn(resourcePattern)
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
|
||||||
} else {
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "PutObjectLegalHold":
|
case "PutObjectLegalHold":
|
||||||
s3Actions = []string{"s3:PutObjectLegalHold"}
|
s3Actions = []string{"s3:PutObjectLegalHold"}
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
resources = buildObjectResourceArn(resourcePattern)
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)}
|
|
||||||
} else {
|
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "GetBucketObjectLockConfiguration":
|
case "GetBucketObjectLockConfiguration":
|
||||||
s3Actions = []string{"s3:GetBucketObjectLockConfiguration"}
|
s3Actions = []string{"s3:GetBucketObjectLockConfiguration"}
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)}
|
bucket, _ := extractBucketAndPrefix(resourcePattern)
|
||||||
|
if bucket != "" {
|
||||||
|
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}
|
||||||
|
} else {
|
||||||
|
// Invalid pattern, return empty resources to fail validation
|
||||||
|
resources = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
case "PutBucketObjectLockConfiguration":
|
case "PutBucketObjectLockConfiguration":
|
||||||
s3Actions = []string{"s3:PutBucketObjectLockConfiguration"}
|
s3Actions = []string{"s3:PutBucketObjectLockConfiguration"}
|
||||||
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)}
|
bucket, _ := extractBucketAndPrefix(resourcePattern)
|
||||||
|
if bucket != "" {
|
||||||
|
resources = []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}
|
||||||
|
} else {
|
||||||
|
// Invalid pattern, return empty resources to fail validation
|
||||||
|
resources = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown action type: %s", actionType)
|
return nil, fmt.Errorf("unknown action type: %s", actionType)
|
||||||
@@ -416,27 +522,15 @@ func ConvertLegacyActions(legacyActions []string) ([]string, error) {
|
|||||||
return uniqueActions, nil
|
return uniqueActions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetResourcesFromLegacyAction extracts resources from a legacy action
|
// GetResourcesFromLegacyAction extracts resources from a legacy action.
|
||||||
|
// It delegates to convertSingleAction to ensure consistent resource ARN generation
|
||||||
|
// across the codebase and avoid duplicating action-type-specific logic.
|
||||||
func GetResourcesFromLegacyAction(legacyAction string) ([]string, error) {
|
func GetResourcesFromLegacyAction(legacyAction string) ([]string, error) {
|
||||||
parts := strings.Split(legacyAction, ":")
|
stmt, err := convertSingleAction(legacyAction)
|
||||||
if len(parts) != 2 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid action format: %s", legacyAction)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return stmt.Resource.Strings(), nil
|
||||||
resourcePattern := parts[1]
|
|
||||||
resources := make([]string, 0)
|
|
||||||
|
|
||||||
if strings.HasSuffix(resourcePattern, "/*") {
|
|
||||||
// Object-level access
|
|
||||||
bucket := strings.TrimSuffix(resourcePattern, "/*")
|
|
||||||
resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s", bucket))
|
|
||||||
resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s/*", bucket))
|
|
||||||
} else {
|
|
||||||
// Bucket-level access
|
|
||||||
resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s", resourcePattern))
|
|
||||||
}
|
|
||||||
|
|
||||||
return resources, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePolicyFromLegacyIdentity creates a policy document from legacy identity actions
|
// CreatePolicyFromLegacyIdentity creates a policy document from legacy identity actions
|
||||||
@@ -447,6 +541,12 @@ func CreatePolicyFromLegacyIdentity(identityName string, actions []string) (*Pol
|
|||||||
resourceActions := make(map[string][]string)
|
resourceActions := make(map[string][]string)
|
||||||
|
|
||||||
for _, action := range actions {
|
for _, action := range actions {
|
||||||
|
// Validate action format before processing
|
||||||
|
if err := ValidateActionMapping(action); err != nil {
|
||||||
|
glog.Warningf("Skipping invalid action %q for identity %q: %v", action, identityName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
parts := strings.Split(action, ":")
|
parts := strings.Split(action, ":")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
continue
|
continue
|
||||||
@@ -464,23 +564,53 @@ func CreatePolicyFromLegacyIdentity(identityName string, actions []string) (*Pol
|
|||||||
// Create statements for each resource pattern
|
// Create statements for each resource pattern
|
||||||
for resourcePattern, actionTypes := range resourceActions {
|
for resourcePattern, actionTypes := range resourceActions {
|
||||||
s3Actions := make([]string, 0)
|
s3Actions := make([]string, 0)
|
||||||
|
resourceSet := make(map[string]struct{})
|
||||||
|
|
||||||
|
// Collect S3 actions and aggregate resource ARNs from all action types.
|
||||||
|
// Different action types have different resource ARN requirements:
|
||||||
|
// - List: bucket-level ARNs only
|
||||||
|
// - Read/Write/Tagging: object-level ARNs
|
||||||
|
// - Admin: full bucket access
|
||||||
|
// We must merge all required ARNs for the combined policy statement.
|
||||||
for _, actionType := range actionTypes {
|
for _, actionType := range actionTypes {
|
||||||
if actionType == "Admin" {
|
if actionType == "Admin" {
|
||||||
s3Actions = []string{"s3:*"}
|
s3Actions = []string{"s3:*"}
|
||||||
|
// Admin action determines the resources, so we can break after processing it.
|
||||||
|
res, err := GetResourcesFromLegacyAction(fmt.Sprintf("Admin:%s", resourcePattern))
|
||||||
|
if err != nil {
|
||||||
|
glog.Warningf("Failed to get resources for Admin action on %s: %v", resourcePattern, err)
|
||||||
|
resourceSet = nil // Invalidate to skip this statement
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, r := range res {
|
||||||
|
resourceSet[r] = struct{}{}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if mapped, exists := GetActionMappings()[actionType]; exists {
|
if mapped, exists := GetActionMappings()[actionType]; exists {
|
||||||
s3Actions = append(s3Actions, mapped...)
|
s3Actions = append(s3Actions, mapped...)
|
||||||
|
res, err := GetResourcesFromLegacyAction(fmt.Sprintf("%s:%s", actionType, resourcePattern))
|
||||||
|
if err != nil {
|
||||||
|
glog.Warningf("Failed to get resources for %s action on %s: %v", actionType, resourcePattern, err)
|
||||||
|
resourceSet = nil // Invalidate to skip this statement
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, r := range res {
|
||||||
|
resourceSet[r] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resources, err := GetResourcesFromLegacyAction(fmt.Sprintf("dummy:%s", resourcePattern))
|
if resourceSet == nil || len(s3Actions) == 0 {
|
||||||
if err != nil {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resources := make([]string, 0, len(resourceSet))
|
||||||
|
for r := range resourceSet {
|
||||||
|
resources = append(resources, r)
|
||||||
|
}
|
||||||
|
|
||||||
statement := PolicyStatement{
|
statement := PolicyStatement{
|
||||||
Sid: fmt.Sprintf("%s-%s", identityName, strings.ReplaceAll(resourcePattern, "/", "-")),
|
Sid: fmt.Sprintf("%s-%s", identityName, strings.ReplaceAll(resourcePattern, "/", "-")),
|
||||||
Effect: PolicyEffectAllow,
|
Effect: PolicyEffectAllow,
|
||||||
|
|||||||
373
weed/s3api/policy_engine/integration_test.go
Normal file
373
weed/s3api/policy_engine/integration_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1028,6 +1028,9 @@ func (s3a *S3ApiServer) streamFromVolumeServers(w http.ResponseWriter, r *http.R
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track time to first byte metric
|
||||||
|
TimeToFirstByte(r.Method, t0, r)
|
||||||
|
|
||||||
// Stream directly to response with counting wrapper
|
// Stream directly to response with counting wrapper
|
||||||
tStreamExec := time.Now()
|
tStreamExec := time.Now()
|
||||||
glog.V(4).Infof("streamFromVolumeServers: starting streamFn, offset=%d, size=%d", offset, size)
|
glog.V(4).Infof("streamFromVolumeServers: starting streamFn, offset=%d, size=%d", offset, size)
|
||||||
@@ -1236,8 +1239,13 @@ func (s3a *S3ApiServer) streamFromVolumeServersWithSSE(w http.ResponseWriter, r
|
|||||||
// Now write status code (headers are all set)
|
// Now write status code (headers are all set)
|
||||||
if isRangeRequest {
|
if isRangeRequest {
|
||||||
w.WriteHeader(http.StatusPartialContent)
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track time to first byte metric
|
||||||
|
TimeToFirstByte(r.Method, t0, r)
|
||||||
|
|
||||||
// Full Range Optimization: Use ViewFromChunks to only fetch/decrypt needed chunks
|
// Full Range Optimization: Use ViewFromChunks to only fetch/decrypt needed chunks
|
||||||
tDecryptSetup := time.Now()
|
tDecryptSetup := time.Now()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user