Vault

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

OptionSignatureDefaultDescription
WithCheckIntervalWithCheckInterval(d time.Duration) ManagerOption1 * time.MinuteHow often the manager checks for due rotations
WithLoggerWithLogger(l *slog.Logger) ManagerOptionslog.Default()Logger for rotation events and errors
WithAppIDWithAppID(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) error
if 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) error
if 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) error
if 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:

  1. Look up rotator -- Find the registered Rotator function for the secret key. Returns an error if none is registered.
  2. Get current secret -- Call secret.Service.Get to retrieve and decrypt the current value.
  3. Invoke rotator -- Call the Rotator function with the current value to produce a new value.
  4. Set new secret -- Call secret.Service.Set with the new value. This auto-creates a new version.
  5. Record rotation -- Persist a Record entry with old and new version numbers.
  6. Update policy -- Set LastRotatedAt to now and calculate NextRotationAt as now + 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"`
}
FieldTypeDescription
SecretKeystringThe secret key this policy applies to
AppIDstringApplication scope identifier
Intervaltime.DurationTime between rotations
EnabledboolWhether the policy is active
LastRotatedAt*time.TimeWhen the last rotation completed
NextRotationAt*time.TimeWhen 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"`
}
FieldTypeDescription
OldVersionint64Secret version before rotation
NewVersionint64Secret version after rotation
RotatedBystringIdentifies who/what triggered the rotation (set to "rotation-manager")
RotatedAttime.TimeUTC 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)
}
MethodDescription
SaveRotationPolicyCreates or updates a rotation policy
GetRotationPolicyRetrieves a policy by secret key and app ID
ListRotationPoliciesReturns all policies for an app (used by the background loop)
DeleteRotationPolicyRemoves a rotation policy
RecordRotationPersists a rotation record
ListRotationRecordsReturns 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)
    }
}

On this page