// Copyright (c) 2024 Mattermost Community Enterprise // Account Migration Implementation package account_migration import ( "net/http" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" ) // AccountMigrationConfig holds configuration for the account migration interface type AccountMigrationConfig struct { Store store.Store Config func() *model.Config Logger mlog.LoggerIFace } // AccountMigrationImpl implements the AccountMigrationInterface type AccountMigrationImpl struct { store store.Store config func() *model.Config logger mlog.LoggerIFace } // NewAccountMigrationInterface creates a new account migration interface func NewAccountMigrationInterface(cfg *AccountMigrationConfig) *AccountMigrationImpl { return &AccountMigrationImpl{ store: cfg.Store, config: cfg.Config, logger: cfg.Logger, } } // MigrateToLdap migrates user accounts from one authentication service to LDAP func (am *AccountMigrationImpl) MigrateToLdap(rctx request.CTX, fromAuthService string, foreignUserFieldNameToMatch string, force bool, dryRun bool) *model.AppError { cfg := am.config() // Check if LDAP is enabled if cfg.LdapSettings.Enable == nil || !*cfg.LdapSettings.Enable { return model.NewAppError("MigrateToLdap", "account_migration.ldap_not_enabled", nil, "LDAP is not enabled", http.StatusNotImplemented) } // Validate fromAuthService if fromAuthService != model.UserAuthServiceEmail && fromAuthService != model.UserAuthServiceSaml && fromAuthService != model.ServiceGoogle && fromAuthService != model.ServiceOffice365 && fromAuthService != model.ServiceOpenid { return model.NewAppError("MigrateToLdap", "account_migration.invalid_auth_service", map[string]any{"AuthService": fromAuthService}, "Invalid source authentication service", http.StatusBadRequest) } // Validate field name to match validFields := []string{"email", "username", "id"} validField := false for _, f := range validFields { if foreignUserFieldNameToMatch == f { validField = true break } } if !validField { return model.NewAppError("MigrateToLdap", "account_migration.invalid_field", map[string]any{"Field": foreignUserFieldNameToMatch}, "Invalid field name to match", http.StatusBadRequest) } am.logger.Info("Starting migration to LDAP", mlog.String("from_auth_service", fromAuthService), mlog.String("match_field", foreignUserFieldNameToMatch), mlog.Bool("force", force), mlog.Bool("dry_run", dryRun), ) // Get users with the source auth service if am.store == nil { if dryRun { am.logger.Info("Dry run: Would migrate users from auth service", mlog.String("from", fromAuthService), mlog.String("to", model.UserAuthServiceLdap), ) return nil } return model.NewAppError("MigrateToLdap", "account_migration.store_not_available", nil, "Store is not available", http.StatusInternalServerError) } // In a real implementation, we would: // 1. Query users with the source auth service // 2. For each user, find the matching LDAP entry // 3. Update the user's auth service and auth data // 4. If force is true, migrate even if there are conflicts // 5. If dryRun is true, log what would be done but don't make changes // Get users to migrate users, err := am.store.User().GetAllUsingAuthService(fromAuthService) if err != nil { return model.NewAppError("MigrateToLdap", "account_migration.get_users_failed", nil, err.Error(), http.StatusInternalServerError) } migratedCount := 0 errorCount := 0 for _, user := range users { // Get the value to match against LDAP var matchValue string switch foreignUserFieldNameToMatch { case "email": matchValue = user.Email case "username": matchValue = user.Username case "id": matchValue = user.Id } if matchValue == "" { am.logger.Warn("User has no value for match field", mlog.String("user_id", user.Id), mlog.String("field", foreignUserFieldNameToMatch), ) errorCount++ continue } if dryRun { am.logger.Info("Dry run: Would migrate user to LDAP", mlog.String("user_id", user.Id), mlog.String("email", user.Email), mlog.String("match_value", matchValue), ) migratedCount++ continue } // Update user's auth service to LDAP user.AuthService = model.UserAuthServiceLdap user.AuthData = model.NewPointer(matchValue) if _, updateErr := am.store.User().Update(rctx, user, true); updateErr != nil { am.logger.Error("Failed to migrate user to LDAP", mlog.String("user_id", user.Id), mlog.Err(updateErr), ) errorCount++ if !force { continue } } else { migratedCount++ } } am.logger.Info("LDAP migration completed", mlog.Int("migrated", migratedCount), mlog.Int("errors", errorCount), mlog.Int("total", len(users)), mlog.Bool("dry_run", dryRun), ) return nil } // MigrateToSaml migrates user accounts from one authentication service to SAML func (am *AccountMigrationImpl) MigrateToSaml(rctx request.CTX, fromAuthService string, usersMap map[string]string, auto bool, dryRun bool) *model.AppError { cfg := am.config() // Check if SAML is enabled if cfg.SamlSettings.Enable == nil || !*cfg.SamlSettings.Enable { return model.NewAppError("MigrateToSaml", "account_migration.saml_not_enabled", nil, "SAML is not enabled", http.StatusNotImplemented) } // Validate fromAuthService if fromAuthService != model.UserAuthServiceEmail && fromAuthService != model.UserAuthServiceLdap && fromAuthService != model.ServiceGoogle && fromAuthService != model.ServiceOffice365 && fromAuthService != model.ServiceOpenid { return model.NewAppError("MigrateToSaml", "account_migration.invalid_auth_service", map[string]any{"AuthService": fromAuthService}, "Invalid source authentication service", http.StatusBadRequest) } am.logger.Info("Starting migration to SAML", mlog.String("from_auth_service", fromAuthService), mlog.Bool("auto", auto), mlog.Bool("dry_run", dryRun), mlog.Int("users_map_count", len(usersMap)), ) if am.store == nil { if dryRun { am.logger.Info("Dry run: Would migrate users from auth service", mlog.String("from", fromAuthService), mlog.String("to", model.UserAuthServiceSaml), ) return nil } return model.NewAppError("MigrateToSaml", "account_migration.store_not_available", nil, "Store is not available", http.StatusInternalServerError) } var usersToMigrate []*model.User var err error if auto { // Auto migration: get all users with the source auth service usersToMigrate, err = am.store.User().GetAllUsingAuthService(fromAuthService) if err != nil { return model.NewAppError("MigrateToSaml", "account_migration.get_users_failed", nil, err.Error(), http.StatusInternalServerError) } } else { // Manual migration: use the provided user mapping for userID := range usersMap { user, err := am.store.User().Get(rctx.Context(), userID) if err != nil { am.logger.Warn("User not found", mlog.String("user_id", userID), ) continue } usersToMigrate = append(usersToMigrate, user) } } migratedCount := 0 errorCount := 0 for _, user := range usersToMigrate { var samlId string if auto { // Auto: use email as SAML ID samlId = user.Email } else { // Manual: use the provided mapping var ok bool samlId, ok = usersMap[user.Id] if !ok { am.logger.Warn("No SAML ID mapping for user", mlog.String("user_id", user.Id), ) errorCount++ continue } } if dryRun { am.logger.Info("Dry run: Would migrate user to SAML", mlog.String("user_id", user.Id), mlog.String("email", user.Email), mlog.String("saml_id", samlId), ) migratedCount++ continue } // Update user's auth service to SAML user.AuthService = model.UserAuthServiceSaml user.AuthData = model.NewPointer(samlId) if _, updateErr := am.store.User().Update(rctx, user, true); updateErr != nil { am.logger.Error("Failed to migrate user to SAML", mlog.String("user_id", user.Id), mlog.Err(updateErr), ) errorCount++ } else { migratedCount++ } } am.logger.Info("SAML migration completed", mlog.Int("migrated", migratedCount), mlog.Int("errors", errorCount), mlog.Int("total", len(usersToMigrate)), mlog.Bool("dry_run", dryRun), ) return nil }