azureclient

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 16, 2026 License: MIT Imports: 9 Imported by: 0

README

azureclient

Resolve the Azure credential a service client is built from — once and shared, or afresh per operation, as the caller chooses.

import "gitlab.com/phpboyscout/go/azureclient"

src := azureclient.Ambient()          // resolved lazily, once, on first use

cred, err := src.AzureCredential(ctx)
client, err := azsecrets.NewClient(vaultURL, cred, nil)

Why it exists

Building an Azure service client is two steps with very different costs. azsecrets.NewClient(url, cred, nil) does no I/O — it errors only on a malformed URL. Getting the credential is the part that walks the identity chain (environment, workload identity, managed identity, developer tooling) and can fail.

Every adapter that needs a client faces the same problem, so it is solved once here instead of three times downstream.

The rungs

Constructor Resolves Use when
FromCredential(cred) nothing — uses what you give it you built a specific credential: a client secret, a certificate, a workload identity
Ambient() once, lazily, shared anything long-lived
PerCall() every call, retaining nothing holding a credential between operations would be wrong

All three return the same Source, so switching posture is a one-word change. Ambient resolves at most once concurrently and — unlike sync.OnceValuesnever caches a failure, so a managed identity that was not ready at startup does not wedge the process.

There is no endpoint here

Unlike its AWS counterpart, which refuses to guess a region, an Azure credential carries no endpoint at all. A vault URL, an App Configuration endpoint and a blob service URL are per-service and belong to the adapter that knows which service it is. This module resolves who you are, never what you are talking to.

What it does refuse is a nil credential — including a typed nil. A nil *azidentity.DefaultAzureCredential carried in an azcore.TokenCredential interface is not nil to a == comparison, which is exactly what you get from a constructor whose error was dropped. Without the guard it is accepted and panics at the first GetToken, far from the mistake.

Sharing is explicit

Two adapters that each call Ambient() resolve the chain twice. That is correct — they may deliberately want different tenants, and a hidden process-wide cache would make one component's transient failure everybody's, invisibly. A caller who wants one chain says so:

src := azureclient.Ambient(azureclient.WithTenantID("contoso"))

kvBackend  := configazurekeyvault.FromSource(src, vaultURL)
acBackend  := configazureappconfig.FromSource(src, endpoint)

Options

Option Effect
WithTenantID(id) Scopes the chain to one Entra tenant. An empty string is ignored, so an unset flag cannot clear one another option set.
WithCredentialOptions(o) The SDK's own options — sovereign cloud, disabled instance discovery, additionally allowed tenants. Replaces anything accumulated, because supplying the struct states the whole intent.
WithBuildTimeout(d) Bounds one attempt (default 30s). Per attempt, not a total budget.
WithLifetimeContext(ctx) Scopes an Ambient source's attempts to the owner's life. Ignored by PerCall.
WithLogger(l) DEBUG diagnostics. Off by default.

With no options set, nil is passed to the SDK — its documented "accept defaults" — rather than an empty struct, which would be a different and undocumented request.

Logging

Off unless you pass a logger. Records carry the credential's type, which says which link of the chain actually won, and how long resolution took. Tokens and secrets are never logged, at any level, and nothing is logged above DEBUG. Both claims have tests, including one that fails if the credential options are echoed.

What it costs

Eleven identity-graph modules, plus clientlifecycle and errors — both dependency-free. depfootprint_test.go asserts the exact set, and fails if an Azure service package ever appears: this module resolves the credential a client is built from, it does not build clients.

An adapter that receives an azcore.TokenCredential through a narrow interface it declares itself pays none of it.

Specification

org 0003 — provider client modules (P-2, P-3, P-12, P-13, P-14), over org 0002. The state machine beneath Ambient is clientlifecycle.

Licence

MIT. See LICENSE.

Documentation

Overview

Package azureclient resolves the Azure credential a service client is built from — once and shared, or afresh per operation, as the caller chooses.

It exists so that resolving the Azure identity chain is written once for the estate rather than in every adapter that needs a client. An adapter takes an azcore.TokenCredential and calls its own service's constructor — azsecrets.NewClient, azappconfig.NewClient, azblob.NewClient — which does no I/O; the expensive, failure-prone part is getting the credential, and that is all this module does.

Choosing a rung

azureclient.FromCredential(cred)  // you built it; used as-is, nothing is resolved
azureclient.Ambient()             // the ambient identity chain, resolved once and shared
azureclient.PerCall()             // the ambient chain, resolved afresh every time

Ambient is the usual choice for anything long-lived: it resolves lazily on first use, at most once concurrently, and — unlike sync.OnceValues — never caches a failure, so a managed identity that was not ready at startup does not wedge the process.

PerCall is for the caller that must not hold a credential between operations. That is a posture, not a slower Ambient.

All three return the same Source, so switching is a one-word change.

There is no endpoint here

Unlike AWS, where a region is part of the configuration and this module's counterpart refuses to guess one, an Azure credential carries no endpoint. A vault URL, an App Configuration endpoint and a blob service URL are per-service and belong to the adapter that knows which service it is. This module resolves *who you are*, never *what you are talking to*.

