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:
StarFleetCPTN committed 2025-04-16 17:18:53 -07:00
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"`
}