configazurekeyvault

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 13 Imported by: 0

README

config-azure-keyvault

Read secrets from Azure Key Vault through config

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Documented with the parent module at config.go.phpboyscout.uk


config reads files. Azure Key Vault is a sibling backend module like this one, so a consumer who reads secrets from Key Vault takes it and one who does not pays nothing.

You build the client — vault URL and credential both — and hand it in:

import (
	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
	"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
	"gitlab.com/phpboyscout/go/config"
	configazurekeyvault "gitlab.com/phpboyscout/go/config-azure-keyvault"
)

cred, _ := azidentity.NewDefaultAzureCredential(nil)
client, _ := azsecrets.NewClient("https://my-vault.vault.azure.net/", cred, nil)

store, err := config.NewStore(ctx,
	config.WithFiles(fsys, "/etc/app.yaml"),                          // YAML defaults
	config.WithBackend(configazurekeyvault.FromClient(client)),       // secrets outrank them
)

Names are keys, verbatim

Key Vault secret names allow only letters, digits and hyphens — no dots, no slashes. There is no hierarchy to map, so a secret's name becomes a config key exactly as it appears in the portal:

db-password  = "s3cr3t"        store.View().GetString("db-password")
api-key      = "k-123"    →    store.View().GetString("api-key")

Hyphens are not separators. my-service-key is one key called my-service-key, not three levels of nesting. That is deliberate: a hyphen is legal in ordinary names and Key Vault gives no character to escape with, so treating it as a separator would silently restructure every hyphenated secret you own with no way to opt out.

Structure comes from a document, not from the names

Since the key space cannot carry a tree, put one in a secret and decode it:

import configjson "gitlab.com/phpboyscout/go/config-json"

config.WithBackend(configazurekeyvault.FromClientSecret(client, "app-config", configjson.Codec{}))
// {"db":{"host":"db.internal","port":5432}}  →  GetString("db.host"), GetInt("db.port")

The codec is a parameter here, not an option: a secret's value is one opaque string, so without something to decode it there is no tree to contribute, and requiring it at compile time beats failing at startup. Reading one named secret also costs a single request rather than the vault-wide N+1 below.

In flat mode the codec stays optional. WithValueCodec decodes any secret that holds a document into a subtree under its own name, and a plain password beside it stays a string:

config.WithBackend(configazurekeyvault.FromClient(client,
    configazurekeyvault.WithValueCodec(configjson.Codec{})))

The layer holds the secrets fit to use

Three kinds of secret are skipped, contributing no key while the rest of the vault loads normally:

Skipped Why
Managed secrets They back a Key Vault certificate and hold its PFX or PEM — key material, not configuration
Disabled secrets (enabled: false) The service will not return them, and an operator disabling one has said not to use it
Expired secrets An operator who set an expiry meant something by it

A start date (nbf) is not a skip, even though Key Vault treats it as informational in the same way. An expiry retires a credential; a start date prepares one — and since this adapter reads each secret's current version, a future start date says nothing about whether that value works.

[!IMPORTANT] A key can disappear while the secret still exists.

Key Vault treats expiry as informational: it will happily return an expired secret's value. This adapter will not. So an application that was working can stop finding a key simply because a date passed — the secret is still there, still readable in the portal, and no longer in your configuration.

If a key goes missing, check the secret's expiry first. It is the least obvious cause and the most common one.

A secret that is fit to use but that the vault refuses to hand over is a different matter, and is an error rather than a skip — that is the vault saying no, not an operator saying no.

Reading a vault costs one request per secret

Key Vault's listing returns metadata without values, and there is no batch read, so loading a vault of n secrets is one listing plus n requests. That is the service's shape, not this adapter's.

Two consequences worth planning around:

  • WithNamePrefix("app-") avoids fetching secrets outside your prefix. It cannot avoid the listing — the service has no server-side name filter — but in a vault shared with other applications it is the cost that matters. The prefix is not stripped from the key.
  • The watch polls at five minutes by default, slower than the rest of this toolkit, because each poll pays that same cost. WithPollInterval overrides it.

A poll notices a rotation, a secret appearing or disappearing — and a secret ceasing to be fit to use, which removes a key without any version changing at all.

Credentials are yours

This adapter never authenticates, resolves a credential, or reads the environment. It uses the client you give it, which is what lets managed identity, workload identity, a service principal or DefaultAzureCredential all work without the adapter knowing any of them.

It also keeps azidentity out of your dependency graph unless you put it there — this module does not import it, and a test asserts it never appears, because its arrival would mean the adapter had started doing something it should not.

The vault access policy or RBAC role needs get and list on secrets.

A secrets layer is sensitive, so writes to it are refused

Every value here is a secret, so this backend declares itself Sensitive, and it is read-only. Because the layer is read-only, a write to a key it provides cannot land in Key Vault — so it would otherwise fall through to the next writable layer, typically a plain YAML file. config refuses that write:

