From 7cb138deb44e6d28a86aa917ad161c95bc6c3c3e Mon Sep 17 00:00:00 2001 From: chrislu Date: Tue, 26 Aug 2025 19:57:51 -0700 Subject: [PATCH] no fake ldap provider, remove stateful sts session doc --- test/s3/iam/DISTRIBUTED.md | 29 +- test/s3/iam/STS_DISTRIBUTED.md | 13 +- weed/iam/integration/iam_integration_test.go | 8 +- weed/iam/ldap/ldap_provider.go | 866 ------------------- weed/iam/ldap/ldap_provider_test.go | 360 -------- weed/iam/ldap/mock_provider.go | 18 +- weed/iam/sts/RUNTIME_FILER_ADDRESS.md | 356 -------- weed/iam/sts/session_store.go | 383 -------- weed/iam/sts/sts_service.go | 15 - weed/s3api/s3_end_to_end_test.go | 8 +- weed/s3api/s3_jwt_auth_test.go | 6 +- weed/s3api/s3_multipart_iam_test.go | 6 +- weed/s3api/s3_presigned_url_iam_test.go | 6 +- 13 files changed, 40 insertions(+), 2034 deletions(-) delete mode 100644 weed/iam/ldap/ldap_provider.go delete mode 100644 weed/iam/ldap/ldap_provider_test.go delete mode 100644 weed/iam/sts/RUNTIME_FILER_ADDRESS.md delete mode 100644 weed/iam/sts/session_store.go diff --git a/test/s3/iam/DISTRIBUTED.md b/test/s3/iam/DISTRIBUTED.md index a0be7b108..e3341bad1 100644 --- a/test/s3/iam/DISTRIBUTED.md +++ b/test/s3/iam/DISTRIBUTED.md @@ -7,19 +7,18 @@ This document explains how to configure SeaweedFS S3 Gateway for distributed env In distributed environments with multiple S3 gateway instances, the default in-memory storage for IAM components causes **serious consistency issues**: - **Roles**: Each instance maintains separate in-memory role definitions -- **Sessions**: STS sessions created on one instance aren't visible on others - **Policies**: Policy updates don't propagate across instances -- **Authentication failures**: Users may get different responses from different instances +- **Authentication inconsistency**: Users may get different responses from different instances -## Solution: Filer-Based Distributed Storage +## Solution: Stateless JWT + Filer-Based Storage -SeaweedFS now supports **distributed IAM storage** using the filer as a centralized backend for: +SeaweedFS now supports **distributed IAM** using: -- ✅ **Role definitions** (FilerRoleStore) -- ✅ **STS sessions** (FilerSessionStore) -- ✅ **IAM policies** (FilerPolicyStore) +- ✅ **Stateless STS**: JWT tokens contain all session information (no session storage needed) +- ✅ **Role definitions**: Filer-based distributed storage (FilerRoleStore) +- ✅ **IAM policies**: Filer-based distributed storage (FilerPolicyStore) -All S3 gateway instances share the same IAM state through the filer. +All S3 gateway instances share the same IAM state through the filer, and STS tokens are completely stateless. ## Configuration @@ -46,12 +45,7 @@ All S3 gateway instances share the same IAM state through the filer. "tokenDuration": 3600000000000, "maxSessionLength": 43200000000000, "issuer": "seaweedfs-sts", - "signingKey": "base64-encoded-signing-key", - "sessionStoreType": "filer", - "sessionStoreConfig": { - "filerAddress": "localhost:8888", - "basePath": "/seaweedfs/iam/sessions" - } + "signingKey": "base64-encoded-signing-key" }, "policy": { "defaultEffect": "Deny", @@ -71,6 +65,12 @@ All S3 gateway instances share the same IAM state through the filer. } ``` +**Key Configuration Changes for Distribution:** + +- **STS remains stateless**: No session store configuration needed as JWT tokens are self-contained +- **Policy store uses filer**: `"storeType": "filer"` enables distributed policy storage +- **Role store uses filer**: Ensures all instances share the same role definitions + ## Storage Backends ### Memory Storage (Default) @@ -169,7 +169,6 @@ When using filer storage, IAM data is stored at: ```json { "policy": { "storeType": "filer" }, - "sts": { "sessionStoreType": "filer" }, "roleStore": { "storeType": "filer" } } ``` diff --git a/test/s3/iam/STS_DISTRIBUTED.md b/test/s3/iam/STS_DISTRIBUTED.md index d7a44bdd9..ee62f58a4 100644 --- a/test/s3/iam/STS_DISTRIBUTED.md +++ b/test/s3/iam/STS_DISTRIBUTED.md @@ -30,16 +30,13 @@ The STS service now supports **automatic provider loading** from configuration f "tokenDuration": 3600000000000, "maxSessionLength": 43200000000000, "issuer": "seaweedfs-sts", - "signingKey": "base64-encoded-signing-key-32-chars-min", - "sessionStoreType": "filer", - "sessionStoreConfig": { - "filerAddress": "localhost:8888", - "basePath": "/seaweedfs/iam/sessions" - } + "signingKey": "base64-encoded-signing-key-32-chars-min" } } ``` +**Note**: The STS service uses a **stateless JWT design** where all session information is embedded directly in the JWT token. No external session storage is required. + ### Configuration-Driven Providers ```json @@ -49,10 +46,6 @@ The STS service now supports **automatic provider loading** from configuration f "maxSessionLength": 43200000000000, "issuer": "seaweedfs-sts", "signingKey": "base64-encoded-signing-key", - "sessionStoreType": "filer", - "sessionStoreConfig": { - "filerAddress": "localhost:8888" - }, "providers": [ { "name": "keycloak-oidc", diff --git a/weed/iam/integration/iam_integration_test.go b/weed/iam/integration/iam_integration_test.go index d9f5281c2..803ec87b5 100644 --- a/weed/iam/integration/iam_integration_test.go +++ b/weed/iam/integration/iam_integration_test.go @@ -387,13 +387,9 @@ func setupTestProviders(t *testing.T, manager *IAMManager) { require.NoError(t, err) oidcProvider.SetupDefaultTestData() - // Set up LDAP provider + // Set up LDAP mock provider (no config needed for mock) ldapProvider := ldap.NewMockLDAPProvider("test-ldap") - ldapConfig := &ldap.LDAPConfig{ - Server: "ldap://test-server:389", - BaseDN: "DC=test,DC=com", - } - err = ldapProvider.Initialize(ldapConfig) + err = ldapProvider.Initialize(nil) // Mock doesn't need real config require.NoError(t, err) ldapProvider.SetupDefaultTestData() diff --git a/weed/iam/ldap/ldap_provider.go b/weed/iam/ldap/ldap_provider.go deleted file mode 100644 index a38b0f940..000000000 --- a/weed/iam/ldap/ldap_provider.go +++ /dev/null @@ -1,866 +0,0 @@ -package ldap - -import ( - "context" - "crypto/tls" - "fmt" - "net" - "strings" - "sync" - "time" - - "github.com/seaweedfs/seaweedfs/weed/glog" - "github.com/seaweedfs/seaweedfs/weed/iam/providers" -) - -// LDAPProvider implements LDAP authentication -type LDAPProvider struct { - name string - config *LDAPConfig - initialized bool - connPool *LDAPConnectionPool -} - -// LDAPConnectionPool manages LDAP connections -type LDAPConnectionPool struct { - config *LDAPConfig - connections chan *LDAPConn - mu sync.Mutex - maxConns int -} - -// LDAPConn represents an LDAP connection (simplified implementation) -type LDAPConn struct { - serverAddr string - conn net.Conn - bound bool - tlsConfig *tls.Config -} - -// LDAPSearchResult represents LDAP search results -type LDAPSearchResult struct { - Entries []*LDAPEntry -} - -// LDAPEntry represents an LDAP directory entry -type LDAPEntry struct { - DN string - Attributes []*LDAPAttribute -} - -// LDAPAttribute represents an LDAP attribute -type LDAPAttribute struct { - Name string - Values []string -} - -// LDAPSearchRequest represents an LDAP search request -type LDAPSearchRequest struct { - BaseDN string - Scope int - DerefAliases int - SizeLimit int - TimeLimit int - TypesOnly bool - Filter string - Attributes []string -} - -// LDAP search scope constants -const ( - ScopeBaseObject = iota - ScopeWholeSubtree - NeverDerefAliases = 0 -) - -// LDAPConfig holds LDAP provider configuration -type LDAPConfig struct { - // Server is the LDAP server URL (e.g., ldap://localhost:389) - Server string `json:"server"` - - // BaseDN is the base distinguished name for searches - BaseDN string `json:"baseDn"` - - // BindDN is the distinguished name for binding (authentication) - BindDN string `json:"bindDn,omitempty"` - - // BindPass is the password for binding - BindPass string `json:"bindPass,omitempty"` - - // UserFilter is the LDAP filter for finding users (e.g., "(sAMAccountName=%s)") - UserFilter string `json:"userFilter"` - - // GroupFilter is the LDAP filter for finding groups (e.g., "(member=%s)") - GroupFilter string `json:"groupFilter,omitempty"` - - // Attributes maps SeaweedFS identity fields to LDAP attributes - Attributes map[string]string `json:"attributes,omitempty"` - - // RoleMapping defines how to map LDAP groups to roles - RoleMapping *providers.RoleMapping `json:"roleMapping,omitempty"` - - // TLS configuration - UseTLS bool `json:"useTls,omitempty"` - TLSCert string `json:"tlsCert,omitempty"` - TLSKey string `json:"tlsKey,omitempty"` - TLSSkipVerify bool `json:"tlsSkipVerify,omitempty"` - - // Connection pool settings - MaxConnections int `json:"maxConnections,omitempty"` - ConnTimeout int `json:"connTimeout,omitempty"` // seconds -} - -// NewLDAPProvider creates a new LDAP provider -func NewLDAPProvider(name string) *LDAPProvider { - return &LDAPProvider{ - name: name, - } -} - -// Name returns the provider name -func (p *LDAPProvider) Name() string { - return p.name -} - -// Initialize initializes the LDAP provider with configuration -func (p *LDAPProvider) Initialize(config interface{}) error { - if config == nil { - return fmt.Errorf("config cannot be nil") - } - - ldapConfig, ok := config.(*LDAPConfig) - if !ok { - return fmt.Errorf("invalid config type for LDAP provider") - } - - if err := p.validateConfig(ldapConfig); err != nil { - return fmt.Errorf("invalid LDAP configuration: %w", err) - } - - p.config = ldapConfig - - // Initialize LDAP connection pool - pool, err := NewLDAPConnectionPool(ldapConfig) - if err != nil { - glog.V(2).Infof("Failed to initialize LDAP connection pool: %v (using mock for testing)", err) - // In case of connection failure, continue but mark as testing mode - p.initialized = true - return nil - } - p.connPool = pool - - // Test connectivity with one connection - conn, err := p.connPool.GetConnection() - if err != nil { - glog.V(2).Infof("Failed to establish test LDAP connection: %v (using mock for testing)", err) - p.initialized = true - return nil - } - p.connPool.ReleaseConnection(conn) - - p.initialized = true - glog.V(2).Infof("LDAP provider %s initialized with server %s", p.name, ldapConfig.Server) - return nil -} - -// validateConfig validates the LDAP configuration -func (p *LDAPProvider) validateConfig(config *LDAPConfig) error { - if config.Server == "" { - return fmt.Errorf("server is required") - } - - if config.BaseDN == "" { - return fmt.Errorf("base DN is required") - } - - // Basic URL validation - if !strings.HasPrefix(config.Server, "ldap://") && !strings.HasPrefix(config.Server, "ldaps://") { - return fmt.Errorf("invalid server URL format") - } - - // Set default user filter if not provided - if config.UserFilter == "" { - config.UserFilter = "(uid=%s)" // Default LDAP user filter - } - - // Set default attributes if not provided - if config.Attributes == nil { - config.Attributes = map[string]string{ - "email": "mail", - "displayName": "cn", - "groups": "memberOf", - } - } - - return nil -} - -// Authenticate authenticates a user with LDAP -func (p *LDAPProvider) Authenticate(ctx context.Context, credentials string) (*providers.ExternalIdentity, error) { - if !p.initialized { - return nil, fmt.Errorf("provider not initialized") - } - - if credentials == "" { - return nil, fmt.Errorf("credentials cannot be empty") - } - - // Parse credentials (username:password format) - parts := strings.SplitN(credentials, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid credentials format (expected username:password)") - } - - username, password := parts[0], parts[1] - - // Get connection from pool - conn, err := p.getConnection() - if err != nil { - return nil, fmt.Errorf("failed to get LDAP connection: %v", err) - } - defer p.releaseConnection(conn) - - // Perform LDAP bind with service account if configured - if p.config.BindDN != "" && p.config.BindPass != "" { - err = conn.Bind(p.config.BindDN, p.config.BindPass) - if err != nil { - return nil, fmt.Errorf("failed to bind with service account: %v", err) - } - } - - // Search for user - userFilter := fmt.Sprintf(p.config.UserFilter, EscapeFilter(username)) - searchRequest := &LDAPSearchRequest{ - BaseDN: p.config.BaseDN, - Scope: ScopeWholeSubtree, - DerefAliases: NeverDerefAliases, - SizeLimit: 0, - TimeLimit: 0, - TypesOnly: false, - Filter: userFilter, - Attributes: p.getSearchAttributes(), - } - - searchResult, err := conn.Search(searchRequest) - if err != nil { - return nil, fmt.Errorf("LDAP search failed: %v", err) - } - - if len(searchResult.Entries) == 0 { - return nil, fmt.Errorf("user not found in LDAP: %s", username) - } - - if len(searchResult.Entries) > 1 { - return nil, fmt.Errorf("multiple users found for username: %s", username) - } - - userEntry := searchResult.Entries[0] - userDN := userEntry.DN - - // Authenticate user by binding with their credentials - err = conn.Bind(userDN, password) - if err != nil { - return nil, fmt.Errorf("LDAP authentication failed for user %s: %v", username, err) - } - - // Extract user attributes - attributes := make(map[string][]string) - for _, attr := range userEntry.Attributes { - attributes[attr.Name] = attr.Values - } - - // Map to ExternalIdentity - identity := p.mapLDAPAttributes(username, attributes) - identity.UserID = username - - // Get user groups if group filter is configured - if p.config.GroupFilter != "" { - groups, err := p.getUserGroups(conn, userDN, username) - if err != nil { - glog.V(2).Infof("Failed to retrieve groups for user %s: %v", username, err) - } else { - identity.Groups = groups - } - } - - glog.V(3).Infof("LDAP authentication successful for user: %s", username) - return identity, nil -} - -// GetUserInfo retrieves user information from LDAP -func (p *LDAPProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) { - if !p.initialized { - return nil, fmt.Errorf("provider not initialized") - } - - if userID == "" { - return nil, fmt.Errorf("user ID cannot be empty") - } - - // Get connection from pool - conn, err := p.getConnection() - if err != nil { - return nil, fmt.Errorf("failed to get LDAP connection: %v", err) - } - defer p.releaseConnection(conn) - - // Perform LDAP bind with service account if configured - if p.config.BindDN != "" && p.config.BindPass != "" { - err = conn.Bind(p.config.BindDN, p.config.BindPass) - if err != nil { - return nil, fmt.Errorf("failed to bind with service account: %v", err) - } - } - - // Search for user by userID using configured user filter - userFilter := fmt.Sprintf(p.config.UserFilter, EscapeFilter(userID)) - searchRequest := &LDAPSearchRequest{ - BaseDN: p.config.BaseDN, - Scope: ScopeWholeSubtree, - DerefAliases: NeverDerefAliases, - SizeLimit: 1, // We only need one user - TimeLimit: 30, // 30 second timeout - TypesOnly: false, - Filter: userFilter, - Attributes: p.getSearchAttributes(), - } - - glog.V(3).Infof("Searching for user %s with filter: %s", userID, userFilter) - searchResult, err := conn.Search(searchRequest) - if err != nil { - return nil, fmt.Errorf("LDAP user search failed: %v", err) - } - - if len(searchResult.Entries) == 0 { - return nil, fmt.Errorf("user not found in LDAP: %s", userID) - } - - if len(searchResult.Entries) > 1 { - glog.V(2).Infof("Multiple entries found for user %s, using first one", userID) - } - - userEntry := searchResult.Entries[0] - userDN := userEntry.DN - - glog.V(3).Infof("Found LDAP user: %s with DN: %s", userID, userDN) - - // Extract user attributes - attributes := make(map[string][]string) - for _, attr := range userEntry.Attributes { - attributes[attr.Name] = attr.Values - } - - // Map to ExternalIdentity - identity := p.mapLDAPAttributes(userID, attributes) - identity.UserID = userID - - // Get user groups if group filter is configured - if p.config.GroupFilter != "" { - groups, err := p.getUserGroups(conn, userDN, userID) - if err != nil { - glog.V(2).Infof("Failed to retrieve groups for user %s: %v", userID, err) - } else { - identity.Groups = groups - } - } - - glog.V(3).Infof("Successfully retrieved user info for: %s", userID) - return identity, nil -} - -// ValidateToken validates credentials (for LDAP, this is username/password) -func (p *LDAPProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) { - if !p.initialized { - return nil, fmt.Errorf("provider not initialized") - } - - if token == "" { - return nil, fmt.Errorf("token cannot be empty") - } - - // Parse credentials (username:password format) - parts := strings.SplitN(token, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid token format (expected username:password)") - } - - username, password := parts[0], parts[1] - - // Get connection from pool - conn, err := p.getConnection() - if err != nil { - return nil, fmt.Errorf("failed to get LDAP connection: %v", err) - } - defer p.releaseConnection(conn) - - // Perform LDAP bind with service account if configured - if p.config.BindDN != "" && p.config.BindPass != "" { - err = conn.Bind(p.config.BindDN, p.config.BindPass) - if err != nil { - return nil, fmt.Errorf("failed to bind with service account: %v", err) - } - } - - // Search for user using configured user filter - userFilter := fmt.Sprintf(p.config.UserFilter, EscapeFilter(username)) - searchRequest := &LDAPSearchRequest{ - BaseDN: p.config.BaseDN, - Scope: ScopeWholeSubtree, - DerefAliases: NeverDerefAliases, - SizeLimit: 1, // We only need one user - TimeLimit: 30, // 30 second timeout - TypesOnly: false, - Filter: userFilter, - Attributes: p.getSearchAttributes(), - } - - glog.V(3).Infof("Validating credentials for user %s with filter: %s", username, userFilter) - searchResult, err := conn.Search(searchRequest) - if err != nil { - return nil, fmt.Errorf("LDAP user search failed: %v", err) - } - - if len(searchResult.Entries) == 0 { - return nil, fmt.Errorf("user not found in LDAP: %s", username) - } - - if len(searchResult.Entries) > 1 { - glog.V(2).Infof("Multiple entries found for user %s, using first one", username) - } - - userEntry := searchResult.Entries[0] - userDN := userEntry.DN - - // Attempt to bind with user credentials to validate password - err = conn.Bind(userDN, password) - if err != nil { - return nil, fmt.Errorf("LDAP authentication failed for user %s: %v", username, err) - } - - glog.V(3).Infof("LDAP credential validation successful for user: %s", username) - - // Extract user claims (DN, attributes, group memberships) - attributes := make(map[string][]string) - for _, attr := range userEntry.Attributes { - attributes[attr.Name] = attr.Values - } - - // Get user groups if group filter is configured - var groups []string - if p.config.GroupFilter != "" { - groups, err = p.getUserGroups(conn, userDN, username) - if err != nil { - glog.V(2).Infof("Failed to retrieve groups for user %s: %v", username, err) - } - } - - // Return TokenClaims with LDAP-specific information - claims := &providers.TokenClaims{ - Subject: username, - Issuer: p.name, - Claims: map[string]interface{}{ - "dn": userDN, - "provider": p.name, - "groups": groups, - "attributes": attributes, - }, - } - - return claims, nil -} - -// mapLDAPAttributes maps LDAP attributes to ExternalIdentity -func (p *LDAPProvider) mapLDAPAttributes(userID string, attrs map[string][]string) *providers.ExternalIdentity { - identity := &providers.ExternalIdentity{ - UserID: userID, - Provider: p.name, - Attributes: make(map[string]string), - } - - // Map configured attributes - for identityField, ldapAttr := range p.config.Attributes { - if values, exists := attrs[ldapAttr]; exists && len(values) > 0 { - switch identityField { - case "email": - identity.Email = values[0] - case "displayName": - identity.DisplayName = values[0] - case "groups": - identity.Groups = values - default: - // Store as custom attribute - identity.Attributes[identityField] = values[0] - } - } - } - - return identity -} - -// mapUserToRole maps user groups to roles based on role mapping rules -func (p *LDAPProvider) mapUserToRole(identity *providers.ExternalIdentity) string { - if p.config.RoleMapping == nil { - return "" - } - - // Create token claims from identity for rule matching - claims := &providers.TokenClaims{ - Subject: identity.UserID, - Claims: map[string]interface{}{ - "groups": identity.Groups, - "email": identity.Email, - }, - } - - // Check mapping rules - for _, rule := range p.config.RoleMapping.Rules { - if rule.Matches(claims) { - return rule.Role - } - } - - // Return default role if no rules match - return p.config.RoleMapping.DefaultRole -} - -// Connection management methods (stubs for now) -func (p *LDAPProvider) getConnectionPool() interface{} { - return p.connPool -} - -func (p *LDAPProvider) getConnection() (*LDAPConn, error) { - if p.connPool == nil { - return nil, fmt.Errorf("LDAP connection pool not initialized") - } - return p.connPool.GetConnection() -} - -func (p *LDAPProvider) releaseConnection(conn *LDAPConn) { - if p.connPool != nil && conn != nil { - p.connPool.ReleaseConnection(conn) - } -} - -// getSearchAttributes returns the list of attributes to retrieve -func (p *LDAPProvider) getSearchAttributes() []string { - attrs := make([]string, 0, len(p.config.Attributes)+1) - attrs = append(attrs, "dn") // Always include DN - - for _, ldapAttr := range p.config.Attributes { - attrs = append(attrs, ldapAttr) - } - - return attrs -} - -// getUserGroups retrieves user groups using the configured group filter -func (p *LDAPProvider) getUserGroups(conn *LDAPConn, userDN, username string) ([]string, error) { - // Try different group search approaches - - // 1. Search by member DN - groupFilter := fmt.Sprintf(p.config.GroupFilter, EscapeFilter(userDN)) - groups, err := p.searchGroups(conn, groupFilter) - if err == nil && len(groups) > 0 { - return groups, nil - } - - // 2. Search by username if DN search fails - groupFilter = fmt.Sprintf(p.config.GroupFilter, EscapeFilter(username)) - groups, err = p.searchGroups(conn, groupFilter) - if err != nil { - return nil, err - } - - return groups, nil -} - -// searchGroups performs the actual group search -func (p *LDAPProvider) searchGroups(conn *LDAPConn, filter string) ([]string, error) { - searchRequest := &LDAPSearchRequest{ - BaseDN: p.config.BaseDN, - Scope: ScopeWholeSubtree, - DerefAliases: NeverDerefAliases, - SizeLimit: 0, - TimeLimit: 0, - TypesOnly: false, - Filter: filter, - Attributes: []string{"cn", "dn"}, - } - - searchResult, err := conn.Search(searchRequest) - if err != nil { - return nil, fmt.Errorf("group search failed: %v", err) - } - - groups := make([]string, 0, len(searchResult.Entries)) - for _, entry := range searchResult.Entries { - // Try to get CN first, fall back to DN - if cn := entry.GetAttributeValue("cn"); cn != "" { - groups = append(groups, cn) - } else { - groups = append(groups, entry.DN) - } - } - - return groups, nil -} - -// NewLDAPConnectionPool creates a new LDAP connection pool -func NewLDAPConnectionPool(config *LDAPConfig) (*LDAPConnectionPool, error) { - maxConns := config.MaxConnections - if maxConns <= 0 { - maxConns = 10 - } - - pool := &LDAPConnectionPool{ - config: config, - connections: make(chan *LDAPConn, maxConns), - maxConns: maxConns, - } - - // Pre-populate the pool with a few connections for testing - for i := 0; i < 2 && i < maxConns; i++ { - conn, err := pool.createConnection() - if err != nil { - // If we can't create any connections, return error - if i == 0 { - return nil, err - } - // If we created at least one, continue - break - } - pool.connections <- conn - } - - return pool, nil -} - -// createConnection creates a new LDAP connection -func (pool *LDAPConnectionPool) createConnection() (*LDAPConn, error) { - var netConn net.Conn - var err error - - timeout := time.Duration(pool.config.ConnTimeout) * time.Second - - // Parse server address - serverAddr := pool.config.Server - if strings.HasPrefix(serverAddr, "ldap://") { - serverAddr = strings.TrimPrefix(serverAddr, "ldap://") - } else if strings.HasPrefix(serverAddr, "ldaps://") { - serverAddr = strings.TrimPrefix(serverAddr, "ldaps://") - } - - // Add default port if not specified - if !strings.Contains(serverAddr, ":") { - if strings.HasPrefix(pool.config.Server, "ldaps://") { - serverAddr += ":636" - } else { - serverAddr += ":389" - } - } - - if strings.HasPrefix(pool.config.Server, "ldaps://") { - // LDAPS connection - tlsConfig := &tls.Config{ - InsecureSkipVerify: pool.config.TLSSkipVerify, - } - dialer := &net.Dialer{Timeout: timeout} - netConn, err = tls.DialWithDialer(dialer, "tcp", serverAddr, tlsConfig) - } else { - // Plain LDAP connection - netConn, err = net.DialTimeout("tcp", serverAddr, timeout) - } - - if err != nil { - return nil, fmt.Errorf("failed to connect to LDAP server %s: %v", pool.config.Server, err) - } - - conn := &LDAPConn{ - serverAddr: serverAddr, - conn: netConn, - bound: false, - tlsConfig: &tls.Config{ - InsecureSkipVerify: pool.config.TLSSkipVerify, - }, - } - - // Start TLS if configured and not already using LDAPS - if pool.config.UseTLS && !strings.HasPrefix(pool.config.Server, "ldaps://") { - err = conn.StartTLS(conn.tlsConfig) - if err != nil { - conn.Close() - return nil, fmt.Errorf("failed to start TLS: %v", err) - } - } - - return conn, nil -} - -// GetConnection retrieves a connection from the pool -func (pool *LDAPConnectionPool) GetConnection() (*LDAPConn, error) { - select { - case conn := <-pool.connections: - // Test if connection is still valid - if pool.isConnectionValid(conn) { - return conn, nil - } - // Connection is stale, close it and create a new one - conn.Close() - default: - // No connection available in pool - } - - // Create a new connection - return pool.createConnection() -} - -// ReleaseConnection returns a connection to the pool -func (pool *LDAPConnectionPool) ReleaseConnection(conn *LDAPConn) { - if conn == nil { - return - } - - select { - case pool.connections <- conn: - // Successfully returned to pool - default: - // Pool is full, close the connection - conn.Close() - } -} - -// isConnectionValid tests if a connection is still valid -func (pool *LDAPConnectionPool) isConnectionValid(conn *LDAPConn) bool { - // Simple test: check if underlying connection is still open - if conn == nil || conn.conn == nil { - return false - } - - // Try to perform a simple operation to test connectivity - searchRequest := &LDAPSearchRequest{ - BaseDN: "", - Scope: ScopeBaseObject, - DerefAliases: NeverDerefAliases, - SizeLimit: 0, - TimeLimit: 0, - TypesOnly: false, - Filter: "(objectClass=*)", - Attributes: []string{"1.1"}, // Minimal attributes - } - - _, err := conn.Search(searchRequest) - return err == nil -} - -// Close closes all connections in the pool -func (pool *LDAPConnectionPool) Close() { - pool.mu.Lock() - defer pool.mu.Unlock() - - close(pool.connections) - for conn := range pool.connections { - conn.Close() - } -} - -// Helper functions and LDAP connection methods - -// EscapeFilter escapes special characters in LDAP filter values -func EscapeFilter(filter string) string { - // Basic LDAP filter escaping - filter = strings.ReplaceAll(filter, "\\", "\\5c") - filter = strings.ReplaceAll(filter, "*", "\\2a") - filter = strings.ReplaceAll(filter, "(", "\\28") - filter = strings.ReplaceAll(filter, ")", "\\29") - filter = strings.ReplaceAll(filter, "/", "\\2f") - filter = strings.ReplaceAll(filter, "=", "\\3d") - return filter -} - -// LDAPConn methods - -// Bind performs an LDAP bind operation -func (conn *LDAPConn) Bind(bindDN, bindPassword string) error { - if conn == nil || conn.conn == nil { - return fmt.Errorf("connection is nil") - } - - // In a real implementation, this would send an LDAP bind request - // For now, we simulate the bind operation - glog.V(3).Infof("LDAP Bind attempt for DN: %s", bindDN) - - // Simple validation - if bindDN == "" { - return fmt.Errorf("bind DN cannot be empty") - } - - // Simulate bind success for valid credentials - if bindPassword != "" { - conn.bound = true - return nil - } - - return fmt.Errorf("invalid credentials") -} - -// Search performs an LDAP search operation -func (conn *LDAPConn) Search(searchRequest *LDAPSearchRequest) (*LDAPSearchResult, error) { - if conn == nil || conn.conn == nil { - return nil, fmt.Errorf("connection is nil") - } - - glog.V(3).Infof("LDAP Search - BaseDN: %s, Filter: %s", searchRequest.BaseDN, searchRequest.Filter) - - // In a real implementation, this would send an LDAP search request - // For now, we simulate a search operation - result := &LDAPSearchResult{ - Entries: []*LDAPEntry{}, - } - - // Simulate finding a test user for certain searches - if strings.Contains(searchRequest.Filter, "testuser") || strings.Contains(searchRequest.Filter, "admin") { - entry := &LDAPEntry{ - DN: fmt.Sprintf("uid=%s,%s", "testuser", searchRequest.BaseDN), - Attributes: []*LDAPAttribute{ - {Name: "uid", Values: []string{"testuser"}}, - {Name: "mail", Values: []string{"testuser@example.com"}}, - {Name: "cn", Values: []string{"Test User"}}, - {Name: "memberOf", Values: []string{"cn=users,ou=groups," + searchRequest.BaseDN}}, - }, - } - result.Entries = append(result.Entries, entry) - } - - return result, nil -} - -// Close closes the LDAP connection -func (conn *LDAPConn) Close() error { - if conn != nil && conn.conn != nil { - return conn.conn.Close() - } - return nil -} - -// StartTLS starts TLS on the connection -func (conn *LDAPConn) StartTLS(config *tls.Config) error { - if conn == nil || conn.conn == nil { - return fmt.Errorf("connection is nil") - } - - // In a real implementation, this would upgrade the connection to TLS - glog.V(3).Info("LDAP StartTLS operation") - return nil -} - -// LDAPEntry methods - -// GetAttributeValue returns the first value of the specified attribute -func (entry *LDAPEntry) GetAttributeValue(attrName string) string { - for _, attr := range entry.Attributes { - if attr.Name == attrName && len(attr.Values) > 0 { - return attr.Values[0] - } - } - return "" -} diff --git a/weed/iam/ldap/ldap_provider_test.go b/weed/iam/ldap/ldap_provider_test.go deleted file mode 100644 index 461918ec5..000000000 --- a/weed/iam/ldap/ldap_provider_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package ldap - -import ( - "context" - "fmt" - "testing" - - "github.com/seaweedfs/seaweedfs/weed/iam/providers" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestLDAPProviderInitialization tests LDAP provider initialization -func TestLDAPProviderInitialization(t *testing.T) { - tests := []struct { - name string - config *LDAPConfig - wantErr bool - }{ - { - name: "valid config", - config: &LDAPConfig{ - Server: "ldap://localhost:389", - BaseDN: "DC=example,DC=com", - BindDN: "CN=admin,DC=example,DC=com", - BindPass: "password", - UserFilter: "(sAMAccountName=%s)", - GroupFilter: "(member=%s)", - }, - wantErr: false, - }, - { - name: "missing server", - config: &LDAPConfig{ - BaseDN: "DC=example,DC=com", - }, - wantErr: true, - }, - { - name: "missing base DN", - config: &LDAPConfig{ - Server: "ldap://localhost:389", - }, - wantErr: true, - }, - { - name: "invalid server URL", - config: &LDAPConfig{ - Server: "invalid-url", - BaseDN: "DC=example,DC=com", - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := NewLDAPProvider("test-ldap") - - err := provider.Initialize(tt.config) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, "test-ldap", provider.Name()) - } - }) - } -} - -// TestLDAPProviderAuthentication tests LDAP authentication -func TestLDAPProviderAuthentication(t *testing.T) { - // Skip if no LDAP test server available - if testing.Short() { - t.Skip("Skipping LDAP integration test in short mode") - } - - provider := NewLDAPProvider("test-ldap") - config := &LDAPConfig{ - Server: "ldap://localhost:389", - BaseDN: "DC=example,DC=com", - BindDN: "CN=admin,DC=example,DC=com", - BindPass: "password", - UserFilter: "(sAMAccountName=%s)", - GroupFilter: "(member=%s)", - Attributes: map[string]string{ - "email": "mail", - "displayName": "displayName", - "groups": "memberOf", - }, - RoleMapping: &providers.RoleMapping{ - Rules: []providers.MappingRule{ - { - Claim: "groups", - Value: "*CN=Admins*", - Role: "arn:seaweed:iam::role/AdminRole", - }, - { - Claim: "groups", - Value: "*CN=Users*", - Role: "arn:seaweed:iam::role/UserRole", - }, - }, - DefaultRole: "arn:seaweed:iam::role/GuestRole", - }, - } - - err := provider.Initialize(config) - require.NoError(t, err) - - t.Run("authenticate with username/password", func(t *testing.T) { - // This would require an actual LDAP server for integration testing - credentials := "user:password" // Basic auth format - - identity, err := provider.Authenticate(context.Background(), credentials) - if err != nil { - t.Skip("LDAP server not available for testing") - } - - assert.NoError(t, err) - assert.Equal(t, "user", identity.UserID) - assert.Equal(t, "test-ldap", identity.Provider) - assert.NotEmpty(t, identity.Email) - }) - - t.Run("authenticate with invalid credentials", func(t *testing.T) { - _, err := provider.Authenticate(context.Background(), "invalid:credentials") - assert.Error(t, err) - }) -} - -// TestLDAPProviderUserInfo tests LDAP user info retrieval -func TestLDAPProviderUserInfo(t *testing.T) { - if testing.Short() { - t.Skip("Skipping LDAP integration test in short mode") - } - - provider := NewLDAPProvider("test-ldap") - config := &LDAPConfig{ - Server: "ldap://localhost:389", - BaseDN: "DC=example,DC=com", - BindDN: "CN=admin,DC=example,DC=com", - BindPass: "password", - UserFilter: "(sAMAccountName=%s)", - Attributes: map[string]string{ - "email": "mail", - "displayName": "displayName", - "department": "department", - }, - } - - err := provider.Initialize(config) - require.NoError(t, err) - - t.Run("get user info", func(t *testing.T) { - identity, err := provider.GetUserInfo(context.Background(), "testuser") - if err != nil { - t.Skip("LDAP server not available for testing") - } - - assert.NoError(t, err) - assert.Equal(t, "testuser", identity.UserID) - assert.Equal(t, "test-ldap", identity.Provider) - assert.NotEmpty(t, identity.Email) - assert.NotEmpty(t, identity.DisplayName) - }) - - t.Run("get user info with empty username", func(t *testing.T) { - _, err := provider.GetUserInfo(context.Background(), "") - assert.Error(t, err) - }) - - t.Run("get user info for non-existent user", func(t *testing.T) { - _, err := provider.GetUserInfo(context.Background(), "nonexistent") - assert.Error(t, err) - }) -} - -// TestLDAPAttributeMapping tests LDAP attribute mapping -func TestLDAPAttributeMapping(t *testing.T) { - provider := NewLDAPProvider("test-ldap") - config := &LDAPConfig{ - Server: "ldap://localhost:389", - BaseDN: "DC=example,DC=com", - Attributes: map[string]string{ - "email": "mail", - "displayName": "cn", - "department": "departmentNumber", - "groups": "memberOf", - }, - } - - err := provider.Initialize(config) - require.NoError(t, err) - - t.Run("map LDAP attributes to identity", func(t *testing.T) { - ldapAttrs := map[string][]string{ - "mail": {"user@example.com"}, - "cn": {"John Doe"}, - "departmentNumber": {"IT"}, - "memberOf": { - "CN=Users,OU=Groups,DC=example,DC=com", - "CN=Developers,OU=Groups,DC=example,DC=com", - }, - } - - identity := provider.mapLDAPAttributes("testuser", ldapAttrs) - - assert.Equal(t, "testuser", identity.UserID) - assert.Equal(t, "user@example.com", identity.Email) - assert.Equal(t, "John Doe", identity.DisplayName) - assert.Equal(t, "test-ldap", identity.Provider) - - // Check groups - assert.Contains(t, identity.Groups, "CN=Users,OU=Groups,DC=example,DC=com") - assert.Contains(t, identity.Groups, "CN=Developers,OU=Groups,DC=example,DC=com") - - // Check attributes - assert.Equal(t, "IT", identity.Attributes["department"]) - }) -} - -// TestLDAPGroupFiltering tests LDAP group filtering and role mapping -func TestLDAPGroupFiltering(t *testing.T) { - provider := NewLDAPProvider("test-ldap") - config := &LDAPConfig{ - Server: "ldap://localhost:389", - BaseDN: "DC=example,DC=com", - RoleMapping: &providers.RoleMapping{ - Rules: []providers.MappingRule{ - { - Claim: "groups", - Value: "*Admins*", - Role: "arn:seaweed:iam::role/AdminRole", - }, - { - Claim: "groups", - Value: "*Users*", - Role: "arn:seaweed:iam::role/UserRole", - }, - }, - DefaultRole: "arn:seaweed:iam::role/GuestRole", - }, - } - - err := provider.Initialize(config) - require.NoError(t, err) - - tests := []struct { - name string - groups []string - expectedRole string - expectedClaims map[string]interface{} - }{ - { - name: "admin user", - groups: []string{"CN=Admins,OU=Groups,DC=example,DC=com", "CN=Users,OU=Groups,DC=example,DC=com"}, - expectedRole: "arn:seaweed:iam::role/AdminRole", - }, - { - name: "regular user", - groups: []string{"CN=Users,OU=Groups,DC=example,DC=com"}, - expectedRole: "arn:seaweed:iam::role/UserRole", - }, - { - name: "guest user", - groups: []string{"CN=Guests,OU=Groups,DC=example,DC=com"}, - expectedRole: "arn:seaweed:iam::role/GuestRole", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - identity := &providers.ExternalIdentity{ - UserID: "testuser", - Groups: tt.groups, - Provider: "test-ldap", - } - - role := provider.mapUserToRole(identity) - assert.Equal(t, tt.expectedRole, role) - }) - } -} - -// TestLDAPConnectionPool tests LDAP connection pooling -func TestLDAPConnectionPool(t *testing.T) { - if testing.Short() { - t.Skip("Skipping LDAP connection pool test in short mode") - } - - provider := NewLDAPProvider("test-ldap") - config := &LDAPConfig{ - Server: "ldap://localhost:389", - BaseDN: "DC=example,DC=com", - BindDN: "CN=admin,DC=example,DC=com", - BindPass: "password", - MaxConnections: 5, - ConnTimeout: 30, - } - - err := provider.Initialize(config) - require.NoError(t, err) - - t.Run("connection pool management", func(t *testing.T) { - // Test that multiple concurrent requests work - // This would require actual LDAP server for full testing - pool := provider.getConnectionPool() - - // In CI environments where no LDAP server is available, pool might be nil - // Skip the test if we can't establish a connection - conn, err := provider.getConnection() - if err != nil { - t.Skip("LDAP server not available - skipping connection pool test") - return - } - - // Only test if we successfully got a connection - assert.NotNil(t, pool) - assert.NotNil(t, conn) - provider.releaseConnection(conn) - }) -} - -// MockLDAPServer for unit testing (without external dependencies) -type MockLDAPServer struct { - users map[string]map[string][]string -} - -func NewMockLDAPServer() *MockLDAPServer { - return &MockLDAPServer{ - users: map[string]map[string][]string{ - "testuser": { - "mail": {"testuser@example.com"}, - "cn": {"Test User"}, - "department": {"Engineering"}, - "memberOf": {"CN=Users,OU=Groups,DC=example,DC=com"}, - }, - "admin": { - "mail": {"admin@example.com"}, - "cn": {"Administrator"}, - "department": {"IT"}, - "memberOf": {"CN=Admins,OU=Groups,DC=example,DC=com", "CN=Users,OU=Groups,DC=example,DC=com"}, - }, - }, - } -} - -func (m *MockLDAPServer) Authenticate(username, password string) bool { - _, exists := m.users[username] - return exists && password == "password" // Mock authentication -} - -func (m *MockLDAPServer) GetUserAttributes(username string) (map[string][]string, error) { - if attrs, exists := m.users[username]; exists { - return attrs, nil - } - return nil, fmt.Errorf("user not found: %s", username) -} diff --git a/weed/iam/ldap/mock_provider.go b/weed/iam/ldap/mock_provider.go index b748a1f72..080fd8bec 100644 --- a/weed/iam/ldap/mock_provider.go +++ b/weed/iam/ldap/mock_provider.go @@ -9,8 +9,10 @@ import ( ) // MockLDAPProvider is a mock implementation for testing +// This is a standalone mock that doesn't depend on production LDAP code type MockLDAPProvider struct { - *LDAPProvider + name string + initialized bool TestUsers map[string]*providers.ExternalIdentity TestCredentials map[string]string // username -> password } @@ -18,12 +20,24 @@ type MockLDAPProvider struct { // NewMockLDAPProvider creates a mock LDAP provider for testing func NewMockLDAPProvider(name string) *MockLDAPProvider { return &MockLDAPProvider{ - LDAPProvider: NewLDAPProvider(name), + name: name, + initialized: true, // Mock is always initialized TestUsers: make(map[string]*providers.ExternalIdentity), TestCredentials: make(map[string]string), } } +// Name returns the provider name +func (m *MockLDAPProvider) Name() string { + return m.name +} + +// Initialize initializes the mock provider (no-op for testing) +func (m *MockLDAPProvider) Initialize(config interface{}) error { + m.initialized = true + return nil +} + // AddTestUser adds a test user with credentials func (m *MockLDAPProvider) AddTestUser(username, password string, identity *providers.ExternalIdentity) { m.TestCredentials[username] = password diff --git a/weed/iam/sts/RUNTIME_FILER_ADDRESS.md b/weed/iam/sts/RUNTIME_FILER_ADDRESS.md deleted file mode 100644 index 37d51c1e2..000000000 --- a/weed/iam/sts/RUNTIME_FILER_ADDRESS.md +++ /dev/null @@ -1,356 +0,0 @@ -# Runtime Filer Address Implementation - -This document describes the implementation of runtime filer address passing for the STSService, addressing the requirement that filer addresses should be passed at call-time rather than initialization time. - -## Problem Statement - -The user identified a critical issue with the original STS implementation: - -> "the filer address should be passed when called, not during init time, since the filer may change." - -This is important because: -1. **Filer Failover**: Filer addresses can change during runtime due to failover scenarios -2. **Load Balancing**: Different requests may need to hit different filer instances -3. **Environment Agnostic**: Configuration files should work across dev/staging/prod without hardcoded addresses -4. **SeaweedFS Patterns**: Follows existing SeaweedFS patterns used throughout the codebase - -## Implementation Changes - -### 1. SessionStore Interface Refactoring - -**Before:** -```go -type SessionStore interface { - StoreSession(ctx context.Context, sessionId string, session *SessionInfo) error - GetSession(ctx context.Context, sessionId string) (*SessionInfo, error) - RevokeSession(ctx context.Context, sessionId string) error - CleanupExpiredSessions(ctx context.Context) error -} -``` - -**After:** -```go -type SessionStore interface { - // filerAddress ignored for memory stores, required for filer stores - StoreSession(ctx context.Context, filerAddress string, sessionId string, session *SessionInfo) error - GetSession(ctx context.Context, filerAddress string, sessionId string) (*SessionInfo, error) - RevokeSession(ctx context.Context, filerAddress string, sessionId string) error - CleanupExpiredSessions(ctx context.Context, filerAddress string) error -} -``` - -### 2. FilerSessionStore Changes - -**Before:** -```go -type FilerSessionStore struct { - filerGrpcAddress string // ❌ Fixed at init time - grpcDialOption grpc.DialOption - basePath string -} - -func NewFilerSessionStore(filerAddress string, config map[string]interface{}) (*FilerSessionStore, error) { - store := &FilerSessionStore{ - filerGrpcAddress: filerAddress, // ❌ Locked in during init - basePath: DefaultSessionBasePath, - } - // ... -} -``` - -**After:** -```go -type FilerSessionStore struct { - grpcDialOption grpc.DialOption // ✅ No fixed filer address - basePath string -} - -func NewFilerSessionStore(config map[string]interface{}) (*FilerSessionStore, error) { - store := &FilerSessionStore{ - basePath: DefaultSessionBasePath, // ✅ Only path configuration - } - // ✅ filerAddress passed at call time -} - -func (f *FilerSessionStore) StoreSession(ctx context.Context, filerAddress string, sessionId string, session *SessionInfo) error { - // ✅ filerAddress provided per call - return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { - // ... store logic - }) -} -``` - -### 3. STS Service Method Signatures - -**Before:** -```go -func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) -func (s *STSService) ValidateSessionToken(ctx context.Context, sessionToken string) (*SessionInfo, error) -func (s *STSService) RevokeSession(ctx context.Context, sessionToken string) error -``` - -**After:** -```go -func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, filerAddress string, request *AssumeRoleWithWebIdentityRequest) (*AssumeRoleResponse, error) -func (s *STSService) ValidateSessionToken(ctx context.Context, filerAddress string, sessionToken string) (*SessionInfo, error) -func (s *STSService) RevokeSession(ctx context.Context, filerAddress string, sessionToken string) error -``` - -### 4. Configuration Cleanup - -**Before (iam_config_distributed.json):** -```json -{ - "sts": { - "sessionStoreConfig": { - "filerAddress": "localhost:8888", // ❌ Environment-specific - "basePath": "/etc/iam/sessions" - } - }, - "policy": { - "storeConfig": { - "filerAddress": "localhost:8888", // ❌ Environment-specific - "basePath": "/etc/iam/policies" - } - } -} -``` - -**After (iam_config_distributed.json):** -```json -{ - "sts": { - "sessionStoreConfig": { - "basePath": "/etc/iam/sessions" // ✅ Environment-agnostic - } - }, - "policy": { - "storeConfig": { - "basePath": "/etc/iam/policies" // ✅ Environment-agnostic - } - } -} -``` - -## Usage Examples - -### Caller Perspective (S3 API Server) - -**Before:** -```go -// STS service locked to specific filer during init -stsService.Initialize(&STSConfig{ - SessionStoreConfig: map[string]interface{}{ - "filerAddress": "filer-1:8888", // ❌ Fixed choice - "basePath": "/etc/iam/sessions", - }, -}) - -// All calls go to filer-1, no failover possible -response, err := stsService.AssumeRoleWithWebIdentity(ctx, request) -``` - -**After:** -```go -// STS service configured without specific filer -stsService.Initialize(&STSConfig{ - SessionStoreConfig: map[string]interface{}{ - "basePath": "/etc/iam/sessions", // ✅ Just the path - }, -}) - -// Caller determines filer address per request -currentFiler := s.getCurrentFilerAddress() // ✅ Dynamic selection -response, err := stsService.AssumeRoleWithWebIdentity(ctx, currentFiler, request) -``` - -### Dynamic Filer Selection - -```go -type S3ApiServer struct { - stsService *sts.STSService - filerClient *filer.Client -} - -func (s *S3ApiServer) getCurrentFilerAddress() string { - // ✅ Can implement any strategy: - // - Load balancing across multiple filers - // - Health checking and failover - // - Geographic routing - // - Round-robin selection - return s.filerClient.GetAvailableFiler() -} - -func (s *S3ApiServer) handleAssumeRole(ctx context.Context, request *AssumeRoleRequest) { - // ✅ Filer address determined at request time - filerAddr := s.getCurrentFilerAddress() - - response, err := s.stsService.AssumeRoleWithWebIdentity(ctx, filerAddr, request) - if err != nil && isNetworkError(err) { - // ✅ Retry with different filer - filerAddr = s.getBackupFilerAddress() - response, err = s.stsService.AssumeRoleWithWebIdentity(ctx, filerAddr, request) - } -} -``` - -## Memory Store Compatibility - -The `MemorySessionStore` accepts the `filerAddress` parameter but ignores it, maintaining interface consistency: - -```go -func (m *MemorySessionStore) StoreSession(ctx context.Context, filerAddress string, sessionId string, session *SessionInfo) error { - // filerAddress ignored for memory store - maintains interface compatibility - if sessionId == "" { - return fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - // ... in-memory storage logic -} -``` - -## Benefits Achieved - -### 1. **Dynamic Filer Selection** -```go -// Load balancing -filerAddr := loadBalancer.GetNextFiler() - -// Failover support -filerAddr := failoverManager.GetHealthyFiler() - -// Geographic routing -filerAddr := geoRouter.GetClosestFiler(clientIP) -``` - -### 2. **Environment Portability** -```bash -# Same config works everywhere -dev: STSService.method(ctx, "dev-filer:8888", ...) -staging: STSService.method(ctx, "staging-filer:8888", ...) -prod: STSService.method(ctx, "prod-filer-lb:8888", ...) -``` - -### 3. **Operational Flexibility** -- **Hot filer replacement**: Switch filers without restarting STS -- **A/B testing**: Route different requests to different filers -- **Disaster recovery**: Automatic failover to backup filers -- **Performance optimization**: Route to least loaded filer - -### 4. **SeaweedFS Consistency** -Follows the same pattern used throughout SeaweedFS codebase where filer addresses are passed to methods, not stored in structs. - -## Migration Guide - -### For Code Calling STS Methods - -**Before:** -```go -response, err := stsService.AssumeRoleWithWebIdentity(ctx, request) -session, err := stsService.ValidateSessionToken(ctx, token) -err := stsService.RevokeSession(ctx, token) -``` - -**After:** -```go -filerAddr := getCurrentFilerAddress() // Implement your strategy -response, err := stsService.AssumeRoleWithWebIdentity(ctx, filerAddr, request) -session, err := stsService.ValidateSessionToken(ctx, filerAddr, token) -err := stsService.RevokeSession(ctx, filerAddr, token) -``` - -### For Configuration Files - -Remove `filerAddress` from all store configurations: - -```bash -# Update all iam_config*.json files -sed -i 's|"filerAddress": ".*",||g' iam_config*.json -``` - -## Testing - -All tests have been updated to pass a test filer address: - -```go -func TestAssumeRoleWithWebIdentity(t *testing.T) { - service := setupTestSTSService(t) - testFilerAddress := "localhost:8888" // Test filer address - - response, err := service.AssumeRoleWithWebIdentity(ctx, testFilerAddress, request) - // ... test logic -} -``` - -## Production Deployment - -### High Availability Setup - -```go -type FilerManager struct { - primaryFilers []string - backupFilers []string - healthChecker *HealthChecker -} - -func (fm *FilerManager) GetAvailableFiler() string { - // Check primary filers first - for _, filer := range fm.primaryFilers { - if fm.healthChecker.IsHealthy(filer) { - return filer - } - } - - // Fallback to backup filers - for _, filer := range fm.backupFilers { - if fm.healthChecker.IsHealthy(filer) { - return filer - } - } - - // Return first primary as last resort - return fm.primaryFilers[0] -} -``` - -### Load Balanced Configuration - -```json -{ - "sts": { - "sessionStoreType": "filer", - "sessionStoreConfig": { - "basePath": "/etc/iam/sessions" - } - } -} -``` - -```go -// Runtime filer selection -filerLoadBalancer := &RoundRobinBalancer{ - Filers: []string{ - "filer-1.prod:8888", - "filer-2.prod:8888", - "filer-3.prod:8888", - }, -} - -response, err := stsService.AssumeRoleWithWebIdentity( - ctx, - filerLoadBalancer.Next(), // ✅ Dynamic selection - request, -) -``` - -## Conclusion - -This refactoring successfully addresses the requirement for runtime filer address passing, enabling: - -- ✅ **Dynamic filer selection** per request -- ✅ **Automatic failover** capabilities -- ✅ **Environment-agnostic** configurations -- ✅ **Load balancing** support -- ✅ **SeaweedFS pattern** compliance -- ✅ **Operational flexibility** for production deployments - -The implementation maintains backward compatibility for memory stores while enabling powerful distributed deployment scenarios for filer-backed stores. diff --git a/weed/iam/sts/session_store.go b/weed/iam/sts/session_store.go deleted file mode 100644 index 0d1647818..000000000 --- a/weed/iam/sts/session_store.go +++ /dev/null @@ -1,383 +0,0 @@ -package sts - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "time" - - "github.com/seaweedfs/seaweedfs/weed/glog" - "github.com/seaweedfs/seaweedfs/weed/pb" - "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" - "google.golang.org/grpc" -) - -// MemorySessionStore implements SessionStore using in-memory storage -type MemorySessionStore struct { - sessions map[string]*SessionInfo - mutex sync.RWMutex -} - -// NewMemorySessionStore creates a new memory-based session store -func NewMemorySessionStore() *MemorySessionStore { - return &MemorySessionStore{ - sessions: make(map[string]*SessionInfo), - } -} - -// StoreSession stores session information in memory (filerAddress ignored for memory store) -func (m *MemorySessionStore) StoreSession(ctx context.Context, filerAddress string, sessionId string, session *SessionInfo) error { - if sessionId == "" { - return fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - - if session == nil { - return fmt.Errorf("session cannot be nil") - } - - m.mutex.Lock() - defer m.mutex.Unlock() - - m.sessions[sessionId] = session - return nil -} - -// GetSession retrieves session information from memory (filerAddress ignored for memory store) -func (m *MemorySessionStore) GetSession(ctx context.Context, filerAddress string, sessionId string) (*SessionInfo, error) { - if sessionId == "" { - return nil, fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - - m.mutex.RLock() - defer m.mutex.RUnlock() - - session, exists := m.sessions[sessionId] - if !exists { - return nil, fmt.Errorf("session not found") - } - - // Check if session has expired - if time.Now().After(session.ExpiresAt) { - return nil, fmt.Errorf("session has expired") - } - - return session, nil -} - -// RevokeSession revokes a session from memory (filerAddress ignored for memory store) -func (m *MemorySessionStore) RevokeSession(ctx context.Context, filerAddress string, sessionId string) error { - if sessionId == "" { - return fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - - m.mutex.Lock() - defer m.mutex.Unlock() - - delete(m.sessions, sessionId) - return nil -} - -// CleanupExpiredSessions removes expired sessions from memory (filerAddress ignored for memory store) -func (m *MemorySessionStore) CleanupExpiredSessions(ctx context.Context, filerAddress string) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - now := time.Now() - for sessionId, session := range m.sessions { - if now.After(session.ExpiresAt) { - delete(m.sessions, sessionId) - } - } - - return nil -} - -// ExpireSessionForTesting manually expires a session for testing purposes (filerAddress ignored for memory store) -func (m *MemorySessionStore) ExpireSessionForTesting(ctx context.Context, filerAddress string, sessionId string) error { - if sessionId == "" { - return fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - - m.mutex.Lock() - defer m.mutex.Unlock() - - session, exists := m.sessions[sessionId] - if !exists { - return fmt.Errorf("session not found") - } - - // Set expiration to 1 minute in the past to ensure it's expired - session.ExpiresAt = time.Now().Add(-1 * time.Minute) - m.sessions[sessionId] = session - - return nil -} - -// FilerSessionStore implements SessionStore using SeaweedFS filer -type FilerSessionStore struct { - grpcDialOption grpc.DialOption - basePath string -} - -// NewFilerSessionStore creates a new filer-based session store -func NewFilerSessionStore(config map[string]interface{}) (*FilerSessionStore, error) { - store := &FilerSessionStore{ - basePath: DefaultSessionBasePath, // Use constant default - } - - // Parse configuration - only basePath and other settings, NOT filerAddress - if config != nil { - if basePath, ok := config[ConfigFieldBasePath].(string); ok && basePath != "" { - store.basePath = strings.TrimSuffix(basePath, "/") - } - } - - glog.V(2).Infof("Initialized FilerSessionStore with basePath %s", store.basePath) - - return store, nil -} - -// StoreSession stores session information in filer -func (f *FilerSessionStore) StoreSession(ctx context.Context, filerAddress string, sessionId string, session *SessionInfo) error { - if filerAddress == "" { - return fmt.Errorf(ErrFilerAddressRequired) - } - if sessionId == "" { - return fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - if session == nil { - return fmt.Errorf("session cannot be nil") - } - - // Serialize session to JSON - sessionData, err := json.Marshal(session) - if err != nil { - return fmt.Errorf("failed to serialize session: %v", err) - } - - sessionPath := f.getSessionPath(sessionId) - - // Store in filer - return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { - request := &filer_pb.CreateEntryRequest{ - Directory: f.basePath, - Entry: &filer_pb.Entry{ - Name: f.getSessionFileName(sessionId), - IsDirectory: false, - Attributes: &filer_pb.FuseAttributes{ - Mtime: time.Now().Unix(), - Crtime: time.Now().Unix(), - FileMode: uint32(0600), // Read/write for owner only - Uid: uint32(0), - Gid: uint32(0), - }, - Content: sessionData, - }, - } - - glog.V(3).Infof("Storing session %s at %s", sessionId, sessionPath) - _, err := client.CreateEntry(ctx, request) - if err != nil { - return fmt.Errorf("failed to store session %s: %v", sessionId, err) - } - - return nil - }) -} - -// GetSession retrieves session information from filer -func (f *FilerSessionStore) GetSession(ctx context.Context, filerAddress string, sessionId string) (*SessionInfo, error) { - if filerAddress == "" { - return nil, fmt.Errorf(ErrFilerAddressRequired) - } - if sessionId == "" { - return nil, fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - - var sessionData []byte - err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { - request := &filer_pb.LookupDirectoryEntryRequest{ - Directory: f.basePath, - Name: f.getSessionFileName(sessionId), - } - - glog.V(3).Infof("Looking up session %s", sessionId) - response, err := client.LookupDirectoryEntry(ctx, request) - if err != nil { - return fmt.Errorf("session not found: %v", err) - } - - if response.Entry == nil { - return fmt.Errorf("session not found") - } - - sessionData = response.Entry.Content - return nil - }) - - if err != nil { - return nil, err - } - - // Deserialize session from JSON - var session SessionInfo - if err := json.Unmarshal(sessionData, &session); err != nil { - return nil, fmt.Errorf("failed to deserialize session: %v", err) - } - - // Check if session has expired - if time.Now().After(session.ExpiresAt) { - // Clean up expired session - _ = f.RevokeSession(ctx, filerAddress, sessionId) - return nil, fmt.Errorf("session has expired") - } - - return &session, nil -} - -// RevokeSession revokes a session from filer -func (f *FilerSessionStore) RevokeSession(ctx context.Context, filerAddress string, sessionId string) error { - if filerAddress == "" { - return fmt.Errorf(ErrFilerAddressRequired) - } - if sessionId == "" { - return fmt.Errorf(ErrSessionIDCannotBeEmpty) - } - - return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { - request := &filer_pb.DeleteEntryRequest{ - Directory: f.basePath, - Name: f.getSessionFileName(sessionId), - IsDeleteData: true, - IsRecursive: false, - IgnoreRecursiveError: false, - } - - glog.V(3).Infof("Revoking session %s", sessionId) - resp, err := client.DeleteEntry(ctx, request) - if err != nil { - // Ignore "not found" errors - session may already be deleted - if strings.Contains(err.Error(), "not found") { - return nil - } - return fmt.Errorf("failed to revoke session %s: %v", sessionId, err) - } - - // Check response error - if resp.Error != "" { - // Ignore "not found" errors - session may already be deleted - if strings.Contains(resp.Error, "not found") { - return nil - } - return fmt.Errorf("failed to revoke session %s: %s", sessionId, resp.Error) - } - - return nil - }) -} - -// CleanupExpiredSessions removes expired sessions from filer -func (f *FilerSessionStore) CleanupExpiredSessions(ctx context.Context, filerAddress string) error { - if filerAddress == "" { - return fmt.Errorf(ErrFilerAddressRequired) - } - - now := time.Now() - expiredCount := 0 - - err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error { - // List all entries in the session directory - request := &filer_pb.ListEntriesRequest{ - Directory: f.basePath, - Prefix: "session_", - StartFromFileName: "", - InclusiveStartFrom: false, - Limit: 1000, // Process in batches of 1000 - } - - stream, err := client.ListEntries(ctx, request) - if err != nil { - return fmt.Errorf("failed to list sessions: %v", err) - } - - for { - resp, err := stream.Recv() - if err != nil { - break // End of stream or error - } - - if resp.Entry == nil || resp.Entry.IsDirectory { - continue - } - - // Parse session data to check expiration - var session SessionInfo - if err := json.Unmarshal(resp.Entry.Content, &session); err != nil { - glog.V(2).Infof("Failed to parse session file %s, deleting: %v", resp.Entry.Name, err) - // Delete corrupted session file - f.deleteSessionFile(ctx, client, resp.Entry.Name) - continue - } - - // Check if session is expired - if now.After(session.ExpiresAt) { - glog.V(3).Infof("Cleaning up expired session: %s", resp.Entry.Name) - if err := f.deleteSessionFile(ctx, client, resp.Entry.Name); err != nil { - glog.V(1).Infof("Failed to delete expired session %s: %v", resp.Entry.Name, err) - } else { - expiredCount++ - } - } - } - - return nil - }) - - if err != nil { - return err - } - - if expiredCount > 0 { - glog.V(2).Infof("Cleaned up %d expired sessions", expiredCount) - } - - return nil -} - -// Helper methods - -// withFilerClient executes a function with a filer client -func (f *FilerSessionStore) withFilerClient(filerAddress string, fn func(client filer_pb.SeaweedFilerClient) error) error { - if filerAddress == "" { - return fmt.Errorf(ErrFilerAddressRequired) - } - - // Use the pb.WithGrpcFilerClient helper similar to existing SeaweedFS code - return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn) -} - -// getSessionPath returns the full path for a session -func (f *FilerSessionStore) getSessionPath(sessionId string) string { - return f.basePath + "/" + f.getSessionFileName(sessionId) -} - -// getSessionFileName returns the filename for a session -func (f *FilerSessionStore) getSessionFileName(sessionId string) string { - return "session_" + sessionId + ".json" -} - -// deleteSessionFile deletes a session file -func (f *FilerSessionStore) deleteSessionFile(ctx context.Context, client filer_pb.SeaweedFilerClient, fileName string) error { - request := &filer_pb.DeleteEntryRequest{ - Directory: f.basePath, - Name: fileName, - IsDeleteData: true, - IsRecursive: false, - IgnoreRecursiveError: false, - } - - _, err := client.DeleteEntry(ctx, request) - return err -} diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index 05999aff0..2fb88eb45 100644 --- a/weed/iam/sts/sts_service.go +++ b/weed/iam/sts/sts_service.go @@ -179,21 +179,6 @@ type SessionInfo struct { Credentials *Credentials `json:"credentials"` } -// SessionStore defines the interface for storing session information -type SessionStore interface { - // StoreSession stores session information (filerAddress ignored for memory stores) - StoreSession(ctx context.Context, filerAddress string, sessionId string, session *SessionInfo) error - - // GetSession retrieves session information (filerAddress ignored for memory stores) - GetSession(ctx context.Context, filerAddress string, sessionId string) (*SessionInfo, error) - - // RevokeSession revokes a session (filerAddress ignored for memory stores) - RevokeSession(ctx context.Context, filerAddress string, sessionId string) error - - // CleanupExpiredSessions removes expired sessions (filerAddress ignored for memory stores) - CleanupExpiredSessions(ctx context.Context, filerAddress string) error -} - // NewSTSService creates a new STS service func NewSTSService() *STSService { return &STSService{ diff --git a/weed/s3api/s3_end_to_end_test.go b/weed/s3api/s3_end_to_end_test.go index d4607333f..217612ac4 100644 --- a/weed/s3api/s3_end_to_end_test.go +++ b/weed/s3api/s3_end_to_end_test.go @@ -400,13 +400,9 @@ func setupTestProviders(t *testing.T, manager *integration.IAMManager) { require.NoError(t, err) oidcProvider.SetupDefaultTestData() - // Set up LDAP provider + // Set up LDAP mock provider (no config needed for mock) ldapProvider := ldap.NewMockLDAPProvider("test-ldap") - ldapConfig := &ldap.LDAPConfig{ - Server: "ldap://test-server:389", - BaseDN: "DC=test,DC=com", - } - err = ldapProvider.Initialize(ldapConfig) + err = ldapProvider.Initialize(nil) // Mock doesn't need real config require.NoError(t, err) ldapProvider.SetupDefaultTestData() diff --git a/weed/s3api/s3_jwt_auth_test.go b/weed/s3api/s3_jwt_auth_test.go index 50518e7b9..3c169414c 100644 --- a/weed/s3api/s3_jwt_auth_test.go +++ b/weed/s3api/s3_jwt_auth_test.go @@ -304,11 +304,7 @@ func setupTestIdentityProviders(t *testing.T, manager *integration.IAMManager) { // Set up LDAP provider ldapProvider := ldap.NewMockLDAPProvider("test-ldap") - ldapConfig := &ldap.LDAPConfig{ - Server: "ldap://test-server:389", - BaseDN: "DC=test,DC=com", - } - err = ldapProvider.Initialize(ldapConfig) + err = ldapProvider.Initialize(nil) // Mock doesn't need real config require.NoError(t, err) ldapProvider.SetupDefaultTestData() diff --git a/weed/s3api/s3_multipart_iam_test.go b/weed/s3api/s3_multipart_iam_test.go index 830edd579..c8a331ebd 100644 --- a/weed/s3api/s3_multipart_iam_test.go +++ b/weed/s3api/s3_multipart_iam_test.go @@ -495,11 +495,7 @@ func setupTestProvidersForMultipart(t *testing.T, manager *integration.IAMManage // Set up LDAP provider ldapProvider := ldap.NewMockLDAPProvider("test-ldap") - ldapConfig := &ldap.LDAPConfig{ - Server: "ldap://test-server:389", - BaseDN: "DC=test,DC=com", - } - err = ldapProvider.Initialize(ldapConfig) + err = ldapProvider.Initialize(nil) // Mock doesn't need real config require.NoError(t, err) ldapProvider.SetupDefaultTestData() diff --git a/weed/s3api/s3_presigned_url_iam_test.go b/weed/s3api/s3_presigned_url_iam_test.go index 5820740de..5a3e88041 100644 --- a/weed/s3api/s3_presigned_url_iam_test.go +++ b/weed/s3api/s3_presigned_url_iam_test.go @@ -456,11 +456,7 @@ func setupTestProvidersForPresigned(t *testing.T, manager *integration.IAMManage // Set up LDAP provider ldapProvider := ldap.NewMockLDAPProvider("test-ldap") - ldapConfig := &ldap.LDAPConfig{ - Server: "ldap://test-server:389", - BaseDN: "DC=test,DC=com", - } - err = ldapProvider.Initialize(ldapConfig) + err = ldapProvider.Initialize(nil) // Mock doesn't need real config require.NoError(t, err) ldapProvider.SetupDefaultTestData()