Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions cmd/app/commands/purge_tokenization_keys.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 80 additions & 0 deletions cmd/app/commands/purge_transit_keys.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
90 changes: 90 additions & 0 deletions cmd/app/system_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions docs/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions internal/tokenization/repository/mysql/mysql_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading
Loading