_, err := store.Apply(ctx, config.Set("db-password", "rotated"))
// errors.Is(err, config.ErrSensitiveLeak) — refused, NOT written to app.yaml

If a key needs to be writable, do not source it from Key Vault.

What it costs

Modules added 15 — 6 for the Key Vault SDK, 9 for the config graph
Requires config v0.9.2+

Pinned by an allowlist test in both directions, so a version bump that widens or narrows the graph fails the build rather than arriving quietly.

Install

go get gitlab.com/phpboyscout/go/config-azure-keyvault

Documentation

Full documentation lives with the parent module at config.go.phpboyscout.uk. The Go API reference is on pkg.go.dev.

Licence

MIT

Documentation

Overview

Package configazurekeyvault contributes secrets from Azure Key Vault 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.

Key Vault names allow only letters, digits and hyphens, so unlike every other backend this family adapts there is no hierarchy to map: a secret's name becomes a config key verbatim, hyphens and all. Structure comes from the other mode instead, where one secret holds a whole document.

Reading a vault costs one listing plus one request per secret, because the service's listing carries metadata without values. That is the service's shape, not this adapter's, and it is why the poll interval defaults to minutes rather than seconds.

The vault is reached through the narrow SecretsAPI interface, which Wrap adapts from a configured *azsecrets.Client. Injecting the client keeps every credential and vault-URL decision with the consumer — the adapter never authenticates — and lets the whole unit suite run against a fake.

See the config-azure-keyvault spec for the decisions behind all of this.

Index

Constants

View Source
const DefaultPollInterval = 5 * time.Minute

DefaultPollInterval is how often the backend polls for change when the consumer does not say otherwise.

Five minutes, deliberately slower than the rest of this family. A poll re-reads the vault, and the service has no batch read — so a sixty-second cadence over a fifty-secret vault would be fifty-one requests a minute, indefinitely.

View Source
const SourceKind = config.SourceKind("azure-keyvault")

SourceKind is what a layer from this backend reports as, so provenance can name Key Vault as the origin of a value.

Variables

View Source
var (
	// ErrNoCredential reports a nil credential supplied to a FromCredential rung.
	//
	// It covers the TYPED nil too, which is the one worth having: a nil
	// *azidentity.DefaultAzureCredential carried in an [azcore.TokenCredential]
	// interface compares unequal to nil, so without this guard it would be
	// accepted here and panic at the first request, far from the mistake.
	ErrNoCredential = errors.NewSentinel("configazurekeyvault.no_credential",
		"no Azure credential supplied; build one with azidentity, or use the ambient rung")

	// ErrNoVaultURL reports a rung called with no vault URL.
	//
	// Unlike an AWS region there is nothing ambient to fall back on: an Azure
	// credential names a principal and carries no endpoint, so the vault must be
	// named explicitly however the credential was obtained.
	ErrNoVaultURL = errors.NewSentinel("configazurekeyvault.no_vault_url",
		"no vault URL supplied; pass https://<name>.vault.azure.net/")
)

Functions

func FromClient

func FromClient(client *azsecrets.Client, opts ...Option) config.Backend

FromClient returns a backend reading a Key Vault through a configured client — the convenience path over New and Wrap.

client must already carry the vault URL and a credential. This adapter never authenticates and never resolves a credential: managed identity, workload identity, a service principal or DefaultAzureCredential all work here precisely because the adapter knows about none of them, and azidentity stays in your dependency graph rather than every consumer's.

func FromClientSecret

func FromClientSecret(
	client *azsecrets.Client, name string, codec config.Codec, opts ...Option,
) config.Backend

FromClientSecret returns a backend reading ONE secret, whose value is a whole document, through a configured client — the convenience path over NewSecret and Wrap. The same client rules as FromClient apply.

func FromCredential added in v0.3.0

func FromCredential(
	cred azcore.TokenCredential, vaultURL string, opts ...Option,
) (config.Backend, error)

FromCredential builds the Key Vault client from a credential the caller holds — a client secret, a certificate, a workload identity — and returns a backend over the vault.

azsecrets.NewClient does no I/O: it errors only on a malformed vault URL, so this rung constructs eagerly and reaching the vault stays deferred to Load, which has a context to bound it.

Where the ambient rung is

Deliberately NOT here. Resolving the ambient Azure identity chain costs this module seven further dependencies — azidentity, MSAL, golang-jwt, uuid, pkg/browser and the rest — and charging every consumer for an identity graph they may never use would spend the dependency-footprint guarantee this adapter states in its own test.

It lives in the ambient subpackage instead, over go/azureclient:

import kvambient "gitlab.com/phpboyscout/go/config-azure-keyvault/ambient"

b, err := kvambient.Default(ctx, "https://my-vault.vault.azure.net/")

