Documentation
¶
Overview ¶
Package secrets provides a secret retrieval interface with implementations for environment variables, GCP Secret Manager, AWS SSM Parameter Store, and Kubernetes secrets.
SecretSource is deliberately narrow — read one secret by name, close what was opened — so a caller can be handed any provider and a test can be handed a map.
Caching, refreshing, and rotation ¶
The providers that talk to a network are fetch-per-call, which pushes callers toward resolving every secret at boot and holding the values for the life of the process. That is the pattern that turns a key rotation into an outage: the backend rotates, every running process keeps the old value until someone redeploys, and nothing in the process can notice.
NewCachingSource is the decorator that removes the reason to do that. It gives the source a TTL and read-through caching with single-flight, so a stampede of cold readers costs one backend call; WithRefresh keeps entries warm in the background so the round-trip leaves the hot path once the cache is warm; and CachingSource.OnChange reports a value that changed, which is what lets a caller re-derive whatever it built out of the old one — a signing keyring, a database credential, an SDK client — without restarting.
Rotation is observed by re-reading on a TTL, not by subscribing. The backends' change-notification stories are divergent to absent, so polling is the portable contract; OnChange does not preclude a push-capable provider later, since a push is only a refresh that arrived early.
secrets/config wires all of this from configuration: set CacheTTL to wrap whichever provider the config selected, and RefreshInterval to keep it warm.
Example (CachingSecretSource) ¶
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/primandproper/platform-go/v11/secrets"
"github.com/primandproper/platform-go/v11/secrets/env"
)
func main() {
os.Setenv("EXAMPLE_CACHED_SECRET", "s3cret")
defer os.Unsetenv("EXAMPLE_CACHED_SECRET")
backend, err := env.NewSecretSource()
if err != nil {
panic(err)
}
// Five minutes of TTL with a refresh every minute: reads are answered from
// memory, the round-trip happens on the refresh goroutine rather than in
// anyone's request, and a rotation is picked up within a minute.
source, err := secrets.NewCachingSource(backend, 5*time.Minute,
secrets.WithRefresh(context.Background(), time.Minute))
if err != nil {
panic(err)
}
// Closing the cache closes the source it wraps.
defer source.Close()
for range 3 {
secret, getErr := source.GetSecret(context.Background(), "EXAMPLE_CACHED_SECRET")
if getErr != nil {
panic(getErr)
}
fmt.Println(secret)
}
}
Output: s3cret s3cret s3cret
Example (EnvSecretSource) ¶
package main
import (
"context"
"fmt"
"os"
"github.com/primandproper/platform-go/v11/secrets/env"
)
func main() {
os.Setenv("EXAMPLE_SECRET", "s3cret")
defer os.Unsetenv("EXAMPLE_SECRET")
source, err := env.NewSecretSource()
if err != nil {
panic(err)
}
defer source.Close()
secret, err := source.GetSecret(context.Background(), "EXAMPLE_SECRET")
if err != nil {
panic(err)
}
fmt.Println(secret)
}
Output: s3cret
Example (RotationHooks) ¶
package main
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/primandproper/platform-go/v11/secrets"
)
// rotatingSource stands in for a backend whose value is rotated out from under
// a running process.
type rotatingSource struct {
reads atomic.Int64
}
func (r *rotatingSource) GetSecret(context.Context, string) (string, error) {
if r.reads.Add(1) == 1 {
return "old-signing-key", nil
}
return "new-signing-key", nil
}
func (r *rotatingSource) Close() error { return nil }
func main() {
// A one-nanosecond TTL so the second read below re-reads immediately; a
// real deployment measures this in minutes and lets WithRefresh do the
// re-reading.
source, err := secrets.NewCachingSource(&rotatingSource{}, time.Nanosecond)
if err != nil {
panic(err)
}
defer source.Close()
rotated := make(chan string, 1)
cancel := source.OnChange("signing-key", func(oldValue, newValue string) {
rotated <- fmt.Sprintf("%s -> %s", oldValue, newValue)
})
defer cancel()
// The first read has nothing to compare against, so no hook fires.
if _, err = source.GetSecret(context.Background(), "signing-key"); err != nil {
panic(err)
}
// The second sees a new value and reports it, which is the cue to rebuild
// whatever was derived from the old one.
if _, err = source.GetSecret(context.Background(), "signing-key"); err != nil {
panic(err)
}
fmt.Println(<-rotated)
}
Output: old-signing-key -> new-signing-key
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidCacheTTL indicates NewCachingSource was given a non-positive // TTL. There is no "cache forever" setting on purpose: an entry that never // expires is read-once-hold-forever with extra steps, which is the pattern // this decorator exists to replace. ErrInvalidCacheTTL = errors.New("caching secret source: ttl must be positive") // ErrInvalidRefreshInterval indicates a refresh interval that cannot beat // the TTL it was paired with, so the refresh could never fire before the // entry it was meant to keep warm had already expired. ErrInvalidRefreshInterval = errors.New("caching secret source: refresh interval must be shorter than the ttl") )
var ErrSecretNotFound = errors.New("secret not found")
ErrSecretNotFound is returned when a requested secret does not exist, so a missing secret is distinguishable from one whose value is legitimately empty.
Functions ¶
This section is empty.
Types ¶
type CachingSource ¶
type CachingSource interface {
SecretSource
// OnChange registers fn to be called when this source observes name's value
// change, and returns a function that unregisters it. Registering the same
// name twice registers two hooks; both fire.
//
// A change is observed by re-reading, so hooks fire for the secrets this
// source holds — a name that has never been read has nothing to compare
// against and no entry for the refresh to visit, so its hooks stay silent
// until the first read of it. Callers wiring a hook for a secret they have
// not read yet should read it once, which is what a boot-time resolution
// does anyway.
OnChange(name string, fn ChangeFunc) (cancel func())
}
CachingSource is what NewCachingSource returns: a SecretSource that answers from memory, plus the one thing caching makes possible that fetch-per-call does not — being told when a value changed.
It is a distinct interface rather than an addition to SecretSource because only a source that re-reads has anything to report. A provider that fetches on every call has no "before" to compare against, and widening the interface every implementation satisfies would oblige each of them to grow a method that could only ever be a stub.
type ChangeFunc ¶
type ChangeFunc func(oldValue, newValue string)
ChangeFunc is a rotation hook: the callback OnChange registers, called with the value a secret used to have and the value it has now.
It runs on its own goroutine, so a slow hook delays neither the refresh that observed the change nor any caller reading a secret. A panic is contained and logged rather than taking the process down with it.
type Option ¶
type Option func(*options)
Option configures the caching source this package constructs. The zero configuration works: an absent logger logs nowhere, an absent tracer provider traces nowhere, an absent metrics provider records nothing, and an absent refresh means the cache is filled only by the reads that miss.
func WithMetricsProvider ¶
WithMetricsProvider attaches a metrics provider for the package's counters and gauges.
func WithRand ¶
WithRand replaces the source that spreads a fleet's background refreshes apart. fn must return a value in [0,1]; a value of 1 yields the un-jittered interval, and every draw shortens rather than lengthens it.
The default draws from math/rand/v2 and needs no seeding. A nil fn is ignored.
func WithRefresh ¶
WithRefresh starts a background goroutine that re-resolves every cached secret every interval, so a warm cache is kept warm without any caller paying for the round-trip.
Without it the cache is filled only by the reads that miss, which means every TTL expiry is paid for by whichever caller happens to arrive first — and, more to the point, that nothing observes a rotation until somebody asks. The refresh is what turns OnChange from a hook that fires on read into one that fires on time.
The interval must be positive and shorter than the TTL, or NewCachingSource returns ErrInvalidRefreshInterval: a refresh that cannot land before the entry expires is not a refresh, it is a second way to spell the TTL. Individual waits are jittered downward from the interval — never past it — so a fleet that started together drifts apart instead of hitting the backend in lockstep, without any wait outliving the TTL it was chosen to stay under.
The refresh stops on Close, and also when ctx is done, whichever happens first. Passing context.Background() and relying on Close is the ordinary shape; a cancellable ctx is for tying the refresh to something narrower than the source's own lifetime. A nil ctx or a non-positive interval starts no goroutine at all.
func WithTracerProvider ¶
WithTracerProvider attaches a tracer provider, enabling spans on every operation.
type SecretSource ¶
type SecretSource interface {
GetSecret(ctx context.Context, name string) (string, error)
Close() error
}
SecretSource provides access to secrets.
type TTLCachingSource ¶
type TTLCachingSource struct {
// contains filtered or unexported fields
}
TTLCachingSource is the CachingSource that holds each secret for a TTL and refreshes it in the background. It is exported, and returned by NewCachingSource, so a caller can depend on the source it built rather than on the CachingSource seam.
func NewCachingSource ¶
func NewCachingSource(source SecretSource, ttl time.Duration, opts ...Option) (*TTLCachingSource, error)
NewCachingSource wraps source in a read-through cache whose entries live for ttl, so repeated reads of a secret cost one backend round-trip per TTL instead of one per call.
It exists because the alternative callers reach for is worse. A GetSecret against GCP or SSM is a network call, which pushes every caller toward resolving at boot and holding the value for the life of the process — and that is precisely the shape that turns a key rotation into an outage, since nothing in the process can react to the backend's value changing. A TTL is the smallest thing that fixes both halves: the round-trip is amortized, and the value is re-read often enough that a rotation is noticed.
Pair it with WithRefresh to keep entries warm in the background, and with OnChange to be told when a re-read sees a new value. Refresh is what moves the round-trip off the hot path entirely; OnChange is what lets a caller re-derive whatever it built out of the old value — a signing keyring, a database credential, an SDK client — without a restart.
The TTL must be positive. Close closes the wrapped source, so a caller closes what this returns and nothing else.
What is and is not cached ¶
A secret the backend reports as absent is not cached: ErrSecretNotFound goes straight back to the caller and nothing is stored, so a secret created after this source started is visible on the next read rather than a TTL later. For the same reason a re-read that comes back ErrSecretNotFound drops whatever was held — an affirmative "no such secret" is an answer, not a failed lookup, and a deleted secret must stop being served.
Every other backend failure is treated as a failure to reach an answer, not as an answer. A read whose fetch fails is served the held value if there is one, counted as a stale read, and reported on the staleness gauge; a background refresh that fails leaves the entry alone and logs. Old secret beats no secret for every purpose except revocation, and revocation outlives any TTL a process could pick anyway.
func (*TTLCachingSource) Close ¶
func (c *TTLCachingSource) Close() error
Close stops the refresh, waits for it to finish so the wrapped source is not closed out from under an in-flight fetch, and then closes that source.
It is idempotent and reports the same error every time: closing the wrapped source twice is the wrapped source's problem, and a decorator should not create it.
func (*TTLCachingSource) GetSecret ¶
GetSecret answers from the cache when it holds an unexpired value, and resolves through the wrapped source otherwise.
func (*TTLCachingSource) OnChange ¶
func (c *TTLCachingSource) OnChange(name string, fn ChangeFunc) func()
OnChange registers a rotation hook for name.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package secretscfg selects and builds a secrets.SecretSource from configuration: environment variables, GCP Secret Manager, AWS SSM Parameter Store, Kubernetes secrets, or noop.
|
Package secretscfg selects and builds a secrets.SecretSource from configuration: environment variables, GCP Secret Manager, AWS SSM Parameter Store, Kubernetes secrets, or noop. |
|
Package env reads secrets from this process's environment.
|
Package env reads secrets from this process's environment. |
|
Package gcp reads secrets from GCP Secret Manager.
|
Package gcp reads secrets from GCP Secret Manager. |
|
Package kubernetes sources secrets from the Kubernetes Secrets API.
|
Package kubernetes sources secrets from the Kubernetes Secrets API. |
|
Package noop is the secrets.SecretSource that holds no secrets, and how it says so is the thing to know: GetSecret returns secrets.ErrSecretNotFound for every name it is ever asked.
|
Package noop is the secrets.SecretSource that holds no secrets, and how it says so is the thing to know: GetSecret returns secrets.ErrSecretNotFound for every name it is ever asked. |
|
Package ssm reads secrets from AWS SSM Parameter Store.
|
Package ssm reads secrets from AWS SSM Parameter Store. |