package s3api import ( "context" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) // TestSTSIdentityPolicyNamesPopulation tests that STS identities have PolicyNames // populated from sessionInfo.Policies, enabling IAM-based authorization. // This is a regression test for GitHub issue #7985. func TestSTSIdentityPolicyNamesPopulation(t *testing.T) { // Setup: Create a mock STS service stsService, config := setupTestSTSService(t) // Create IAM with STS integration iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, "memory") s3iam := &S3IAMIntegration{ stsService: stsService, } iam.SetIAMIntegration(s3iam) // Generate a session token with policies sessionId := "test-session-id" expiresAt := time.Now().Add(time.Hour) // Create session claims with policies sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt). WithSessionName("test-session"). WithRoleInfo("arn:aws:iam::role/adminRole", "arn:aws:sts::assumed-role/adminRole/test-session", "arn:aws:sts::assumed-role/adminRole/test-session") // Add policies to the session claims sessionClaims.Policies = []string{"AdminPolicy", "S3FullAccess"} // Generate JWT token tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer) sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims) require.NoError(t, err, "Should generate session token successfully") // Validate the session token sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken) require.NoError(t, err, "Session token should be valid") require.NotNil(t, sessionInfo, "Session info should not be nil") // Verify that sessionInfo has policies assert.NotEmpty(t, sessionInfo.Policies, "SessionInfo should have policies") assert.Equal(t, []string{"AdminPolicy", "S3FullAccess"}, sessionInfo.Policies, "Policies should match") // Create a mock credential from session info cred := &Credential{ AccessKey: sessionInfo.Credentials.AccessKeyId, SecretKey: sessionInfo.Credentials.SecretAccessKey, Status: "Active", Expiration: sessionInfo.ExpiresAt.Unix(), } // Create identity as validateSTSSessionToken does identity := &Identity{ Name: sessionInfo.AssumedRoleUser, Account: &AccountAdmin, Credentials: []*Credential{cred}, PrincipalArn: sessionInfo.Principal, PolicyNames: sessionInfo.Policies, // This is the fix for issue #7985 } // Verify the fix: PolicyNames should be populated assert.NotNil(t, identity.PolicyNames, "Identity PolicyNames should not be nil") assert.Equal(t, []string{"AdminPolicy", "S3FullAccess"}, identity.PolicyNames, "PolicyNames should be populated from sessionInfo.Policies") // Verify that Actions is empty (STS identities should use IAM authorization, not legacy Actions) assert.Empty(t, identity.Actions, "STS identities should have empty Actions to trigger IAM authorization path") // Verify legacy canDo returns false (forcing fallback to IAM) assert.False(t, identity.canDo("Read", "test-bucket", "/any/path"), "canDo should return false for STS identities with no Actions") // Verify authorization path selection // When identity.Actions is empty and iamIntegration is available, it should use IAM authorization hasActions := len(identity.Actions) > 0 hasIAMIntegration := iam.iamIntegration != nil assert.False(t, hasActions, "STS identity should not have Actions") assert.True(t, hasIAMIntegration, "IAM integration should be available") // This combination means authorization will go through the IAM path t.Log("✓ STS identity will use IAM authorization path (correct behavior)") } // TestSTSIdentityAuthorizationFlow tests the complete authorization flow for STS identities // to ensure they are properly authorized via IAM integration. func TestSTSIdentityAuthorizationFlow(t *testing.T) { // This test validates that: // 1. STS identities have empty Actions // 2. STS identities have PolicyNames populated // 3. Authorization logic routes to IAM path when Actions is empty and iamIntegration exists // Create a mock STS session info sessionInfo := &sts.SessionInfo{ SessionId: "test-session", SessionName: "s3-session", RoleArn: "arn:aws:iam::role/adminRole", AssumedRoleUser: "arn:aws:sts::assumed-role/adminRole/s3-session", Principal: "arn:aws:sts::assumed-role/adminRole/s3-session", Policies: []string{"AdminPolicy", "S3WritePolicy"}, ExpiresAt: time.Now().Add(time.Hour), Credentials: &sts.Credentials{ AccessKeyId: "AKIAIOSFODNN7EXAMPLE", SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", SessionToken: "test-session-token", Expiration: time.Now().Add(time.Hour), }, } // Create credential from session info cred := &Credential{ AccessKey: sessionInfo.Credentials.AccessKeyId, SecretKey: sessionInfo.Credentials.SecretAccessKey, Status: "Active", Expiration: sessionInfo.ExpiresAt.Unix(), } // Create identity as validateSTSSessionToken does (with the fix) identity := &Identity{ Name: sessionInfo.AssumedRoleUser, Account: &AccountAdmin, Credentials: []*Credential{cred}, PrincipalArn: sessionInfo.Principal, PolicyNames: sessionInfo.Policies, // Fix for #7985 } // Test 1: Verify PolicyNames are populated assert.Equal(t, []string{"AdminPolicy", "S3WritePolicy"}, identity.PolicyNames, "PolicyNames should be populated from sessionInfo.Policies") assert.Empty(t, identity.Actions, "STS identities should have empty Actions to trigger the IAM authorization path") // Test 2: Verify canDo returns false (legacy auth should be bypassed) // This is important because it confirms that identity.Actions being empty // correctly forces the authorization logic to fall back to iam.authorizeWithIAM assert.False(t, identity.canDo("Read", "test-bucket", "/any/path"), "canDo should return false for STS identities with no Actions") // With empty Actions and populated PolicyNames, IAM authorization path will be used // as per auth_credentials.go:703-713 t.Log("✓ Verified: STS identity correctly bypasses legacy canDo() to use IAM authorization path") } // TestSTSIdentityWithoutPolicyNames tests the bug scenario where PolicyNames is not populated // This reproduces the original issue #7985 func TestSTSIdentityWithoutPolicyNames(t *testing.T) { // Create identity WITHOUT PolicyNames (the bug scenario) identityBuggy := newTestIdentity(nil) // Create identity WITH PolicyNames (the fix) identityFixed := newTestIdentity([]string{"AdminPolicy"}) // Verify the bug scenario assert.Empty(t, identityBuggy.Actions, "Buggy identity has no Actions") assert.Empty(t, identityBuggy.PolicyNames, "Buggy identity has no PolicyNames (the bug)") // Verify the fix assert.Empty(t, identityFixed.Actions, "Fixed identity has no Actions (correct)") assert.NotEmpty(t, identityFixed.PolicyNames, "Fixed identity has PolicyNames (the fix)") t.Logf("Bug scenario: Actions=%d, PolicyNames=%d → Would be denied", len(identityBuggy.Actions), len(identityBuggy.PolicyNames)) t.Logf("Fixed scenario: Actions=%d, PolicyNames=%d → Can use IAM authorization", len(identityFixed.Actions), len(identityFixed.PolicyNames)) } // TestCanDoPathConstruction tests that bucket and objectKey are properly concatenated // with a slash separator. This is a regression test for the second issue mentioned in #7985. func TestCanDoPathConstruction(t *testing.T) { // Create a test identity with wildcard actions (standard pattern) identity := &Identity{ Name: "test-user", Account: &AccountAdmin, Actions: []Action{ "Write:test-bucket/*", // Wildcard for all objects "Read:test-bucket/docs/*", // Wildcard for specific path }, } // Test cases for path construction testCases := []struct { name string action Action bucket string objectKey string shouldPass bool }{ { name: "Wildcard match for write", action: "Write", bucket: "test-bucket", objectKey: "path/to/object.txt", shouldPass: true, }, { name: "Wildcard match for read in docs path", action: "Read", bucket: "test-bucket", objectKey: "docs/file.txt", shouldPass: true, }, { name: "No match - wrong action", action: "Delete", bucket: "test-bucket", objectKey: "file.txt", shouldPass: false, }, { name: "No match - read outside docs path", action: "Read", bucket: "test-bucket", objectKey: "other/file.txt", shouldPass: false, }, { name: "ObjectKey with leading slash", action: "Read", bucket: "test-bucket", objectKey: "/docs/file.txt", // Already has leading slash shouldPass: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := identity.canDo(tc.action, tc.bucket, tc.objectKey) // Robust path construction for verification fullPath := tc.bucket if tc.objectKey != "" && !strings.HasPrefix(tc.objectKey, "/") { fullPath += "/" } fullPath += tc.objectKey t.Logf("Testing path: %s", fullPath) if tc.shouldPass { assert.True(t, result, "Should allow action %s on %s", tc.action, fullPath) } else { assert.False(t, result, "Should deny action %s on %s", tc.action, fullPath) } }) } } // TestValidateSTSSessionTokenIntegration is an integration test that validates // the complete flow from session token validation to identity creation func TestValidateSTSSessionTokenIntegration(t *testing.T) { // Setup STS service stsService, config := setupTestSTSService(t) // Create IAM with STS integration iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, "memory") s3iam := &S3IAMIntegration{ stsService: stsService, } iam.SetIAMIntegration(s3iam) // Create a mock HTTP request with STS session token req, err := http.NewRequest("PUT", "/test-bucket/test-object.txt", nil) require.NoError(t, err) // Generate session token sessionId := "integration-test-session" expiresAt := time.Now().Add(time.Hour) sessionClaims := sts.NewSTSSessionClaims(sessionId, config.Issuer, expiresAt). WithSessionName("integration-test"). WithRoleInfo("arn:aws:iam::role/testRole", "arn:aws:sts::assumed-role/testRole/integration-test", "arn:aws:sts::assumed-role/testRole/integration-test") sessionClaims.Policies = []string{"TestPolicy"} tokenGen := sts.NewTokenGenerator(config.SigningKey, config.Issuer) sessionToken, err := tokenGen.GenerateJWTWithClaims(sessionClaims) require.NoError(t, err) // Validate session token sessionInfo, err := stsService.ValidateSessionToken(context.Background(), sessionToken) require.NoError(t, err) require.NotNil(t, sessionInfo) // Validate session token and check identity creation identity, _, errCode := iam.validateSTSSessionToken(req, sessionToken, sessionInfo.Credentials.AccessKeyId) require.Equal(t, s3err.ErrNone, errCode) require.NotNil(t, identity) // Verify the identity is properly configured for IAM authorization assert.Empty(t, identity.Actions, "STS identity should have empty Actions") assert.Equal(t, []string{"TestPolicy"}, identity.PolicyNames, "PolicyNames should be populated (this requires the fix in #7985)") assert.NotEmpty(t, identity.PrincipalArn, "PrincipalArn should be set") t.Log("✓ Integration test passed: STS identity properly configured for IAM authorization") } // Helper functions for tests func setupTestSTSService(t *testing.T) (*sts.STSService, *sts.STSConfig) { t.Helper() stsService := sts.NewSTSService() config := &sts.STSConfig{ TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour}, Issuer: "test-issuer", SigningKey: []byte("test-signing-key-at-least-32-bytes-long-for-security"), } err := stsService.Initialize(config) require.NoError(t, err, "STS service should initialize successfully") return stsService, config } func newTestIdentity(policyNames []string) *Identity { return &Identity{ Name: "arn:aws:sts::assumed-role/adminRole/s3-session", Account: &AccountAdmin, Credentials: []*Credential{{AccessKey: "test", SecretKey: "test"}}, PrincipalArn: "arn:aws:sts::assumed-role/adminRole/s3-session", PolicyNames: policyNames, } }