What it does refuse is a nil credential, including a typed nil — a nil *azidentity.DefaultAzureCredential in an azcore.TokenCredential interface is not nil to a == comparison, and would panic on first use rather than at the construction that could have caught it.

Sharing is explicit

Two adapters that each call Ambient get two independent sources and resolve the chain twice. That is correct: they may deliberately want different tenants, and a hidden process-wide cache would make the first one's transient failure everybody's. A caller who wants one chain builds one Source and hands it to each adapter.

Specified by org 0003 (P-2, P-3, P-12, P-13, P-14), which applies org 0002's connection lifecycle across the estate.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNoCredential = errors.NewSentinel("azureclient.no_credential",
	"no Azure credential supplied; pass one to FromCredential, or use Ambient to resolve the identity chain")

ErrNoCredential reports a nil credential supplied to FromCredential.

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 GetToken, far from the mistake.

Functions

This section is empty.

Types

type Option

type Option func(*options)

Option configures a source.

func WithBuildTimeout

func WithBuildTimeout(d time.Duration) Option

WithBuildTimeout bounds a single resolution attempt. It is per attempt, not a total budget: a failure caches nothing, so a later call gets a fresh allowance.

func WithCredentialOptions

func WithCredentialOptions(opts *azidentity.DefaultAzureCredentialOptions) Option

WithCredentialOptions passes the SDK's own options through to azidentity.NewDefaultAzureCredential — a sovereign cloud, a disabled instance discovery, additionally allowed tenants.

It is the escape hatch for everything WithTenantID does not cover, and it replaces any options accumulated so far rather than merging into them, because a caller supplying the whole struct is stating the whole intent.

func WithLifetimeContext

func WithLifetimeContext(ctx context.Context) Option

WithLifetimeContext scopes an Ambient source's resolution attempts to the life of whatever owns it. PerCall ignores it, being already scoped to its caller.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger enables DEBUG diagnostics. nil disables them, which is the default: a library that logs without being asked writes to somebody else's stderr.

Records carry only non-secret identifiers — the credential's concrete type and how long resolution took. Tokens and secrets are never logged, at any level, and nothing is logged above DEBUG.

func WithTenantID

func WithTenantID(tenantID string) Option

WithTenantID scopes the ambient chain to one Microsoft Entra tenant.

An empty string is ignored, so a caller threading through an unset flag cannot clear a tenant another option set. Leaving it unset lets the credential authenticate to whichever tenant is requested, which is the SDK's own default.

type Source

type Source interface {
	// AzureCredential returns the credential, resolving it if the source's
	// strategy says to. It is safe for concurrent use.
	AzureCredential(ctx context.Context) (azcore.TokenCredential, error)
}

Source yields the Azure credential a service client is built from.

The method is named for what it returns rather than a generic Get, so a call site reads as what it is and two providers' sources are not accidentally interchangeable.

func Ambient

func Ambient(opts ...Option) Source

Ambient returns a source that resolves the ambient Azure identity chain — environment, workload identity, managed identity, developer tooling — the first time it is asked, and shares that result.

It returns immediately and performs no I/O; resolution happens off the construction path, at most once concurrently, and a failure is retried rather than cached.

Example

ExampleAmbient shows the usual wiring. It has no Output because resolving the chain needs a real Azure identity; it is here to be read and kept compiling.

The source is built at startup and resolves nothing until something asks. The first call runs one bounded attempt, every later call reuses it, and a failure is retried rather than cached.

package main

import (
	"context"

	"gitlab.com/phpboyscout/go/azureclient"
)

func main() {
	src := azureclient.Ambient(azureclient.WithTenantID("contoso"))

	cred, err := src.AzureCredential(context.Background())
	if err != nil {
		return
	}

	// Building the service client from here does no I/O:
	//
	//	client, err := azsecrets.NewClient(vaultURL, cred, nil)
	_ = cred
}

func FromCredential

func FromCredential(cred azcore.TokenCredential, opts ...Option) (Source, error)

FromCredential returns a source over a credential the caller already built — a specific client secret, a workload identity, a certificate.

The credential is used as-is and nothing is ever resolved, so this rung has no build policy to apply. It is checked for nil now rather than left to panic on first use.

Example (TypedNil)

ExampleFromCredential_typedNil shows the guard a plain nil check misses.

A nil *azidentity.DefaultAzureCredential carried in an azcore.TokenCredential interface is NOT nil to a == comparison — which is exactly what you are holding if you dropped a constructor's error. Without the guard it is accepted here and panics at the first GetToken, a long way from the mistake.

package main

import (
	"errors"
	"fmt"

	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"

	"gitlab.com/phpboyscout/go/azureclient"
)

func main() {
	var typed *azidentity.DefaultAzureCredential // nil, but not a nil interface

	_, err := azureclient.FromCredential(typed)

	fmt.Println(typed == nil, errors.Is(err, azureclient.ErrNoCredential))
}
Output:
true true

func PerCall

func PerCall(opts ...Option) Source

PerCall returns a source that resolves the ambient chain afresh on every call and retains nothing between them.

Concurrent calls are deliberately not collapsed: sharing one resolution between callers who asked not to share one is the thing this rung exists to avoid.

Jump to

Keyboard shortcuts

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