Files
oneterm/backend/internal/service/authorization_migration.go
2025-07-16 18:11:04 +08:00

626 lines
18 KiB
Go

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,
}
}