You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							490 lines
						
					
					
						
							14 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							490 lines
						
					
					
						
							14 KiB
						
					
					
				| package s3api | |
| 
 | |
| import ( | |
| 	"context" | |
| 	"net/http" | |
| 	"net/http/httptest" | |
| 	"net/url" | |
| 	"testing" | |
| 	"time" | |
| 
 | |
| 	"github.com/seaweedfs/seaweedfs/weed/iam/integration" | |
| 	"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" | |
| ) | |
| 
 | |
| // TestS3IAMMiddleware tests the basic S3 IAM middleware functionality | |
| func TestS3IAMMiddleware(t *testing.T) { | |
| 	// Create IAM manager | |
| 	iamManager := integration.NewIAMManager() | |
| 
 | |
| 	// Initialize with test configuration | |
| 	config := &integration.IAMConfig{ | |
| 		STS: &sts.STSConfig{ | |
| 			TokenDuration:    sts.FlexibleDuration{time.Hour}, | |
| 			MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, | |
| 			Issuer:           "test-sts", | |
| 			SigningKey:       []byte("test-signing-key-32-characters-long"), | |
| 		}, | |
| 		Policy: &policy.PolicyEngineConfig{ | |
| 			DefaultEffect: "Deny", | |
| 			StoreType:     "memory", | |
| 		}, | |
| 		Roles: &integration.RoleStoreConfig{ | |
| 			StoreType: "memory", | |
| 		}, | |
| 	} | |
| 
 | |
| 	err := iamManager.Initialize(config, func() string { | |
| 		return "localhost:8888" // Mock filer address for testing | |
| 	}) | |
| 	require.NoError(t, err) | |
| 
 | |
| 	// Create S3 IAM integration | |
| 	s3IAMIntegration := NewS3IAMIntegration(iamManager, "localhost:8888") | |
| 
 | |
| 	// Test that integration is created successfully | |
| 	assert.NotNil(t, s3IAMIntegration) | |
| 	assert.True(t, s3IAMIntegration.enabled) | |
| } | |
| 
 | |
| // TestS3IAMMiddlewareJWTAuth tests JWT authentication | |
| func TestS3IAMMiddlewareJWTAuth(t *testing.T) { | |
| 	// Skip for now since it requires full setup | |
| 	t.Skip("JWT authentication test requires full IAM setup") | |
| 
 | |
| 	// Create IAM integration | |
| 	s3iam := NewS3IAMIntegration(nil, "localhost:8888") // Disabled integration | |
|  | |
| 	// Create test request with JWT token | |
| 	req := httptest.NewRequest("GET", "/test-bucket/test-object", http.NoBody) | |
| 	req.Header.Set("Authorization", "Bearer test-token") | |
| 
 | |
| 	// Test authentication (should return not implemented when disabled) | |
| 	ctx := context.Background() | |
| 	identity, errCode := s3iam.AuthenticateJWT(ctx, req) | |
| 
 | |
| 	assert.Nil(t, identity) | |
| 	assert.NotEqual(t, errCode, 0) // Should return an error | |
| } | |
| 
 | |
| // TestBuildS3ResourceArn tests resource ARN building | |
| func TestBuildS3ResourceArn(t *testing.T) { | |
| 	tests := []struct { | |
| 		name     string | |
| 		bucket   string | |
| 		object   string | |
| 		expected string | |
| 	}{ | |
| 		{ | |
| 			name:     "empty bucket and object", | |
| 			bucket:   "", | |
| 			object:   "", | |
| 			expected: "arn:seaweed:s3:::*", | |
| 		}, | |
| 		{ | |
| 			name:     "bucket only", | |
| 			bucket:   "test-bucket", | |
| 			object:   "", | |
| 			expected: "arn:seaweed:s3:::test-bucket", | |
| 		}, | |
| 		{ | |
| 			name:     "bucket and object", | |
| 			bucket:   "test-bucket", | |
| 			object:   "test-object.txt", | |
| 			expected: "arn:seaweed:s3:::test-bucket/test-object.txt", | |
| 		}, | |
| 		{ | |
| 			name:     "bucket and object with leading slash", | |
| 			bucket:   "test-bucket", | |
| 			object:   "/test-object.txt", | |
| 			expected: "arn:seaweed:s3:::test-bucket/test-object.txt", | |
| 		}, | |
| 		{ | |
| 			name:     "bucket and nested object", | |
| 			bucket:   "test-bucket", | |
| 			object:   "folder/subfolder/test-object.txt", | |
| 			expected: "arn:seaweed:s3:::test-bucket/folder/subfolder/test-object.txt", | |
| 		}, | |
| 	} | |
| 
 | |
| 	for _, tt := range tests { | |
| 		t.Run(tt.name, func(t *testing.T) { | |
| 			result := buildS3ResourceArn(tt.bucket, tt.object) | |
| 			assert.Equal(t, tt.expected, result) | |
| 		}) | |
| 	} | |
| } | |
| 
 | |
