11 changed files with 696 additions and 73 deletions
-
2test/s3/iam/docker-compose.yml
-
31test/s3/iam/s3_iam_distributed_test.go
-
51weed/iam/oidc/oidc_provider.go
-
2weed/iam/policy/aws_iam_compliance_test.go
-
45weed/iam/policy/policy_engine.go
-
24weed/iam/providers/provider.go
-
192weed/iam/sts/security_test.go
-
58weed/iam/sts/sts_service.go
-
11weed/iam/sts/sts_service_test.go
-
94weed/s3api/s3_iam_middleware.go
-
259weed/s3api/s3_iam_role_selection_test.go
@ -0,0 +1,192 @@ |
|||
package sts |
|||
|
|||
import ( |
|||
"context" |
|||
"fmt" |
|||
"strings" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/golang-jwt/jwt/v5" |
|||
"github.com/seaweedfs/seaweedfs/weed/iam/providers" |
|||
"github.com/stretchr/testify/assert" |
|||
"github.com/stretchr/testify/require" |
|||
) |
|||
|
|||
// TestSecurityIssuerToProviderMapping tests the security fix that ensures JWT tokens
|
|||
// with specific issuer claims can only be validated by the provider registered for that issuer
|
|||
func TestSecurityIssuerToProviderMapping(t *testing.T) { |
|||
ctx := context.Background() |
|||
|
|||
// Create STS service with two mock providers
|
|||
service := NewSTSService() |
|||
config := &STSConfig{ |
|||
TokenDuration: time.Hour, |
|||
MaxSessionLength: time.Hour * 12, |
|||
Issuer: "test-sts", |
|||
SigningKey: []byte("test-signing-key-32-characters-long"), |
|||
} |
|||
|
|||
err := service.Initialize(config) |
|||
require.NoError(t, err) |
|||
|
|||
// Set up mock trust policy validator
|
|||
mockValidator := &MockTrustPolicyValidator{} |
|||
service.SetTrustPolicyValidator(mockValidator) |
|||
|
|||
// Create two mock providers with different issuers
|
|||
providerA := &MockIdentityProviderWithIssuer{ |
|||
name: "provider-a", |
|||
issuer: "https://provider-a.com", |
|||
validTokens: map[string]bool{ |
|||
"token-for-provider-a": true, |
|||
}, |
|||
} |
|||
|
|||
providerB := &MockIdentityProviderWithIssuer{ |
|||
name: "provider-b", |
|||
issuer: "https://provider-b.com", |
|||
validTokens: map[string]bool{ |
|||
"token-for-provider-b": true, |
|||
}, |
|||
} |
|||
|
|||
// Register both providers
|
|||
err = service.RegisterProvider(providerA) |
|||
require.NoError(t, err) |
|||
err = service.RegisterProvider(providerB) |
|||
require.NoError(t, err) |
|||
|
|||
// Create JWT tokens with specific issuer claims
|
|||
tokenForProviderA := createTestJWT(t, "https://provider-a.com", "user-a") |
|||
tokenForProviderB := createTestJWT(t, "https://provider-b.com", "user-b") |
|||
|
|||
t.Run("jwt_token_with_issuer_a_only_validated_by_provider_a", func(t *testing.T) { |
|||
// This should succeed - token has issuer A and provider A is registered
|
|||
identity, provider, err := service.validateWebIdentityToken(ctx, tokenForProviderA) |
|||
assert.NoError(t, err) |
|||
assert.NotNil(t, identity) |
|||
assert.Equal(t, "provider-a", provider.Name()) |
|||
}) |
|||
|
|||
t.Run("jwt_token_with_issuer_b_only_validated_by_provider_b", func(t *testing.T) { |
|||
// This should succeed - token has issuer B and provider B is registered
|
|||
identity, provider, err := service.validateWebIdentityToken(ctx, tokenForProviderB) |
|||
assert.NoError(t, err) |
|||
assert.NotNil(t, identity) |
|||
assert.Equal(t, "provider-b", provider.Name()) |
|||
}) |
|||
|
|||
t.Run("jwt_token_with_unregistered_issuer_fails", func(t *testing.T) { |
|||
// Create token with unregistered issuer
|
|||
tokenWithUnknownIssuer := createTestJWT(t, "https://unknown-issuer.com", "user-x") |
|||
|
|||
// This should fail - no provider registered for this issuer
|
|||
identity, provider, err := service.validateWebIdentityToken(ctx, tokenWithUnknownIssuer) |
|||
assert.Error(t, err) |
|||
assert.Nil(t, identity) |
|||
assert.Nil(t, provider) |
|||
assert.Contains(t, err.Error(), "no identity provider registered for issuer: https://unknown-issuer.com") |
|||
}) |
|||
|
|||
t.Run("non_jwt_tokens_still_work_with_fallback", func(t *testing.T) { |
|||
// Non-JWT tokens should still work via fallback mechanism
|
|||
identity, provider, err := service.validateWebIdentityToken(ctx, "token-for-provider-a") |
|||
assert.NoError(t, err) |
|||
assert.NotNil(t, identity) |
|||
assert.Equal(t, "provider-a", provider.Name()) |
|||
}) |
|||
} |
|||
|
|||
// createTestJWT creates a test JWT token with the specified issuer and subject
|
|||
func createTestJWT(t *testing.T, issuer, subject string) string { |
|||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ |
|||
"iss": issuer, |
|||
"sub": subject, |
|||
"aud": "test-client", |
|||
"exp": time.Now().Add(time.Hour).Unix(), |
|||
"iat": time.Now().Unix(), |
|||
}) |
|||
|
|||
tokenString, err := token.SignedString([]byte("test-signing-key")) |
|||
require.NoError(t, err) |
|||
return tokenString |
|||
} |
|||
|
|||
// MockIdentityProviderWithIssuer is a mock provider that supports issuer mapping
|
|||
type MockIdentityProviderWithIssuer struct { |
|||
name string |
|||
issuer string |
|||
validTokens map[string]bool |
|||
} |
|||
|
|||
func (m *MockIdentityProviderWithIssuer) Name() string { |
|||
return m.name |
|||
} |
|||
|
|||
func (m *MockIdentityProviderWithIssuer) GetIssuer() string { |
|||
return m.issuer |
|||
} |
|||
|
|||
func (m *MockIdentityProviderWithIssuer) Initialize(config interface{}) error { |
|||
return nil |
|||
} |
|||
|
|||
func (m *MockIdentityProviderWithIssuer) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) { |
|||
// For JWT tokens, parse and validate the token format
|
|||
if len(token) > 50 && strings.Contains(token, ".") { |
|||
// This looks like a JWT - parse it to get the subject
|
|||
parsedToken, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("invalid JWT token") |
|||
} |
|||
|
|||
claims, ok := parsedToken.Claims.(jwt.MapClaims) |
|||
if !ok { |
|||
return nil, fmt.Errorf("invalid claims") |
|||
} |
|||
|
|||
issuer, _ := claims["iss"].(string) |
|||
subject, _ := claims["sub"].(string) |
|||
|
|||
// Verify the issuer matches what we expect
|
|||
if issuer != m.issuer { |
|||
return nil, fmt.Errorf("token issuer %s does not match provider issuer %s", issuer, m.issuer) |
|||
} |
|||
|
|||
return &providers.ExternalIdentity{ |
|||
UserID: subject, |
|||
Email: subject + "@" + m.name + ".com", |
|||
Provider: m.name, |
|||
}, nil |
|||
} |
|||
|
|||
// For non-JWT tokens, check our simple token list
|
|||
if m.validTokens[token] { |
|||
return &providers.ExternalIdentity{ |
|||
UserID: "test-user", |
|||
Email: "test@" + m.name + ".com", |
|||
Provider: m.name, |
|||
}, nil |
|||
} |
|||
|
|||
return nil, fmt.Errorf("invalid token") |
|||
} |
|||
|
|||
func (m *MockIdentityProviderWithIssuer) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) { |
|||
return &providers.ExternalIdentity{ |
|||
UserID: userID, |
|||
Email: userID + "@" + m.name + ".com", |
|||
Provider: m.name, |
|||
}, nil |
|||
} |
|||
|
|||
func (m *MockIdentityProviderWithIssuer) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) { |
|||
if m.validTokens[token] { |
|||
return &providers.TokenClaims{ |
|||
Subject: "test-user", |
|||
Issuer: m.issuer, |
|||
}, nil |
|||
} |
|||
return nil, fmt.Errorf("invalid token") |
|||
} |
@ -0,0 +1,259 @@ |
|||
package s3api |
|||
|
|||
import ( |
|||
"strings" |
|||
"testing" |
|||
|
|||
"github.com/seaweedfs/seaweedfs/weed/iam/providers" |
|||
"github.com/stretchr/testify/assert" |
|||
) |
|||
|
|||
func TestSelectPrimaryRole(t *testing.T) { |
|||
s3iam := &S3IAMIntegration{} |
|||
|
|||
t.Run("single_role_returns_that_role", func(t *testing.T) { |
|||
roles := []string{"admin"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "admin", result) |
|||
}) |
|||
|
|||
t.Run("explicit_primary_role_takes_precedence", func(t *testing.T) { |
|||
roles := []string{"admin", "reader", "writer"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: map[string]string{ |
|||
"primary_role": "reader", |
|||
}, |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "reader", result) |
|||
}) |
|||
|
|||
t.Run("explicit_primary_role_case_insensitive", func(t *testing.T) { |
|||
roles := []string{"Admin", "Reader", "Writer"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: map[string]string{ |
|||
"primary_role": "admin", |
|||
}, |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "Admin", result) |
|||
}) |
|||
|
|||
t.Run("invalid_primary_role_falls_back_to_hierarchy", func(t *testing.T) { |
|||
roles := []string{"admin", "reader", "writer"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: map[string]string{ |
|||
"primary_role": "nonexistent", |
|||
}, |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "admin", result) // Should select admin via hierarchy
|
|||
}) |
|||
|
|||
t.Run("hierarchy_selection_admin_over_reader", func(t *testing.T) { |
|||
roles := []string{"reader", "admin", "writer"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "admin", result) // Admin has higher priority
|
|||
}) |
|||
|
|||
t.Run("hierarchy_selection_case_insensitive", func(t *testing.T) { |
|||
roles := []string{"Reader", "ADMIN", "writer"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "ADMIN", result) |
|||
}) |
|||
|
|||
t.Run("hierarchy_selection_contains_match", func(t *testing.T) { |
|||
roles := []string{"system-reader", "system-admin-user", "system-writer"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "system-admin-user", result) // Contains "admin"
|
|||
}) |
|||
|
|||
t.Run("deterministic_fallback_alphabetical", func(t *testing.T) { |
|||
// Roles that don't match any hierarchy
|
|||
roles := []string{"zebra", "alpha", "beta"} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "alpha", result) // First alphabetically
|
|||
}) |
|||
|
|||
t.Run("complex_enterprise_roles", func(t *testing.T) { |
|||
roles := []string{ |
|||
"app-user-readonly", |
|||
"app-user-contributor", |
|||
"app-admin-full", |
|||
"system-guest", |
|||
} |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(roles, externalIdentity) |
|||
assert.Equal(t, "app-admin-full", result) // Contains "admin"
|
|||
}) |
|||
} |
|||
|
|||
func TestSelectByRoleHierarchy(t *testing.T) { |
|||
s3iam := &S3IAMIntegration{} |
|||
|
|||
t.Run("super_admin_highest_priority", func(t *testing.T) { |
|||
roles := []string{"admin", "super-admin", "reader"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
assert.Equal(t, "super-admin", result) |
|||
}) |
|||
|
|||
t.Run("admin_over_manager", func(t *testing.T) { |
|||
roles := []string{"manager", "admin", "reader"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
assert.Equal(t, "admin", result) |
|||
}) |
|||
|
|||
t.Run("manager_over_editor", func(t *testing.T) { |
|||
roles := []string{"editor", "manager", "reader"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
assert.Equal(t, "manager", result) |
|||
}) |
|||
|
|||
t.Run("editor_over_viewer", func(t *testing.T) { |
|||
roles := []string{"viewer", "editor"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
assert.Equal(t, "editor", result) |
|||
}) |
|||
|
|||
t.Run("no_hierarchy_match_returns_empty", func(t *testing.T) { |
|||
roles := []string{"custom-role-1", "custom-role-2", "special-user"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
assert.Equal(t, "", result) |
|||
}) |
|||
|
|||
t.Run("multiple_same_tier_returns_first_found", func(t *testing.T) { |
|||
roles := []string{"viewer", "reader", "guest"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
// Should return first match found in the hierarchy (viewer comes first in tier definition)
|
|||
assert.Equal(t, "viewer", result) |
|||
}) |
|||
|
|||
t.Run("case_variations", func(t *testing.T) { |
|||
roles := []string{"ADMIN", "Reader", "writer"} |
|||
result := s3iam.selectByRoleHierarchy(roles) |
|||
assert.Equal(t, "ADMIN", result) |
|||
}) |
|||
} |
|||
|
|||
func TestRoleSelectionIntegration(t *testing.T) { |
|||
t.Run("real_world_enterprise_scenario", func(t *testing.T) { |
|||
// Simulate a real enterprise OIDC token with multiple roles
|
|||
testCases := []struct { |
|||
name string |
|||
roles []string |
|||
primaryRole string // explicit primary_role claim
|
|||
expectedRole string |
|||
selectionType string |
|||
}{ |
|||
{ |
|||
name: "explicit_primary_overrides_hierarchy", |
|||
roles: []string{"admin", "reader", "writer"}, |
|||
primaryRole: "reader", |
|||
expectedRole: "reader", |
|||
selectionType: "explicit", |
|||
}, |
|||
{ |
|||
name: "hierarchy_selects_admin_over_others", |
|||
roles: []string{"contributor", "admin", "viewer"}, |
|||
primaryRole: "", // No explicit primary
|
|||
expectedRole: "admin", |
|||
selectionType: "hierarchy", |
|||
}, |
|||
{ |
|||
name: "deterministic_fallback_for_unknown_roles", |
|||
roles: []string{"zebra-role", "alpha-role", "beta-role"}, |
|||
primaryRole: "", |
|||
expectedRole: "alpha-role", |
|||
selectionType: "deterministic", |
|||
}, |
|||
{ |
|||
name: "complex_enterprise_naming", |
|||
roles: []string{"org-user-readonly", "org-power-user", "org-system-admin"}, |
|||
primaryRole: "", |
|||
expectedRole: "org-system-admin", // Contains "admin"
|
|||
selectionType: "hierarchy", |
|||
}, |
|||
} |
|||
|
|||
s3iam := &S3IAMIntegration{} |
|||
|
|||
for _, tc := range testCases { |
|||
t.Run(tc.name, func(t *testing.T) { |
|||
externalIdentity := &providers.ExternalIdentity{ |
|||
Attributes: make(map[string]string), |
|||
} |
|||
if tc.primaryRole != "" { |
|||
externalIdentity.Attributes["primary_role"] = tc.primaryRole |
|||
} |
|||
|
|||
result := s3iam.selectPrimaryRole(tc.roles, externalIdentity) |
|||
assert.Equal(t, tc.expectedRole, result, |
|||
"Expected %s selection to return %s, got %s", |
|||
tc.selectionType, tc.expectedRole, result) |
|||
}) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// Test helper function to verify role parsing improvements
|
|||
func TestRoleParsingImprovements(t *testing.T) { |
|||
t.Run("whitespace_handling", func(t *testing.T) { |
|||
// Test the improved role parsing logic
|
|||
rolesStr := " admin , reader , writer " |
|||
roles := strings.Split(rolesStr, ",") |
|||
|
|||
// Clean up role names (this is what the main code does now)
|
|||
var cleanRoles []string |
|||
for _, role := range roles { |
|||
cleanRole := strings.TrimSpace(role) |
|||
if cleanRole != "" { |
|||
cleanRoles = append(cleanRoles, cleanRole) |
|||
} |
|||
} |
|||
|
|||
expected := []string{"admin", "reader", "writer"} |
|||
assert.Equal(t, expected, cleanRoles) |
|||
}) |
|||
|
|||
t.Run("empty_roles_filtered", func(t *testing.T) { |
|||
rolesStr := "admin,,reader, ,writer" |
|||
roles := strings.Split(rolesStr, ",") |
|||
|
|||
var cleanRoles []string |
|||
for _, role := range roles { |
|||
cleanRole := strings.TrimSpace(role) |
|||
if cleanRole != "" { |
|||
cleanRoles = append(cleanRoles, cleanRole) |
|||
} |
|||
} |
|||
|
|||
expected := []string{"admin", "reader", "writer"} |
|||
assert.Equal(t, expected, cleanRoles) |
|||
}) |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue