references/case-studies.md
# Credential Chain Case Studies
Two production designs at opposite ends of the complexity scale. Read these
to calibrate how much chain your provider actually needs — then implement
with the generic pattern in `credential-chain.md`.
## Case study 1: The AWS provider (`aws-sdk-go-base`)
The Terraform AWS provider, the AWSCC provider, and the S3 backend all
resolve credentials through one shared library,
[`hashicorp/aws-sdk-go-base`](https://github.com/hashicorp/aws-sdk-go-base)
(MPL-2.0). Its entry point `GetAwsConfig(ctx, *Config)` is the most
battle-tested credential chain in the Terraform ecosystem.
### Resolution order
1. **Static credentials short-circuit.** If the provider config carries any
of access key / secret key / token, it builds a static credentials
provider immediately and skips the rest of the chain. Explicit config
always wins, and partial static credentials fail loudly rather than
falling through — a deliberate choice for secrets.
2. **The SDK default chain**, with the profile pinned first when the
provider config sets one. The AWS SDK then resolves, in order:
environment variables → shared credentials/config files (including SSO,
web-identity, and source-profile assume-role directives declared *inside*
those files) → container credentials → EC2 instance metadata (IMDS).
3. **Explicit web identity override.** An `assume_role_with_web_identity`
block replaces whatever the default chain produced with an STS
web-identity provider (validated: role ARN required, exactly one token
source).
4. **Assume-role wrapping.** `assume_role` is a *list*; each entry wraps the
previously resolved credentials in an STS assume-role provider, enabling
role *chaining* (credentials → role A → role B). Each hop carries its own
session name, external ID, policy, tags, and duration, wrapped in a
credentials cache.
### Techniques worth copying regardless of scale
- **Eager verification.** The resolved provider's `Retrieve()` is called
during configuration, and — unless `skip_credentials_validation` is set —
an `sts:GetCallerIdentity` call proves the credentials actually work.
Failures surface at plan time with configuration-shaped errors instead of
mid-apply with API-shaped ones.
- **`NoValidCredentialSourcesError`.** The missing-credentials diagnostic
embeds a caller-supplied documentation URL (`CallerDocumentationURL`) and
the underlying error. Every downstream product (provider, backend) points
users at *its own* auth docs through the same error type.
- **Conflict warnings.** If both a `profile` and static env-var credentials
are present, it emits a "configuration conflict" warning explaining which
source took precedence — and if resolution then fails, the error is
annotated with that context.
- **Endpoint injection for the chain itself.** Custom STS/SSO/IAM endpoints
are threaded into the chain's own calls, so air-gapped and
government-partition deployments can authenticate at all. If your platform
has regional or private auth endpoints, the chain must honor them too.
- **Legacy env-var migration.** Deprecated variables (e.g.
`AWS_METADATA_URL`) are still read, with a warning naming the replacement.
Renaming an env var without a migration warning breaks users silently.
### What this scale of chain costs
`Config` carries dozens of fields (proxies, CA bundles, retry modes, IMDS
toggles, account-ID allow/deny lists), and the behavioral spec lives in a
very large test suite exercising every precedence branch. Do not start
here — grow toward it.
## Case study 2: A hand-rolled chain in a small provider
A recently built provider for an appliance-style API (IBM Power HMC) needed
exactly three sources — provider block, environment variables, and a YAML
credentials file with named profiles — and implemented the chain by hand in
a self-contained `internal/credentials` package, the same shape as
`credential-chain.md`. Its design choices, generalized:
- **A one-method `Provider` interface + sentinel error.** `Retrieve(ctx)`
returns the credentials or `ErrNoCredentials` to mean "fall through". An
aggregate `ChainError` records every source tried and implements
`Is(ErrNoCredentials)` so the provider's `Configure` can pick between
"here is how to supply credentials" and "your file is broken" with a
single `errors.Is`.
- **Secrets as a set; connection settings field-by-field.** Username and
password resolve together through the chain, while `host` and `insecure`
each independently follow config > env > file profile > default. A profile
can therefore hold only connection settings while credentials come from
the environment.
- **Redaction at the type level.** The credentials struct's
`String()`/`GoString()` print `***REDACTED***` for the secret, making the
value log-safe by construction.
- **File semantics tuned for humans.** A missing file or profile that was
merely *defaulted* falls through silently; a missing file at an
*explicitly configured* path, an *explicitly requested* profile that does
not exist, or a malformed file are hard errors — each one is a user
mistake worth reporting precisely.
- **Warnings for the almost-right.** Group/world-readable credentials files
produce a `chmod 0600` warning; disabling TLS verification produces a
warning naming the risk.
- **Hermetic unit tests.** An injectable `getenv` function and temp-dir
credential files make precedence tests (`StaticWins`, `EnvBeatsFile`,
`FallsThroughToFile`), aggregate-error tests, and redaction tests run
without touching the real environment.
## Choosing your chain
| Your situation | Chain to build |
|---|---|
| Single API token, no files | Static + env. Two providers, still worth the chain for its error aggregation |
| Human operators, multiple accounts | Add a credentials file with profiles (case study 2) |
| Runs inside the platform it manages | Add a platform-identity source (metadata/OIDC) at the end |
| Delegation/role semantics in the API | Add assume-role *wrapping* on top of the chain (case study 1) |
Whatever the size: keep the order in one constructor, aggregate every
skipped source into the failure message, and validate eagerly in
`Configure`.
references/credential-chain.md
# Credential Provider Chain: Complete Implementation
A full, compilable credential chain for a fictional `examplecloud` provider.
Everything lives in one package, `internal/credentials/`, so it can be unit
tested without any Terraform machinery. Adapt names, file formats, and the
set of sources to your API's ecosystem — the structure is what transfers.
## Contents
- [Package layout](#package-layout)
- [Core types: Credentials, Provider, errors](#core-types) — `provider.go`
- [The chain](#the-chain) — `chain.go`
- [Static provider](#static-provider) — `static.go`
- [Environment provider](#environment-provider) — `env.go`
- [File provider with profiles](#file-provider-with-profiles) — `file.go`
- [Default chain constructor](#default-chain-constructor) — `resolve.go`
- [Unit tests](#unit-tests)
## Package layout
```
internal/credentials/
├── provider.go # Provider interface, Credentials, ErrNoCredentials, ChainError
├── chain.go # Chain: ordered resolution, error aggregation
├── static.go # source 1: provider block values
├── env.go # source 2: environment variables
├── file.go # source 3: shared credentials file profiles
├── resolve.go # NewDefaultChain: assembles the canonical order
└── *_test.go
```
## Core types
`provider.go`:
```go
package credentials
import (
"context"
"errors"
"fmt"
"strings"
)
// ErrNoCredentials signals that a source had nothing to offer and the chain
// should fall through to the next source. Any other error from Retrieve
// means the source was configured but unusable (malformed file, missing
// profile) and must be surfaced to the user, not silently skipped.
var ErrNoCredentials = errors.New("no credentials found")
// Credentials is a complete set of secrets. Secrets are resolved as a set:
// a source that supplies only one of the two fields supplies nothing.
type Credentials struct {
APIKey string
APISecret string
Source string // name of the provider that supplied them
}
func (c Credentials) Complete() bool {
return c.APIKey != "" && c.APISecret != ""
}
// String and GoString redact the secret so %v, %+v, and %#v can never leak
// it into logs, diagnostics, or wrapped errors.
func (c Credentials) String() string {
return fmt.Sprintf("Credentials{APIKey: %s, APISecret: ***REDACTED***, Source: %s}", c.APIKey, c.Source)
}
func (c Credentials) GoString() string { return c.String() }
// Provider is one source of credentials. Retrieve returns ErrNoCredentials
// (possibly wrapped) when the source has nothing to offer.
type Provider interface {
Retrieve(ctx context.Context) (Credentials, error)
Name() string
}
// ChainError aggregates the outcome of every source the chain consulted, so
// the final diagnostic can show users exactly what was tried and why each
// source was skipped.
type ChainError struct {
attempts []attempt
}
type attempt struct {
source string
err error
}
func (e *ChainError) record(source string, err error) {
e.attempts = append(e.attempts, attempt{source: source, err: err})
}
func (e *ChainError) Error() string {
if len(e.attempts) == 0 {
return ErrNoCredentials.Error()
}
var b strings.Builder
b.WriteString("no valid credential sources found. Sources tried:")
for _, a := range e.attempts {
fmt.Fprintf(&b, "\n - %s: %s", a.source, a.err)
}
return b.String()
}
// Is makes errors.Is(err, ErrNoCredentials) true only when every source
// fell through cleanly. If any source failed hard (e.g. malformed file),
// the caller should show that failure instead of the generic
// "no credentials" guidance.
func (e *ChainError) Is(target error) bool {
if target != ErrNoCredentials {
return false
}
for _, a := range e.attempts {
if !errors.Is(a.err, ErrNoCredentials) {
return false
}
}
return true
}
```
Design notes:
- Two secret fields demonstrate set-resolution; a single-token API works the
same with `Complete()` checking one field.
- `Source` exists purely for observability — log it, never the secrets.
- `ChainError.Is` is what lets `Configure` choose between the "here is how
to supply credentials" message and the "your credentials file is broken"
message with one `errors.Is` call.
## The chain
`chain.go`:
```go
package credentials
import "context"
// Chain consults providers in order and returns the first complete set of
// credentials. Every skipped source is recorded so the aggregate error can
// explain the full resolution attempt.
type Chain struct {
providers []Provider
}
func NewChain(providers ...Provider) *Chain {
return &Chain{providers: providers}
}
func (c *Chain) Retrieve(ctx context.Context) (Credentials, error) {
chainErr := &ChainError{}
for _, p := range c.providers {
creds, err := p.Retrieve(ctx)
switch {
case err != nil:
// Record and continue: a broken source should not mask a
// working one later in the chain, but it must appear in the
// final error if nothing works. (Alternative: fail fast on
// non-sentinel errors. Continue-and-record is friendlier when
// e.g. a stale credentials file exists but env vars are set.)
chainErr.record(p.Name(), err)
case !creds.Complete():
chainErr.record(p.Name(), ErrNoCredentials)
default:
creds.Source = p.Name()
return creds, nil
}
}
return Credentials{}, chainErr
}
func (c *Chain) Name() string { return "Chain" }
```
The chain itself implements `Provider`, so chains compose: a platform
identity source that is itself a chain of metadata endpoints slots in as one
entry.
## Static provider
`static.go` — values from the `provider` block. Highest priority: explicit
configuration always wins.
```go
package credentials
import "context"
type StaticProvider struct {
APIKey string
APISecret string
}
func (p *StaticProvider) Retrieve(_ context.Context) (Credentials, error) {
creds := Credentials{APIKey: p.APIKey, APISecret: p.APISecret}
if !creds.Complete() {
return Credentials{}, ErrNoCredentials
}
return creds, nil
}
func (p *StaticProvider) Name() string { return "provider configuration" }
```
## Environment provider
`env.go` — the injectable `GetEnv` field is what makes precedence unit
tests hermetic (no `os.Setenv` cross-test contamination).
```go
package credentials
import (
"context"
"os"
)
const (
EnvAPIKey = "EXAMPLECLOUD_API_KEY"
EnvAPISecret = "EXAMPLECLOUD_API_SECRET"
EnvProfile = "EXAMPLECLOUD_PROFILE"
EnvCredsFile = "EXAMPLECLOUD_SHARED_CREDENTIALS_FILE"
)
type EnvProvider struct {
// GetEnv defaults to os.Getenv; inject a map-backed func in tests.
GetEnv func(string) string
}
func (p *EnvProvider) getenv(key string) string {
if p.GetEnv != nil {
return p.GetEnv(key)
}
return os.Getenv(key)
}
func (p *EnvProvider) Retrieve(_ context.Context) (Credentials, error) {
creds := Credentials{
APIKey: p.getenv(EnvAPIKey),
APISecret: p.getenv(EnvAPISecret),
}
if !creds.Complete() {
return Credentials{}, ErrNoCredentials
}
return creds, nil
}
func (p *EnvProvider) Name() string {
return "environment variables (" + EnvAPIKey + ", " + EnvAPISecret + ")"
}
```
Naming the actual variables in `Name()` pays off directly in the aggregate
error message.
## File provider with profiles
`file.go`. The format here is minimal INI-style parsing to avoid
dependencies; use YAML/TOML if your ecosystem prefers it. The error
semantics are the part to copy exactly:
The rule is uniform: a *defaulted* value that resolves to nothing falls
through; an *explicit* value that resolves to nothing is a user mistake and
errors. It applies identically to the file path and the profile name.
| Condition | Behavior | Why |
|---|---|---|
| File absent, path defaulted | `ErrNoCredentials` | Most users have no file; fall through silently |
| File absent, path set explicitly | hard error | The user pointed at it; tell them it is missing |
| File unreadable or malformed | hard error | Never silently skip a file the user wrote |
| Profile missing, name set explicitly | hard error | An explicit profile that resolves to nothing is a typo |
| Profile missing, name defaulted | `ErrNoCredentials` | A file holding only named profiles shouldn't break users who never asked for `default` |
| Profile present, fields incomplete | `ErrNoCredentials` | The profile may intentionally hold only non-secret settings |
```go
package credentials
import (
"bufio"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
const DefaultProfile = "default"
func DefaultCredentialsFilePath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".examplecloud", "credentials")
}
// ResolveFilePath: explicit config > env var > default location.
func ResolveFilePath(explicit string, getenv func(string) string) (path string, explicitlySet bool) {
if explicit != "" {
return explicit, true
}
if fromEnv := getenv(EnvCredsFile); fromEnv != "" {
return fromEnv, true
}
return DefaultCredentialsFilePath(), false
}
// ResolveProfile: explicit config > env var > "default". The explicitlySet
// result feeds the same defaulted-vs-explicit semantics as the file path.
func ResolveProfile(explicit string, getenv func(string) string) (profile string, explicitlySet bool) {
if explicit != "" {
return explicit, true
}
if fromEnv := getenv(EnvProfile); fromEnv != "" {
return fromEnv, true
}
return DefaultProfile, false
}
type FileProvider struct {
Path string // resolved via ResolveFilePath
PathExplicit bool // missing file: hard error if true, fall through if not
Profile string // resolved via ResolveProfile
ProfileExplicit bool // missing profile: hard error if true, fall through if not
}
func (p *FileProvider) Retrieve(_ context.Context) (Credentials, error) {
if p.Path == "" {
return Credentials{}, ErrNoCredentials
}
profiles, err := parseCredentialsFile(p.Path)
if errors.Is(err, fs.ErrNotExist) {
if p.PathExplicit {
return Credentials{}, fmt.Errorf("credentials file %q does not exist", p.Path)
}
return Credentials{}, ErrNoCredentials
}
if err != nil {
return Credentials{}, fmt.Errorf("reading credentials file %q: %w", p.Path, err)
}
profile, ok := profiles[p.Profile]
if !ok {
if p.ProfileExplicit {
return Credentials{}, fmt.Errorf("profile %q not found in %q", p.Profile, p.Path)
}
return Credentials{}, ErrNoCredentials
}
creds := Credentials{APIKey: profile["api_key"], APISecret: profile["api_secret"]}
if !creds.Complete() {
return Credentials{}, ErrNoCredentials
}
return creds, nil
}
func (p *FileProvider) Name() string {
return fmt.Sprintf("shared credentials file (%s, profile %q)", p.Path, p.Profile)
}
// PermissionsTooOpen reports whether the file is group- or world-accessible.
// Surface this as a warning diagnostic, not an error. POSIX permission bits
// are not meaningful on Windows.
func PermissionsTooOpen(path string) bool {
if runtime.GOOS == "windows" {
return false
}
info, err := os.Stat(path)
if err != nil {
return false
}
return info.Mode().Perm()&0o077 != 0
}
// parseCredentialsFile reads a minimal INI format:
//
// [default]
// api_key = abc
// api_secret = xyz
func parseCredentialsFile(path string) (map[string]map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
profiles := map[string]map[string]string{}
var current map[string]string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
switch {
case line == "" || strings.HasPrefix(line, "#"):
case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"):
name := strings.TrimSpace(line[1 : len(line)-1])
current = map[string]string{}
profiles[name] = current
default:
key, value, found := strings.Cut(line, "=")
if !found || current == nil {
return nil, fmt.Errorf("malformed line: %q", line)
}
current[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
return profiles, scanner.Err()
}
```
## Default chain constructor
`resolve.go` — one constructor owns the canonical order so `Configure` and
tests can never disagree about precedence.
```go
package credentials
import "os"
type Options struct {
FilePath string // explicit credentials_file from provider config
Profile string // explicit profile from provider config
GetEnv func(string) string
// DefaultFilePath overrides the default file location when the user set
// nothing (keeps defaulted fall-through semantics). Tests use this to
// stay hermetic without hand-assembling the chain.
DefaultFilePath string
}
// NewDefaultChain assembles the canonical resolution order:
// static provider configuration > environment variables > credentials file.
// Add a platform identity provider at the end where the platform offers one.
func NewDefaultChain(staticKey, staticSecret string, opts Options) *Chain {
getenv := opts.GetEnv
if getenv == nil {
getenv = os.Getenv
}
path, pathExplicit := ResolveFilePath(opts.FilePath, getenv)
if !pathExplicit && opts.DefaultFilePath != "" {
path = opts.DefaultFilePath
}
profile, profileExplicit := ResolveProfile(opts.Profile, getenv)
return NewChain(
&StaticProvider{APIKey: staticKey, APISecret: staticSecret},
&EnvProvider{GetEnv: getenv},
&FileProvider{Path: path, PathExplicit: pathExplicit, Profile: profile, ProfileExplicit: profileExplicit},
)
}
```
For `Configure` wiring — unknown-value guards, the `errors.Is` branch that
selects the right diagnostic, and the permission warning — see the skill
body (SKILL.md); it composes directly with this package.
## Unit tests
The essential coverage, hermetic via injected env and `t.TempDir()`:
```go
package credentials
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func mapEnv(m map[string]string) func(string) string {
return func(key string) string { return m[key] }
}
func writeCredentialsFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "credentials")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
const sampleFile = "[default]\napi_key = file-key\napi_secret = file-secret\n"
func TestChain_StaticWins(t *testing.T) {
env := mapEnv(map[string]string{EnvAPIKey: "env-key", EnvAPISecret: "env-secret"})
chain := NewDefaultChain("static-key", "static-secret", Options{GetEnv: env})
creds, err := chain.Retrieve(context.Background())
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "static-key" {
t.Errorf("expected static credentials to win, got source %q", creds.Source)
}
}
func TestChain_EnvBeatsFile(t *testing.T) {
path := writeCredentialsFile(t, sampleFile)
env := mapEnv(map[string]string{EnvAPIKey: "env-key", EnvAPISecret: "env-secret"})
chain := NewDefaultChain("", "", Options{FilePath: path, GetEnv: env})
creds, err := chain.Retrieve(context.Background())
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "env-key" {
t.Errorf("expected env credentials to win, got %q from %q", creds.APIKey, creds.Source)
}
}
func TestChain_FallsThroughToFile(t *testing.T) {
path := writeCredentialsFile(t, sampleFile)
chain := NewDefaultChain("", "", Options{FilePath: path, GetEnv: mapEnv(nil)})
creds, err := chain.Retrieve(context.Background())
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "file-key" {
t.Errorf("expected file credentials, got %q from %q", creds.APIKey, creds.Source)
}
}
func TestChain_IncompleteSourceSkipped(t *testing.T) {
// Env supplies only the key: the set is incomplete, so the whole
// source is skipped and the file supplies both values.
path := writeCredentialsFile(t, sampleFile)
env := mapEnv(map[string]string{EnvAPIKey: "env-key"})
chain := NewDefaultChain("", "", Options{FilePath: path, GetEnv: env})
creds, err := chain.Retrieve(context.Background())
if err != nil {
t.Fatal(err)
}
if creds.APIKey != "file-key" {
t.Errorf("incomplete env source must not win: got %q from %q", creds.APIKey, creds.Source)
}
}
func TestChain_AllSourcesEmpty(t *testing.T) {
chain := NewDefaultChain("", "", Options{
DefaultFilePath: filepath.Join(t.TempDir(), "missing"), // defaulted semantics, hermetic location
GetEnv: mapEnv(nil),
})
_, err := chain.Retrieve(context.Background())
if !errors.Is(err, ErrNoCredentials) {
t.Fatalf("expected ErrNoCredentials, got %v", err)
}
for _, source := range []string{"provider configuration", "environment variables", "credentials file"} {
if !strings.Contains(err.Error(), source) {
t.Errorf("aggregate error should mention %q:\n%s", source, err)
}
}
}
func TestChain_ExplicitMissingProfileIsHardError(t *testing.T) {
path := writeCredentialsFile(t, sampleFile)
chain := NewDefaultChain("", "", Options{FilePath: path, Profile: "prod", GetEnv: mapEnv(nil)})
_, err := chain.Retrieve(context.Background())
if err == nil || errors.Is(err, ErrNoCredentials) {
t.Fatalf("expected hard error for missing profile, got %v", err)
}
if !strings.Contains(err.Error(), `profile "prod" not found`) {
t.Errorf("error should name the missing profile:\n%s", err)
}
}
func TestChain_DefaultProfileMissingFallsThrough(t *testing.T) {
// The file exists but holds only a named profile; nobody asked for
// "default", so the file source falls through instead of erroring.
path := writeCredentialsFile(t, "[work]\napi_key = k\napi_secret = s\n")
chain := NewDefaultChain("", "", Options{FilePath: path, GetEnv: mapEnv(nil)})
_, err := chain.Retrieve(context.Background())
if !errors.Is(err, ErrNoCredentials) {
t.Fatalf("expected fall-through for defaulted missing profile, got %v", err)
}
}
func TestCredentials_Redaction(t *testing.T) {
creds := Credentials{APIKey: "key", APISecret: "super-secret"}
for _, formatted := range []string{
fmt.Sprintf("%v", creds), fmt.Sprintf("%+v", creds), fmt.Sprintf("%#v", creds), creds.String(),
} {
if strings.Contains(formatted, "super-secret") {
t.Errorf("secret leaked: %s", formatted)
}
}
}
```
SKILL.md
---
name: provider-configuration
description: >-
Implement Terraform provider configuration and authentication with the
Plugin Framework: provider schema for credentials (Optional + Sensitive
attributes), environment variable fallbacks, credential provider chains
(static config, then environment variables, shared credentials file, and
platform identity), unknown-value guards in Configure(), secret redaction,
configure-time credential validation, and diagnostics that name every
source tried. Use when implementing or reviewing a provider's Configure
method or provider schema, adding authentication options (API keys,
tokens, profiles, credentials files, assume-role), deciding how a provider
should resolve credentials, debugging "no valid credential sources" or
missing-credentials errors, or unit testing credential resolution.
license: MPL-2.0
metadata:
lifecycle-status: active
copyright: Copyright IBM Corp. 2026
version: "0.0.1"
---
# Terraform Provider Configuration and Authentication
How a provider accepts connection settings and resolves credentials. Poor
authentication UX is the first thing every user of a provider hits; a
well-designed credential provider chain is what separates a production-grade
provider from a demo. The examples use a fictional `examplecloud` provider
and the [Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework/providers).
**References** (load when needed):
- `references/credential-chain.md` — complete, compilable credential chain
implementation (providers, chain, file profiles, Configure wiring, tests)
- `references/case-studies.md` — how the AWS provider (`aws-sdk-go-base`)
and smaller providers structure real credential chains
---
## Provider Schema for Authentication
Every authentication attribute must be `Optional`, never `Required` — a
`Required` attribute forces users to put credentials in configuration and
makes environment-variable and credentials-file resolution impossible. Mark
secrets `Sensitive` so Terraform redacts them in plan output, and state the
environment-variable fallback in each description so `tfplugindocs` publishes
the resolution rules.
```go
func (p *examplecloudProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
Optional: true,
MarkdownDescription: "API endpoint. May also be set via the `EXAMPLECLOUD_ENDPOINT` environment variable.",
},
"api_key": schema.StringAttribute{
Optional: true,
MarkdownDescription: "API key. May also be set via the `EXAMPLECLOUD_API_KEY` environment variable, or in a shared credentials file.",
},
"api_secret": schema.StringAttribute{
Optional: true,
Sensitive: true,
MarkdownDescription: "API secret. May also be set via the `EXAMPLECLOUD_API_SECRET` environment variable, or in a shared credentials file.",
},
"profile": schema.StringAttribute{
Optional: true,
MarkdownDescription: "Named profile in the shared credentials file. May also be set via the `EXAMPLECLOUD_PROFILE` environment variable. Defaults to `default`.",
},
"skip_credentials_validation": schema.BoolAttribute{
Optional: true,
MarkdownDescription: "Skip the identity check normally performed during provider configuration.",
},
},
}
}
```
Never add a `Default` to a credential attribute, and never hardcode a
credential anywhere in the provider. Defaults belong in the resolution logic
(where environment variables and files can override them), not in the schema.
## The Credential Provider Chain
Resolve credentials by consulting an ordered list of sources and taking the
first one that produces a **complete** set. This is the pattern the AWS
provider uses via [`aws-sdk-go-base`](https://github.com/hashicorp/aws-sdk-go-base),
and it generalizes to any provider. The canonical precedence, highest first:
1. **Static configuration** — values set directly in the `provider` block.
Explicit always wins.
2. **Environment variables** — `EXAMPLECLOUD_API_KEY`, etc. The CI-friendly
path.
3. **Shared credentials file** — named profiles in
`~/.examplecloud/credentials`, for humans with multiple accounts.
4. **Platform identity** — instance metadata, workload identity, or OIDC
token exchange, where the platform offers it. Credentials nobody has to
store.
Two rules make the chain predictable:
- **Resolve secrets as a set, not field-by-field.** If the environment
supplies an API key but no secret, that source offers nothing — fall
through to the next source for *both* values. Mixing an env-var key with a
file-profile secret produces authentication failures that are nearly
impossible for users to debug.
- **Resolve non-secret connection settings field-by-field.** `endpoint`,
`profile`, or `insecure` can each independently follow
config > env > file > default, because a mismatch there is visible and
harmless.
The core abstraction is a single-method interface with a sentinel error that
distinguishes "this source has nothing to offer" (fall through) from "this
source is misconfigured" (surface it):
```go
// ErrNoCredentials signals a source had nothing to offer. The chain falls
// through to the next source. Any other error means the source was
// configured but unusable (e.g. malformed credentials file) and is
// preserved so the final diagnostics can surface it.
var ErrNoCredentials = errors.New("no credentials found")
type Credentials struct {
APIKey string
APISecret string
Source string // which provider supplied them, for logging
}
func (c Credentials) Complete() bool {
return c.APIKey != "" && c.APISecret != ""
}
type Provider interface {
Retrieve(ctx context.Context) (Credentials, error)
Name() string
}
```
A `Chain` (itself a `Provider`, so chains compose) walks the providers in
order and returns the first complete set of credentials. Every skipped
source is recorded into an aggregate `ChainError` whose `Error()` lists each
source with the reason it was skipped, and whose `Is` method makes
`errors.Is(err, ErrNoCredentials)` true only when every source fell through
cleanly — so `Configure` can tell "nothing supplied" from "something
supplied but broken" with one check. The full implementation — the chain
loop, the static, environment, and file providers, and the
`NewDefaultChain` constructor that owns the canonical order — lives in
`references/credential-chain.md`.
## Wiring the Chain into Configure
`Configure` runs once per Terraform operation, before any resource CRUD.
The shape:
```go
func (p *examplecloudProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config examplecloudProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
// 1. Guard against unknown values (e.g. api_key = some_resource.output).
if config.APIKey.IsUnknown() {
resp.Diagnostics.AddAttributeError(
path.Root("api_key"),
"Unknown API Key",
"The provider cannot connect because api_key depends on a value known only after apply. "+
"Set a static value, or use the EXAMPLECLOUD_API_KEY environment variable.",
)
}
// ... repeat for each auth attribute, then:
if resp.Diagnostics.HasError() {
return
}
// 2. Resolve credentials through the chain.
chain := credentials.NewDefaultChain(
config.APIKey.ValueString(),
config.APISecret.ValueString(),
credentials.Options{Profile: config.Profile.ValueString()},
)
creds, err := chain.Retrieve(ctx)
if err != nil {
if errors.Is(err, credentials.ErrNoCredentials) {
resp.Diagnostics.AddError(
"No Valid Credential Sources Found",
"No examplecloud credentials were found. Sources tried, in order:\n\n"+err.Error()+
"\n\nSet api_key and api_secret in the provider block, export "+
"EXAMPLECLOUD_API_KEY and EXAMPLECLOUD_API_SECRET, or add a profile to "+
"~/.examplecloud/credentials. See https://example.com/docs/auth.",
)
} else {
resp.Diagnostics.AddError("Failed to Resolve Credentials", err.Error())
}
return
}
tflog.Debug(ctx, "resolved credentials", map[string]any{"source": creds.Source})
// 3. Build the client once; share it with every resource and data source.
client := examplecloud.NewClient(endpoint, creds.APIKey, creds.APISecret)
resp.DataSourceData = client
resp.ResourceData = client
}
```
Why each step matters:
- **Unknown-value guards.** During planning, an attribute wired to another
resource's output is *unknown*, not null. Without the guard the provider
silently treats it as empty, falls through the chain, and authenticates as
the wrong identity — or fails with a misleading "missing credentials"
error. Name the environment-variable workaround in the guard message.
- **The sentinel check picks the right message.** "You gave me nothing"
(actionable list of options) is a different failure from "you gave me
something broken" (show the parse error). Collapsing them into one message
is how providers end up with users pasting secrets into config to debug.
- **Log the source, never the secret.** Knowing *which* source won is the
single most useful debugging fact and costs nothing to log.
## Diagnostics That Unblock Users
An authentication error message is the provider's most-read documentation.
Every credential failure diagnostic should name:
- **Every source tried, in order, with why it was skipped** — the
`ChainError` provides this. `aws-sdk-go-base` does the same with its
`NoValidCredentialSourcesError`.
- **The exact environment variable names** and the credentials file path and
profile that were consulted — not "set the appropriate environment
variables".
- **A documentation URL** for the provider's authentication guide.
Use warnings (not errors) for conditions that are suspicious but not fatal,
naming what took precedence: a `profile` set while environment credentials
are also present (which wins?), or a credentials file with group/world-read
permissions (suggest `chmod 0600`).
## Secret Hygiene
- Give the `Credentials` type `String()` and `GoString()` methods that
redact secret fields, so a stray `%v`, `%+v`, or error wrap can never leak
a secret into logs or diagnostics.
- Never include credential *values* in diagnostics, log lines, or wrapped
errors — log the source name and non-secret identifiers only.
- Warn when a credentials file is readable by other users
(`info.Mode().Perm()&0o077 != 0`); skip this check on Windows, where POSIX
permission bits are not meaningful.
## Configure-Time Validation
Resolve the chain eagerly in `Configure` — never lazily on first resource
use — so a credentials problem fails one time, at plan, with a good message,
instead of failing in the middle of an apply. If the API has a cheap
identity endpoint (the equivalent of AWS `sts:GetCallerIdentity` or a
`/whoami`), call it after resolving credentials so *invalid* (not just
missing) credentials also fail at configure time. Gate it behind a
`skip_credentials_validation` attribute for air-gapped or stubbed
environments.
## Unit Testing the Chain
The chain is pure logic — test it with unit tests (`Test` prefix, no
`TF_ACC`), not acceptance tests. Make the environment injectable (a
`getenv func(string) string` field defaulting to `os.Getenv`, or use
`t.Setenv`) and point the file provider at `t.TempDir()` fixtures. The
tests that matter:
- **Per-source**: each provider returns its credentials when set and
`ErrNoCredentials` when incomplete (a key with no secret is incomplete).
- **Precedence**: static beats env; env beats file; chain falls through to
the file when nothing above supplies a complete set.
- **Failure aggregation**: with all sources empty,
`errors.Is(err, ErrNoCredentials)` is true and the message names every
source.
- **Hard errors**: a malformed credentials file or an *explicitly requested*
profile that does not exist surfaces a descriptive error rather than
silently falling through (a merely defaulted profile falls through).
- **Redaction**: `fmt.Sprintf("%v")` and `%+v` of a `Credentials` value
never contain the secret.
Full test examples are in `references/credential-chain.md`.
## Checklist
- [ ] All auth attributes `Optional`; secrets marked `Sensitive: true`
- [ ] Attribute descriptions name their environment-variable fallbacks
- [ ] Unknown-value guards on every auth attribute in `Configure`
- [ ] Chain precedence: static config > env vars > credentials file > platform identity
- [ ] Secrets resolved as a complete set; non-secret settings field-by-field
- [ ] Sentinel `ErrNoCredentials` distinguishes fall-through from hard failure
- [ ] Missing-credentials diagnostic lists every source tried + docs URL
- [ ] `Credentials` type redacts secrets in `String()`/`GoString()`
- [ ] Credentials-file permission warning (non-Windows)
- [ ] Eager resolution in `Configure`; optional identity check with `skip_credentials_validation`
- [ ] Unit tests cover per-source behavior, precedence, aggregation, redaction
- [ ] No credential value ever logged or embedded in an error
## Related Skills
Use the `new-terraform-provider` skill (if available) to scaffold the
provider this configuration lives in, and the `provider-resources` skill for
consuming the configured client from resources and data sources.