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
-
39weed/iam/policy/policy_engine.go
-
24weed/iam/providers/provider.go
-
192weed/iam/sts/security_test.go
-
54weed/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