package service import ( "context" "encoding/json" "fmt" "time" "go.uber.org/zap" "gorm.io/gorm" "github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/repository" dbpkg "github.com/veops/oneterm/pkg/db" "github.com/veops/oneterm/pkg/logger" ) // AuthorizationMigrationService handles V1 to V2 authorization migration type AuthorizationMigrationService struct { db *gorm.DB v1Repo repository.IAuthorizationRepository v2Repo repository.IAuthorizationV2Repository } // NewAuthorizationMigrationService creates a new migration service func NewAuthorizationMigrationService( db *gorm.DB, v1Repo repository.IAuthorizationRepository, v2Repo repository.IAuthorizationV2Repository, ) *AuthorizationMigrationService { return &AuthorizationMigrationService{ db: db, v1Repo: v1Repo, v2Repo: v2Repo, } } // MigrateV1ToV2 performs the complete migration from V1 to V2 func (s *AuthorizationMigrationService) MigrateV1ToV2(ctx context.Context) error { logger.L().Info("Starting V1 to V2 authorization migration") // Check if migration is already completed if completed, err := s.IsMigrationCompleted(ctx); err != nil { return fmt.Errorf("failed to check migration status: %w", err) } else if completed { logger.L().Info("Migration already completed, skipping") return nil } // Get all V1 authorization rules v1Rules, err := s.getAllV1Rules(ctx) if err != nil { return fmt.Errorf("failed to get V1 rules: %w", err) } if len(v1Rules) == 0 { logger.L().Info("No V1 authorization rules found, marking migration as completed") return s.markMigrationCompleted(ctx, 0) } logger.L().Info("Found V1 authorization rules", zap.Int("count", len(v1Rules))) // Mark migration as running if err := s.markMigrationRunning(ctx); err != nil { return fmt.Errorf("failed to mark migration as running: %w", err) } // Start transaction tx := s.db.Begin() defer func() { if r := recover(); r != nil { tx.Rollback() s.markMigrationFailed(ctx, fmt.Sprintf("panic during migration: %v", r)) } }() // Migrate each V1 rule to V2 migratedCount := 0 for _, v1Rule := range v1Rules { v2Rule := s.convertV1ToV2(v1Rule) if err := s.v2Repo.Create(ctx, v2Rule); err != nil { tx.Rollback() s.markMigrationFailed(ctx, fmt.Sprintf("failed to create V2 rule for V1 rule %d: %v", v1Rule.Id, err)) return fmt.Errorf("failed to create V2 rule for V1 rule %d: %w", v1Rule.Id, err) } migratedCount++ logger.L().Debug("Migrated V1 rule", zap.Int("v1_id", v1Rule.Id), zap.Int("v2_id", v2Rule.Id)) } // Also migrate asset authorization fields from V1 to V2 format if err := s.migrateAssetAuthorizationFields(ctx, tx); err != nil { tx.Rollback() s.markMigrationFailed(ctx, fmt.Sprintf("failed to migrate asset authorization fields: %v", err)) return fmt.Errorf("failed to migrate asset authorization fields: %w", err) } // Mark migration as completed if err := s.markMigrationCompleted(ctx, migratedCount); err != nil { tx.Rollback() return fmt.Errorf("failed to mark migration as completed: %w", err) } // Commit transaction if err := tx.Commit().Error; err != nil { return fmt.Errorf("failed to commit migration transaction: %w", err) } logger.L().Info("V1 to V2 migration completed successfully", zap.Int("migrated_count", migratedCount)) return nil } // IsMigrationCompleted checks if the migration has been completed func (s *AuthorizationMigrationService) IsMigrationCompleted(ctx context.Context) (bool, error) { var record model.MigrationRecord err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error if err != nil { if err == gorm.ErrRecordNotFound { return false, nil } return false, err } return record.Status == model.MigrationStatusCompleted, nil } // markMigrationCompleted marks the migration as completed func (s *AuthorizationMigrationService) markMigrationCompleted(ctx context.Context, recordsCount int) error { now := time.Now() // Try to update existing record, or create new one var record model.MigrationRecord err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error if err != nil && err != gorm.ErrRecordNotFound { return err } if err == gorm.ErrRecordNotFound { // Create new record record = model.MigrationRecord{ MigrationName: model.MigrationAuthV1ToV2, Status: model.MigrationStatusCompleted, StartedAt: &now, CompletedAt: &now, RecordsCount: recordsCount, } return s.db.Create(&record).Error } // Update existing record return s.db.Model(&record).Updates(map[string]interface{}{ "status": model.MigrationStatusCompleted, "completed_at": &now, "error_message": "", "records_count": recordsCount, }).Error } // getAllV1Rules retrieves all V1 authorization rules func (s *AuthorizationMigrationService) getAllV1Rules(ctx context.Context) ([]*model.Authorization, error) { var rules []*model.Authorization err := s.db.Find(&rules).Error return rules, err } // convertV1ToV2 converts a V1 authorization rule to V2 format func (s *AuthorizationMigrationService) convertV1ToV2(v1 *model.Authorization) *model.AuthorizationV2 { v2 := &model.AuthorizationV2{ Name: s.generateRuleName(v1), Description: s.generateRuleDescription(v1), Enabled: true, Rids: v1.Rids, // Copy standard fields ResourceId: v1.ResourceId, CreatorId: v1.CreatorId, UpdaterId: v1.UpdaterId, CreatedAt: v1.CreatedAt, UpdatedAt: v1.UpdatedAt, } // Convert specific IDs to selectors if v1.NodeId > 0 { v2.NodeSelector = model.TargetSelector{ Type: model.SelectorTypeIds, Values: []string{fmt.Sprintf("%d", v1.NodeId)}, } } else { v2.NodeSelector = model.TargetSelector{ Type: model.SelectorTypeAll, } } if v1.AssetId > 0 { v2.AssetSelector = model.TargetSelector{ Type: model.SelectorTypeIds, Values: []string{fmt.Sprintf("%d", v1.AssetId)}, } } else { v2.AssetSelector = model.TargetSelector{ Type: model.SelectorTypeAll, } } if v1.AccountId > 0 { v2.AccountSelector = model.TargetSelector{ Type: model.SelectorTypeIds, Values: []string{fmt.Sprintf("%d", v1.AccountId)}, } } else { v2.AccountSelector = model.TargetSelector{ Type: model.SelectorTypeAll, } } // Set default permissions - V1 only had connect permission v2.Permissions = model.AuthPermissions{ Connect: true, FileUpload: s.getDefaultFileUploadPermission(), FileDownload: s.getDefaultFileDownloadPermission(), Copy: s.getDefaultCopyPermission(), Paste: s.getDefaultPastePermission(), Share: false, // Default to false for security } // Set default access control v2.AccessControl = model.AccessControl{ IPWhitelist: []string{}, // No IP restrictions by default MaxSessions: 0, // No session limit by default SessionTimeout: 0, // Use system default } return v2 } // generateRuleName generates a name for the migrated rule func (s *AuthorizationMigrationService) generateRuleName(v1 *model.Authorization) string { parts := []string{"Migrated"} if v1.NodeId > 0 { parts = append(parts, fmt.Sprintf("Node-%d", v1.NodeId)) } if v1.AssetId > 0 { parts = append(parts, fmt.Sprintf("Asset-%d", v1.AssetId)) } if v1.AccountId > 0 { parts = append(parts, fmt.Sprintf("Account-%d", v1.AccountId)) } return fmt.Sprintf("%s-Rule-V1-%d", joinParts(parts), v1.Id) } // generateRuleDescription generates a description for the migrated rule func (s *AuthorizationMigrationService) generateRuleDescription(v1 *model.Authorization) string { return fmt.Sprintf("Automatically migrated from V1 authorization rule (ID: %d). "+ "Node: %d, Asset: %d, Account: %d, Roles: %v", v1.Id, v1.NodeId, v1.AssetId, v1.AccountId, v1.Rids) } // Helper functions to get default permissions from system config func (s *AuthorizationMigrationService) getDefaultFileUploadPermission() bool { // In a real implementation, you might check system configuration // For now, return true as a safe default return true } func (s *AuthorizationMigrationService) getDefaultFileDownloadPermission() bool { return true } func (s *AuthorizationMigrationService) getDefaultCopyPermission() bool { return true } func (s *AuthorizationMigrationService) getDefaultPastePermission() bool { return true } // joinParts joins string parts with "-" func joinParts(parts []string) string { result := "" for i, part := range parts { if i > 0 { result += "-" } result += part } return result } // markMigrationRunning marks the migration as running func (s *AuthorizationMigrationService) markMigrationRunning(ctx context.Context) error { now := time.Now() // Try to update existing record, or create new one var record model.MigrationRecord err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error if err != nil && err != gorm.ErrRecordNotFound { return err } if err == gorm.ErrRecordNotFound { // Create new record record = model.MigrationRecord{ MigrationName: model.MigrationAuthV1ToV2, Status: model.MigrationStatusRunning, StartedAt: &now, } return s.db.Create(&record).Error } // Update existing record return s.db.Model(&record).Updates(map[string]interface{}{ "status": model.MigrationStatusRunning, "started_at": &now, "completed_at": nil, "error_message": "", }).Error } // markMigrationFailed marks the migration as failed with error message func (s *AuthorizationMigrationService) markMigrationFailed(ctx context.Context, errorMsg string) error { var record model.MigrationRecord err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error if err != nil { if err == gorm.ErrRecordNotFound { // Create new record with failed status record = model.MigrationRecord{ MigrationName: model.MigrationAuthV1ToV2, Status: model.MigrationStatusFailed, ErrorMessage: errorMsg, } return s.db.Create(&record).Error } return err } // Update existing record return s.db.Model(&record).Updates(map[string]interface{}{ "status": model.MigrationStatusFailed, "error_message": errorMsg, }).Error } // migrateAssetAuthorizationFields migrates asset authorization fields from V1 to V2 format func (s *AuthorizationMigrationService) migrateAssetAuthorizationFields(ctx context.Context, tx *gorm.DB) error { logger.L().Info("Starting asset authorization field migration") // Query assets with potential V1 authorization data var assets []*model.Asset if err := tx.Select("id", "authorization").Find(&assets).Error; err != nil { logger.L().Error("Failed to get assets for authorization field migration", zap.Error(err)) return err } migrationCount := 0 for _, asset := range assets { if asset.Id == 0 { continue } // Check if authorization field needs migration needsMigration, err := s.checkAssetAuthorizationNeedsMigration(tx, asset.Id) if err != nil { logger.L().Error("Failed to check asset authorization migration need", zap.Int("assetId", asset.Id), zap.Error(err)) continue } if !needsMigration { continue } // Migrate this asset if err := s.migrateAssetAuthorizationField(ctx, tx, asset.Id); err != nil { logger.L().Error("Failed to migrate asset authorization field", zap.Int("assetId", asset.Id), zap.Error(err)) continue } migrationCount++ } logger.L().Info("Asset authorization field migration completed", zap.Int("migrated", migrationCount)) return nil } // MigrateV1AuthorizationData migrates V1 authorization data format to V2 func MigrateV1AuthorizationData(ctx context.Context) error { logger.L().Info("Starting V1 to V2 authorization data migration") // Query assets with potential V1 authorization data var assets []*model.Asset if err := dbpkg.DB.Select("id", "authorization").Find(&assets).Error; err != nil { logger.L().Error("Failed to get assets for migration", zap.Error(err)) return err } migrationCount := 0 for _, asset := range assets { if asset.Id == 0 { continue } // Check if authorization field needs migration needsMigration, err := checkNeedsMigration(asset.Id) if err != nil { logger.L().Error("Failed to check migration need", zap.Int("assetId", asset.Id), zap.Error(err)) continue } if !needsMigration { continue } // Migrate this asset if err := migrateAssetAuthorization(ctx, asset.Id); err != nil { logger.L().Error("Failed to migrate asset authorization", zap.Int("assetId", asset.Id), zap.Error(err)) continue } migrationCount++ } logger.L().Info("V1 to V2 authorization migration completed", zap.Int("migrated", migrationCount)) return nil } // checkNeedsMigration checks if an asset's authorization data needs migration func checkNeedsMigration(assetId int) (bool, error) { var rawAuth json.RawMessage if err := dbpkg.DB.Model(&model.Asset{}). Where("id = ?", assetId). Select("authorization"). Scan(&rawAuth).Error; err != nil { if err == gorm.ErrRecordNotFound { return false, nil } return false, err } if len(rawAuth) == 0 { return false, nil } // Try to parse as V2 format first var v2Auth map[int]model.AccountAuthorization if err := json.Unmarshal(rawAuth, &v2Auth); err == nil { // Successfully parsed as V2, no migration needed return false, nil } // Try to parse as V1 format var v1Auth map[int][]int if err := json.Unmarshal(rawAuth, &v1Auth); err == nil { // Successfully parsed as V1, needs migration return true, nil } // Cannot parse as either format, skip return false, nil } // migrateAssetAuthorization migrates a single asset's authorization data func migrateAssetAuthorization(ctx context.Context, assetId int) error { // Get raw authorization data var rawAuth json.RawMessage if err := dbpkg.DB.Model(&model.Asset{}). Where("id = ?", assetId). Select("authorization"). Scan(&rawAuth).Error; err != nil { return err } if len(rawAuth) == 0 { return nil } // Parse as V1 format var v1Auth map[int][]int if err := json.Unmarshal(rawAuth, &v1Auth); err != nil { return fmt.Errorf("failed to parse V1 authorization data: %w", err) } // Get default permissions from config defaultPermissions := getDefaultAuthPermissions() // Convert V1 to V2 format v2Auth := make(map[int]model.AccountAuthorization) for accountId, roleIds := range v1Auth { v2Auth[accountId] = model.AccountAuthorization{ Rids: roleIds, Permissions: &defaultPermissions, } } // Update the database if err := dbpkg.DB.Model(&model.Asset{}). Where("id = ?", assetId). Update("authorization", v2Auth).Error; err != nil { return fmt.Errorf("failed to update authorization data: %w", err) } logger.L().Debug("Migrated asset authorization data", zap.Int("assetId", assetId), zap.Int("accounts", len(v1Auth))) return nil } // getDefaultAuthPermissions returns default permissions for migration func getDefaultAuthPermissions() model.AuthPermissions { // Get from config if available if config := model.GlobalConfig.Load(); config != nil { return config.GetDefaultPermissionsAsAuthPermissions() } // Fallback to connect-only permissions return model.AuthPermissions{ Connect: true, FileUpload: false, FileDownload: false, Copy: false, Paste: false, Share: false, } } // checkAssetAuthorizationNeedsMigration checks if an asset's authorization data needs migration func (s *AuthorizationMigrationService) checkAssetAuthorizationNeedsMigration(tx *gorm.DB, assetId int) (bool, error) { var rawAuth json.RawMessage if err := tx.Model(&model.Asset{}). Where("id = ?", assetId). Select("authorization"). Scan(&rawAuth).Error; err != nil { if err == gorm.ErrRecordNotFound { return false, nil } return false, err } if len(rawAuth) == 0 { return false, nil } // Try to parse as V2 format first var v2Auth map[int]model.AccountAuthorization if err := json.Unmarshal(rawAuth, &v2Auth); err == nil { // Successfully parsed as V2, no migration needed return false, nil } // Try to parse as V1 format var v1Auth map[int][]int if err := json.Unmarshal(rawAuth, &v1Auth); err == nil { // Successfully parsed as V1, needs migration return true, nil } // Cannot parse as either format, skip return false, nil } // migrateAssetAuthorizationField migrates a single asset's authorization data func (s *AuthorizationMigrationService) migrateAssetAuthorizationField(ctx context.Context, tx *gorm.DB, assetId int) error { // Get raw authorization data var rawAuth json.RawMessage if err := tx.Model(&model.Asset{}). Where("id = ?", assetId). Select("authorization"). Scan(&rawAuth).Error; err != nil { return err } if len(rawAuth) == 0 { return nil } // Parse as V1 format var v1Auth map[int][]int if err := json.Unmarshal(rawAuth, &v1Auth); err != nil { return fmt.Errorf("failed to parse V1 authorization data: %w", err) } // Get default permissions defaultPermissions := s.getDefaultAuthPermissions() // Convert V1 to V2 format v2Auth := make(map[int]model.AccountAuthorization) for accountId, roleIds := range v1Auth { v2Auth[accountId] = model.AccountAuthorization{ Rids: roleIds, Permissions: &defaultPermissions, } } // Update the database if err := tx.Model(&model.Asset{}). Where("id = ?", assetId). Update("authorization", v2Auth).Error; err != nil { return fmt.Errorf("failed to update authorization data: %w", err) } logger.L().Debug("Migrated asset authorization data", zap.Int("assetId", assetId), zap.Int("accounts", len(v1Auth))) return nil } // getDefaultAuthPermissions returns default permissions for migration service func (s *AuthorizationMigrationService) getDefaultAuthPermissions() model.AuthPermissions { // Get from config if available if config := model.GlobalConfig.Load(); config != nil { return config.GetDefaultPermissionsAsAuthPermissions() } // Fallback to connect-only permissions return model.AuthPermissions{ Connect: true, FileUpload: false, FileDownload: false, Copy: false, Paste: false, Share: false, } }