| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602 |
- 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"
- )
- // createTestJWTPresigned creates a test JWT token with the specified issuer, subject and signing key
- func createTestJWTPresigned(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
- }
- // TestPresignedURLIAMValidation tests IAM validation for presigned URLs
- func TestPresignedURLIAMValidation(t *testing.T) {
- // Set up IAM system
- iamManager := setupTestIAMManagerForPresigned(t)
- s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
- // Create IAM with integration
- iam := &IdentityAccessManagement{
- isAuthEnabled: true,
- }
- iam.SetIAMIntegration(s3iam)
- // Set up roles
- ctx := context.Background()
- setupTestRolesForPresigned(ctx, iamManager)
- // Create a valid JWT token for testing
- validJWTToken := createTestJWTPresigned(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/S3ReadOnlyRole",
- WebIdentityToken: validJWTToken,
- RoleSessionName: "presigned-test-session",
- })
- require.NoError(t, err)
- sessionToken := response.Credentials.SessionToken
- tests := []struct {
- name string
- method string
- path string
- sessionToken string
- expectedResult s3err.ErrorCode
- }{
- {
- name: "GET object with read permissions",
- method: "GET",
- path: "/test-bucket/test-file.txt",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrNone,
- },
- {
- name: "PUT object with read-only permissions (should fail)",
- method: "PUT",
- path: "/test-bucket/new-file.txt",
- sessionToken: sessionToken,
- expectedResult: s3err.ErrAccessDenied,
- },
- {
- name: "GET object without session token",
- method: "GET",
- path: "/test-bucket/test-file.txt",
- sessionToken: "",
- expectedResult: s3err.ErrNone, // Falls back to standard auth
- },
- {
- name: "Invalid session token",
- method: "GET",
- path: "/test-bucket/test-file.txt",
- sessionToken: "invalid-token",
- expectedResult: s3err.ErrAccessDenied,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Create request with presigned URL parameters
- req := createPresignedURLRequest(t, tt.method, tt.path, tt.sessionToken)
- // Create identity for testing
- identity := &Identity{
- Name: "test-user",
- Account: &AccountAdmin,
- }
- // Test validation
- result := iam.ValidatePresignedURLWithIAM(req, identity)
- assert.Equal(t, tt.expectedResult, result, "IAM validation result should match expected")
- })
- }
- }
- // TestPresignedURLGeneration tests IAM-aware presigned URL generation
- func TestPresignedURLGeneration(t *testing.T) {
- // Set up IAM system
- iamManager := setupTestIAMManagerForPresigned(t)
- s3iam := NewS3IAMIntegration(iamManager, "localhost:8888")
- s3iam.enabled = true // Enable IAM integration
- presignedManager := NewS3PresignedURLManager(s3iam)
- ctx := context.Background()
- setupTestRolesForPresigned(ctx, iamManager)
- // Create a valid JWT token for testing
- validJWTToken := createTestJWTPresigned(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/S3AdminRole",
- WebIdentityToken: validJWTToken,
- RoleSessionName: "presigned-gen-test-session",
- })
- require.NoError(t, err)
- sessionToken := response.Credentials.SessionToken
- tests := []struct {
- name string
- request *PresignedURLRequest
- shouldSucceed bool
- expectedError string
- }{
- {
- name: "Generate valid presigned GET URL",
- request: &PresignedURLRequest{
- Method: "GET",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: time.Hour,
- SessionToken: sessionToken,
- },
- shouldSucceed: true,
- },
- {
- name: "Generate valid presigned PUT URL",
- request: &PresignedURLRequest{
- Method: "PUT",
- Bucket: "test-bucket",
- ObjectKey: "new-file.txt",
- Expiration: time.Hour,
- SessionToken: sessionToken,
- },
- shouldSucceed: true,
- },
- {
- name: "Generate URL with invalid session token",
- request: &PresignedURLRequest{
- Method: "GET",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: time.Hour,
- SessionToken: "invalid-token",
- },
- shouldSucceed: false,
- expectedError: "IAM authorization failed",
- },
- {
- name: "Generate URL without session token",
- request: &PresignedURLRequest{
- Method: "GET",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: time.Hour,
- },
- shouldSucceed: false,
- expectedError: "IAM authorization failed",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- response, err := presignedManager.GeneratePresignedURLWithIAM(ctx, tt.request, "http://localhost:8333")
- if tt.shouldSucceed {
- assert.NoError(t, err, "Presigned URL generation should succeed")
- if response != nil {
- assert.NotEmpty(t, response.URL, "URL should not be empty")
- assert.Equal(t, tt.request.Method, response.Method, "Method should match")
- assert.True(t, response.ExpiresAt.After(time.Now()), "URL should not be expired")
- } else {
- t.Errorf("Response should not be nil when generation should succeed")
- }
- } else {
- assert.Error(t, err, "Presigned URL generation should fail")
- if tt.expectedError != "" {
- assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
- }
- }
- })
- }
- }
- // TestPresignedURLExpiration tests URL expiration validation
- func TestPresignedURLExpiration(t *testing.T) {
- tests := []struct {
- name string
- setupRequest func() *http.Request
- expectedError string
- }{
- {
- name: "Valid non-expired URL",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
- q := req.URL.Query()
- // Set date to 30 minutes ago with 2 hours expiration for safe margin
- q.Set("X-Amz-Date", time.Now().UTC().Add(-30*time.Minute).Format("20060102T150405Z"))
- q.Set("X-Amz-Expires", "7200") // 2 hours
- req.URL.RawQuery = q.Encode()
- return req
- },
- expectedError: "",
- },
- {
- name: "Expired URL",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
- q := req.URL.Query()
- // Set date to 2 hours ago with 1 hour expiration
- q.Set("X-Amz-Date", time.Now().UTC().Add(-2*time.Hour).Format("20060102T150405Z"))
- q.Set("X-Amz-Expires", "3600") // 1 hour
- req.URL.RawQuery = q.Encode()
- return req
- },
- expectedError: "presigned URL has expired",
- },
- {
- name: "Missing date parameter",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
- q := req.URL.Query()
- q.Set("X-Amz-Expires", "3600")
- req.URL.RawQuery = q.Encode()
- return req
- },
- expectedError: "missing required presigned URL parameters",
- },
- {
- name: "Invalid date format",
- setupRequest: func() *http.Request {
- req := httptest.NewRequest("GET", "/test-bucket/test-file.txt", nil)
- q := req.URL.Query()
- q.Set("X-Amz-Date", "invalid-date")
- q.Set("X-Amz-Expires", "3600")
- req.URL.RawQuery = q.Encode()
- return req
- },
- expectedError: "invalid X-Amz-Date format",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := tt.setupRequest()
- err := ValidatePresignedURLExpiration(req)
- if tt.expectedError == "" {
- assert.NoError(t, err, "Validation should succeed")
- } else {
- assert.Error(t, err, "Validation should fail")
- assert.Contains(t, err.Error(), tt.expectedError, "Error message should contain expected text")
- }
- })
- }
- }
- // TestPresignedURLSecurityPolicy tests security policy enforcement
- func TestPresignedURLSecurityPolicy(t *testing.T) {
- policy := &PresignedURLSecurityPolicy{
- MaxExpirationDuration: 24 * time.Hour,
- AllowedMethods: []string{"GET", "PUT"},
- RequiredHeaders: []string{"Content-Type"},
- MaxFileSize: 1024 * 1024, // 1MB
- }
- tests := []struct {
- name string
- request *PresignedURLRequest
- expectedError string
- }{
- {
- name: "Valid request",
- request: &PresignedURLRequest{
- Method: "GET",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: 12 * time.Hour,
- Headers: map[string]string{"Content-Type": "application/json"},
- },
- expectedError: "",
- },
- {
- name: "Expiration too long",
- request: &PresignedURLRequest{
- Method: "GET",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: 48 * time.Hour, // Exceeds 24h limit
- Headers: map[string]string{"Content-Type": "application/json"},
- },
- expectedError: "expiration duration",
- },
- {
- name: "Method not allowed",
- request: &PresignedURLRequest{
- Method: "DELETE", // Not in allowed methods
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: 12 * time.Hour,
- Headers: map[string]string{"Content-Type": "application/json"},
- },
- expectedError: "HTTP method DELETE is not allowed",
- },
- {
- name: "Missing required header",
- request: &PresignedURLRequest{
- Method: "GET",
- Bucket: "test-bucket",
- ObjectKey: "test-file.txt",
- Expiration: 12 * time.Hour,
- Headers: map[string]string{}, // Missing Content-Type
- },
- expectedError: "required header Content-Type is missing",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := policy.ValidatePresignedURLRequest(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")
- }
- })
- }
- }
- // TestS3ActionDetermination tests action determination from HTTP methods
- func TestS3ActionDetermination(t *testing.T) {
- tests := []struct {
- name string
- method string
- bucket string
- object string
- expectedAction Action
- }{
- {
- name: "GET object",
- method: "GET",
- bucket: "test-bucket",
- object: "test-file.txt",
- expectedAction: s3_constants.ACTION_READ,
- },
- {
- name: "GET bucket (list)",
- method: "GET",
- bucket: "test-bucket",
- object: "",
- expectedAction: s3_constants.ACTION_LIST,
- },
- {
- name: "PUT object",
- method: "PUT",
- bucket: "test-bucket",
- object: "new-file.txt",
- expectedAction: s3_constants.ACTION_WRITE,
- },
- {
- name: "DELETE object",
- method: "DELETE",
- bucket: "test-bucket",
- object: "old-file.txt",
- expectedAction: s3_constants.ACTION_WRITE,
- },
- {
- name: "DELETE bucket",
- method: "DELETE",
- bucket: "test-bucket",
- object: "",
- expectedAction: s3_constants.ACTION_DELETE_BUCKET,
- },
- {
- name: "HEAD object",
- method: "HEAD",
- bucket: "test-bucket",
- object: "test-file.txt",
- expectedAction: s3_constants.ACTION_READ,
- },
- {
- name: "POST object",
- method: "POST",
- bucket: "test-bucket",
- object: "upload-file.txt",
- expectedAction: s3_constants.ACTION_WRITE,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- action := determineS3ActionFromMethodAndPath(tt.method, tt.bucket, tt.object)
- assert.Equal(t, tt.expectedAction, action, "S3 action should match expected")
- })
- }
- }
- // Helper functions for tests
- func setupTestIAMManagerForPresigned(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
- setupTestProvidersForPresigned(t, manager)
- return manager
- }
- func setupTestProvidersForPresigned(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 setupTestRolesForPresigned(ctx context.Context, manager *integration.IAMManager) {
- // Create read-only policy
- readOnlyPolicy := &policy.PolicyDocument{
- Version: "2012-10-17",
- Statement: []policy.Statement{
- {
- Sid: "AllowS3ReadOperations",
- Effect: "Allow",
- Action: []string{"s3:GetObject", "s3:ListBucket", "s3:HeadObject"},
- Resource: []string{
- "arn:seaweed:s3:::*",
- "arn:seaweed:s3:::*/*",
- },
- },
- },
- }
- manager.CreatePolicy(ctx, "", "S3ReadOnlyPolicy", readOnlyPolicy)
- // Create read-only role
- manager.CreateRole(ctx, "", "S3ReadOnlyRole", &integration.RoleDefinition{
- RoleName: "S3ReadOnlyRole",
- 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{"S3ReadOnlyPolicy"},
- })
- // Create admin policy
- adminPolicy := &policy.PolicyDocument{
- Version: "2012-10-17",
- Statement: []policy.Statement{
- {
- Sid: "AllowAllS3Operations",
- Effect: "Allow",
- Action: []string{"s3:*"},
- Resource: []string{
- "arn:seaweed:s3:::*",
- "arn:seaweed:s3:::*/*",
- },
- },
- },
- }
- manager.CreatePolicy(ctx, "", "S3AdminPolicy", adminPolicy)
- // Create admin role
- manager.CreateRole(ctx, "", "S3AdminRole", &integration.RoleDefinition{
- RoleName: "S3AdminRole",
- 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{"S3AdminPolicy"},
- })
- // Create a role for presigned URL users with admin permissions for testing
- manager.CreateRole(ctx, "", "PresignedUser", &integration.RoleDefinition{
- RoleName: "PresignedUser",
- 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{"S3AdminPolicy"}, // Use admin policy for testing
- })
- }
- func createPresignedURLRequest(t *testing.T, method, path, sessionToken string) *http.Request {
- req := httptest.NewRequest(method, path, nil)
- // Add presigned URL parameters if session token is provided
- if sessionToken != "" {
- q := req.URL.Query()
- q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
- q.Set("X-Amz-Security-Token", sessionToken)
- q.Set("X-Amz-Date", time.Now().Format("20060102T150405Z"))
- q.Set("X-Amz-Expires", "3600")
- req.URL.RawQuery = q.Encode()
- }
- return req
- }
|