mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-10-17 19:48:15 +02:00
feat: Implement storage provider management functionality
- Added new routes and handlers for managing storage providers, including creation, editing, and deletion. - Introduced a new StorageProvider form component for user input. - Enhanced the database schema to support storage provider references in transfer configurations. - Implemented encryption for sensitive fields in storage provider data. - Added tests for storage provider API endpoints and integration with the database. - Updated frontend components to support storage provider selection and testing.
This commit is contained in:
1 parent
88d0ac815a
commit
31871bd16e
59 files changed
+15842
-594
No files matched your search
@@ -0,0 +1,286 @@
|
||||
package keyrotation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/audit"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrNoOldKey = errors.New("old encryption key not found")
|
||||
ErrNoNewKey = errors.New("new encryption key not found")
|
||||
ErrSameKey = errors.New("old and new keys are the same")
|
||||
ErrNoDataToMigrate = errors.New("no data to migrate")
|
||||
ErrNilDB = errors.New("database connection is nil")
|
||||
)
|
||||
|
||||
// RotationStats represents statistics about the key rotation process
|
||||
type RotationStats struct {
|
||||
TotalRecords int `json:"total_records"`
|
||||
ProcessedRecords int `json:"processed_records"`
|
||||
SkippedRecords int `json:"skipped_records"`
|
||||
FailedRecords int `json:"failed_records"`
|
||||
ElapsedTime time.Duration `json:"elapsed_time"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// KeyRotator manages the process of changing encryption keys and re-encrypting data
|
||||
type KeyRotator struct {
|
||||
db *gorm.DB
|
||||
oldService *encryption.EncryptionService
|
||||
newService *encryption.EncryptionService
|
||||
auditor *audit.SecurityAuditor
|
||||
dryRun bool
|
||||
batchSize int
|
||||
maxErrors int
|
||||
}
|
||||
|
||||
// NewKeyRotator creates a new KeyRotator
|
||||
func NewKeyRotator(db *gorm.DB, oldService, newService *encryption.EncryptionService, auditor *audit.SecurityAuditor) (*KeyRotator, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
|
||||
if oldService == nil {
|
||||
return nil, ErrNoOldKey
|
||||
}
|
||||
|
||||
if newService == nil {
|
||||
return nil, ErrNoNewKey
|
||||
}
|
||||
|
||||
if oldService == newService {
|
||||
return nil, ErrSameKey
|
||||
}
|
||||
|
||||
if auditor == nil {
|
||||
// Use the global auditor if none provided
|
||||
auditor = audit.GetGlobalAuditor()
|
||||
}
|
||||
|
||||
return &KeyRotator{
|
||||
db: db,
|
||||
oldService: oldService,
|
||||
newService: newService,
|
||||
auditor: auditor,
|
||||
dryRun: false,
|
||||
batchSize: 100,
|
||||
maxErrors: 50,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDryRun enables or disables dry run mode
|
||||
func (r *KeyRotator) SetDryRun(dryRun bool) {
|
||||
r.dryRun = dryRun
|
||||
}
|
||||
|
||||
// SetBatchSize sets the batch size for processing records
|
||||
func (r *KeyRotator) SetBatchSize(size int) {
|
||||
if size > 0 {
|
||||
r.batchSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// SetMaxErrors sets the maximum number of errors allowed before aborting
|
||||
func (r *KeyRotator) SetMaxErrors(max int) {
|
||||
if max >= 0 {
|
||||
r.maxErrors = max
|
||||
}
|
||||
}
|
||||
|
||||
// RotateKeys rotates encryption keys for a specific model type
|
||||
func (r *KeyRotator) RotateKeys(modelType interface{}, primaryKeyName string) (*RotationStats, error) {
|
||||
stats := &RotationStats{
|
||||
StartTime: time.Now(),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
// Get the model type
|
||||
modelValue := reflect.ValueOf(modelType)
|
||||
if modelValue.Kind() == reflect.Ptr {
|
||||
modelValue = modelValue.Elem()
|
||||
}
|
||||
|
||||
// Skip if the value is not a struct
|
||||
if modelValue.Kind() != reflect.Struct {
|
||||
return stats, errors.New("model type must be a struct")
|
||||
}
|
||||
|
||||
modelName := modelValue.Type().Name()
|
||||
|
||||
// Count total records
|
||||
var count int64
|
||||
if err := r.db.Model(modelType).Count(&count).Error; err != nil {
|
||||
return stats, fmt.Errorf("failed to count records: %w", err)
|
||||
}
|
||||
|
||||
stats.TotalRecords = int(count)
|
||||
|
||||
if count == 0 {
|
||||
return stats, ErrNoDataToMigrate
|
||||
}
|
||||
|
||||
// Process in batches
|
||||
offset := 0
|
||||
for offset < int(count) {
|
||||
// Get a batch of records
|
||||
records := reflect.New(reflect.SliceOf(modelValue.Type())).Interface()
|
||||
|
||||
if err := r.db.Model(modelType).Offset(offset).Limit(r.batchSize).Find(records).Error; err != nil {
|
||||
stats.Errors = append(stats.Errors, fmt.Sprintf("failed to fetch batch at offset %d: %v", offset, err))
|
||||
if len(stats.Errors) >= r.maxErrors {
|
||||
return stats, fmt.Errorf("too many errors (%d), aborting key rotation", len(stats.Errors))
|
||||
}
|
||||
offset += r.batchSize
|
||||
continue
|
||||
}
|
||||
|
||||
// Process this batch
|
||||
batchRecords := reflect.ValueOf(records).Elem()
|
||||
for i := 0; i < batchRecords.Len(); i++ {
|
||||
record := batchRecords.Index(i)
|
||||
if record.Kind() == reflect.Ptr {
|
||||
record = record.Elem()
|
||||
}
|
||||
|
||||
if err := r.rotateKeysForRecord(record, modelName, primaryKeyName); err != nil {
|
||||
pkValue := getPrimaryKeyValue(record, primaryKeyName)
|
||||
stats.Errors = append(stats.Errors, fmt.Sprintf("failed to rotate keys for %s with ID %v: %v", modelName, pkValue, err))
|
||||
stats.FailedRecords++
|
||||
|
||||
if len(stats.Errors) >= r.maxErrors {
|
||||
stats.EndTime = time.Now()
|
||||
stats.ElapsedTime = stats.EndTime.Sub(stats.StartTime)
|
||||
return stats, fmt.Errorf("too many errors (%d), aborting key rotation", len(stats.Errors))
|
||||
}
|
||||
} else {
|
||||
stats.ProcessedRecords++
|
||||
}
|
||||
}
|
||||
|
||||
offset += r.batchSize
|
||||
}
|
||||
|
||||
stats.EndTime = time.Now()
|
||||
stats.ElapsedTime = stats.EndTime.Sub(stats.StartTime)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// rotateKeysForRecord processes a single record
|
||||
func (r *KeyRotator) rotateKeysForRecord(record reflect.Value, modelName, primaryKeyName string) error {
|
||||
if !record.IsValid() || record.Kind() != reflect.Struct {
|
||||
return errors.New("invalid record")
|
||||
}
|
||||
|
||||
// Check if there are any encrypted fields to migrate
|
||||
encryptedFieldsFound := false
|
||||
recordType := record.Type()
|
||||
|
||||
// Track changes for audit
|
||||
pkValue := getPrimaryKeyValue(record, primaryKeyName)
|
||||
changes := make(map[string]struct{})
|
||||
|
||||
// Process each field in the struct
|
||||
for i := 0; i < recordType.NumField(); i++ {
|
||||
field := recordType.Field(i)
|
||||
|
||||
// Look for encrypted fields
|
||||
fieldName := field.Name
|
||||
if strings.HasPrefix(fieldName, "Encrypted") {
|
||||
// Get the field value
|
||||
fieldValue := record.Field(i)
|
||||
if !fieldValue.CanInterface() || !fieldValue.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the encrypted value
|
||||
encryptedValue, ok := fieldValue.Interface().(string)
|
||||
if !ok || encryptedValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's not encrypted with our old key, skip it
|
||||
if !strings.HasPrefix(encryptedValue, encryption.EncryptedPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
encryptedFieldsFound = true
|
||||
|
||||
// Try to decrypt with the old key
|
||||
trimmedValue := strings.TrimPrefix(encryptedValue, encryption.EncryptedPrefix)
|
||||
plaintext, err := r.oldService.DecryptString(trimmedValue)
|
||||
if err != nil {
|
||||
// Skip this field if we can't decrypt it (might be encrypted with a different key)
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-encrypt with the new key
|
||||
newEncrypted, err := r.newService.EncryptString(plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-encrypt field %s: %w", fieldName, err)
|
||||
}
|
||||
|
||||
// Only update if different
|
||||
newValue := encryption.EncryptedPrefix + newEncrypted
|
||||
if newValue != encryptedValue {
|
||||
if !r.dryRun {
|
||||
fieldValue.SetString(newValue)
|
||||
}
|
||||
changes[fieldName] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no encrypted fields were found or modified, return
|
||||
if !encryptedFieldsFound || len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save the changes to the database
|
||||
if !r.dryRun {
|
||||
if err := r.db.Save(record.Addr().Interface()).Error; err != nil {
|
||||
return fmt.Errorf("failed to save record: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log the rotation
|
||||
if r.auditor != nil {
|
||||
changedFields := make([]string, 0, len(changes))
|
||||
for field := range changes {
|
||||
changedFields = append(changedFields, field)
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("Rotated keys for %s (ID: %v) - fields: %s",
|
||||
modelName, pkValue, strings.Join(changedFields, ", "))
|
||||
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old", "new", true, description, 0,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPrimaryKeyValue gets the value of the primary key field
|
||||
func getPrimaryKeyValue(record reflect.Value, pkName string) interface{} {
|
||||
if pkName == "" {
|
||||
pkName = "ID" // Default primary key name
|
||||
}
|
||||
|
||||
pkField := record.FieldByName(pkName)
|
||||
if !pkField.IsValid() {
|
||||
return "<unknown>"
|
||||
}
|
||||
|
||||
return pkField.Interface()
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package keyrotation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/audit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestModel is a simple model with encrypted fields for testing
|
||||
type TestModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string
|
||||
EncryptedField string
|
||||
EncryptedData string
|
||||
EncryptedKey string
|
||||
StandardField string
|
||||
}
|
||||
|
||||
// setupTestAuditor creates an auditor for testing with buffer for capturing logs
|
||||
func setupTestAuditor(t testing.TB) (*audit.SecurityAuditor, *bytes.Buffer) {
|
||||
logBuffer := new(bytes.Buffer)
|
||||
errorBuffer := new(bytes.Buffer)
|
||||
|
||||
auditor, err := audit.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set log writers to capture output
|
||||
auditValue := reflect.ValueOf(auditor).Elem()
|
||||
if logField := auditValue.FieldByName("logWriter"); logField.IsValid() && logField.CanSet() {
|
||||
logField.Set(reflect.ValueOf(logBuffer))
|
||||
}
|
||||
if errorField := auditValue.FieldByName("errorWriter"); errorField.IsValid() && errorField.CanSet() {
|
||||
errorField.Set(reflect.ValueOf(errorBuffer))
|
||||
}
|
||||
|
||||
return auditor, logBuffer
|
||||
}
|
||||
|
||||
// setupTestDB creates a test database with the TestModel
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Migrate the schema
|
||||
err = db.AutoMigrate(&TestModel{})
|
||||
require.NoError(t, err)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// setupEncryptionServices creates old and new encryption services for testing
|
||||
func setupEncryptionServices(t testing.TB) (*encryption.EncryptionService, *encryption.EncryptionService) {
|
||||
// Setup old key
|
||||
oldKeyEnv := "TEST_OLD_KEY"
|
||||
oldKey := make([]byte, encryption.AES256KeySize)
|
||||
for i := range oldKey {
|
||||
oldKey[i] = byte(i % 256)
|
||||
}
|
||||
os.Setenv(oldKeyEnv, base64.StdEncoding.EncodeToString(oldKey))
|
||||
|
||||
// Setup new key
|
||||
newKeyEnv := "TEST_NEW_KEY"
|
||||
newKey := make([]byte, encryption.AES256KeySize)
|
||||
for i := range newKey {
|
||||
newKey[i] = byte((i + 128) % 256) // Different key
|
||||
}
|
||||
os.Setenv(newKeyEnv, base64.StdEncoding.EncodeToString(newKey))
|
||||
|
||||
if t, ok := t.(*testing.T); ok {
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv(oldKeyEnv)
|
||||
os.Unsetenv(newKeyEnv)
|
||||
})
|
||||
}
|
||||
|
||||
// Create key managers
|
||||
oldKM := encryption.NewKeyManager(oldKeyEnv)
|
||||
err := oldKM.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newKM := encryption.NewKeyManager(newKeyEnv)
|
||||
err = newKM.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create encryption services
|
||||
oldService, err := encryption.NewEncryptionService(oldKM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newService, err := encryption.NewEncryptionService(newKM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return oldService, newService
|
||||
}
|
||||
|
||||
// createTestData creates test records with encrypted fields
|
||||
func createTestData(t testing.TB, db *gorm.DB, oldService *encryption.EncryptionService, count int) {
|
||||
for i := 1; i <= count; i++ {
|
||||
// Create encrypted values with the old key
|
||||
field1, err := oldService.EncryptString(fmt.Sprintf("secret-field-%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
field2, err := oldService.EncryptString(fmt.Sprintf("secret-data-%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
field3, err := oldService.EncryptString(fmt.Sprintf("secret-key-%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a test record
|
||||
record := TestModel{
|
||||
Name: fmt.Sprintf("Test Record %d", i),
|
||||
EncryptedField: encryption.EncryptedPrefix + field1,
|
||||
EncryptedData: encryption.EncryptedPrefix + field2,
|
||||
EncryptedKey: encryption.EncryptedPrefix + field3,
|
||||
StandardField: fmt.Sprintf("standard-field-%d", i),
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
result := db.Create(&record)
|
||||
if err := result.Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKeyRotator(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, _ := setupTestAuditor(t)
|
||||
|
||||
t.Run("Valid rotator creation", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, rotator)
|
||||
assert.False(t, rotator.dryRun)
|
||||
assert.Equal(t, 100, rotator.batchSize)
|
||||
assert.Equal(t, 50, rotator.maxErrors)
|
||||
})
|
||||
|
||||
t.Run("Nil DB", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(nil, oldService, newService, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrNilDB, err)
|
||||
})
|
||||
|
||||
t.Run("Nil old service", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, nil, newService, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrNoOldKey, err)
|
||||
})
|
||||
|
||||
t.Run("Nil new service", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, nil, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrNoNewKey, err)
|
||||
})
|
||||
|
||||
t.Run("Same service", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, oldService, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrSameKey, err)
|
||||
})
|
||||
|
||||
t.Run("Default auditor", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, rotator)
|
||||
assert.NotNil(t, rotator.auditor)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyRotatorConfigMethods(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, _ := setupTestAuditor(t)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("SetDryRun", func(t *testing.T) {
|
||||
rotator.SetDryRun(true)
|
||||
assert.True(t, rotator.dryRun)
|
||||
|
||||
rotator.SetDryRun(false)
|
||||
assert.False(t, rotator.dryRun)
|
||||
})
|
||||
|
||||
t.Run("SetBatchSize", func(t *testing.T) {
|
||||
rotator.SetBatchSize(200)
|
||||
assert.Equal(t, 200, rotator.batchSize)
|
||||
|
||||
// Test with invalid value
|
||||
rotator.SetBatchSize(0)
|
||||
assert.Equal(t, 200, rotator.batchSize) // Shouldn't change
|
||||
|
||||
rotator.SetBatchSize(-10)
|
||||
assert.Equal(t, 200, rotator.batchSize) // Shouldn't change
|
||||
})
|
||||
|
||||
t.Run("SetMaxErrors", func(t *testing.T) {
|
||||
rotator.SetMaxErrors(100)
|
||||
assert.Equal(t, 100, rotator.maxErrors)
|
||||
|
||||
rotator.SetMaxErrors(0)
|
||||
assert.Equal(t, 0, rotator.maxErrors) // 0 is valid (no max)
|
||||
|
||||
// Test with invalid value
|
||||
rotator.SetMaxErrors(-10)
|
||||
assert.Equal(t, 0, rotator.maxErrors) // Shouldn't change
|
||||
})
|
||||
}
|
||||
|
||||
func TestRotateKeys(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, logBuffer := setupTestAuditor(t)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Rotate keys for model with no records", func(t *testing.T) {
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrNoDataToMigrate, err)
|
||||
assert.Equal(t, 0, stats.TotalRecords)
|
||||
})
|
||||
|
||||
t.Run("Rotate keys for non-struct model", func(t *testing.T) {
|
||||
stats, err := rotator.RotateKeys("not a struct", "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must be a struct")
|
||||
assert.Equal(t, 0, stats.TotalRecords, "Expected total records to be 0 for non-struct model")
|
||||
})
|
||||
|
||||
t.Run("Rotate keys for model with records", func(t *testing.T) {
|
||||
// Reset log buffer
|
||||
logBuffer.Reset()
|
||||
|
||||
// Create test data
|
||||
createTestData(t, db, oldService, 10)
|
||||
|
||||
// Perform key rotation
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, stats.TotalRecords)
|
||||
assert.Equal(t, 10, stats.ProcessedRecords)
|
||||
assert.Equal(t, 0, stats.FailedRecords)
|
||||
assert.NotZero(t, stats.ElapsedTime)
|
||||
assert.Empty(t, stats.Errors)
|
||||
|
||||
// Verify that records were updated with re-encrypted values
|
||||
var records []TestModel
|
||||
result := db.Find(&records)
|
||||
require.NoError(t, result.Error)
|
||||
assert.Equal(t, 10, len(records))
|
||||
|
||||
// Test a sample record to ensure it was re-encrypted properly
|
||||
record := records[0]
|
||||
|
||||
// Verify the old key can't decrypt the new values
|
||||
_, err = oldService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
assert.Error(t, err, "Old key should not be able to decrypt new values")
|
||||
|
||||
// Verify the new key can decrypt the values
|
||||
decryptedField, err := newService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "secret-field-1", decryptedField)
|
||||
|
||||
// Verify audit logs were created
|
||||
logContent := logBuffer.String()
|
||||
assert.Contains(t, logContent, "key_rotation")
|
||||
assert.Contains(t, logContent, "TestModel")
|
||||
|
||||
// Verify no sensitive data in logs
|
||||
assert.NotContains(t, logContent, "secret-field")
|
||||
assert.NotContains(t, logContent, "secret-data")
|
||||
assert.NotContains(t, logContent, "secret-key")
|
||||
})
|
||||
|
||||
t.Run("Dry run mode", func(t *testing.T) {
|
||||
// Reset the database
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(t, db, oldService, 5)
|
||||
|
||||
// Create a new rotator with dry run enabled
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
rotator.SetDryRun(true)
|
||||
|
||||
// Perform key rotation
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, stats.TotalRecords)
|
||||
|
||||
// Verify that records were NOT updated with re-encrypted values
|
||||
var records []TestModel
|
||||
result := db.Find(&records)
|
||||
require.NoError(t, result.Error)
|
||||
|
||||
// Test a sample record to ensure it was NOT re-encrypted
|
||||
record := records[0]
|
||||
|
||||
// Verify the old key CAN decrypt the values (because they weren't changed)
|
||||
decryptedField, err := oldService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "secret-field-1", decryptedField)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRotateKeysWithErrors(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, _ := setupTestAuditor(t)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test data with one corrupted record
|
||||
createTestData(t, db, oldService, 5)
|
||||
|
||||
// Create a corrupted record that can't be decrypted
|
||||
corruptedRecord := TestModel{
|
||||
Name: "Corrupted Record",
|
||||
EncryptedField: encryption.EncryptedPrefix + "corrupted-data",
|
||||
EncryptedData: encryption.EncryptedPrefix + "corrupted-data",
|
||||
StandardField: "standard-field",
|
||||
}
|
||||
result := db.Create(&corruptedRecord)
|
||||
require.NoError(t, result.Error)
|
||||
|
||||
// Perform key rotation
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.NoError(t, err) // Should still succeed overall
|
||||
assert.Equal(t, 6, stats.TotalRecords)
|
||||
assert.Equal(t, 5, stats.ProcessedRecords) // Only 5 should be processed successfully
|
||||
assert.Equal(t, 0, stats.FailedRecords) // Failure to decrypt is skipped, not counted as error
|
||||
|
||||
// Verify that the valid records were updated
|
||||
var records []TestModel
|
||||
db.Where("name LIKE ?", "Test Record%").Find(&records)
|
||||
require.Equal(t, 5, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
// Verify the new key can decrypt
|
||||
_, err = newService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Verify the corrupted record wasn't changed
|
||||
var corrupted TestModel
|
||||
db.Where("name = ?", "Corrupted Record").First(&corrupted)
|
||||
assert.Equal(t, encryption.EncryptedPrefix+"corrupted-data", corrupted.EncryptedField)
|
||||
}
|
||||
|
||||
func TestGetPrimaryKeyValue(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
ID uint
|
||||
CustomID string
|
||||
NotAnID string
|
||||
OtherData string
|
||||
}
|
||||
|
||||
t.Run("Default ID field", func(t *testing.T) {
|
||||
test := TestStruct{ID: 123, OtherData: "test"}
|
||||
val := getPrimaryKeyValue(reflect.ValueOf(test), "")
|
||||
assert.Equal(t, uint(123), val)
|
||||
})
|
||||
|
||||
t.Run("Custom ID field", func(t *testing.T) {
|
||||
test := TestStruct{ID: 123, CustomID: "ABC123", OtherData: "test"}
|
||||
val := getPrimaryKeyValue(reflect.ValueOf(test), "CustomID")
|
||||
assert.Equal(t, "ABC123", val)
|
||||
})
|
||||
|
||||
t.Run("Non-existent ID field", func(t *testing.T) {
|
||||
test := TestStruct{ID: 123, OtherData: "test"}
|
||||
val := getPrimaryKeyValue(reflect.ValueOf(test), "NonExistentID")
|
||||
assert.Equal(t, "<unknown>", val)
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkKeyRotation measures the performance of key rotation
|
||||
func BenchmarkKeyRotation(b *testing.B) {
|
||||
// Setup
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
db.AutoMigrate(&TestModel{})
|
||||
|
||||
oldService, newService := setupEncryptionServices(b)
|
||||
auditor, _ := setupTestAuditor(b)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create benchmark data sets of different sizes
|
||||
benchmarks := []struct {
|
||||
name string
|
||||
numRecords int
|
||||
}{
|
||||
{"Small (10 records)", 10},
|
||||
{"Medium (100 records)", 100},
|
||||
{"Large (500 records)", 500},
|
||||
}
|
||||
|
||||
for _, bm := range benchmarks {
|
||||
b.Run(bm.name, func(b *testing.B) {
|
||||
// Reset the database for each benchmark iteration
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(b, db, oldService, bm.numRecords)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if stats.ProcessedRecords != bm.numRecords {
|
||||
b.Fatalf("Expected %d records, got %d", bm.numRecords, stats.ProcessedRecords)
|
||||
}
|
||||
|
||||
// Reset for the next iteration
|
||||
if i < b.N-1 {
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(b, db, oldService, bm.numRecords)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks for different batch sizes
|
||||
func BenchmarkBatchSizes(b *testing.B) {
|
||||
// Setup
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
db.AutoMigrate(&TestModel{})
|
||||
|
||||
oldService, newService := setupEncryptionServices(b)
|
||||
auditor, _ := setupTestAuditor(b)
|
||||
|
||||
// Create a dataset of 500 records
|
||||
const numRecords = 500
|
||||
createTestData(b, db, oldService, numRecords)
|
||||
|
||||
// Test different batch sizes
|
||||
batchSizes := []int{10, 50, 100, 200, 500}
|
||||
|
||||
for _, batchSize := range batchSizes {
|
||||
b.Run(fmt.Sprintf("BatchSize_%d", batchSize), func(b *testing.B) {
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
rotator.SetBatchSize(batchSize)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Reset data before each run
|
||||
if i > 0 {
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(b, db, oldService, numRecords)
|
||||
}
|
||||
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if stats.ProcessedRecords != numRecords {
|
||||
b.Fatalf("Expected %d records, got %d", numRecords, stats.ProcessedRecords)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package keyrotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/audit"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RotationOptions contains configuration for the key rotation process
|
||||
type RotationOptions struct {
|
||||
// DryRun performs all operations but doesn't save changes to database
|
||||
DryRun bool
|
||||
// BatchSize sets the number of records to process in each batch
|
||||
BatchSize int
|
||||
// MaxErrors sets the threshold of errors before aborting
|
||||
MaxErrors int
|
||||
// Parallelism controls how many models are processed in parallel
|
||||
Parallelism int
|
||||
// Timeout specifies a maximum duration for the entire operation
|
||||
Timeout time.Duration
|
||||
// WorkerTimeout specifies maximum duration for a single batch
|
||||
WorkerTimeout time.Duration
|
||||
// ProgressCallback receives updates on rotation progress
|
||||
ProgressCallback func(modelName string, processed, total int)
|
||||
}
|
||||
|
||||
// RotationUtility provides comprehensive capabilities for rotating encryption keys
|
||||
// across multiple database models with detailed auditing and progress tracking
|
||||
type RotationUtility struct {
|
||||
db *gorm.DB
|
||||
oldService *encryption.EncryptionService
|
||||
newService *encryption.EncryptionService
|
||||
auditor *audit.SecurityAuditor
|
||||
testingHooks map[string]func(interface{}) error
|
||||
mu sync.Mutex
|
||||
options RotationOptions
|
||||
}
|
||||
|
||||
// NewRotationUtility creates a new RotationUtility
|
||||
func NewRotationUtility(
|
||||
db *gorm.DB,
|
||||
oldService, newService *encryption.EncryptionService,
|
||||
auditor *audit.SecurityAuditor,
|
||||
options RotationOptions,
|
||||
) (*RotationUtility, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database connection is required")
|
||||
}
|
||||
|
||||
if oldService == nil {
|
||||
return nil, fmt.Errorf("old encryption service is required")
|
||||
}
|
||||
|
||||
if newService == nil {
|
||||
return nil, fmt.Errorf("new encryption service is required")
|
||||
}
|
||||
|
||||
if auditor == nil {
|
||||
auditor = audit.GetGlobalAuditor()
|
||||
}
|
||||
|
||||
// Set default options
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
|
||||
if options.MaxErrors <= 0 {
|
||||
options.MaxErrors = 50
|
||||
}
|
||||
|
||||
if options.Parallelism <= 0 {
|
||||
options.Parallelism = 1
|
||||
}
|
||||
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = 24 * time.Hour // Default long timeout
|
||||
}
|
||||
|
||||
if options.WorkerTimeout <= 0 {
|
||||
options.WorkerTimeout = 30 * time.Minute
|
||||
}
|
||||
|
||||
return &RotationUtility{
|
||||
db: db,
|
||||
oldService: oldService,
|
||||
newService: newService,
|
||||
auditor: auditor,
|
||||
options: options,
|
||||
testingHooks: make(map[string]func(interface{}) error),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterTestingHook registers a hook for testing purposes
|
||||
func (r *RotationUtility) RegisterTestingHook(name string, hook func(interface{}) error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.testingHooks[name] = hook
|
||||
}
|
||||
|
||||
// runHook runs a testing hook if it exists
|
||||
func (r *RotationUtility) runHook(name string, data interface{}) error {
|
||||
r.mu.Lock()
|
||||
hook, exists := r.testingHooks[name]
|
||||
r.mu.Unlock()
|
||||
|
||||
if exists && hook != nil {
|
||||
return hook(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateKeysForModels performs key rotation for multiple model types with detailed monitoring
|
||||
func (r *RotationUtility) RotateKeysForModels(ctx context.Context, models []interface{}) (*RotationStats, error) {
|
||||
// Create master context with timeout
|
||||
masterCtx, cancel := context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Track overall stats
|
||||
overallStats := &RotationStats{
|
||||
StartTime: time.Now(),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
// Create key rotator
|
||||
rotator, err := NewKeyRotator(r.db, r.oldService, r.newService, r.auditor)
|
||||
if err != nil {
|
||||
return overallStats, fmt.Errorf("failed to create key rotator: %w", err)
|
||||
}
|
||||
|
||||
// Apply options
|
||||
rotator.SetDryRun(r.options.DryRun)
|
||||
rotator.SetBatchSize(r.options.BatchSize)
|
||||
rotator.SetMaxErrors(r.options.MaxErrors)
|
||||
|
||||
// Log the start of rotation
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"starting",
|
||||
"pending",
|
||||
true,
|
||||
fmt.Sprintf("Starting key rotation for %d model types (dry run: %v)", len(models), r.options.DryRun),
|
||||
0,
|
||||
)
|
||||
|
||||
// Process all models (sequentially)
|
||||
for _, model := range models {
|
||||
// Check if context is canceled
|
||||
select {
|
||||
case <-masterCtx.Done():
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("key rotation aborted: %v", masterCtx.Err()))
|
||||
return overallStats, masterCtx.Err()
|
||||
default:
|
||||
// Continue processing
|
||||
}
|
||||
|
||||
// Get model type info
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Run pre-rotation hook if any
|
||||
if err := r.runHook("pre_rotation_"+modelName, model); err != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("pre-rotation hook failed for %s: %v", modelName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Log model rotation start
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"starting",
|
||||
"pending",
|
||||
true,
|
||||
fmt.Sprintf("Starting key rotation for model: %s", modelName),
|
||||
0,
|
||||
)
|
||||
|
||||
// Create a worker context with timeout
|
||||
workerCtx, workerCancel := context.WithTimeout(masterCtx, r.options.WorkerTimeout)
|
||||
|
||||
// Create a goroutine to handle timeouts
|
||||
rotationDone := make(chan struct{})
|
||||
var modelStats *RotationStats
|
||||
var rotationErr error
|
||||
|
||||
go func() {
|
||||
// Perform the actual rotation
|
||||
modelStats, rotationErr = rotator.RotateKeys(model, "")
|
||||
close(rotationDone)
|
||||
}()
|
||||
|
||||
// Wait for rotation to complete or timeout
|
||||
select {
|
||||
case <-workerCtx.Done():
|
||||
if workerCtx.Err() == context.DeadlineExceeded {
|
||||
errorMsg := fmt.Sprintf("key rotation for model %s timed out after %v", modelName, r.options.WorkerTimeout)
|
||||
overallStats.Errors = append(overallStats.Errors, errorMsg)
|
||||
|
||||
// Log timeout error
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
errorMsg,
|
||||
0,
|
||||
)
|
||||
}
|
||||
case <-rotationDone:
|
||||
// Rotation completed
|
||||
}
|
||||
|
||||
// Clean up the worker context
|
||||
workerCancel()
|
||||
|
||||
// Check for rotation errors
|
||||
if rotationErr != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("failed to rotate keys for %s: %v", modelName, rotationErr))
|
||||
|
||||
// Log rotation error
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
fmt.Sprintf("Key rotation failed for model %s: %v", modelName, rotationErr),
|
||||
0,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Update overall stats
|
||||
if modelStats != nil {
|
||||
overallStats.TotalRecords += modelStats.TotalRecords
|
||||
overallStats.ProcessedRecords += modelStats.ProcessedRecords
|
||||
overallStats.SkippedRecords += modelStats.SkippedRecords
|
||||
overallStats.FailedRecords += modelStats.FailedRecords
|
||||
overallStats.Errors = append(overallStats.Errors, modelStats.Errors...)
|
||||
|
||||
// Call progress callback if set
|
||||
if r.options.ProgressCallback != nil {
|
||||
r.options.ProgressCallback(modelName, modelStats.ProcessedRecords, modelStats.TotalRecords)
|
||||
}
|
||||
|
||||
// Log progress
|
||||
successRate := 0.0
|
||||
if modelStats.TotalRecords > 0 {
|
||||
successRate = float64(modelStats.ProcessedRecords) / float64(modelStats.TotalRecords) * 100
|
||||
}
|
||||
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
true,
|
||||
fmt.Sprintf("Completed key rotation for model %s: %d/%d records (%.1f%%) processed, %d skipped, %d failed",
|
||||
modelName, modelStats.ProcessedRecords, modelStats.TotalRecords, successRate,
|
||||
modelStats.SkippedRecords, modelStats.FailedRecords),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// Run post-rotation hook if any
|
||||
if err := r.runHook("post_rotation_"+modelName, model); err != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("post-rotation hook failed for %s: %v", modelName, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Complete overall stats
|
||||
overallStats.EndTime = time.Now()
|
||||
overallStats.ElapsedTime = overallStats.EndTime.Sub(overallStats.StartTime)
|
||||
|
||||
// Calculate overall success rate
|
||||
successRate := 0.0
|
||||
if overallStats.TotalRecords > 0 {
|
||||
successRate = float64(overallStats.ProcessedRecords) / float64(overallStats.TotalRecords) * 100
|
||||
}
|
||||
|
||||
// Log completion
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
len(overallStats.Errors) == 0,
|
||||
fmt.Sprintf("Completed key rotation for all models: %d/%d records (%.1f%%) processed, %d skipped, %d failed, %d errors in %s",
|
||||
overallStats.ProcessedRecords, overallStats.TotalRecords, successRate,
|
||||
overallStats.SkippedRecords, overallStats.FailedRecords, len(overallStats.Errors),
|
||||
overallStats.ElapsedTime),
|
||||
0,
|
||||
)
|
||||
|
||||
return overallStats, nil
|
||||
}
|
||||
|
||||
// FindModelsWithEncryptedFields automatically finds all database models with encrypted fields
|
||||
func (r *RotationUtility) FindModelsWithEncryptedFields() ([]interface{}, error) {
|
||||
// This is a placeholder - in a real implementation, we would scan the codebase
|
||||
// or database schema to automatically detect models with encrypted fields
|
||||
// Since that requires knowledge of the codebase structure, this would be
|
||||
// customized for the specific application
|
||||
|
||||
return []interface{}{}, fmt.Errorf("automatic model detection not implemented, provide models explicitly")
|
||||
}
|
||||
|
||||
// ValidateRotation tests the key rotation on sample records without saving changes
|
||||
func (r *RotationUtility) ValidateRotation(models []interface{}) (map[string]bool, error) {
|
||||
results := make(map[string]bool)
|
||||
|
||||
// Save current options to restore later
|
||||
originalDryRun := r.options.DryRun
|
||||
originalBatchSize := r.options.BatchSize
|
||||
|
||||
// Set temporary options for validation
|
||||
r.options.DryRun = true
|
||||
r.options.BatchSize = 10 // Test with small batch
|
||||
|
||||
// Create a context with short timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Run rotation with dry run mode
|
||||
stats, err := r.RotateKeysForModels(ctx, models)
|
||||
|
||||
// Restore original options
|
||||
r.options.DryRun = originalDryRun
|
||||
r.options.BatchSize = originalBatchSize
|
||||
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Process results for each model
|
||||
for _, model := range models {
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Check if there were errors for this model
|
||||
hasModelErrors := false
|
||||
for _, errMsg := range stats.Errors {
|
||||
if strings.Contains(errMsg, modelName) {
|
||||
hasModelErrors = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
results[modelName] = !hasModelErrors
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CreateEncryptionMigrationPlan creates a detailed plan for migrating data to a new encryption key
|
||||
func (r *RotationUtility) CreateEncryptionMigrationPlan(models []interface{}) (*EncryptionMigrationPlan, error) {
|
||||
plan := &EncryptionMigrationPlan{
|
||||
ModelPlans: make(map[string]*ModelMigrationPlan),
|
||||
EstimatedDuration: 0,
|
||||
EstimatedRecords: 0,
|
||||
RecommendedOptions: r.options, // Start with current options
|
||||
}
|
||||
|
||||
// Calculate record counts for each model
|
||||
totalRecords := 0
|
||||
for _, model := range models {
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Get record count
|
||||
var count int64
|
||||
if err := r.db.Model(model).Count(&count).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count records for %s: %w", modelName, err)
|
||||
}
|
||||
|
||||
encryptedFields := r.identifyEncryptedFields(model)
|
||||
|
||||
// Create model plan
|
||||
modelPlan := &ModelMigrationPlan{
|
||||
ModelName: modelName,
|
||||
RecordCount: int(count),
|
||||
EstimatedTime: r.estimateMigrationTime(int(count), len(encryptedFields)),
|
||||
EncryptedFields: encryptedFields,
|
||||
BatchSizeRec: r.calculateOptimalBatchSize(int(count)),
|
||||
}
|
||||
|
||||
plan.ModelPlans[modelName] = modelPlan
|
||||
totalRecords += int(count)
|
||||
plan.EstimatedDuration += modelPlan.EstimatedTime
|
||||
}
|
||||
|
||||
plan.EstimatedRecords = totalRecords
|
||||
|
||||
// Calculate optimal batch size and parallelism based on total record count
|
||||
plan.RecommendedOptions.BatchSize = r.calculateOptimalBatchSize(totalRecords)
|
||||
plan.RecommendedOptions.Parallelism = r.calculateOptimalParallelism(totalRecords)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// identifyEncryptedFields finds all encrypted fields in a model
|
||||
func (r *RotationUtility) identifyEncryptedFields(model interface{}) []string {
|
||||
fields := []string{}
|
||||
|
||||
// Get model value and type
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
// Skip if not a struct
|
||||
if modelType.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
// Scan all fields for encrypted ones
|
||||
for i := 0; i < modelType.NumField(); i++ {
|
||||
field := modelType.Field(i)
|
||||
|
||||
// Look for fields starting with "Encrypted"
|
||||
if strings.HasPrefix(field.Name, "Encrypted") && field.Type.Kind() == reflect.String {
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// calculateOptimalBatchSize determines the optimal batch size based on record count
|
||||
func (r *RotationUtility) calculateOptimalBatchSize(recordCount int) int {
|
||||
// This is a simplistic approach - in a real system, this would be based on
|
||||
// benchmarking and system characteristics
|
||||
if recordCount < 1000 {
|
||||
return 100
|
||||
} else if recordCount < 10000 {
|
||||
return 250
|
||||
} else if recordCount < 100000 {
|
||||
return 500
|
||||
} else {
|
||||
return 1000
|
||||
}
|
||||
}
|
||||
|
||||
// calculateOptimalParallelism determines the optimal parallelism level
|
||||
func (r *RotationUtility) calculateOptimalParallelism(recordCount int) int {
|
||||
// Simple heuristic - adjust based on actual system performance
|
||||
cpuCount := runtime.NumCPU()
|
||||
|
||||
if recordCount < 10000 {
|
||||
return 1
|
||||
} else if recordCount < 100000 {
|
||||
return min(2, cpuCount)
|
||||
} else {
|
||||
return min(4, cpuCount)
|
||||
}
|
||||
}
|
||||
|
||||
// min returns the minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// estimateMigrationTime provides a rough estimate of time needed for migration
|
||||
func (r *RotationUtility) estimateMigrationTime(recordCount, fieldCount int) time.Duration {
|
||||
// This is a very rough estimate - in a real system, this would be based on
|
||||
// benchmarking results and system characteristics
|
||||
|
||||
// Assume roughly 10ms per record per field
|
||||
msPerRecordField := 10
|
||||
|
||||
// Calculate total time in milliseconds
|
||||
totalTimeMs := recordCount * fieldCount * msPerRecordField
|
||||
|
||||
// Add overhead
|
||||
totalTimeMs = int(float64(totalTimeMs) * 1.2) // 20% overhead
|
||||
|
||||
return time.Duration(totalTimeMs) * time.Millisecond
|
||||
}
|
||||
|
||||
// EncryptionMigrationPlan contains the complete plan for migration
|
||||
type EncryptionMigrationPlan struct {
|
||||
ModelPlans map[string]*ModelMigrationPlan `json:"model_plans"`
|
||||
EstimatedDuration time.Duration `json:"estimated_duration"`
|
||||
EstimatedRecords int `json:"estimated_records"`
|
||||
RecommendedOptions RotationOptions `json:"recommended_options"`
|
||||
}
|
||||
|
||||
// ModelMigrationPlan contains migration details for a specific model
|
||||
type ModelMigrationPlan struct {
|
||||
ModelName string `json:"model_name"`
|
||||
RecordCount int `json:"record_count"`
|
||||
EstimatedTime time.Duration `json:"estimated_time"`
|
||||
EncryptedFields []string `json:"encrypted_fields"`
|
||||
BatchSizeRec int `json:"batch_size_recommendation"`
|
||||
}
|
||||
Reference in new issue
Block a user