To share ONE resolved credential across several adapters, resolve it with go/azureclient and pass it here (spec 0012 L-4, L-5).

func FromCredentialSecret added in v0.3.0

func FromCredentialSecret(
	cred azcore.TokenCredential, vaultURL, name string, codec config.Codec, opts ...Option,
) (config.Backend, error)

FromCredentialSecret is FromCredential for the single-secret shape: one secret whose payload is a whole document, decoded through the supplied codec.

func New

func New(api SecretsAPI, opts ...Option) config.Backend

New returns a backend contributing a Key Vault's secrets as one config layer, each secret's name becoming a key verbatim.

api is the injected client: a fake in tests, a Wrap-ped *azsecrets.Client in production.

func NewSecret

func NewSecret(api SecretsAPI, name string, codec config.Codec, opts ...Option) config.Backend

NewSecret returns a backend contributing ONE secret, whose value is a whole document, as a config layer.

This is how structure is expressed against a store whose names cannot carry it: the tree comes from the document rather than from the key space. It also costs a single request, where reading the vault costs one per secret.

The codec is a parameter rather than an option because it is not optional here — a secret's value is one opaque string, so without something to decode it there is no tree to contribute, and an option would only move that failure to run time.

The "fit to use" rules still apply: a disabled, expired or managed document secret is reported absent rather than served.

Types

type Fitness

type Fitness struct {
	// Enabled reports whether the service will return the value at all.
	Enabled bool

	// Managed reports a secret backing a Key Vault certificate, holding its
	// PFX or PEM rather than configuration.
	Managed bool

	// Expires is the secret's expiry, if one is set.
	Expires *time.Time
}

Fitness is what the skip rules read: whether a secret is one the layer should carry at all.

It is the same set in both modes because it is a property of the secret, not of how it was read — a listing reports it, and so does a get.

type Option

type Option func(*backend)

Option configures a backend.

func WithNamePrefix

func WithNamePrefix(prefix string) Option

WithNamePrefix reads only the secrets whose names begin with prefix.

The filter is applied after the listing, because the service offers no server-side name filter. So it cannot save the listing itself — but it does avoid fetching every secret in a vault shared with other applications, which is the cost that grows.

The prefix is not stripped: a Key Vault name is a config key verbatim, and removing part of it here would reintroduce, through the back door, exactly the name-rewriting this adapter refuses to do.

func WithPollInterval

func WithPollInterval(d time.Duration) Option

WithPollInterval sets how often the backend polls for change. The default is DefaultPollInterval.

func WithValueCodec

func WithValueCodec(codec config.Codec) Option

WithValueCodec decodes each secret's value through codec in the flat mode: a value that decodes to a mapping becomes a subtree under the secret's own name, and a value the codec rejects — a bare password — stays a scalar string, so a vault mixing plain secrets and documents reads correctly.

The name is still the key either way: this adds structure beneath a key, never structure between keys, which the naming rules forbid.

type Properties

type Properties struct {
	Name        string
	Version     string
	ContentType string

	// Fitness is what the skip rules read (see wanted).
	Fitness

	// NotBefore is the secret's start date, if one is set. It is carried for
	// completeness and deliberately NOT acted on — see wanted.
	NotBefore *time.Time
}

Properties is a secret's metadata, as a listing returns it — everything the adapter needs to decide whether a secret is worth fetching.

type Secret

type Secret struct {
	// Name is the secret's name, which is also its config key.
	Name string

	// Value is the secret's value. Key Vault returns secrets as strings.
	Value string

	// Version identifies this revision — the marker the poll compares.
	Version string

	// ContentType is the free-text hint the vault carries, surfaced for a
	// consumer to act on and never used to choose a decoder: it is a hint, not
	// a contract, so decoding on it would be guessing at a format the service
	// does not enforce.
	ContentType string

	// Fitness carries the attributes the skip rules read. A get returns them
	// alongside the value, so document mode can apply the same rule as the
	// flat mode without a second request.
	Fitness Fitness
}

Secret is one secret's current value.

type SecretsAPI

type SecretsAPI interface {
	// Get reads one secret's current value. It returns a nil Secret and a nil
	// error when the secret does not exist, so absence is not an error.
	Get(ctx context.Context, name string) (*Secret, error)

	// List returns every secret's metadata. Deliberately not its value: the
	// service's listing does not carry one, and an interface that pretended
	// otherwise would hide the per-secret cost from the code above it.
	List(ctx context.Context) ([]Properties, error)
}

SecretsAPI is the slice of Key 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

func Wrap(client *azsecrets.Client) SecretsAPI

Wrap adapts a real Key Vault client to the narrow SecretsAPI interface.

Directories

Path Synopsis
Package ambient adds the zero-conf rung to config-azure-keyvault.
Package ambient adds the zero-conf rung to config-azure-keyvault.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL