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
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v6

- name: Run gosec
uses: securego/gosec@v2
uses: securego/gosec@v2.25.0
with:
args: -no-fail -fmt sarif -out gosec-results.sarif ./...

Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

## v0.0.2 - 2026-04-17

### Added

- Added `cmd/migrate-gen`, a stable CLI entrypoint for generating or printing the PostgreSQL encryption keystore migration.
- Added package helpers in `keystore/postgres/migrations` for rendering and writing migration SQL with `postgres.Config` schema and table overrides.

### Changed

- Updated the README to document the CLI-first migration flow and the package-based advanced alternative.

### Fixed

- Pinned the GitHub Actions gosec step to `securego/gosec@v2.25.0`, fixing workflow resolution failures caused by the missing `v2` ref.
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Envelope encryption for event-sourced Go systems — with PII crypto-shredding a
- **Secret encryption** — versioned keys with rotation (old ciphertext stays decryptable)
- **Pluggable cipher** — ships AES-256-GCM, bring your own `cipher.Cipher`
- **Pluggable key store** — ships PostgreSQL adapter, bring your own `keystore.KeyStore`
- **Migration CLI** — `cmd/migrate-gen` generates or prints the PostgreSQL keystore migration
- **Deterministic hashing** — HMAC-SHA256 for generating aggregate IDs from sensitive data
- **Memory hygiene** — DEKs are zeroed after use via `ZeroBytes`
- **Zero external dependencies** — only Go standard library
Expand All @@ -19,7 +20,38 @@ The library ships a PostgreSQL-backed key store. You need three things to get go

### Migration

The `keystore/postgres/migrations` package embeds the SQL migration. Apply it to your database before using the store — either by executing it directly or by feeding it to your migration tool:
Generate the PostgreSQL key-store migration through the stable CLI entrypoint:

```bash
go run github.com/eventsalsa/encryption/cmd/migrate-gen -output migrations
# writes migrations/20260417123456_init_encryption_keys.sql
```

You can print the SQL directly when piping into your own tooling:

```bash
go run github.com/eventsalsa/encryption/cmd/migrate-gen -stdout
go run github.com/eventsalsa/encryption/cmd/migrate-gen -schema custom_schema -table custom_keys -stdout
```

For advanced package-level usage, `keystore/postgres/migrations` can render the SQL directly with the same schema and table overrides used by `postgres.Config`:

```go
import (
"github.com/eventsalsa/encryption/keystore/postgres"
"github.com/eventsalsa/encryption/keystore/postgres/migrations"
)

sql, err := migrations.SQL(postgres.Config{
Schema: "custom_schema",
Table: "custom_keys",
})
if err != nil {
// handle error
}
```

The raw embedded default migration is also available if you want the exact shipped SQL without any overrides:

