From a1f673c76f8ab16ef2f91d6f63c5f421d1f7336c Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Wed, 4 Mar 2026 13:20:26 -0300 Subject: [PATCH] feat: add purge commands for transit and tokenization keys - Implement `purge-transit-keys` and `purge-tokenization-keys` CLI commands to perform hard deletion of soft-deleted keys. - Add `HardDelete` method to `TransitKeyRepository` and `TokenizationKeyRepository` for both PostgreSQL and MySQL. - For tokenization, ensure hard-deleting a key also deletes all associated tokens in the `tokenization_tokens` table. - Implement `PurgeDeleted` use case logic with metrics decoration for observability. - Add comprehensive unit and integration tests for all new repository and use case methods. - Update `docs/cli-commands.md` with usage instructions and examples. - Suppress `gosec` G101 false positives in tokenization repositories. - Bump application version to `v0.26.0` in `cmd/app/main.go` and update `CHANGELOG.md`. This aligns the transit and tokenization modules with the existing `purge-secrets` maintenance functionality. --- CHANGELOG.md | 6 + cmd/app/commands/purge_tokenization_keys.go | 84 ++++++++++ cmd/app/commands/purge_transit_keys.go | 80 ++++++++++ cmd/app/main.go | 2 +- cmd/app/system_commands.go | 90 +++++++++++ docs/cli-commands.md | 34 ++++ .../repository/mysql/mysql_repository.go | 45 ++++++ .../repository/mysql/mysql_repository_test.go | 115 ++++++++++++++ .../postgresql/postgresql_repository.go | 45 ++++++ .../postgresql/postgresql_repository_test.go | 111 +++++++++++++ internal/tokenization/usecase/interface.go | 13 ++ internal/tokenization/usecase/mocks/mocks.go | 144 +++++++++++++++++ .../tokenization_key_metrics_decorator.go | 20 +++ .../usecase/tokenization_key_usecase.go | 15 ++ .../usecase/tokenization_key_usecase_test.go | 147 ++++++++++++++++++ .../mysql/mysql_transit_key_repository.go | 33 ++++ .../mysql_transit_key_repository_test.go | 89 +++++++++++ .../postgresql_transit_key_repository.go | 33 ++++ .../postgresql_transit_key_repository_test.go | 86 ++++++++++ internal/transit/usecase/interface.go | 12 ++ internal/transit/usecase/metrics_decorator.go | 20 +++ internal/transit/usecase/mocks/mocks.go | 145 +++++++++++++++++ .../transit/usecase/transit_key_usecase.go | 10 ++ .../usecase/transit_key_usecase_test.go | 135 ++++++++++++++++ 24 files changed, 1513 insertions(+), 1 deletion(-) create mode 100644 cmd/app/commands/purge_tokenization_keys.go create mode 100644 cmd/app/commands/purge_transit_keys.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c60c87..0dceb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.26.0] - 2026-03-04 + +### Added +- Added `purge-transit-keys` and `purge-tokenization-keys` CLI commands with dry-run and formatting support for permanently deleting soft-deleted keys. + ## [0.25.0] - 2026-03-03 ### Added @@ -428,6 +433,7 @@ If you are using `sslmode=disable` (PostgreSQL) or `tls=false` (MySQL) in produc - Security model documentation - Architecture documentation +[0.26.0]: https://github.com/allisson/secrets/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/allisson/secrets/compare/v0.24.0...v0.25.0 [0.24.0]: https://github.com/allisson/secrets/compare/v0.23.0...v0.24.0 [0.23.0]: https://github.com/allisson/secrets/compare/v0.22.1...v0.23.0 diff --git a/cmd/app/commands/purge_tokenization_keys.go b/cmd/app/commands/purge_tokenization_keys.go new file mode 100644 index 0000000..9eec7c8 --- /dev/null +++ b/cmd/app/commands/purge_tokenization_keys.go @@ -0,0 +1,84 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + + tokenizationUseCase "github.com/allisson/secrets/internal/tokenization/usecase" +) + +// PurgeTokenizationKeysResult holds the result of the tokenization key purge operation. +type PurgeTokenizationKeysResult struct { + Count int64 `json:"count"` + Days int `json:"days"` + DryRun bool `json:"dry_run"` +} + +// ToText returns a human-readable representation of the purge result. +func (r *PurgeTokenizationKeysResult) ToText() string { + if r.DryRun { + return fmt.Sprintf( + "Dry-run mode: Would delete %d tokenization key(s) (and associated tokens) older than %d day(s)", + r.Count, + r.Days, + ) + } + return fmt.Sprintf( + "Successfully deleted %d tokenization key(s) (and associated tokens) older than %d day(s)", + r.Count, + r.Days, + ) +} + +// ToJSON returns a JSON representation of the purge result. +func (r *PurgeTokenizationKeysResult) ToJSON() string { + jsonBytes, _ := json.MarshalIndent(r, "", " ") + return string(jsonBytes) +} + +// RunPurgeTokenizationKeys permanently deletes soft-deleted tokenization keys and their tokens older than the specified number of days. +// Supports dry-run mode and multiple output formats. +func RunPurgeTokenizationKeys( + ctx context.Context, + tokenizationUseCase tokenizationUseCase.TokenizationKeyUseCase, + logger *slog.Logger, + writer io.Writer, + days int, + dryRun bool, + format string, +) error { + // Validate days parameter + if days < 0 { + return fmt.Errorf("days must be a positive number, got: %d", days) + } + + logger.Info("purging deleted tokenization keys", + slog.Int("days", days), + slog.Bool("dry_run", dryRun), + ) + + // Execute purge operation + count, err := tokenizationUseCase.PurgeDeleted(ctx, days, dryRun) + if err != nil { + return fmt.Errorf("failed to purge tokenization keys: %w", err) + } + + // Output result + result := &PurgeTokenizationKeysResult{ + Count: count, + Days: days, + DryRun: dryRun, + } + WriteOutput(writer, format, result) + + logger.Info("purge completed", + slog.Int64("count", count), + slog.Int("days", days), + slog.Bool("dry_run", dryRun), + ) + + return nil +} diff --git a/cmd/app/commands/purge_transit_keys.go b/cmd/app/commands/purge_transit_keys.go new file mode 100644 index 0000000..8c8116d --- /dev/null +++ b/cmd/app/commands/purge_transit_keys.go @@ -0,0 +1,80 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + + transitUseCase "github.com/allisson/secrets/internal/transit/usecase" +) + +// PurgeTransitKeysResult holds the result of the transit key purge operation. +type PurgeTransitKeysResult struct { + Count int64 `json:"count"` + Days int `json:"days"` + DryRun bool `json:"dry_run"` +} + +// ToText returns a human-readable representation of the purge result. +func (r *PurgeTransitKeysResult) ToText() string { + if r.DryRun { + return fmt.Sprintf( + "Dry-run mode: Would delete %d transit key(s) older than %d day(s)", + r.Count, + r.Days, + ) + } + return fmt.Sprintf("Successfully deleted %d transit key(s) older than %d day(s)", r.Count, r.Days) +} + +// ToJSON returns a JSON representation of the purge result. +func (r *PurgeTransitKeysResult) ToJSON() string { + jsonBytes, _ := json.MarshalIndent(r, "", " ") + return string(jsonBytes) +} + +// RunPurgeTransitKeys permanently deletes soft-deleted transit keys older than the specified number of days. +// Supports dry-run mode and multiple output formats. +func RunPurgeTransitKeys( + ctx context.Context, + transitUseCase transitUseCase.TransitKeyUseCase, + logger *slog.Logger, + writer io.Writer, + days int, + dryRun bool, + format string, +) error { + // Validate days parameter + if days < 0 { + return fmt.Errorf("days must be a positive number, got: %d", days) + } + + logger.Info("purging deleted transit keys", + slog.Int("days", days), + slog.Bool("dry_run", dryRun), + ) + + // Execute purge operation + count, err := transitUseCase.PurgeDeleted(ctx, days, dryRun) + if err != nil { + return fmt.Errorf("failed to purge transit keys: %w", err) + } + + // Output result + result := &PurgeTransitKeysResult{ + Count: count, + Days: days, + DryRun: dryRun, + } + WriteOutput(writer, format, result) + + logger.Info("purge completed", + slog.Int64("count", count), + slog.Int("days", days), + slog.Bool("dry_run", dryRun), + ) + + return nil +} diff --git a/cmd/app/main.go b/cmd/app/main.go index d22c0b2..7686b80 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -12,7 +12,7 @@ import ( // Build-time version information (injected via ldflags during build). var ( - version = "v0.25.0" // Semantic version with "v" prefix (e.g., "v0.12.0") + version = "v0.26.0" // Semantic version with "v" prefix (e.g., "v0.12.0") buildDate = "unknown" // ISO 8601 build timestamp commitSHA = "unknown" // Git commit SHA ) diff --git a/cmd/app/system_commands.go b/cmd/app/system_commands.go index fc195e3..1972c80 100644 --- a/cmd/app/system_commands.go +++ b/cmd/app/system_commands.go @@ -153,6 +153,96 @@ func getSystemCommands(version string) []*cli.Command { ) }, }, + { + Name: "purge-transit-keys", + Usage: "Permanently delete soft-deleted transit keys older than specified days", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "days", + Aliases: []string{"d"}, + Value: 30, + Usage: "Delete transit keys soft-deleted more than this many days ago", + }, + &cli.BoolFlag{ + Name: "dry-run", + Aliases: []string{"n"}, + Value: false, + Usage: "Show how many transit keys would be deleted without deleting", + }, + &cli.StringFlag{ + Name: "format", + Aliases: []string{"f"}, + Value: "text", + Usage: "Output format: 'text' or 'json'", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return commands.ExecuteWithContainer( + ctx, + func(ctx context.Context, container *app.Container) error { + transitUseCase, err := container.TransitKeyUseCase(ctx) + if err != nil { + return err + } + + return commands.RunPurgeTransitKeys( + ctx, + transitUseCase, + container.Logger(), + commands.DefaultIO().Writer, + int(cmd.Int("days")), + cmd.Bool("dry-run"), + cmd.String("format"), + ) + }, + ) + }, + }, + { + Name: "purge-tokenization-keys", + Usage: "Permanently delete soft-deleted tokenization keys and associated tokens older than specified days", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "days", + Aliases: []string{"d"}, + Value: 30, + Usage: "Delete tokenization keys soft-deleted more than this many days ago", + }, + &cli.BoolFlag{ + Name: "dry-run", + Aliases: []string{"n"}, + Value: false, + Usage: "Show how many tokenization keys would be deleted without deleting", + }, + &cli.StringFlag{ + Name: "format", + Aliases: []string{"f"}, + Value: "text", + Usage: "Output format: 'text' or 'json'", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + return commands.ExecuteWithContainer( + ctx, + func(ctx context.Context, container *app.Container) error { + tokenizationUseCase, err := container.TokenizationKeyUseCase(ctx) + if err != nil { + return err + } + + return commands.RunPurgeTokenizationKeys( + ctx, + tokenizationUseCase, + container.Logger(), + commands.DefaultIO().Writer, + int(cmd.Int("days")), + cmd.Bool("dry-run"), + cmd.String("format"), + ) + }, + ) + }, + }, { Name: "verify-audit-logs", Usage: "Verify cryptographic integrity of audit logs", diff --git a/docs/cli-commands.md b/docs/cli-commands.md index 1227d25..ff4a72b 100644 --- a/docs/cli-commands.md +++ b/docs/cli-commands.md @@ -595,6 +595,40 @@ Requirements: - Database must be reachable and migrated - Use `--dry-run` before deletion in production environments +### `purge-transit-keys` + +Permanently deletes soft-deleted transit keys older than a specified number of days. This operation is **irreversible** and any data encrypted with these keys will become permanently inaccessible. + +Flags: + +- `--days`, `-d` (default `30`): delete transit keys soft-deleted more than this many days ago +- `--dry-run`, `-n` (default `false`): preview count without deleting +- `--format`, `-f`: `text` (default) or `json` + +Examples: + +```bash +./bin/app purge-transit-keys --days 30 --dry-run +./bin/app purge-transit-keys --days 90 --format json +``` + +### `purge-tokenization-keys` + +Permanently deletes soft-deleted tokenization keys and all of their associated tokens older than a specified number of days. This operation is **irreversible** and any tokens generated with these keys will be permanently deleted. + +Flags: + +- `--days`, `-d` (default `30`): delete tokenization keys soft-deleted more than this many days ago +- `--dry-run`, `-n` (default `false`): preview count of keys without deleting +- `--format`, `-f`: `text` (default) or `json` + +Examples: + +```bash +./bin/app purge-tokenization-keys --days 30 --dry-run +./bin/app purge-tokenization-keys --days 90 --format json +``` + ## See also - [Docker getting started](getting-started/docker.md) diff --git a/internal/tokenization/repository/mysql/mysql_repository.go b/internal/tokenization/repository/mysql/mysql_repository.go index 2779689..9a6a18f 100644 --- a/internal/tokenization/repository/mysql/mysql_repository.go +++ b/internal/tokenization/repository/mysql/mysql_repository.go @@ -390,6 +390,51 @@ func (m *MySQLTokenizationKeyRepository) ListCursor( return keys, nil } +// HardDelete permanently removes soft-deleted tokenization keys and their associated tokens. +func (m *MySQLTokenizationKeyRepository) HardDelete( + ctx context.Context, + olderThan time.Time, + dryRun bool, +) (int64, error) { + querier := database.GetTx(ctx, m.db) + + if dryRun { + query := `SELECT COUNT(*) FROM tokenization_keys WHERE deleted_at IS NOT NULL AND deleted_at < ?` + var count int64 + err := querier.QueryRowContext(ctx, query, olderThan).Scan(&count) + if err != nil { + return 0, apperrors.Wrap(err, "failed to count tokenization keys for hard delete") + } + return count, nil + } + + // Delete associated tokens first + //nolint:gosec // false positive: this is a SQL query, not a credential + deleteTokensQuery := ` + DELETE FROM tokenization_tokens + WHERE tokenization_key_id IN ( + SELECT id FROM tokenization_keys WHERE deleted_at IS NOT NULL AND deleted_at < ? + )` + _, err := querier.ExecContext(ctx, deleteTokensQuery, olderThan) + if err != nil { + return 0, apperrors.Wrap(err, "failed to delete associated tokens") + } + + // Delete keys + deleteKeysQuery := `DELETE FROM tokenization_keys WHERE deleted_at IS NOT NULL AND deleted_at < ?` + result, err := querier.ExecContext(ctx, deleteKeysQuery, olderThan) + if err != nil { + return 0, apperrors.Wrap(err, "failed to hard delete tokenization keys") + } + + count, err := result.RowsAffected() + if err != nil { + return 0, apperrors.Wrap(err, "failed to get rows affected for hard delete") + } + + return count, nil +} + // NewMySQLTokenizationKeyRepository creates a new MySQL tokenization key repository instance. func NewMySQLTokenizationKeyRepository(db *sql.DB) *MySQLTokenizationKeyRepository { return &MySQLTokenizationKeyRepository{db: db} diff --git a/internal/tokenization/repository/mysql/mysql_repository_test.go b/internal/tokenization/repository/mysql/mysql_repository_test.go index cb444c5..e8f4ae3 100644 --- a/internal/tokenization/repository/mysql/mysql_repository_test.go +++ b/internal/tokenization/repository/mysql/mysql_repository_test.go @@ -557,3 +557,118 @@ func TestMySQLTokenizationKeyRepository_ListCursor_EmptyResult(t *testing.T) { assert.NotNil(t, keys) assert.Len(t, keys, 0) } + +func TestMySQLTokenizationKeyRepository_HardDelete(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLTokenizationKeyRepository(db) + tokenRepo := NewMySQLTokenRepository(db) + ctx := context.Background() + + _, dekID := createKekAndDekMySQL(t, db) + + // Create keys and tokens: + // 1. Not deleted + // 2. Deleted 10 days ago + // 3. Deleted 40 days ago (with associated token) + + now := time.Now().UTC() + tenDaysAgo := now.AddDate(0, 0, -10) + fortyDaysAgo := now.AddDate(0, 0, -40) + + key1 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "active-key", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key1)) + + key2 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-recent", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key2)) + key2IDBytes, _ := key2.ID.MarshalBinary() + _, err := db.ExecContext(ctx, "UPDATE tokenization_keys SET deleted_at = ? WHERE id = ?", tenDaysAgo, key2IDBytes) + require.NoError(t, err) + + key3 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-old", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key3)) + key3IDBytes, _ := key3.ID.MarshalBinary() + _, err = db.ExecContext(ctx, "UPDATE tokenization_keys SET deleted_at = ? WHERE id = ?", fortyDaysAgo, key3IDBytes) + require.NoError(t, err) + + // Add a token for key3 + token := &tokenizationDomain.Token{ + ID: uuid.Must(uuid.NewV7()), + TokenizationKeyID: key3.ID, + Token: "tok_for_key3", + Ciphertext: []byte("encrypted"), + Nonce: []byte("nonce"), + CreatedAt: now.AddDate(0, 0, -45), + } + require.NoError(t, tokenRepo.Create(ctx, token)) + tokenIDBytes, _ := token.ID.MarshalBinary() + + cutoff := now.AddDate(0, 0, -30) + + t.Run("DryRun", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key still exists + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = ?)", key3IDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + + // Verify token still exists + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_tokens WHERE id = ?)", tokenIDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("ActualDelete", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, false) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key3 is gone + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = ?)", key3IDBytes).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists) + + // Verify token for key3 is gone + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_tokens WHERE id = ?)", tokenIDBytes).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists) + + // Verify key1 and key2 still exist + key1IDBytes, _ := key1.ID.MarshalBinary() + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = ?)", key1IDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = ?)", key2IDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) +} diff --git a/internal/tokenization/repository/postgresql/postgresql_repository.go b/internal/tokenization/repository/postgresql/postgresql_repository.go index 85776d5..5b84e0c 100644 --- a/internal/tokenization/repository/postgresql/postgresql_repository.go +++ b/internal/tokenization/repository/postgresql/postgresql_repository.go @@ -327,6 +327,51 @@ func (p *PostgreSQLTokenizationKeyRepository) ListCursor( return keys, nil } +// HardDelete permanently removes soft-deleted tokenization keys and their associated tokens. +func (p *PostgreSQLTokenizationKeyRepository) HardDelete( + ctx context.Context, + olderThan time.Time, + dryRun bool, +) (int64, error) { + querier := database.GetTx(ctx, p.db) + + if dryRun { + query := `SELECT COUNT(*) FROM tokenization_keys WHERE deleted_at IS NOT NULL AND deleted_at < $1` + var count int64 + err := querier.QueryRowContext(ctx, query, olderThan).Scan(&count) + if err != nil { + return 0, apperrors.Wrap(err, "failed to count tokenization keys for hard delete") + } + return count, nil + } + + // Delete associated tokens first + //nolint:gosec // false positive: this is a SQL query, not a credential + deleteTokensQuery := ` + DELETE FROM tokenization_tokens + WHERE tokenization_key_id IN ( + SELECT id FROM tokenization_keys WHERE deleted_at IS NOT NULL AND deleted_at < $1 + )` + _, err := querier.ExecContext(ctx, deleteTokensQuery, olderThan) + if err != nil { + return 0, apperrors.Wrap(err, "failed to delete associated tokens") + } + + // Delete keys + deleteKeysQuery := `DELETE FROM tokenization_keys WHERE deleted_at IS NOT NULL AND deleted_at < $1` + result, err := querier.ExecContext(ctx, deleteKeysQuery, olderThan) + if err != nil { + return 0, apperrors.Wrap(err, "failed to hard delete tokenization keys") + } + + count, err := result.RowsAffected() + if err != nil { + return 0, apperrors.Wrap(err, "failed to get rows affected for hard delete") + } + + return count, nil +} + // NewPostgreSQLTokenizationKeyRepository creates a new PostgreSQL tokenization key repository instance. func NewPostgreSQLTokenizationKeyRepository(db *sql.DB) *PostgreSQLTokenizationKeyRepository { return &PostgreSQLTokenizationKeyRepository{db: db} diff --git a/internal/tokenization/repository/postgresql/postgresql_repository_test.go b/internal/tokenization/repository/postgresql/postgresql_repository_test.go index 5d57cd4..e02ecbe 100644 --- a/internal/tokenization/repository/postgresql/postgresql_repository_test.go +++ b/internal/tokenization/repository/postgresql/postgresql_repository_test.go @@ -557,3 +557,114 @@ func TestPostgreSQLTokenizationKeyRepository_ListCursor_EmptyResult(t *testing.T assert.NotNil(t, keys) assert.Len(t, keys, 0) } + +func TestPostgreSQLTokenizationKeyRepository_HardDelete(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLTokenizationKeyRepository(db) + tokenRepo := NewPostgreSQLTokenRepository(db) + ctx := context.Background() + + _, dekID := createKekAndDek(t, db) + + // Create keys and tokens: + // 1. Not deleted + // 2. Deleted 10 days ago + // 3. Deleted 40 days ago (with associated token) + + now := time.Now().UTC() + tenDaysAgo := now.AddDate(0, 0, -10) + fortyDaysAgo := now.AddDate(0, 0, -40) + + key1 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "active-key", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key1)) + + key2 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-recent", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key2)) + _, err := db.ExecContext(ctx, "UPDATE tokenization_keys SET deleted_at = $1 WHERE id = $2", tenDaysAgo, key2.ID) + require.NoError(t, err) + + key3 := &tokenizationDomain.TokenizationKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-old", + Version: 1, + FormatType: tokenizationDomain.FormatUUID, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key3)) + _, err = db.ExecContext(ctx, "UPDATE tokenization_keys SET deleted_at = $1 WHERE id = $2", fortyDaysAgo, key3.ID) + require.NoError(t, err) + + // Add a token for key3 + token := &tokenizationDomain.Token{ + ID: uuid.Must(uuid.NewV7()), + TokenizationKeyID: key3.ID, + Token: "tok_for_key3", + Ciphertext: []byte("encrypted"), + Nonce: []byte("nonce"), + CreatedAt: now.AddDate(0, 0, -45), + } + require.NoError(t, tokenRepo.Create(ctx, token)) + + cutoff := now.AddDate(0, 0, -30) + + t.Run("DryRun", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key still exists + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = $1)", key3.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + + // Verify token still exists + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_tokens WHERE id = $1)", token.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("ActualDelete", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, false) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key3 is gone + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = $1)", key3.ID).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists) + + // Verify token for key3 is gone + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_tokens WHERE id = $1)", token.ID).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists) + + // Verify key1 and key2 still exist + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = $1)", key1.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM tokenization_keys WHERE id = $1)", key2.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) +} diff --git a/internal/tokenization/usecase/interface.go b/internal/tokenization/usecase/interface.go index 60a12fb..8b29f0c 100644 --- a/internal/tokenization/usecase/interface.go +++ b/internal/tokenization/usecase/interface.go @@ -39,6 +39,13 @@ type TokenizationKeyRepository interface { afterName *string, limit int, ) ([]*tokenizationDomain.TokenizationKey, error) + + // HardDelete permanently removes soft-deleted tokenization keys older than the specified time. + // It must also cascade the deletion to any associated tokens in the tokenization_tokens table. + // Only affects keys where deleted_at IS NOT NULL. + // If dryRun is true, returns count of keys without performing deletion. + // Returns the number of keys that were (or would be) deleted. + HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) } // TokenRepository defines the interface for token mapping persistence. @@ -93,6 +100,12 @@ type TokenizationKeyUseCase interface { afterName *string, limit int, ) ([]*tokenizationDomain.TokenizationKey, error) + + // PurgeDeleted permanently removes soft-deleted tokenization keys older than specified days. + // It also removes all tokens associated with those keys. + // If dryRun is true, returns count of keys without performing deletion. + // Returns the number of keys that were (or would be) deleted. + PurgeDeleted(ctx context.Context, olderThanDays int, dryRun bool) (int64, error) } // TokenizationUseCase defines the interface for token generation and management operations. diff --git a/internal/tokenization/usecase/mocks/mocks.go b/internal/tokenization/usecase/mocks/mocks.go index 85b2fd5..5452ccf 100644 --- a/internal/tokenization/usecase/mocks/mocks.go +++ b/internal/tokenization/usecase/mocks/mocks.go @@ -601,6 +601,78 @@ func (_c *MockTokenizationKeyRepository_GetByNameAndVersion_Call) RunAndReturn(r return _c } +// HardDelete provides a mock function for the type MockTokenizationKeyRepository +func (_mock *MockTokenizationKeyRepository) HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) { + ret := _mock.Called(ctx, olderThan, dryRun) + + if len(ret) == 0 { + panic("no return value specified for HardDelete") + } + + var r0 int64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, bool) (int64, error)); ok { + return returnFunc(ctx, olderThan, dryRun) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, bool) int64); ok { + r0 = returnFunc(ctx, olderThan, dryRun) + } else { + r0 = ret.Get(0).(int64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, bool) error); ok { + r1 = returnFunc(ctx, olderThan, dryRun) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTokenizationKeyRepository_HardDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HardDelete' +type MockTokenizationKeyRepository_HardDelete_Call struct { + *mock.Call +} + +// HardDelete is a helper method to define mock.On call +// - ctx context.Context +// - olderThan time.Time +// - dryRun bool +func (_e *MockTokenizationKeyRepository_Expecter) HardDelete(ctx interface{}, olderThan interface{}, dryRun interface{}) *MockTokenizationKeyRepository_HardDelete_Call { + return &MockTokenizationKeyRepository_HardDelete_Call{Call: _e.mock.On("HardDelete", ctx, olderThan, dryRun)} +} + +func (_c *MockTokenizationKeyRepository_HardDelete_Call) Run(run func(ctx context.Context, olderThan time.Time, dryRun bool)) *MockTokenizationKeyRepository_HardDelete_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 time.Time + if args[1] != nil { + arg1 = args[1].(time.Time) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTokenizationKeyRepository_HardDelete_Call) Return(n int64, err error) *MockTokenizationKeyRepository_HardDelete_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockTokenizationKeyRepository_HardDelete_Call) RunAndReturn(run func(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error)) *MockTokenizationKeyRepository_HardDelete_Call { + _c.Call.Return(run) + return _c +} + // ListCursor provides a mock function for the type MockTokenizationKeyRepository func (_mock *MockTokenizationKeyRepository) ListCursor(ctx context.Context, afterName *string, limit int) ([]*domain0.TokenizationKey, error) { ret := _mock.Called(ctx, afterName, limit) @@ -1334,6 +1406,78 @@ func (_c *MockTokenizationKeyUseCase_ListCursor_Call) RunAndReturn(run func(ctx return _c } +// PurgeDeleted provides a mock function for the type MockTokenizationKeyUseCase +func (_mock *MockTokenizationKeyUseCase) PurgeDeleted(ctx context.Context, olderThanDays int, dryRun bool) (int64, error) { + ret := _mock.Called(ctx, olderThanDays, dryRun) + + if len(ret) == 0 { + panic("no return value specified for PurgeDeleted") + } + + var r0 int64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, bool) (int64, error)); ok { + return returnFunc(ctx, olderThanDays, dryRun) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, bool) int64); ok { + r0 = returnFunc(ctx, olderThanDays, dryRun) + } else { + r0 = ret.Get(0).(int64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, bool) error); ok { + r1 = returnFunc(ctx, olderThanDays, dryRun) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTokenizationKeyUseCase_PurgeDeleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeDeleted' +type MockTokenizationKeyUseCase_PurgeDeleted_Call struct { + *mock.Call +} + +// PurgeDeleted is a helper method to define mock.On call +// - ctx context.Context +// - olderThanDays int +// - dryRun bool +func (_e *MockTokenizationKeyUseCase_Expecter) PurgeDeleted(ctx interface{}, olderThanDays interface{}, dryRun interface{}) *MockTokenizationKeyUseCase_PurgeDeleted_Call { + return &MockTokenizationKeyUseCase_PurgeDeleted_Call{Call: _e.mock.On("PurgeDeleted", ctx, olderThanDays, dryRun)} +} + +func (_c *MockTokenizationKeyUseCase_PurgeDeleted_Call) Run(run func(ctx context.Context, olderThanDays int, dryRun bool)) *MockTokenizationKeyUseCase_PurgeDeleted_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTokenizationKeyUseCase_PurgeDeleted_Call) Return(n int64, err error) *MockTokenizationKeyUseCase_PurgeDeleted_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockTokenizationKeyUseCase_PurgeDeleted_Call) RunAndReturn(run func(ctx context.Context, olderThanDays int, dryRun bool) (int64, error)) *MockTokenizationKeyUseCase_PurgeDeleted_Call { + _c.Call.Return(run) + return _c +} + // Rotate provides a mock function for the type MockTokenizationKeyUseCase func (_mock *MockTokenizationKeyUseCase) Rotate(ctx context.Context, name string, formatType domain0.FormatType, isDeterministic bool, alg domain.Algorithm) (*domain0.TokenizationKey, error) { ret := _mock.Called(ctx, name, formatType, isDeterministic, alg) diff --git a/internal/tokenization/usecase/tokenization_key_metrics_decorator.go b/internal/tokenization/usecase/tokenization_key_metrics_decorator.go index 81938d8..dd7045a 100644 --- a/internal/tokenization/usecase/tokenization_key_metrics_decorator.go +++ b/internal/tokenization/usecase/tokenization_key_metrics_decorator.go @@ -107,3 +107,23 @@ func (t *tokenizationKeyUseCaseWithMetrics) ListCursor( return keys, err } + +// PurgeDeleted records metrics for tokenization key purge operations. +func (t *tokenizationKeyUseCaseWithMetrics) PurgeDeleted( + ctx context.Context, + olderThanDays int, + dryRun bool, +) (int64, error) { + start := time.Now() + count, err := t.next.PurgeDeleted(ctx, olderThanDays, dryRun) + + status := "success" + if err != nil { + status = "error" + } + + t.metrics.RecordOperation(ctx, "tokenization", "tokenization_key_purge", status) + t.metrics.RecordDuration(ctx, "tokenization", "tokenization_key_purge", time.Since(start), status) + + return count, err +} diff --git a/internal/tokenization/usecase/tokenization_key_usecase.go b/internal/tokenization/usecase/tokenization_key_usecase.go index 65460c9..67c94c2 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase.go +++ b/internal/tokenization/usecase/tokenization_key_usecase.go @@ -232,6 +232,21 @@ func (t *tokenizationKeyUseCase) ListCursor( return keys, nil } +// PurgeDeleted permanently removes soft-deleted tokenization keys older than specified days. +// It also removes all tokens associated with those keys. +func (t *tokenizationKeyUseCase) PurgeDeleted( + ctx context.Context, + olderThanDays int, + dryRun bool, +) (int64, error) { + if olderThanDays < 0 { + return 0, apperrors.New("olderThanDays must be a positive number") + } + + olderThan := time.Now().UTC().AddDate(0, 0, -olderThanDays) + return t.tokenizationKeyRepo.HardDelete(ctx, olderThan, dryRun) +} + // NewTokenizationKeyUseCase creates a new tokenization key use case instance. func NewTokenizationKeyUseCase( txManager database.TxManager, diff --git a/internal/tokenization/usecase/tokenization_key_usecase_test.go b/internal/tokenization/usecase/tokenization_key_usecase_test.go index 60de86d..e7142bd 100644 --- a/internal/tokenization/usecase/tokenization_key_usecase_test.go +++ b/internal/tokenization/usecase/tokenization_key_usecase_test.go @@ -606,3 +606,150 @@ func TestTokenizationKeyUseCase_Delete(t *testing.T) { assert.Contains(t, err.Error(), "failed to delete tokenization key") }) } + +// TestTokenizationKeyUseCase_PurgeDeleted tests the PurgeDeleted method. +func TestTokenizationKeyUseCase_PurgeDeleted(t *testing.T) { + ctx := context.Background() + + t.Run("Success_PurgeDeletedKeys", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + olderThanDays := 30 + dryRun := false + expectedDeletedCount := int64(5) + + // Setup expectations + mockTokenizationKeyRepo.EXPECT(). + HardDelete(ctx, mock.AnythingOfType("time.Time"), dryRun). + Return(expectedDeletedCount, nil). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedDeletedCount, count) + }) + + t.Run("Success_PurgeDeletedKeys_DryRun", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + olderThanDays := 30 + dryRun := true + expectedDeletedCount := int64(10) + + // Setup expectations + mockTokenizationKeyRepo.EXPECT(). + HardDelete(ctx, mock.AnythingOfType("time.Time"), dryRun). + Return(expectedDeletedCount, nil). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedDeletedCount, count) + }) + + t.Run("Error_InvalidOlderThanDays", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + olderThanDays := -1 + dryRun := false + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.Error(t, err) + assert.Equal(t, int64(0), count) + assert.Contains(t, err.Error(), "olderThanDays must be a positive number") + }) + t.Run("Error_HardDeleteFails", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTokenizationKeyRepo := tokenizationMocks.NewMockTokenizationKeyRepository(t) + mockDekRepo := tokenizationMocks.NewMockDekRepository(t) + mockKeyManager := cryptoServiceMocks.NewMockKeyManager(t) + + // Create test data + masterKey := tokenizationTesting.CreateMasterKey() + kekChain := tokenizationTesting.CreateKekChain(masterKey) + defer kekChain.Close() + + olderThanDays := 30 + dryRun := false + expectedError := errors.New("database error") + + // Setup expectations + mockTokenizationKeyRepo.EXPECT(). + HardDelete(ctx, mock.AnythingOfType("time.Time"), dryRun). + Return(int64(0), expectedError). + Once() + + // Execute + uc := NewTokenizationKeyUseCase( + mockTxManager, + mockTokenizationKeyRepo, + mockDekRepo, + mockKeyManager, + kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.Error(t, err) + assert.Equal(t, int64(0), count) + assert.True(t, errors.Is(err, expectedError)) + }) +} diff --git a/internal/transit/repository/mysql/mysql_transit_key_repository.go b/internal/transit/repository/mysql/mysql_transit_key_repository.go index e195094..eb4aea5 100644 --- a/internal/transit/repository/mysql/mysql_transit_key_repository.go +++ b/internal/transit/repository/mysql/mysql_transit_key_repository.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/google/uuid" @@ -313,6 +314,38 @@ func (m *MySQLTransitKeyRepository) ListCursor( return transitKeys, nil } +// HardDelete permanently removes soft-deleted transit keys older than the specified time. +func (m *MySQLTransitKeyRepository) HardDelete( + ctx context.Context, + olderThan time.Time, + dryRun bool, +) (int64, error) { + querier := database.GetTx(ctx, m.db) + + if dryRun { + query := `SELECT COUNT(*) FROM transit_keys WHERE deleted_at IS NOT NULL AND deleted_at < ?` + var count int64 + err := querier.QueryRowContext(ctx, query, olderThan).Scan(&count) + if err != nil { + return 0, apperrors.Wrap(err, "failed to count transit keys for hard delete") + } + return count, nil + } + + query := `DELETE FROM transit_keys WHERE deleted_at IS NOT NULL AND deleted_at < ?` + result, err := querier.ExecContext(ctx, query, olderThan) + if err != nil { + return 0, apperrors.Wrap(err, "failed to hard delete transit keys") + } + + count, err := result.RowsAffected() + if err != nil { + return 0, apperrors.Wrap(err, "failed to get rows affected for hard delete") + } + + return count, nil +} + // NewMySQLTransitKeyRepository creates a new MySQL transit key repository instance. func NewMySQLTransitKeyRepository(db *sql.DB) *MySQLTransitKeyRepository { return &MySQLTransitKeyRepository{db: db} diff --git a/internal/transit/repository/mysql/mysql_transit_key_repository_test.go b/internal/transit/repository/mysql/mysql_transit_key_repository_test.go index f22dc9e..0756ed8 100644 --- a/internal/transit/repository/mysql/mysql_transit_key_repository_test.go +++ b/internal/transit/repository/mysql/mysql_transit_key_repository_test.go @@ -963,3 +963,92 @@ func TestMySQLTransitKeyRepository_ListCursor_EmptyResult(t *testing.T) { assert.NotNil(t, keys) assert.Len(t, keys, 0) } + +func TestMySQLTransitKeyRepository_HardDelete(t *testing.T) { + db := testutil.SetupMySQLDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupMySQLDB(t, db) + + repo := NewMySQLTransitKeyRepository(db) + ctx := context.Background() + + dekID := createTestDekMySQL(t, db) + + // Create keys: + // 1. Not deleted + // 2. Deleted 10 days ago + // 3. Deleted 40 days ago + + now := time.Now().UTC() + tenDaysAgo := now.AddDate(0, 0, -10) + fortyDaysAgo := now.AddDate(0, 0, -40) + + key1 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "active-key", + Version: 1, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key1)) + + key2 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-recent", + Version: 1, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key2)) + key2IDBytes, _ := key2.ID.MarshalBinary() + _, err := db.ExecContext(ctx, "UPDATE transit_keys SET deleted_at = ? WHERE id = ?", tenDaysAgo, key2IDBytes) + require.NoError(t, err) + + key3 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-old", + Version: 1, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key3)) + key3IDBytes, _ := key3.ID.MarshalBinary() + _, err = db.ExecContext(ctx, "UPDATE transit_keys SET deleted_at = ? WHERE id = ?", fortyDaysAgo, key3IDBytes) + require.NoError(t, err) + + cutoff := now.AddDate(0, 0, -30) + + t.Run("DryRun", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key still exists + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = ?)", key3IDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("ActualDelete", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, false) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key3 is gone + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = ?)", key3IDBytes).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists) + + // Verify key1 and key2 still exist + key1IDBytes, _ := key1.ID.MarshalBinary() + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = ?)", key1IDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = ?)", key2IDBytes).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) +} diff --git a/internal/transit/repository/postgresql/postgresql_transit_key_repository.go b/internal/transit/repository/postgresql/postgresql_transit_key_repository.go index ca9d317..bfaf082 100644 --- a/internal/transit/repository/postgresql/postgresql_transit_key_repository.go +++ b/internal/transit/repository/postgresql/postgresql_transit_key_repository.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/google/uuid" @@ -259,6 +260,38 @@ func (p *PostgreSQLTransitKeyRepository) ListCursor( return transitKeys, nil } +// HardDelete permanently removes soft-deleted transit keys older than the specified time. +func (p *PostgreSQLTransitKeyRepository) HardDelete( + ctx context.Context, + olderThan time.Time, + dryRun bool, +) (int64, error) { + querier := database.GetTx(ctx, p.db) + + if dryRun { + query := `SELECT COUNT(*) FROM transit_keys WHERE deleted_at IS NOT NULL AND deleted_at < $1` + var count int64 + err := querier.QueryRowContext(ctx, query, olderThan).Scan(&count) + if err != nil { + return 0, apperrors.Wrap(err, "failed to count transit keys for hard delete") + } + return count, nil + } + + query := `DELETE FROM transit_keys WHERE deleted_at IS NOT NULL AND deleted_at < $1` + result, err := querier.ExecContext(ctx, query, olderThan) + if err != nil { + return 0, apperrors.Wrap(err, "failed to hard delete transit keys") + } + + count, err := result.RowsAffected() + if err != nil { + return 0, apperrors.Wrap(err, "failed to get rows affected for hard delete") + } + + return count, nil +} + // NewPostgreSQLTransitKeyRepository creates a new PostgreSQL transit key repository instance. func NewPostgreSQLTransitKeyRepository(db *sql.DB) *PostgreSQLTransitKeyRepository { return &PostgreSQLTransitKeyRepository{db: db} diff --git a/internal/transit/repository/postgresql/postgresql_transit_key_repository_test.go b/internal/transit/repository/postgresql/postgresql_transit_key_repository_test.go index 564aab0..d446c8b 100644 --- a/internal/transit/repository/postgresql/postgresql_transit_key_repository_test.go +++ b/internal/transit/repository/postgresql/postgresql_transit_key_repository_test.go @@ -912,3 +912,89 @@ func TestPostgreSQLTransitKeyRepository_ListCursor_EmptyResult(t *testing.T) { assert.NotNil(t, keys) assert.Len(t, keys, 0) } + +func TestPostgreSQLTransitKeyRepository_HardDelete(t *testing.T) { + db := testutil.SetupPostgresDB(t) + defer testutil.TeardownDB(t, db) + defer testutil.CleanupPostgresDB(t, db) + + repo := NewPostgreSQLTransitKeyRepository(db) + ctx := context.Background() + + dekID := createTestDek(t, db) + + // Create keys: + // 1. Not deleted + // 2. Deleted 10 days ago + // 3. Deleted 40 days ago + + now := time.Now().UTC() + tenDaysAgo := now.AddDate(0, 0, -10) + fortyDaysAgo := now.AddDate(0, 0, -40) + + key1 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "active-key", + Version: 1, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key1)) + + key2 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-recent", + Version: 1, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key2)) + _, err := db.ExecContext(ctx, "UPDATE transit_keys SET deleted_at = $1 WHERE id = $2", tenDaysAgo, key2.ID) + require.NoError(t, err) + + key3 := &transitDomain.TransitKey{ + ID: uuid.Must(uuid.NewV7()), + Name: "deleted-old", + Version: 1, + DekID: dekID, + CreatedAt: now.AddDate(0, 0, -50), + } + require.NoError(t, repo.Create(ctx, key3)) + _, err = db.ExecContext(ctx, "UPDATE transit_keys SET deleted_at = $1 WHERE id = $2", fortyDaysAgo, key3.ID) + require.NoError(t, err) + + cutoff := now.AddDate(0, 0, -30) + + t.Run("DryRun", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key still exists + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = $1)", key3.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("ActualDelete", func(t *testing.T) { + count, err := repo.HardDelete(ctx, cutoff, false) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + // Verify key3 is gone + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = $1)", key3.ID).Scan(&exists) + require.NoError(t, err) + assert.False(t, exists) + + // Verify key1 and key2 still exist + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = $1)", key1.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM transit_keys WHERE id = $1)", key2.ID).Scan(&exists) + require.NoError(t, err) + assert.True(t, exists) + }) +} diff --git a/internal/transit/usecase/interface.go b/internal/transit/usecase/interface.go index d35a7c9..e636bd0 100644 --- a/internal/transit/usecase/interface.go +++ b/internal/transit/usecase/interface.go @@ -4,6 +4,7 @@ package usecase import ( "context" + "time" "github.com/google/uuid" @@ -39,6 +40,12 @@ type TransitKeyRepository interface { // Returns the latest version for each key. Filters out soft-deleted keys. // Returns empty slice if no keys found. Limit is pre-validated (1-1000). ListCursor(ctx context.Context, afterName *string, limit int) ([]*transitDomain.TransitKey, error) + + // HardDelete permanently removes soft-deleted transit keys older than the specified time. + // Only affects keys where deleted_at IS NOT NULL. + // If dryRun is true, returns count without performing deletion. + // Returns the number of keys that were (or would be) deleted. + HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) } // TransitKeyUseCase defines the interface for transit encryption operations. @@ -70,4 +77,9 @@ type TransitKeyUseCase interface { // Returns the latest version for each key. Filters out soft-deleted keys. // Returns empty slice if no keys found. Limit is pre-validated (1-1000). ListCursor(ctx context.Context, afterName *string, limit int) ([]*transitDomain.TransitKey, error) + + // PurgeDeleted permanently removes soft-deleted transit keys older than specified days. + // If dryRun is true, returns count without performing deletion. + // Returns the number of keys that were (or would be) deleted. + PurgeDeleted(ctx context.Context, olderThanDays int, dryRun bool) (int64, error) } diff --git a/internal/transit/usecase/metrics_decorator.go b/internal/transit/usecase/metrics_decorator.go index 442e4e2..aa59936 100644 --- a/internal/transit/usecase/metrics_decorator.go +++ b/internal/transit/usecase/metrics_decorator.go @@ -140,3 +140,23 @@ func (t *transitKeyUseCaseWithMetrics) ListCursor( return keys, err } + +// PurgeDeleted records metrics for transit key purge operations. +func (t *transitKeyUseCaseWithMetrics) PurgeDeleted( + ctx context.Context, + olderThanDays int, + dryRun bool, +) (int64, error) { + start := time.Now() + count, err := t.next.PurgeDeleted(ctx, olderThanDays, dryRun) + + status := "success" + if err != nil { + status = "error" + } + + t.metrics.RecordOperation(ctx, "transit", "transit_key_purge", status) + t.metrics.RecordDuration(ctx, "transit", "transit_key_purge", time.Since(start), status) + + return count, err +} diff --git a/internal/transit/usecase/mocks/mocks.go b/internal/transit/usecase/mocks/mocks.go index 05c7cde..862da4a 100644 --- a/internal/transit/usecase/mocks/mocks.go +++ b/internal/transit/usecase/mocks/mocks.go @@ -6,6 +6,7 @@ package mocks import ( "context" + "time" "github.com/allisson/secrets/internal/crypto/domain" domain0 "github.com/allisson/secrets/internal/transit/domain" @@ -448,6 +449,78 @@ func (_c *MockTransitKeyRepository_GetByNameAndVersion_Call) RunAndReturn(run fu return _c } +// HardDelete provides a mock function for the type MockTransitKeyRepository +func (_mock *MockTransitKeyRepository) HardDelete(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error) { + ret := _mock.Called(ctx, olderThan, dryRun) + + if len(ret) == 0 { + panic("no return value specified for HardDelete") + } + + var r0 int64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, bool) (int64, error)); ok { + return returnFunc(ctx, olderThan, dryRun) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, bool) int64); ok { + r0 = returnFunc(ctx, olderThan, dryRun) + } else { + r0 = ret.Get(0).(int64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, bool) error); ok { + r1 = returnFunc(ctx, olderThan, dryRun) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTransitKeyRepository_HardDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HardDelete' +type MockTransitKeyRepository_HardDelete_Call struct { + *mock.Call +} + +// HardDelete is a helper method to define mock.On call +// - ctx context.Context +// - olderThan time.Time +// - dryRun bool +func (_e *MockTransitKeyRepository_Expecter) HardDelete(ctx interface{}, olderThan interface{}, dryRun interface{}) *MockTransitKeyRepository_HardDelete_Call { + return &MockTransitKeyRepository_HardDelete_Call{Call: _e.mock.On("HardDelete", ctx, olderThan, dryRun)} +} + +func (_c *MockTransitKeyRepository_HardDelete_Call) Run(run func(ctx context.Context, olderThan time.Time, dryRun bool)) *MockTransitKeyRepository_HardDelete_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 time.Time + if args[1] != nil { + arg1 = args[1].(time.Time) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTransitKeyRepository_HardDelete_Call) Return(n int64, err error) *MockTransitKeyRepository_HardDelete_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockTransitKeyRepository_HardDelete_Call) RunAndReturn(run func(ctx context.Context, olderThan time.Time, dryRun bool) (int64, error)) *MockTransitKeyRepository_HardDelete_Call { + _c.Call.Return(run) + return _c +} + // ListCursor provides a mock function for the type MockTransitKeyRepository func (_mock *MockTransitKeyRepository) ListCursor(ctx context.Context, afterName *string, limit int) ([]*domain0.TransitKey, error) { ret := _mock.Called(ctx, afterName, limit) @@ -902,6 +975,78 @@ func (_c *MockTransitKeyUseCase_ListCursor_Call) RunAndReturn(run func(ctx conte return _c } +// PurgeDeleted provides a mock function for the type MockTransitKeyUseCase +func (_mock *MockTransitKeyUseCase) PurgeDeleted(ctx context.Context, olderThanDays int, dryRun bool) (int64, error) { + ret := _mock.Called(ctx, olderThanDays, dryRun) + + if len(ret) == 0 { + panic("no return value specified for PurgeDeleted") + } + + var r0 int64 + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, bool) (int64, error)); ok { + return returnFunc(ctx, olderThanDays, dryRun) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, bool) int64); ok { + r0 = returnFunc(ctx, olderThanDays, dryRun) + } else { + r0 = ret.Get(0).(int64) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, bool) error); ok { + r1 = returnFunc(ctx, olderThanDays, dryRun) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockTransitKeyUseCase_PurgeDeleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PurgeDeleted' +type MockTransitKeyUseCase_PurgeDeleted_Call struct { + *mock.Call +} + +// PurgeDeleted is a helper method to define mock.On call +// - ctx context.Context +// - olderThanDays int +// - dryRun bool +func (_e *MockTransitKeyUseCase_Expecter) PurgeDeleted(ctx interface{}, olderThanDays interface{}, dryRun interface{}) *MockTransitKeyUseCase_PurgeDeleted_Call { + return &MockTransitKeyUseCase_PurgeDeleted_Call{Call: _e.mock.On("PurgeDeleted", ctx, olderThanDays, dryRun)} +} + +func (_c *MockTransitKeyUseCase_PurgeDeleted_Call) Run(run func(ctx context.Context, olderThanDays int, dryRun bool)) *MockTransitKeyUseCase_PurgeDeleted_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockTransitKeyUseCase_PurgeDeleted_Call) Return(n int64, err error) *MockTransitKeyUseCase_PurgeDeleted_Call { + _c.Call.Return(n, err) + return _c +} + +func (_c *MockTransitKeyUseCase_PurgeDeleted_Call) RunAndReturn(run func(ctx context.Context, olderThanDays int, dryRun bool) (int64, error)) *MockTransitKeyUseCase_PurgeDeleted_Call { + _c.Call.Return(run) + return _c +} + // Rotate provides a mock function for the type MockTransitKeyUseCase func (_mock *MockTransitKeyUseCase) Rotate(ctx context.Context, name string, alg domain.Algorithm) (*domain0.TransitKey, error) { ret := _mock.Called(ctx, name, alg) diff --git a/internal/transit/usecase/transit_key_usecase.go b/internal/transit/usecase/transit_key_usecase.go index 186232b..5c1f785 100644 --- a/internal/transit/usecase/transit_key_usecase.go +++ b/internal/transit/usecase/transit_key_usecase.go @@ -286,6 +286,16 @@ func (t *transitKeyUseCase) ListCursor( return t.transitRepo.ListCursor(ctx, afterName, limit) } +// PurgeDeleted permanently removes soft-deleted transit keys older than specified days. +func (t *transitKeyUseCase) PurgeDeleted(ctx context.Context, olderThanDays int, dryRun bool) (int64, error) { + if olderThanDays < 0 { + return 0, apperrors.New("olderThanDays must be a positive number") + } + + olderThan := time.Now().UTC().AddDate(0, 0, -olderThanDays) + return t.transitRepo.HardDelete(ctx, olderThan, dryRun) +} + // NewTransitKeyUseCase creates a new TransitKeyUseCase with injected dependencies. func NewTransitKeyUseCase( txManager database.TxManager, diff --git a/internal/transit/usecase/transit_key_usecase_test.go b/internal/transit/usecase/transit_key_usecase_test.go index 9c388d5..8ba3600 100644 --- a/internal/transit/usecase/transit_key_usecase_test.go +++ b/internal/transit/usecase/transit_key_usecase_test.go @@ -1327,3 +1327,138 @@ func TestTransitKeyUseCase_Decrypt(t *testing.T) { assert.True(t, apperrors.Is(err, cryptoDomain.ErrDecryptionFailed)) }) } + +// TestTransitKeyUseCase_PurgeDeleted tests the PurgeDeleted method of transitKeyUseCase. +func TestTransitKeyUseCase_PurgeDeleted(t *testing.T) { + ctx := context.Background() + + t.Run("Success_PurgeDeletedKeys", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTransitRepo := usecaseMocks.NewMockTransitKeyRepository(t) + mockDekRepo := usecaseMocks.NewMockDekRepository(t) + mockKeyManager := serviceMocks.NewMockKeyManager(t) + mockAeadManager := serviceMocks.NewMockAEADManager(t) + + // Create test data + kek := createTestKek() + kekChain := createTestKekChain(kek.ID, kek) + defer kekChain.Close() + + olderThanDays := 30 + dryRun := false + expectedDeletedCount := int64(5) + + // Setup expectations + mockTransitRepo.EXPECT(). + HardDelete(ctx, mock.AnythingOfType("time.Time"), dryRun). + Return(expectedDeletedCount, nil). + Once() + + // Execute + uc := NewTransitKeyUseCase( + mockTxManager, mockTransitRepo, mockDekRepo, mockKeyManager, mockAeadManager, kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedDeletedCount, count) + }) + + t.Run("Success_PurgeDeletedKeys_DryRun", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTransitRepo := usecaseMocks.NewMockTransitKeyRepository(t) + mockDekRepo := usecaseMocks.NewMockDekRepository(t) + mockKeyManager := serviceMocks.NewMockKeyManager(t) + mockAeadManager := serviceMocks.NewMockAEADManager(t) + + // Create test data + kek := createTestKek() + kekChain := createTestKekChain(kek.ID, kek) + defer kekChain.Close() + + olderThanDays := 30 + dryRun := true + expectedDeletedCount := int64(10) + + // Setup expectations + mockTransitRepo.EXPECT(). + HardDelete(ctx, mock.AnythingOfType("time.Time"), dryRun). + Return(expectedDeletedCount, nil). + Once() + + // Execute + uc := NewTransitKeyUseCase( + mockTxManager, mockTransitRepo, mockDekRepo, mockKeyManager, mockAeadManager, kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedDeletedCount, count) + }) + + t.Run("Error_InvalidOlderThanDays", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTransitRepo := usecaseMocks.NewMockTransitKeyRepository(t) + mockDekRepo := usecaseMocks.NewMockDekRepository(t) + mockKeyManager := serviceMocks.NewMockKeyManager(t) + mockAeadManager := serviceMocks.NewMockAEADManager(t) + + // Create test data + kek := createTestKek() + kekChain := createTestKekChain(kek.ID, kek) + defer kekChain.Close() + + olderThanDays := -1 + dryRun := false + + // Execute + uc := NewTransitKeyUseCase( + mockTxManager, mockTransitRepo, mockDekRepo, mockKeyManager, mockAeadManager, kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.Error(t, err) + assert.Equal(t, int64(0), count) + assert.Contains(t, err.Error(), "olderThanDays must be a positive number") + }) + t.Run("Error_HardDeleteFails", func(t *testing.T) { + // Setup mocks + mockTxManager := databaseMocks.NewMockTxManager(t) + mockTransitRepo := usecaseMocks.NewMockTransitKeyRepository(t) + mockDekRepo := usecaseMocks.NewMockDekRepository(t) + mockKeyManager := serviceMocks.NewMockKeyManager(t) + mockAeadManager := serviceMocks.NewMockAEADManager(t) + + // Create test data + kek := createTestKek() + kekChain := createTestKekChain(kek.ID, kek) + defer kekChain.Close() + + olderThanDays := 30 + dryRun := false + expectedError := errors.New("database error") + + // Setup expectations + mockTransitRepo.EXPECT(). + HardDelete(ctx, mock.AnythingOfType("time.Time"), dryRun). + Return(int64(0), expectedError). + Once() + + // Execute + uc := NewTransitKeyUseCase( + mockTxManager, mockTransitRepo, mockDekRepo, mockKeyManager, mockAeadManager, kekChain, + ) + count, err := uc.PurgeDeleted(ctx, olderThanDays, dryRun) + + // Assert + assert.Error(t, err) + assert.Equal(t, int64(0), count) + assert.Equal(t, expectedError, err) + }) +}