| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614 |
- package s3api
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
- "github.com/seaweedfs/seaweedfs/weed/iam/integration"
- "github.com/seaweedfs/seaweedfs/weed/iam/ldap"
- "github.com/seaweedfs/seaweedfs/weed/iam/oidc"
- "github.com/seaweedfs/seaweedfs/weed/iam/policy"
- "github.com/seaweedfs/seaweedfs/weed/iam/sts"
- "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
- "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // createTestJWTMultipart creates a test JWT token with the specified issuer, subject and signing key
- func createTestJWTMultipart(t *testing.T, issuer, subject, signingKey string) string {
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
- "iss": issuer,
- "sub": subject,
- "aud": "test-client-id",
- "exp": time.Now().Add(time.Hour).Unix(),
- "iat": time.Now().Unix(),
- // Add claims that trust policy validation expects
- "idp": "test-oidc", // Identity provider claim for trust policy matching
- })
- tokenString, err := token.SignedString([]byte(signingKey))
- require.NoError(t, err)
- return tokenString
- }
- // TestMultipartIAMValidation tests IAM validation for multipart operations
- func TestMultipartIAMValidation(t *testing.T) {
- // Set up IAM system
- iamManager := setupTestIAMManagerForMultipart(t)
- s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
- s3iam.enabled = true
- // Create IAM with integration
- iam := &IdentityAccessManagement{
- isAuthEnabled: true,
- }
- iam.SetIAMIntegration(s3iam)
- // Set up roles
- ctx := context.Background()
- setupTestRolesForMultipart(ctx, iamManager)
- // Create a valid JWT token for testing
- validJWTToken := createTestJWTMultipart(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
- // Get session token
- response, err := iamManager.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityRequest{
- RoleArn: "arn:seaweed:iam::role/S3WriteRole",
- WebIdentityToken: validJWTToken,
- RoleSessionName: "multipart-test-session",
- })
- require.NoError(t, err)
- sessionToken := response.Credentials.SessionToken
- tests := []struct {
- name string
- operation MultipartOperation
- method string
- path string
- sessionToken string
- expectedResult s3err.ErrorCode
- }{
- {
- name: "Initiate multipart upload",
- operation: MultipartOpInitiate,
- method: "POST",
- path: "/test-bucket/test-file.txt?uploads",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrNone,
- },
- {
- name: "Upload part",
- operation: MultipartOpUploadPart,
- method: "PUT",
- path: "/test-bucket/test-file.txt?partNumber=1&uploadId=test-upload-id",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrNone,
- },
- {
- name: "Complete multipart upload",
- operation: MultipartOpComplete,
- method: "POST",
- path: "/test-bucket/test-file.txt?uploadId=test-upload-id",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrNone,
- },
- {
- name: "Abort multipart upload",
- operation: MultipartOpAbort,
- method: "DELETE",
- path: "/test-bucket/test-file.txt?uploadId=test-upload-id",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrNone,
- },
- {
- name: "List multipart uploads",
- operation: MultipartOpList,
- method: "GET",
- path: "/test-bucket?uploads",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrNone,
- },
- {
- name: "Upload part without session token",
- operation: MultipartOpUploadPart,
- method: "PUT",
- path: "/test-bucket/test-file.txt?partNumber=1&uploadId=test-upload-id",
- sessionToken: "",
- expectedResult: s3err.ErrNone, // Falls back to standard auth
- },
- {
- name: "Upload part with invalid session token",
- operation: MultipartOpUploadPart,
- method: "PUT",
- path: "/test-bucket/test-file.txt?partNumber=1&uploadId=test-upload-id",
- sessionToken: "invalid-token",
- expectedResult: s3err.ErrAccessDenied,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create request for multipart operation
- req := createMultipartRequest(t, tt.method, tt.path, tt.sessionToken)
- // Create identity for testing
- identity := &Identity{
- Name: "test-user",
- Account: &AccountAdmin,
- }
- // Test validation
- result := iam.ValidateMultipartOperationWithIAM(req, identity, tt.operation)
- assert.Equal(t, tt.expectedResult, result, "Multipart IAM validation result should match expected")
- })
- }
- }
- // TestMultipartUploadPolicy tests multipart upload security policies
- func TestMultipartUploadPolicy(t *testing.T) {
- policy := &MultipartUploadPolicy{
- MaxPartSize: 10 * 1024 * 1024, // 10MB for testing
- MinPartSize: 5 * 1024 * 1024, // 5MB minimum
- MaxParts: 100, // 100 parts max for testing
- AllowedContentTypes: []string{"application/json", "text/plain"},
- RequiredHeaders: []string{"Content-Type"},
- }
- tests := []struct {
- name string
- request *MultipartUploadRequest
- expectedError string
- }{
- {
- name: "Valid upload part request",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- PartNumber: 1,
- Operation: string(MultipartOpUploadPart),
- ContentSize: 8 * 1024 * 1024, // 8MB
- Headers: map[string]string{
- "Content-Type": "application/json",
- },
- },
- expectedError: "",
- },
- {
- name: "Part size too large",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- PartNumber: 1,
- Operation: string(MultipartOpUploadPart),
- ContentSize: 15 * 1024 * 1024, // 15MB exceeds limit
- Headers: map[string]string{
- "Content-Type": "application/json",
- },
- },
- expectedError: "part size",
- },
- {
- name: "Invalid part number (too high)",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- PartNumber: 150, // Exceeds max parts
- Operation: string(MultipartOpUploadPart),
- ContentSize: 8 * 1024 * 1024,
- Headers: map[string]string{
- "Content-Type": "application/json",
- },
- },
- expectedError: "part number",
- },
- {
- name: "Invalid part number (too low)",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- PartNumber: 0, // Must be >= 1
- Operation: string(MultipartOpUploadPart),
- ContentSize: 8 * 1024 * 1024,
- Headers: map[string]string{
- "Content-Type": "application/json",
- },
- },
- expectedError: "part number",
- },
- {
- name: "Content type not allowed",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- PartNumber: 1,
- Operation: string(MultipartOpUploadPart),
- ContentSize: 8 * 1024 * 1024,
- Headers: map[string]string{
- "Content-Type": "video/mp4", // Not in allowed list
- },
- },
- expectedError: "content type video/mp4 is not allowed",
- },
- {
- name: "Missing required header",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- PartNumber: 1,
- Operation: string(MultipartOpUploadPart),
- ContentSize: 8 * 1024 * 1024,
- Headers: map[string]string{}, // Missing Content-Type
- },
- expectedError: "required header Content-Type is missing",
- },
- {
- name: "Non-upload operation (should not validate size)",
- request: &MultipartUploadRequest{
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Operation: string(MultipartOpInitiate),
- Headers: map[string]string{
- "Content-Type": "application/json",
- },
- },
- expectedError: "",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := policy.ValidateMultipartRequestWithPolicy(tt.request)
- if tt.expectedError == "" {
- assert.NoError(t, err, "Policy validation should succeed")
- } else {
- assert.Error(t, err, "Policy validation should fail")
- assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
- }
- })
- }
- }
- // TestMultipartS3ActionMapping tests the mapping of multipart operations to S3 actions
- func TestMultipartS3ActionMapping(t *testing.T) {
- tests := []struct {
- operation MultipartOperation
- expectedAction Action
- }{
- {MultipartOpInitiate, s3_constants.ACTION_CREATE_MULTIPART_UPLOAD},
- {MultipartOpUploadPart, s3_constants.ACTION_UPLOAD_PART},
- {MultipartOpComplete, s3_constants.ACTION_COMPLETE_MULTIPART},
- {MultipartOpAbort, s3_constants.ACTION_ABORT_MULTIPART},
- {MultipartOpList, s3_constants.ACTION_LIST_MULTIPART_UPLOADS},
- {MultipartOpListParts, s3_constants.ACTION_LIST_PARTS},
- {MultipartOperation("unknown"), "s3:InternalErrorUnknownMultipartAction"}, // Fail-closed for security
- }
- for _, tt := range tests {
- t.Run(string(tt.operation), func(t *testing.T) {
- action := determineMultipartS3Action(tt.operation)
- assert.Equal(t, tt.expectedAction, action, "S3 action mapping should match expected")
- })
- }
- }
- // TestSessionTokenExtraction tests session token extraction from various sources
- func TestSessionTokenExtraction(t *testing.T) {
- tests := []struct {
- name string
- setupRequest func() *http.Request
- expectedToken string
- }{
- {
- name: "Bearer token in Authorization header",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
- req.Header.Set("Authorization", "Bearer test-session-token-123")
- return req
- },
- expectedToken: "test-session-token-123",
- },
- {
- name: "X-Amz-Security-Token header",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
- req.Header.Set("X-Amz-Security-Token", "security-token-456")
- return req
- },
- expectedToken: "security-token-456",
- },
- {
- name: "X-Amz-Security-Token query parameter",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?X-Amz-Security-Token=query-token-789", nil)
- return req
- },
- expectedToken: "query-token-789",
- },
- {
- name: "No token present",
- setupRequest: func() *http.Request {
- return httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
- },
- expectedToken: "",
- },
- {
- name: "Authorization header without Bearer",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt", nil)
- req.Header.Set("Authorization", "AWS access_key:signature")
- return req
- },
- expectedToken: "",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := tt.setupRequest()
- token := extractSessionTokenFromRequest(req)
- assert.Equal(t, tt.expectedToken, token, "Extracted token should match expected")
- })
- }
- }
- // TestUploadPartValidation tests upload part request validation
- func TestUploadPartValidation(t *testing.T) {
- s3Server := &S3ApiServer{}
- tests := []struct {
- name string
- setupRequest func() *http.Request
- expectedError string
- }{
- {
- name: "Valid upload part request",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?partNumber=1&uploadId=test-123", nil)
- req.Header.Set("Content-Type", "application/octet-stream")
- req.ContentLength = 6 * 1024 * 1024 // 6MB
- return req
- },
- expectedError: "",
- },
- {
- name: "Missing partNumber parameter",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?uploadId=test-123", nil)
- req.Header.Set("Content-Type", "application/octet-stream")
- req.ContentLength = 6 * 1024 * 1024
- return req
- },
- expectedError: "missing partNumber parameter",
- },
- {
- name: "Invalid partNumber format",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?partNumber=abc&uploadId=test-123", nil)
- req.Header.Set("Content-Type", "application/octet-stream")
- req.ContentLength = 6 * 1024 * 1024
- return req
- },
- expectedError: "invalid partNumber",
- },
- {
- name: "Part size too large",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("PUT", "/test-bucket/test-file.txt?partNumber=1&uploadId=test-123", nil)
- req.Header.Set("Content-Type", "application/octet-stream")
- req.ContentLength = 6 * 1024 * 1024 * 1024 // 6GB exceeds 5GB limit
- return req
- },
- expectedError: "part size",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := tt.setupRequest()
- err := s3Server.validateUploadPartRequest(req)
- if tt.expectedError == "" {
- assert.NoError(t, err, "Upload part validation should succeed")
- } else {
- assert.Error(t, err, "Upload part validation should fail")
- assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
- }
- })
- }
- }
- // TestDefaultMultipartUploadPolicy tests the default policy configuration
- func TestDefaultMultipartUploadPolicy(t *testing.T) {
- policy := DefaultMultipartUploadPolicy()
- assert.Equal(t, int64(5*1024*1024*1024), policy.MaxPartSize, "Max part size should be 5GB")
- assert.Equal(t, int64(5*1024*1024), policy.MinPartSize, "Min part size should be 5MB")
- assert.Equal(t, 10000, policy.MaxParts, "Max parts should be 10,000")
- assert.Equal(t, 7*24*time.Hour, policy.MaxUploadDuration, "Max upload duration should be 7 days")
- assert.Empty(t, policy.AllowedContentTypes, "Should allow all content types by default")
- assert.Empty(t, policy.RequiredHeaders, "Should have no required headers by default")
- assert.Empty(t, policy.IPWhitelist, "Should have no IP restrictions by default")
- }
- // TestMultipartUploadSession tests multipart upload session structure
- func TestMultipartUploadSession(t *testing.T) {
- session := &MultipartUploadSession{
- UploadID: "test-upload-123",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Initiator: "arn:seaweed:iam::user/testuser",
- Owner: "arn:seaweed:iam::user/testuser",
- CreatedAt: time.Now(),
- Parts: []MultipartUploadPart{
- {
- PartNumber: 1,
- Size: 5 * 1024 * 1024,
- ETag: "abc123",
- LastModified: time.Now(),
- Checksum: "sha256:def456",
- },
- },
- Metadata: map[string]string{
- "Content-Type": "application/octet-stream",
- "x-amz-meta-custom": "value",
- },
- Policy: DefaultMultipartUploadPolicy(),
- SessionToken: "session-token-789",
- }
- assert.NotEmpty(t, session.UploadID, "Upload ID should not be empty")
- assert.NotEmpty(t, session.Bucket, "Bucket should not be empty")
- assert.NotEmpty(t, session.ObjectKey, "Object key should not be empty")
- assert.Len(t, session.Parts, 1, "Should have one part")
- assert.Equal(t, 1, session.Parts[0].PartNumber, "Part number should be 1")
- assert.NotNil(t, session.Policy, "Policy should not be nil")
- }
- // Helper functions for tests
- func setupTestIAMManagerForMultipart(t *testing.T) *integration.IAMManager {
- // Create IAM manager
- manager := 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 := manager.Initialize(config, func() string {
- return "localhost:8888" // Mock filer address for testing
- })
- require.NoError(t, err)
- // Set up test identity providers
- setupTestProvidersForMultipart(t, manager)
- return manager
- }
- func setupTestProvidersForMultipart(t *testing.T, manager *integration.IAMManager) {
- // Set up OIDC provider
- oidcProvider := oidc.NewMockOIDCProvider("test-oidc")
- oidcConfig := &oidc.OIDCConfig{
- Issuer: "https://test-issuer.com",
- ClientID: "test-client-id",
- }
- err := oidcProvider.Initialize(oidcConfig)
- require.NoError(t, err)
- oidcProvider.SetupDefaultTestData()
- // Set up LDAP provider
- ldapProvider := ldap.NewMockLDAPProvider("test-ldap")
- err = ldapProvider.Initialize(nil) // Mock doesn't need real config
- require.NoError(t, err)
- ldapProvider.SetupDefaultTestData()
- // Register providers
- err = manager.RegisterIdentityProvider(oidcProvider)
- require.NoError(t, err)
- err = manager.RegisterIdentityProvider(ldapProvider)
- require.NoError(t, err)
- }
- func setupTestRolesForMultipart(ctx context.Context, manager *integration.IAMManager) {
- // Create write policy for multipart operations
- writePolicy := &policy.PolicyDocument{
- Version: "2012-10-17",
- Statement: []policy.Statement{
- {
- Sid: "AllowS3MultipartOperations",
- Effect: "Allow",
- Action: []string{
- "s3:PutObject",
- "s3:GetObject",
- "s3:ListBucket",
- "s3:DeleteObject",
- "s3:CreateMultipartUpload",
- "s3:UploadPart",
- "s3:CompleteMultipartUpload",
- "s3:AbortMultipartUpload",
- "s3:ListMultipartUploads",
- "s3:ListParts",
- },
- Resource: []string{
- "arn:seaweed:s3:::*",
- "arn:seaweed:s3:::*/*",
- },
- },
- },
- }
- manager.CreatePolicy(ctx, "", "S3WritePolicy", writePolicy)
- // Create write role
- manager.CreateRole(ctx, "", "S3WriteRole", &integration.RoleDefinition{
- RoleName: "S3WriteRole",
- TrustPolicy: &policy.PolicyDocument{
- Version: "2012-10-17",
- Statement: []policy.Statement{
- {
- Effect: "Allow",
- Principal: map[string]interface{}{
- "Federated": "test-oidc",
- },
- Action: []string{"sts:AssumeRoleWithWebIdentity"},
- },
- },
- },
- AttachedPolicies: []string{"S3WritePolicy"},
- })
- // Create a role for multipart users
- manager.CreateRole(ctx, "", "MultipartUser", &integration.RoleDefinition{
- RoleName: "MultipartUser",
- TrustPolicy: &policy.PolicyDocument{
- Version: "2012-10-17",
- Statement: []policy.Statement{
- {
- Effect: "Allow",
- Principal: map[string]interface{}{
- "Federated": "test-oidc",
- },
- Action: []string{"sts:AssumeRoleWithWebIdentity"},
- },
- },
- },
- AttachedPolicies: []string{"S3WritePolicy"},
- })
- }
- func createMultipartRequest(t *testing.T, method, path, sessionToken string) *http.Request {
- req := httptest.NewRequest(method, path, nil)
- // Add session token if provided
- if sessionToken != "" {
- req.Header.Set("Authorization", "Bearer "+sessionToken)
- // Set the principal ARN header that matches the assumed role from the test setup
- // This corresponds to the role "arn:seaweed:iam::role/S3WriteRole" with session name "multipart-test-session"
- req.Header.Set("X-SeaweedFS-Principal", "arn:seaweed:sts::assumed-role/S3WriteRole/multipart-test-session")
- }
- // Add common headers
- req.Header.Set("Content-Type", "application/octet-stream")
- return req
- }
|