Documentation
¶
Overview ¶
Package configvault contributes secrets from a HashiCorp Vault KV v2 store as a first-class config layer: precedence, per-key merge, provenance and safe hot-reload, exactly like a file layer — but marked sensitive, so the core refuses to write a value sourced from here into a plainer layer beneath.
A Vault secret is a path holding a map of fields, and each field holds arbitrary JSON. In the default single-secret mode (New) one secret's fields become the layer; NewPrefix opts into a recursive walk over a whole tree of secrets. Values keep the structure Vault returns them with — nested maps and slices included — so no value codec is needed here, unlike the byte-valued stores this family also adapts.
Vault is reached through the narrow KV interface, which Wrap adapts from a configured *api.Client. Injecting the client keeps every credential, auth method, namespace and TLS decision with the consumer — the adapter never authenticates — and lets the whole unit suite run against a fake, needing no Vault. Tokens expire: renewing them is the consumer's job, and a lapsed token surfaces as a Load error rather than being swallowed.
See the config-vault spec for the decisions behind all of this.
Example (AppRoleAuth) ¶
Example_appRoleAuth authenticates with AppRole, the common service-to-service method, before handing the client over.
Note the shape: every auth method ends the same way — an authenticated *api.Client goes into FromClient. The adapter knows about none of them, which is exactly why they all work.
package main
import (
"context"
"fmt"
"log"
vaultapi "github.com/hashicorp/vault/api"
auth "github.com/hashicorp/vault/api/auth/approle"
"gitlab.com/phpboyscout/go/config"
configvault "gitlab.com/phpboyscout/go/config-vault"
)
func main() {
client, err := vaultapi.NewClient(vaultapi.DefaultConfig())
if err != nil {
log.Fatal(err)
}
appRole, err := auth.NewAppRoleAuth("your-role-id",
&auth.SecretID{FromEnv: "APPROLE_SECRET_ID"})
if err != nil {
log.Fatal(err)
}
if _, err = client.Auth().Login(context.Background(), appRole); err != nil {
log.Fatal(err)
}
store, err := config.NewStore(context.Background(),
config.WithBackend(configvault.FromClient(client, "secret", "app")),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(store.View().GetString("db.host"))
}
Output:
Example (KubernetesAuth) ¶
Example_kubernetesAuth authenticates with the Kubernetes method, the common in-cluster path: the pod's service-account token is exchanged for a Vault one.
package main
import (
"context"
"fmt"
"log"
vaultapi "github.com/hashicorp/vault/api"
k8sauth "github.com/hashicorp/vault/api/auth/kubernetes"
"gitlab.com/phpboyscout/go/config"
configvault "gitlab.com/phpboyscout/go/config-vault"
)
func main() {
client, err := vaultapi.NewClient(vaultapi.DefaultConfig())
if err != nil {
log.Fatal(err)
}
k8s, err := k8sauth.NewKubernetesAuth("your-role")
if err != nil {
log.Fatal(err)
}
if _, err = client.Auth().Login(context.Background(), k8s); err != nil {
log.Fatal(err)
}
store, err := config.NewStore(context.Background(),
config.WithBackend(configvault.FromClient(client, "secret", "app")),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(store.View().GetString("db.host"))
}
Output:
Example (TokenRenewal) ¶
Example_tokenRenewal keeps a client's token alive for the life of a long-running process.
This adapter does not renew tokens, and nothing else will do it for you. A Vault token has a TTL; when it lapses, reads through this backend start failing — reloads and the poll watch included — and the failure is Vault rejecting the token, not a defect in the adapter. If your process outlives its token's TTL, run something like this.
package main
import (
"context"
"fmt"
"log"
vaultapi "github.com/hashicorp/vault/api"
auth "github.com/hashicorp/vault/api/auth/approle"
"gitlab.com/phpboyscout/go/config"
configvault "gitlab.com/phpboyscout/go/config-vault"
)
func main() {
client, err := vaultapi.NewClient(vaultapi.DefaultConfig())
if err != nil {
log.Fatal(err)
}
appRole, err := auth.NewAppRoleAuth("your-role-id",
&auth.SecretID{FromEnv: "APPROLE_SECRET_ID"})
if err != nil {
log.Fatal(err)
}
login, err := client.Auth().Login(context.Background(), appRole)
if err != nil {
log.Fatal(err)
}
watcher, err := client.NewLifetimeWatcher(&vaultapi.LifetimeWatcherInput{Secret: login})
if err != nil {
log.Fatal(err)
}
go watcher.Start()
defer watcher.Stop()
// The same client, now kept alive, backs the config layer for as long as the
// process runs. Returning rather than log.Fatal-ing from here on, so the
// watcher's Stop actually runs.
store, err := config.NewStore(context.Background(),
config.WithBackend(configvault.FromClient(client, "secret", "app")),
)
if err != nil {
fmt.Println("config:", err)
return
}
fmt.Println(store.View().GetString("db.host"))
}
Output:
Index ¶
- Constants
- Variables
- func Default(mount, secretPath string, opts ...Option) (config.Backend, error)
- func DefaultPrefix(mount, prefix string, opts ...Option) (config.Backend, error)
- func FromClient(client *vaultapi.Client, mount, secretPath string, opts ...Option) config.Backend
- func FromClientPrefix(client *vaultapi.Client, mount, prefix string, opts ...Option) config.Backend
- func FromConfig(cfg *vaultapi.Config, mount, secretPath string, opts ...Option) (config.Backend, error)
- func FromConfigPrefix(cfg *vaultapi.Config, mount, prefix string, opts ...Option) (config.Backend, error)
- func New(kv KV, path string, opts ...Option) config.Backend
- func NewPrefix(kv KV, prefix string, opts ...Option) config.Backend
- type KV
- type Option
- type Secret
Examples ¶
Constants ¶
const DefaultPollInterval = time.Minute
DefaultPollInterval is how often a Vault backend polls for change when the consumer does not say otherwise.
Sixty seconds, deliberately slower than the file watcher's cadence: every poll is an authenticated read that lands in Vault's audit log, and a two-second cadence would flood a stream that exists to be readable. WithPollInterval overrides it.
const SourceKind = config.SourceKind("vault")
SourceKind is what a layer from this backend reports as, so provenance can name Vault as the origin of a value.
Variables ¶
var ( // ErrNoConfig reports a nil config supplied to a FromConfig rung. ErrNoConfig = errors.NewSentinel("configvault.no_config", "no Vault config supplied; pass one, or use Default to read the ambient environment") // ErrNoAddress reports a config that names no Vault address. // // It cannot arise from the Default rungs, which inherit Vault's own // documented 127.0.0.1:8200, but a caller assembling a *vaultapi.Config by // hand can leave it empty — and an empty address fails at the first request // rather than here. ErrNoAddress = errors.NewSentinel("configvault.no_address", "no Vault address configured; set Address on the config or VAULT_ADDR") )
var ErrFieldCollision = errors.NewSentinel("config-vault.field_collision", "configvault: secret field collides with a child secret")
ErrFieldCollision is returned when a secret's field and a child secret claim the same name, so the layer cannot represent both.
In Vault a path is simultaneously a secret and a directory, so in prefix mode a secret's own fields merge with its child secrets into one node. If secret "app" holds a field "db" while secret "app/db" also exists, both claim "db" and either resolution silently discards a value the operator can see in Vault. Refusing is the honest outcome — the same choice config-xml makes for an attribute colliding with a child element.
Resolve it in Vault, by renaming the field or moving the child secret. Single- secret mode cannot produce this, having only one flat map of fields.
Functions ¶
func Default ¶ added in v0.4.0
Default builds a single-secret backend from the ambient Vault environment — VAULT_ADDR, VAULT_TOKEN, VAULT_NAMESPACE and the rest — as vaultapi.DefaultConfig reads them.
This is the zero-conf rung. The defaults are Vault's own, not this adapter's: with nothing set, DefaultConfig resolves https://127.0.0.1:8200, which is what every Vault client on the machine does. Adopting a provider's documented default is a different act from inventing one, which is why config-etcd has no rung here at all (spec 0012 R1).
To share ONE Vault client across this adapter, go/signing and go/encryption, use gitlab.com/phpboyscout/go/vaultclient and hand the client it yields to FromClient. Vault is the provider where the client itself is the connection prerequisite, so no further seam is needed.
The token is read once, and this rung does not renew it ¶
vaultapi.NewClient reads VAULT_TOKEN a single time and holds it for the life of the client; nothing re-reads the environment and nothing renews the lease. A backend built here therefore carries whatever token was set when it was built, for as long as the Store holds it.
That is fine for a command, which outlives its token rarely. It is NOT fine for a long-lived process reloading configuration: once the token's TTL expires every subsequent Load fails with a permission error and there is no path back, because the backend has no way to learn its credential lapsed.
This is a property of Vault's client rather than of this rung — FromClient has always behaved the same way — but the zero-conf rung is where it is easiest to reach without noticing, precisely because the caller never handles the token. If the process outlives the TTL, build the client yourself, keep a vaultapi.LifetimeWatcher renewing it, and pass it to FromClient.
func DefaultPrefix ¶ added in v0.4.0
DefaultPrefix is Default for the prefix shape.
func FromClient ¶
FromClient returns a backend reading one secret from a configured Vault client — the convenience path over New and Wrap.
client must already be authenticated. This adapter never authenticates, never renews, and never reads a token from the environment: every auth method Vault supports works here precisely because the adapter knows about none of them. Vault tokens expire, and renewing them (with the SDK's LifetimeWatcher, Vault Agent, or whatever the platform provides) is the consumer's job — a lapsed token surfaces as a Load error.
mount is the KV v2 mount, conventionally "secret"; path is the secret within it, without the mount or the engine's internal "data"/"metadata" segments.
Example ¶
ExampleFromClient reads configuration from a Vault KV v2 secret.
You build and authenticate the Vault client — address, auth method, namespace and TLS all stay yours — and hand it in with the mount and the secret path. The secret's fields become the layer, so a secret holding {"host": …} at secret/app reads back as db.host when it lives under a "db" field.
package main
import (
"context"
"fmt"
"log"
vaultapi "github.com/hashicorp/vault/api"
"gitlab.com/phpboyscout/go/config"
configvault "gitlab.com/phpboyscout/go/config-vault"
)
func main() {
client, err := vaultapi.NewClient(vaultapi.DefaultConfig())
if err != nil {
log.Fatal(err)
}
// The simplest auth there is: a token you already hold. VAULT_TOKEN in the
// environment is picked up by DefaultConfig, so this line is only needed
// when you source the token yourself.
client.SetToken("s.your-token")
store, err := config.NewStore(context.Background(),
config.WithBackend(configvault.FromClient(client, "secret", "app")),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(store.View().GetInt("db.port"))
}
Output:
func FromClientPrefix ¶ added in v0.2.0
FromClientPrefix returns a backend reading every secret beneath prefix from a configured Vault client — the convenience path over NewPrefix and Wrap.
The same authentication rules apply as for FromClient: client must already be authenticated, and keeping it renewed is the consumer's job. Prefix mode additionally needs the "list" capability on every directory it descends, and costs one request per directory plus one per secret.
func FromConfig ¶ added in v0.4.0
func FromConfig(cfg *vaultapi.Config, mount, secretPath string, opts ...Option) (config.Backend, error)
FromConfig builds the client from a config the caller assembled and returns a single-secret backend over it.
func FromConfigPrefix ¶ added in v0.4.0
func FromConfigPrefix(cfg *vaultapi.Config, mount, prefix string, opts ...Option) (config.Backend, error)
FromConfigPrefix is FromConfig for the prefix shape: every secret under the prefix becomes one layer.
func New ¶
New returns a backend contributing one Vault secret as a config layer: the secret's fields become the layer's values.
This is the common shape — one secret holds one application's configuration — and it costs exactly one API call and one `read` capability in the consumer's Vault policy.
kv is the injected client: a fake in tests, a Wrap-ped *api.Client in production.
func NewPrefix ¶ added in v0.2.0
NewPrefix returns a backend contributing every Vault secret beneath prefix as one config layer: the prefix is stripped, and each secret's fields nest under its remaining path segments.
This is the opt-in mode. It costs one API call per directory plus one per secret, and it needs the "list" capability on every directory it descends — which least-privilege Vault policies often withhold. Prefer New unless the configuration genuinely spans a tree of secrets.
A field colliding with a child secret refuses the Load with ErrFieldCollision.
Types ¶
type KV ¶
type KV interface {
// Get reads the latest version of the secret at path. It returns a nil
// Secret and a nil error when the secret is absent, soft-deleted or
// destroyed — three states Vault reports differently but which mean the
// same thing to a reader: there is nothing there.
Get(ctx context.Context, path string) (*Secret, error)
// List returns the immediate children of a directory. Child directories
// carry a trailing "/", and a path that is both a secret and a directory
// appears twice — once each way. An absent directory returns nil, nil.
//
// Only the prefix mode calls this, so a single-secret consumer may supply
// a KV whose List is never reached.
List(ctx context.Context, dir string) (children []string, err error)
}
KV is the slice of Vault this adapter uses, behind an interface it owns so a fake drives the unit suite and the real client is adapted by Wrap.
func Wrap ¶
Wrap adapts a real Vault client to the narrow KV interface.
The mount belongs here rather than in the secret paths because KV v2 splits its endpoints — reads go through the typed KVv2 helper while listing goes to the mount's "metadata" tree — and that split is an artefact of the engine that the adapter's own logic should not have to know about.
type Option ¶
type Option func(*backend)
Option configures a backend.
func WithPollInterval ¶ added in v0.2.0
WithPollInterval sets how often the backend polls Vault for change.
Vault has no change feed, so watching is polling (D10) and the interval is a direct trade between how fresh configuration is and how much audit-log traffic and load the process generates. The default is DefaultPollInterval.
type Secret ¶
type Secret struct {
// Data holds the secret's fields. Vault preserves JSON structure, so a
// value may be a scalar, a nested map or a slice. Nil or empty means the
// secret is deleted or holds nothing.
Data map[string]any
// Version is the KV v2 version. It is the marker the poll watch compares
// to notice a foreign change.
Version int
}
Secret is one version of one Vault secret: its fields, and the KV v2 version that identifies this revision of them.