Open source implementation of Mattermost Enterprise features: Authentication & SSO: - LDAP authentication and sync - LDAP diagnostics - SAML 2.0 SSO - OAuth providers (Google, Office365, OpenID Connect) Infrastructure: - Redis-based cluster implementation - Prometheus metrics - IP filtering - Push proxy authentication Search: - Bleve search engine (lightweight Elasticsearch alternative) Compliance & Security: - Compliance reporting - Data retention policies - Message export (Actiance, GlobalRelay, CSV) - Access control (PAP/PDP) User Management: - Account migration (LDAP/SAML) - ID-loaded push notifications - Outgoing OAuth connections 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
277 lines
8.3 KiB
Go
277 lines
8.3 KiB
Go
// 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
|
|
}
|