Host-Aware Login and Hostname Resolution#
Issues: #569 , #570 , #571 , #572 Status: Implemented (not yet released)
Problem#
Some auth handlers (notably an OpenShift/Kubernetes-style handler) authenticate against a specific endpoint. Users must pass the concrete API server URL at login:
scafctl auth login openshift --hostname https://api.cluster-01.example.com:6443For a large fleet (hundreds of clusters) this is error-prone and unmemorable. Users want to log in with a short selector:
scafctl auth login openshift --hostname cluster-01The mapping from selector to endpoint must be:
- Configurable per handler – different handlers have different fleets.
- Static or dynamic – small teams alias a handful of endpoints by hand; large orgs have a live inventory endpoint listing every cluster.
- Shape-blind in the core – scafctl must not hardcode any organization’s inventory JSON format. Each org adapts its own shape.
- Non-interactive at resolution time – resolving a selector must never trigger a browser login on its own.
Two supporting configuration capabilities were needed to make this ergonomic:
- A CLI to manage static aliases without hand-editing YAML (#569).
- A drop-in
config.dlayering mechanism so tooling can supply handler and hostname config without clobbering the user’s main file (#571), plus a way for embedders to discover the config directory (#572).
Design#
Configuration model (#570)#
Per-handler configuration lives under auth.handlers.<name>. The host consumes
a reserved hostname block; every other key is forwarded opaquely to the
handler plugin.
auth:
handlers:
openshift:
hostname:
aliases:
prod: https://api.prod.example.com:6443
resolver:
source:
url: https://clusters.example.com/inventory
authProvider: entra
authScope: api://fleet-inventory/.default
headers:
Accept: application/json
transform: |
_.map(k, _[k].status != "deleted", {
"name": k,
"url": _[k].apiServerURL,
})
ttl: 1h
# Any other key (not "hostname") is passed to the plugin unchanged.
apiTimeout: 30sTypes (in pkg/config/types.go):
GlobalAuthConfig.Handlers map[string]HandlerConfigHandlerConfig{ Hostname *HostnameConfig; Settings map[string]any }–Settingsusesmapstructure:",remain"to capture passthrough keys.HostnameConfig{ Aliases map[string]string; Resolver *HostnameResolverConfig }HostnameResolverConfig{ Source HostnameResolverSource; Transform string; TTL string }HostnameResolverSource{ URL, AuthProvider, AuthScope string; Headers map[string]string }
The core is shape-blind: an organization adapts its inventory response entirely
through the CEL transform. The canonical normalized contract is a
list of {name, url} objects.
Resolution precedence#
For a given --hostname <selector>, the host resolves in this order (first
match wins):
- Concrete URL – the selector already parses as an
http/httpsURL; forward unchanged. - Static alias – a
hostname.aliasesentry. - Dynamic resolver – fetch the inventory, run the transform, match by
name. - Otherwise fail, listing the available selectors.
Static aliases overlay the dynamic inventory: if both define the same selector, the alias wins.
Host resolution package (pkg/auth/hostname)#
The resolver is a small, dependency-injected core so it is fully unit-testable without network or disk:
func ResolveWith(
ctx context.Context,
cfg *config.HostnameConfig,
handler, selector string,
deps Deps,
) (string, error)Deps carries Fetch, Token, Transform, Cache, and Now, each with a
production default via withDefaults(). Resolve(ctx, handler, selector) is
the thin production entry point: it pulls the handler’s HostnameConfig from
config.FromContext(ctx) and calls ResolveWith with disk-backed defaults.
Sentinel errors (errors.go): ErrSelectorNotFound, ErrResolverLoop,
ErrTransformShape, ErrNoCredentials.
- Fetch (
fetch.go) – validates the URL scheme, builds anhttpcclient from the app HTTP config, GETs withAccept: application/json, static headers, and an optionalBearertoken, and reads a size-limited body. - Transform (
transform.go) – JSON-unmarshals the body, evaluates the CEL transform (celexp.EvaluateExpression) with the body bound to_, then round-trips the result through JSON into[]Entry, validating that each entry has a non-emptynameandurl. Any other shape yieldsErrTransformShape. - Token (
token.go) –tokenprovider.GetTokenwithCaller: callerscope.ServerCaller. This is cached and non-interactive: if the user is not already logged in to theauthProvider, it errors (wrapped asErrNoCredentialswith a hint to runauth login <provider>first) rather than opening a browser.
Loop guard: a resolver whose source.authProvider equals the handler being
logged into is rejected (ErrResolverLoop) – a handler cannot bootstrap its
own inventory token.
On-disk TTL cache#
A single-shot CLI process cannot benefit from an in-memory cache, so the
inventory cache is a dedicated on-disk JSON store under
paths.CacheDir()/hostname/.
- Key:
sha256(handler \0 source.URL \0 transform). - File:
{ ExpiresAt time.Time, Entries []Entry }, written0600in a0700directory. Gettreats expired or corrupt entries as a miss (and removes expired ones);Setis skipped whenttl <= 0.ttlparsing treats"","0", negative, and unparseable values as “no caching”.
Login hook (pkg/cmd/scafctl/auth/login.go)#
After the existing --hostname/CapHostname capability gate, the command
resolves the selector before delegating to the handler:
if hostname != "" {
resolved, err := hostnameresolver.Resolve(ctx, handlerName, hostname)
// ErrSelectorNotFound / ErrResolverLoop / ErrTransformShape -> InvalidInput
// otherwise -> GeneralError
hostname = resolved
}The resolved concrete URL is what the plugin receives. Resolution failures never call the handler.
auth alias command (#569)#
A thin CLI over the static aliases map, written to the user’s main
config.yaml:
scafctl auth alias set openshift prod https://api.prod.example.com:6443
scafctl auth alias list openshift # kvx table / -o json / -o yaml
scafctl auth alias remove openshift prod # prunes empty hostname/handler entriesAll subcommands gate on auth.CapHostname via getHandler + HasCapability.
The command follows scafctl conventions: writer.FromContext for status
output, kvx for structured list output, and settings.CliBinaryName ->
cliParams.BinaryName substitution so embedders get their own binary name in
help text.
Config layering: config.d (#571) and ConfigDir (#572)#
Manager.Load() merges *.yaml/*.yml fragments from a config.d/ directory
next to the main config file, in lexical filename order, between the
embedder base layer and the user’s config.yaml.
Precedence (lowest to highest):
- Built-in defaults
- Embedder base config (
WithBaseConfig) config.d/*.yamlfragments (lexical order)config.yaml(user’s main file)- Environment variables (
SCAFCTL_*) - Command-line flags
Maps deep-merge across layers; arrays (e.g. catalogs) are replaced by the
highest-precedence layer that defines them. A missing config.d is not an
error; a malformed fragment fails the load with the offending filename.
Manager.ConfigDir() returns the directory containing the config file – the
anchor for config.d and for embedders that need to locate config-relative
resources.
Key decisions#
- Cache backend: dedicated on-disk JSON, not in-memory LRU. A single-shot
CLI invocation has no long-lived process, so an in-memory TTL cache never
hits. The on-disk store honors
resolver.ttlacross invocations. - Token caller scope: reuse
callerscope.ServerCaller. Matches the existing non-interactive enumerator precedent – resolution reads cached tokens only and never triggers interactive login. auth alias setwrites to the mainconfig.yaml. Aliases are user-owned state; they belong in the user’s primary file, not aconfig.dfragment (which is for tooling-supplied layers).- #572 scope is minimal.
ConfigDir()plusWithBaseConfig/config.dcover the need; no new merge path was introduced.
Serialization note#
HandlerConfig.Settings is mapstructure:",remain" for decode and
yaml:",inline" for encode. The inline tag is required so opaque passthrough
settings survive a save round-trip (e.g. when auth alias rewrites
config.yaml); without it viper’s WriteConfig would drop them. Viper
lowercases all config keys, so plugins receive lowercased setting names.
Security considerations#
- No interactive login during resolution. Inventory token acquisition is cached-only; unauthenticated providers fail with a clear hint.
- Trusted-config fetch. The inventory URL comes from admin-owned config; the
fetch validates the scheme (
http/https) and bounds the response size. - Loop protection. A resolver cannot use the handler it configures as its
own
authProvider. - Cache isolation. Cache files are keyed by handler + URL + transform and
written with restrictive permissions (
0600in a0700directory).
Testing#
pkg/auth/hostname– precedence matrix, loop guard, no-credentials path, transform-shape errors, token injection, cache hit/miss/expiry, TTL parsing, URL validation. Includes the map-keyed transform (_.map(k, {"name": k, "url": _[k].apiServerURL})).pkg/cmd/scafctl/auth– login hook (alias resolved, selector-not-found, concrete-URL passthrough, embedder binary name) and theauth aliascommand (set/list/remove, capability rejection, prune, opaque-settings round-trip, embedder binary name).pkg/config–config.dmerge order and precedence, missing/ malformed fragments, andConfigDirfor explicit and default paths.
Related#
- Authentication
- Multi-Profile Authentication
- Identity Provider
- Examples:
examples/auth/hostname-resolution.md,examples/auth/README.md,examples/config/README.md