aboutsummaryrefslogtreecommitdiff
path: root/weed/s3api/s3_presigned_url_iam_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'weed/s3api/s3_presigned_url_iam_test.go')
-rw-r--r--weed/s3api/s3_presigned_url_iam_test.go602
1 files changed, 602 insertions, 0 deletions
diff --git a/weed/s3api/s3_presigned_url_iam_test.go b/weed/s3api/s3_presigned_url_iam_test.go
new file mode 100644
index 000000000..890162121
--- /dev/null
+++ b/weed/s3api/s3_presigned_url_iam_test.go
@@ -0,0 +1,602 @@
+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
+}