| // TestDetermineGranularS3Action tests granular S3 action determination from HTTP requests | |
| func TestDetermineGranularS3Action(t *testing.T) { | |
| 	tests := []struct { | |
| 		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_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:         "unknown_action_with_s3_prefix", | |
| 			legacyAction: "s3:CustomAction", | |
| 			expected:     "s3:CustomAction", | |
| 		}, | |
| 		{ | |
| 			name:         "unknown_action_without_prefix", | |
| 			legacyAction: "CustomAction", | |
| 			expected:     "s3:CustomAction", | |
| 		}, | |
| 	} | |
| 
 | |
| 	for _, tt := range tests { | |
| 		t.Run(tt.name, func(t *testing.T) { | |
| 			result := mapLegacyActionToIAM(tt.legacyAction) | |
| 			assert.Equal(t, tt.expected, result) | |
| 		}) | |
| 	} | |
| } | |
| 
 | |
| // TestExtractSourceIP tests source IP extraction from requests | |
| func TestExtractSourceIP(t *testing.T) { | |
| 	tests := []struct { | |
| 		name       string | |
| 		setupReq   func() *http.Request | |
| 		expectedIP string | |
| 	}{ | |
| 		{ | |
| 			name: "X-Forwarded-For header", | |
| 			setupReq: func() *http.Request { | |
| 				req := httptest.NewRequest("GET", "/test", http.NoBody) | |
| 				req.Header.Set("X-Forwarded-For", "192.168.1.100, 10.0.0.1") | |
| 				return req | |
| 			}, | |
| 			expectedIP: "192.168.1.100", | |
| 		}, | |
| 		{ | |
| 			name: "X-Real-IP header", | |
| 			setupReq: func() *http.Request { | |
| 				req := httptest.NewRequest("GET", "/test", http.NoBody) | |
| 				req.Header.Set("X-Real-IP", "192.168.1.200") | |
| 				return req | |
| 			}, | |
| 			expectedIP: "192.168.1.200", | |
| 		}, | |
| 		{ | |
| 			name: "RemoteAddr fallback", | |
| 			setupReq: func() *http.Request { | |
| 				req := httptest.NewRequest("GET", "/test", http.NoBody) | |
| 				req.RemoteAddr = "192.168.1.300:12345" | |
| 				return req | |
| 			}, | |
| 			expectedIP: "192.168.1.300", | |
| 		}, | |
| 	} | |
| 
 | |
| 	for _, tt := range tests { | |
| 		t.Run(tt.name, func(t *testing.T) { | |
| 			req := tt.setupReq() | |
| 			result := extractSourceIP(req) | |
| 			assert.Equal(t, tt.expectedIP, result) | |
| 		}) | |
| 	} | |
| } | |
| 
 | |
| // TestExtractRoleNameFromPrincipal tests role name extraction | |
| func TestExtractRoleNameFromPrincipal(t *testing.T) { | |
| 	tests := []struct { | |
| 		name      string | |
| 		principal string | |
| 		expected  string | |
| 	}{ | |
| 		{ | |
| 			name:      "valid assumed role ARN", | |
| 			principal: "arn:seaweed:sts::assumed-role/S3ReadOnlyRole/session-123", | |
| 			expected:  "S3ReadOnlyRole", | |
| 		}, | |
| 		{ | |
| 			name:      "invalid format", | |
| 			principal: "invalid-principal", | |
| 			expected:  "", // Returns empty string to signal invalid format | |
| 		}, | |
| 		{ | |
| 			name:      "missing session name", | |
| 			principal: "arn:seaweed:sts::assumed-role/TestRole", | |
| 			expected:  "TestRole", // Extracts role name even without session name | |
| 		}, | |
| 		{ | |
| 			name:      "empty principal", | |
| 			principal: "", | |
| 			expected:  "", | |
| 		}, | |
| 	} | |
| 
 | |
| 	for _, tt := range tests { | |
| 		t.Run(tt.name, func(t *testing.T) { | |
| 			result := utils.ExtractRoleNameFromPrincipal(tt.principal) | |
| 			assert.Equal(t, tt.expected, result) | |
| 		}) | |
| 	} | |
| } | |
| 
 | |
| // TestIAMIdentityIsAdmin tests the IsAdmin method | |
| func TestIAMIdentityIsAdmin(t *testing.T) { | |
| 	identity := &IAMIdentity{ | |
| 		Name:         "test-identity", | |
| 		Principal:    "arn:seaweed:sts::assumed-role/TestRole/session", | |
| 		SessionToken: "test-token", | |
| 	} | |
| 
 | |
| 	// In our implementation, IsAdmin always returns false since admin status | |
| 	// is determined by policies, not identity | |
| 	result := identity.IsAdmin() | |
| 	assert.False(t, result) | |
| }
 |