Secret Rotation
Scheduled secret rotation with custom strategies.
The rotation package provides scheduled secret rotation with custom rotation strategies, a background check loop, and a full rotation history. It integrates directly with the secret.Service for reading and writing secret values.
Architecture
rotation.Manager
├── rotation.Store (policies + rotation records)
├── secret.Service (get current / set new value)
├── map[string]Rotator (registered rotator functions)
└── background loop (periodic policy check)The Manager periodically checks rotation policies. When a policy is due, it retrieves the current secret, invokes the registered Rotator function to produce a new value, writes the new value (creating a new version), records the rotation event, and updates the policy timestamps.
Installation
import "github.com/xraph/vault/rotation"Rotator function
A Rotator is a function that takes the current secret value and produces a new one. This is where you implement your rotation logic (e.g., generating a new API key, rotating a database password).
type Rotator func(ctx context.Context, currentValue []byte) ([]byte, error)// Example: generate a random 32-byte API key.
apiKeyRotator := func(ctx context.Context, current []byte) ([]byte, error) {
newKey := make([]byte, 32)
if _, err := rand.Read(newKey); err != nil {
return nil, err
}
return []byte(hex.EncodeToString(newKey)), nil
}Manager
The Manager handles the rotation lifecycle: scheduling, execution, recording, and policy updates.
Creating a Manager
mgr := rotation.NewManager(rotationStore, secretService,
rotation.WithAppID("myapp"),
rotation.WithCheckInterval(5*time.Minute),
rotation.WithLogger(logger),
)ManagerOption
| Option | Signature | Default | Description |
|---|---|---|---|
WithCheckInterval | WithCheckInterval(d time.Duration) ManagerOption | 1 * time.Minute | How often the manager checks for due rotations |
WithLogger | WithLogger(l *slog.Logger) ManagerOption | slog.Default() | Logger for rotation events and errors |
WithAppID | WithAppID(appID string) ManagerOption | "" | Default app ID for policy lookups |
RegisterRotator
Registers a rotator function for a specific secret key. Each secret key can have at most one rotator.
func (m *Manager) RegisterRotator(secretKey string, r Rotator)mgr.RegisterRotator("db-password", func(ctx context.Context, current []byte) ([]byte, error) {
// Call your database to change the password.
newPass := generatePassword()
if err := updateDatabasePassword(ctx, newPass); err != nil {
return nil, err
}
return []byte(newPass), nil
})
mgr.RegisterRotator("api-key", apiKeyRotator)Start
Begins the background rotation check loop. The loop runs on a ticker at the configured CheckInterval and checks all enabled policies.
func (m *Manager) Start(ctx context.Context) errorif err := mgr.Start(ctx); err != nil {
log.Fatal(err)
}Stop
Cancels the background loop and waits for it to finish.
func (m *Manager) Stop(ctx context.Context) errorif err := mgr.Stop(ctx); err != nil {
log.Printf("rotation manager stop: %v", err)
}RotateNow
Performs an immediate rotation for a specific secret, bypassing the schedule.
func (m *Manager) RotateNow(ctx context.Context, secretKey, appID string) errorif err := mgr.RotateNow(ctx, "db-password", "myapp"); err != nil {
log.Printf("manual rotation failed: %v", err)
}RotateNow flow
When RotateNow is called (either manually or from the background loop), the following steps execute:
- Look up rotator -- Find the registered
Rotatorfunction for the secret key. Returns an error if none is registered. - Get current secret -- Call
secret.Service.Getto retrieve and decrypt the current value. - Invoke rotator -- Call the
Rotatorfunction with the current value to produce a new value. - Set new secret -- Call
secret.Service.Setwith the new value. This auto-creates a new version. - Record rotation -- Persist a
Recordentry with old and new version numbers. - Update policy -- Set
LastRotatedAtto now and calculateNextRotationAtasnow + policy.Interval.
If any step fails, the error is returned (for manual rotations) or logged (for scheduled rotations).
Policy
A Policy defines the rotation schedule for a secret.
type Policy struct {
vault.Entity
ID id.ID `json:"id"`
SecretKey string `json:"secret_key"`
AppID string `json:"app_id"`
Interval time.Duration `json:"interval"`
Enabled bool `json:"enabled"`
LastRotatedAt *time.Time `json:"last_rotated_at,omitempty"`
NextRotationAt *time.Time `json:"next_rotation_at,omitempty"`
}| Field | Type | Description |
|---|---|---|
SecretKey | string | The secret key this policy applies to |
AppID | string | Application scope identifier |
Interval | time.Duration | Time between rotations |
Enabled | bool | Whether the policy is active |
LastRotatedAt | *time.Time | When the last rotation completed |
NextRotationAt | *time.Time | When the next rotation is due |
The background loop skips policies where Enabled is false or NextRotationAt is in the future.
Record
A Record captures the details of a completed rotation event.
type Record struct {
ID id.ID `json:"id"`
SecretKey string `json:"secret_key"`
AppID string `json:"app_id"`
OldVersion int64 `json:"old_version"`
NewVersion int64 `json:"new_version"`
RotatedBy string `json:"rotated_by"`
RotatedAt time.Time `json:"rotated_at"`
}| Field | Type | Description |
|---|---|---|
OldVersion | int64 | Secret version before rotation |
NewVersion | int64 | Secret version after rotation |
RotatedBy | string | Identifies who/what triggered the rotation (set to "rotation-manager") |
RotatedAt | time.Time | UTC timestamp of the rotation |
ListOpts
type ListOpts struct {
Limit int
Offset int
}Store interface
type Store interface {
SaveRotationPolicy(ctx context.Context, p *Policy) error
GetRotationPolicy(ctx context.Context, key, appID string) (*Policy, error)
ListRotationPolicies(ctx context.Context, appID string) ([]*Policy, error)
DeleteRotationPolicy(ctx context.Context, key, appID string) error
RecordRotation(ctx context.Context, r *Record) error
ListRotationRecords(ctx context.Context, key, appID string, opts ListOpts) ([]*Record, error)
}| Method | Description |
|---|---|
SaveRotationPolicy | Creates or updates a rotation policy |
GetRotationPolicy | Retrieves a policy by secret key and app ID |
ListRotationPolicies | Returns all policies for an app (used by the background loop) |
DeleteRotationPolicy | Removes a rotation policy |
RecordRotation | Persists a rotation record |
ListRotationRecords | Returns rotation history for a secret |
Full example
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"time"
"github.com/xraph/vault/rotation"
"github.com/xraph/vault/secret"
"github.com/xraph/vault/crypto"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// Set up secret service (assumes store and encryptor are ready).
enc, _ := crypto.NewEncryptor(encryptionKey)
secretSvc := secret.NewService(secretStore, enc,
secret.WithAppID("myapp"),
)
// Create rotation manager.
mgr := rotation.NewManager(rotationStore, secretSvc,
rotation.WithAppID("myapp"),
rotation.WithCheckInterval(1*time.Minute),
rotation.WithLogger(slog.Default()),
)
// Register a rotator for API keys.
mgr.RegisterRotator("stripe-api-key", func(ctx context.Context, current []byte) ([]byte, error) {
newKey := make([]byte, 32)
if _, err := rand.Read(newKey); err != nil {
return nil, err
}
return []byte("sk_live_" + hex.EncodeToString(newKey)), nil
})
// Create a rotation policy (every 30 days).
now := time.Now().UTC()
next := now.Add(30 * 24 * time.Hour)
_ = rotationStore.SaveRotationPolicy(ctx, &rotation.Policy{
SecretKey: "stripe-api-key",
AppID: "myapp",
Interval: 30 * 24 * time.Hour,
Enabled: true,
NextRotationAt: &next,
})
// Start the background loop.
if err := mgr.Start(ctx); err != nil {
log.Fatal(err)
}
fmt.Println("Rotation manager running. Press Ctrl+C to stop.")
// Trigger an immediate rotation.
if err := mgr.RotateNow(ctx, "stripe-api-key", "myapp"); err != nil {
log.Printf("manual rotation failed: %v", err)
}
// Wait for shutdown signal.
<-ctx.Done()
if err := mgr.Stop(context.Background()); err != nil {
log.Printf("shutdown: %v", err)
}
// List rotation history.
records, _ := rotationStore.ListRotationRecords(context.Background(),
"stripe-api-key", "myapp", rotation.ListOpts{Limit: 10})
for _, r := range records {
fmt.Printf(" v%d -> v%d at %s by %s\n",
r.OldVersion, r.NewVersion, r.RotatedAt, r.RotatedBy)
}
}