```sql
CREATE TABLE IF NOT EXISTS infrastructure.encryption_keys (
Expand Down
58 changes: 58 additions & 0 deletions cmd/migrate-gen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Command migrate-gen generates SQL migration files for the PostgreSQL keystore.
//
// Usage:
//
// go run github.com/eventsalsa/encryption/cmd/migrate-gen -output migrations
//
// Or print the SQL directly:
//
// go run github.com/eventsalsa/encryption/cmd/migrate-gen -stdout
package main

import (
"flag"
"fmt"
"os"
"path/filepath"

"github.com/eventsalsa/encryption/keystore/postgres"
"github.com/eventsalsa/encryption/keystore/postgres/migrations"
)

func main() {
var (
outputFolder = flag.String("output", "migrations", "Output folder for migration file")
outputFilename = flag.String("filename", "", "Output filename (default: timestamp-based)")
schema = flag.String("schema", postgres.DefaultSchema, "Schema name for encryption keys")
table = flag.String("table", postgres.DefaultTable, "Table name for encryption keys")
stdout = flag.Bool("stdout", false, "Print the migration to stdout instead of writing a file")
)

flag.Parse()

config := migrations.DefaultConfig()
config.OutputFolder = *outputFolder
config.Postgres.Schema = *schema
config.Postgres.Table = *table

if *outputFilename != "" {
config.OutputFilename = *outputFilename
}

if *stdout {
sql, err := migrations.SQL(config.Postgres)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating migration: %v\n", err)
os.Exit(1)
}
fmt.Print(sql)
return
}

if err := migrations.GeneratePostgres(&config); err != nil {
fmt.Fprintf(os.Stderr, "Error generating migration: %v\n", err)
os.Exit(1)
}

fmt.Printf("Generated PostgreSQL migration: %s\n", filepath.Join(config.OutputFolder, config.OutputFilename))
}
12 changes: 12 additions & 0 deletions keystore/postgres/migrations/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package migrations exposes the embedded PostgreSQL keystore migration.
//
// For the quick CLI path, use:
//
// go run github.com/eventsalsa/encryption/cmd/migrate-gen -output migrations
//
// To print the SQL instead of writing a file:
//
// go run github.com/eventsalsa/encryption/cmd/migrate-gen -stdout
//
// For advanced usage, call [SQL] or [GeneratePostgres] directly.
package migrations
99 changes: 99 additions & 0 deletions keystore/postgres/migrations/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package migrations

import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"

"github.com/eventsalsa/encryption/keystore/postgres"
)

var validIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

// Config configures migration generation.
type Config struct {
// OutputFolder is the directory where the migration file will be written.
OutputFolder string

// OutputFilename is the name of the migration file.
OutputFilename string

// Postgres controls schema and table overrides using postgres.Config.
Postgres postgres.Config
}

// DefaultConfig returns the default migration generator configuration.
func DefaultConfig() Config {
return Config{
OutputFolder: "migrations",
OutputFilename: timestampedFilename(),
Postgres: postgres.DefaultConfig(),
}
}

// SQL returns the PostgreSQL keystore migration SQL for the given config.
func SQL(cfg postgres.Config) (string, error) {
cfg = postgres.ApplyDefaults(cfg)
if err := validateConfig(cfg); err != nil {
return "", err
}

data, err := FS.ReadFile("001_encryption_keys.sql")
if err != nil {
return "", fmt.Errorf("read embedded migration: %w", err)
}

defaults := postgres.DefaultConfig()
sql := string(data)
sql = strings.ReplaceAll(sql, defaults.Schema+"."+defaults.Table, cfg.Schema+"."+cfg.Table)
sql = strings.ReplaceAll(sql, "idx_"+defaults.Table+"_", "idx_"+cfg.Table+"_")

return sql, nil
}

// GeneratePostgres writes the PostgreSQL keystore migration file.
func GeneratePostgres(config *Config) error {
if config == nil {
return fmt.Errorf("config is required")
}

if config.OutputFolder == "" {
config.OutputFolder = "migrations"
}
if config.OutputFilename == "" {
config.OutputFilename = timestampedFilename()
}

sql, err := SQL(config.Postgres)
if err != nil {
return err
}

if err := os.MkdirAll(config.OutputFolder, 0o750); err != nil {
return fmt.Errorf("create output folder: %w", err)
}

outputPath := filepath.Join(config.OutputFolder, config.OutputFilename)
if err := os.WriteFile(outputPath, []byte(sql), 0o600); err != nil {
return fmt.Errorf("write migration file: %w", err)
}

return nil
}

func timestampedFilename() string {
return fmt.Sprintf("%s_init_encryption_keys.sql", time.Now().Format("20060102150405"))
}

func validateConfig(cfg postgres.Config) error {
if !validIdentifier.MatchString(cfg.Schema) {
return fmt.Errorf("invalid schema %q: must be a PostgreSQL identifier", cfg.Schema)
}
if !validIdentifier.MatchString(cfg.Table) {
return fmt.Errorf("invalid table %q: must be a PostgreSQL identifier", cfg.Table)
}
return nil
}
95 changes: 95 additions & 0 deletions keystore/postgres/migrations/generator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package migrations

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/eventsalsa/encryption/keystore/postgres"
)

func TestSQLDefaultConfigMatchesEmbeddedMigration(t *testing.T) {
t.Parallel()

want, err := FS.ReadFile("001_encryption_keys.sql")
if err != nil {
t.Fatalf("read embedded migration: %v", err)
}

got, err := SQL(postgres.Config{})
if err != nil {
t.Fatalf("build migration SQL: %v", err)
}

if got != string(want) {
t.Fatalf("expected generated SQL to match embedded migration")
}
}

func TestSQLAppliesSchemaAndTableOverrides(t *testing.T) {
t.Parallel()

got, err := SQL(postgres.Config{
Schema: "custom_schema",
Table: "custom_keys",
})
if err != nil {
t.Fatalf("build migration SQL: %v", err)
}

if !strings.Contains(got, "CREATE TABLE IF NOT EXISTS custom_schema.custom_keys") {
t.Fatalf("expected custom schema and table in CREATE TABLE statement")
}
if !strings.Contains(got, "ON custom_schema.custom_keys(scope, scope_id, revoked_at)") {
t.Fatalf("expected custom schema and table in index statement")
}
if !strings.Contains(got, "CREATE INDEX IF NOT EXISTS idx_custom_keys_active") {
t.Fatalf("expected index names to follow custom table name")
}
if strings.Contains(got, "infrastructure.encryption_keys") {
t.Fatalf("did not expect default schema/table in custom migration")
}
}

func TestSQLRejectsInvalidIdentifiers(t *testing.T) {
t.Parallel()

_, err := SQL(postgres.Config{
Schema: "custom-schema",
Table: "custom_keys",
})
if err == nil {
t.Fatalf("expected invalid identifier error")
}
}

func TestGeneratePostgresWritesFile(t *testing.T) {
t.Parallel()

config := DefaultConfig()
config.OutputFolder = t.TempDir()
config.OutputFilename = "custom.sql"
config.Postgres = postgres.Config{
Schema: "custom_schema",
Table: "custom_keys",
}

if err := GeneratePostgres(&config); err != nil {
t.Fatalf("generate migration: %v", err)
}

got, err := os.ReadFile(filepath.Join(config.OutputFolder, config.OutputFilename))
if err != nil {
t.Fatalf("read generated file: %v", err)
}

want, err := SQL(config.Postgres)
if err != nil {
t.Fatalf("build expected migration: %v", err)
}

if string(got) != want {
t.Fatalf("generated file did not match expected migration")
}
}
26 changes: 21 additions & 5 deletions keystore/postgres/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,35 @@ type Config struct {
Table string
}

const (
// DefaultSchema is the default PostgreSQL schema for encryption keys.
DefaultSchema = "infrastructure"
// DefaultTable is the default PostgreSQL table name for encryption keys.
DefaultTable = "encryption_keys"
)

// DefaultConfig returns the default PostgreSQL keystore configuration.
func DefaultConfig() Config {
return Config{
Schema: DefaultSchema,
Table: DefaultTable,
}
}

// Store implements keystore.KeyStore backed by PostgreSQL.
type Store struct {
cfg Config
db *sql.DB
extract TxExtractor
}

func applyDefaults(cfg Config) Config {
// ApplyDefaults fills in the default schema and table names when omitted.
func ApplyDefaults(cfg Config) Config {
if cfg.Schema == "" {
cfg.Schema = "infrastructure"
cfg.Schema = DefaultSchema
}
if cfg.Table == "" {
cfg.Table = "encryption_keys"
cfg.Table = DefaultTable
}
return cfg
}
Expand All @@ -48,7 +64,7 @@ func applyDefaults(cfg Config) Config {
// Reads use the connection pool. Writes auto-commit via *sql.DB.
// Use keystore.WithTx(ctx, tx) to opt into transaction participation.
func NewStore(cfg Config, db *sql.DB) *Store {
return &Store{cfg: applyDefaults(cfg), db: db}
return &Store{cfg: ApplyDefaults(cfg), db: db}
}

// NewStoreWithTxExtractor creates a store with a custom tx extractor.
Expand All @@ -57,7 +73,7 @@ func NewStore(cfg Config, db *sql.DB) *Store {
// Use this for Unit of Work patterns where *sql.Tx lives under your
// own context key rather than the library's keystore.WithTx key.
func NewStoreWithTxExtractor(cfg Config, db *sql.DB, extract TxExtractor) *Store {
return &Store{cfg: applyDefaults(cfg), db: db, extract: extract}
return &Store{cfg: ApplyDefaults(cfg), db: db, extract: extract}
}

// conn resolves the active database handle for this context.
Expand Down
Loading