feat(vault-tui): implement KV client and service for managing secrets

- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
package config
import (
"fmt"
"gopkg.in/yaml.v3"
)
// UnmarshalYAML accepts either a scalar ("#7D56F4") applied to both
// appearances, or a mapping ({light: "#...", dark: "#..."}).
func (c *Color) UnmarshalYAML(n *yaml.Node) error {
switch n.Kind {
case yaml.ScalarNode:
var s string
if err := n.Decode(&s); err != nil {
return err
}
c.Light, c.Dark = s, s
return nil
case yaml.MappingNode:
var pair struct {
Light string `yaml:"light"`
Dark string `yaml:"dark"`
}
if err := n.Decode(&pair); err != nil {
return err
}
c.Light, c.Dark = pair.Light, pair.Dark
return nil
default:
return fmt.Errorf("color must be a string or a {light, dark} mapping, got %v", n.Kind)
}
}
func (c Color) MarshalYAML() (interface{}, error) {
if c.Light == c.Dark {
return c.Light, nil
}
return map[string]string{"light": c.Light, "dark": c.Dark}, nil
}
+127
View File
@@ -0,0 +1,127 @@
// Package config defines the vault-tui configuration schema and how it is
// loaded from disk, merged with per-profile defaults, and combined with
// environment variables and CLI flags to produce a fully resolved
// [vault.Settings] (see internal/vault/settings.go and resolve.go in this
// package).
package config
import "time"
// SchemaVersion is the only value config.Version currently accepts.
const SchemaVersion = 1
// File is the root of ~/.config/vault-tui/config.yaml.
type File struct {
Version int `yaml:"version"`
CurrentProfile string `yaml:"current_profile,omitempty"`
Defaults Profile `yaml:"defaults,omitempty"`
Profiles map[string]*Profile `yaml:"profiles,omitempty"`
UI UI `yaml:"ui,omitempty"`
Keys map[string][]string `yaml:"keys,omitempty"`
Theme Theme `yaml:"theme,omitempty"`
}
// UI holds settings that are not connection-related.
type UI struct {
Appearance string `yaml:"appearance,omitempty"` // auto | dark | light
ConfirmDestructive *bool `yaml:"confirm_destructive,omitempty"`
TTLWarnBelow *time.Duration `yaml:"ttl_warn_below,omitempty"`
MaskValues *bool `yaml:"mask_values,omitempty"`
ClipboardClear *time.Duration `yaml:"clipboard_clear_after,omitempty"`
CacheTTL *time.Duration `yaml:"cache_ttl,omitempty"`
RequireCAS *bool `yaml:"require_cas,omitempty"`
ReadOnly *bool `yaml:"read_only,omitempty"`
BrowserCommand string `yaml:"browser_command,omitempty"`
}
// Profile is one named Vault connection + auth configuration. The
// zero-valued Profile is a legal "say nothing" value; every optional scalar
// is a pointer so that "false" and "unset" are distinguishable (this is
// what makes the defaults/profile merge in resolve.go correct).
type Profile struct {
Address string `yaml:"address,omitempty"`
Namespace string `yaml:"namespace,omitempty"`
Production *bool `yaml:"production,omitempty"`
ReadOnly *bool `yaml:"read_only,omitempty"`
IgnoreEnv *bool `yaml:"ignore_env,omitempty"`
TLS TLS `yaml:"tls,omitempty"`
Client ClientOpts `yaml:"client,omitempty"`
Auth Auth `yaml:"auth,omitempty"`
Token TokenOpts `yaml:"token,omitempty"`
Favourites []string `yaml:"favourites,omitempty"`
}
type TLS struct {
CACert string `yaml:"ca_cert,omitempty"`
CAPath string `yaml:"ca_path,omitempty"`
ClientCert string `yaml:"client_cert,omitempty"`
ClientKey string `yaml:"client_key,omitempty"`
ServerName string `yaml:"tls_server_name,omitempty"`
SkipVerify *bool `yaml:"skip_verify,omitempty"`
}
type ClientOpts struct {
Timeout *time.Duration `yaml:"timeout,omitempty"`
MaxRetries *int `yaml:"max_retries,omitempty"`
MinRetryWait *time.Duration `yaml:"min_retry_wait,omitempty"`
MaxRetryWait *time.Duration `yaml:"max_retry_wait,omitempty"`
SRVLookup *bool `yaml:"srv_lookup,omitempty"`
DisableRedirects *bool `yaml:"disable_redirects,omitempty"`
HTTPProxy string `yaml:"http_proxy,omitempty"`
RateLimit string `yaml:"rate_limit,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
}
// Auth configures which auth method a profile logs in with, plus prefilled
// parameters. Params values are looked up by internal/auth.Field.Name (or
// Field.ConfigKey when set) and never store secrets that have an
// EnvFallback equivalent (see internal/auth/method.go).
type Auth struct {
Method string `yaml:"method,omitempty"`
Mount string `yaml:"mount,omitempty"`
Params map[string]string `yaml:"params,omitempty"`
OIDC OIDCOpts `yaml:"oidc,omitempty"`
}
type OIDCOpts struct {
ListenAddress string `yaml:"listen_address,omitempty"`
Port *int `yaml:"port,omitempty"`
CallbackMethod string `yaml:"callback_method,omitempty"`
CallbackHost string `yaml:"callback_host,omitempty"`
CallbackPort *int `yaml:"callback_port,omitempty"`
CallbackPath string `yaml:"callback_path,omitempty"`
SkipBrowser *bool `yaml:"skip_browser,omitempty"`
AbortOnBrowserError *bool `yaml:"abort_on_browser_error,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"`
}
// TokenOpts controls how the resolved token is persisted between runs.
type TokenOpts struct {
// Storage is "vault-cli" (default, shares ~/.vault-token / the
// configured token helper with the real Vault CLI), "profile" (its own
// file under the XDG state dir, keyed by profile name), or "none"
// (never persisted).
Storage string `yaml:"storage,omitempty"`
File string `yaml:"file,omitempty"`
Value string `yaml:"value,omitempty"` // discouraged; Load warns
MirrorToVaultCLI *bool `yaml:"mirror_to_vault_cli,omitempty"`
AutoRenew *bool `yaml:"auto_renew,omitempty"`
RenewIncrement *time.Duration `yaml:"renew_increment,omitempty"`
RevokeOnLogout *bool `yaml:"revoke_on_logout,omitempty"`
ValidateOnStartup *bool `yaml:"validate_on_startup,omitempty"`
}
// Theme is the YAML-facing color/appearance schema; see internal/ui/theme
// for the Go types actually consumed by rendering.
type Theme struct {
BorderStyle string `yaml:"border_style,omitempty"`
MaskChar string `yaml:"mask_char,omitempty"`
Colors map[string]Color `yaml:"colors,omitempty"`
}
// Color is either a single hex value (same in light and dark) or an
// adaptive pair. UnmarshalYAML (color.go) accepts both forms.
type Color struct {
Light string
Dark string
}
+86
View File
@@ -0,0 +1,86 @@
package config
import (
"os"
"strconv"
"time"
)
// Getenv abstracts os.Getenv so tests can inject a fake environment.
type Getenv func(string) (string, bool)
// OSEnviron is the default Getenv backed by the real process environment.
func OSEnviron(key string) (string, bool) { return os.LookupEnv(key) }
// Env is every environment variable vault-tui recognises. Vault-native
// names keep their VAULT_ prefix for CLI parity; vault-tui-only settings use
// VAULT_TUI_. Kept as named constants (rather than inlined strings in
// resolve.go) so the set is easy to audit and document in one place.
const (
EnvAddress = "VAULT_ADDR"
EnvNamespace = "VAULT_NAMESPACE"
EnvCACert = "VAULT_CACERT"
EnvCACertBytes = "VAULT_CACERT_BYTES"
EnvCAPath = "VAULT_CAPATH"
EnvClientCert = "VAULT_CLIENT_CERT"
EnvClientKey = "VAULT_CLIENT_KEY"
EnvTLSServerName = "VAULT_TLS_SERVER_NAME"
EnvSkipVerify = "VAULT_SKIP_VERIFY"
EnvClientTimeout = "VAULT_CLIENT_TIMEOUT"
EnvMaxRetries = "VAULT_MAX_RETRIES"
EnvSRVLookup = "VAULT_SRV_LOOKUP"
EnvDisableRedirects = "VAULT_DISABLE_REDIRECTS"
EnvHTTPProxy = "VAULT_HTTP_PROXY"
EnvProxyAddr = "VAULT_PROXY_ADDR"
EnvRateLimit = "VAULT_RATE_LIMIT"
EnvToken = "VAULT_TOKEN"
EnvConfigPathVaultCLI = "VAULT_CONFIG_PATH" // consumed by api/cliconfig, not by us directly
EnvTUIConfig = "VAULT_TUI_CONFIG"
EnvTUIProfile = "VAULT_TUI_PROFILE"
EnvTUINoEnv = "VAULT_TUI_NO_ENV"
EnvTUIReadOnly = "VAULT_TUI_READ_ONLY"
// EnvAuthParamPrefix + strings.ToUpper(Field.Name) is consulted by
// internal/auth.Prefill alongside each Field's own EnvFallback list.
EnvAuthParamPrefix = "VAULT_TUI_AUTH_"
)
func envBool(get Getenv, key string) (bool, bool) {
v, ok := get(key)
if !ok || v == "" {
return false, false
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, false
}
return b, true
}
func envDuration(get Getenv, key string) (time.Duration, bool) {
v, ok := get(key)
if !ok || v == "" {
return 0, false
}
d, err := time.ParseDuration(v)
if err != nil {
if secs, serr := strconv.Atoi(v); serr == nil {
return time.Duration(secs) * time.Second, true
}
return 0, false
}
return d, true
}
func envInt(get Getenv, key string) (int, bool) {
v, ok := get(key)
if !ok || v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}
+78
View File
@@ -0,0 +1,78 @@
package config
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Load reads and decodes the file at path. A missing file is not an error:
// it returns a zero-valued *File so the tool remains usable purely from
// flags/env (see the package doc). Unknown YAML keys are a hard error so
// typos in a hand-edited config surface immediately instead of being
// silently ignored.
func Load(path string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &File{Version: SchemaVersion}, nil
}
return nil, fmt.Errorf("reading config %q: %w", path, err)
}
var f File
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("parsing config %q: %w", path, err)
}
if f.Version == 0 {
f.Version = SchemaVersion
}
if f.Version != SchemaVersion {
return nil, fmt.Errorf("config %q has version %d, vault-tui supports version %d", path, f.Version, SchemaVersion)
}
for name, p := range f.Profiles {
if p != nil && p.Token.Value != "" {
fmt.Fprintf(os.Stderr, "warning: profile %q sets token.value directly in the config file; prefer VAULT_TOKEN or token.file\n", name)
}
}
return &f, nil
}
// Save writes f to path as YAML, creating parent directories as needed.
// The file is written with 0600 permissions since profiles may carry
// TLS key paths and (discouraged but supported) inline token values.
func Save(path string, f *File) error {
if err := os.MkdirAll(dirOf(path), 0o700); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(f); err != nil {
return fmt.Errorf("encoding config: %w", err)
}
if err := enc.Close(); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o600)
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' {
return path[:i]
}
}
return "."
}
// Exists reports whether a config file is present at path.
func Exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"os"
"path/filepath"
)
const (
appDirName = "vault-tui"
fileName = "config.yaml"
configEnv = "VAULT_TUI_CONFIG"
profileEnv = "VAULT_TUI_PROFILE"
noEnvEnv = "VAULT_TUI_NO_ENV"
readOnlyEnv = "VAULT_TUI_READ_ONLY"
)
// DefaultPath returns the config file location used when neither --config
// nor VAULT_TUI_CONFIG is set: $XDG_CONFIG_HOME/vault-tui/config.yaml,
// falling back to ~/.config/vault-tui/config.yaml.
func DefaultPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, appDirName, fileName), nil
}
// ResolvePath applies the discovery order: --config flag > VAULT_TUI_CONFIG
// env > default XDG location.
func ResolvePath(flag string) (string, error) {
if flag != "" {
return flag, nil
}
if v := os.Getenv(configEnv); v != "" {
return v, nil
}
return DefaultPath()
}
// StateDir returns the directory vault-tui uses for its own per-profile
// token cache (internal/token/store_profile.go): $XDG_STATE_HOME/vault-tui,
// falling back to ~/.local/state/vault-tui.
func StateDir() (string, error) {
if v := os.Getenv("XDG_STATE_HOME"); v != "" {
return filepath.Join(v, appDirName), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "state", appDirName), nil
}
+414
View File
@@ -0,0 +1,414 @@
package config
import (
"fmt"
"time"
)
// Overrides is the "flag" layer: whatever the user passed on the command
// line. Every field is the zero value when not set; string "" and bool
// pointer nil both mean "the flag layer says nothing about this field",
// which is why the bool fields here are pointers (a flag can meaningfully
// set skip-verify back to false) while the plainly additive string fields
// are not.
type Overrides struct {
Profile string
Address string
Namespace string
Token string
CACert string
ClientCert string
ClientKey string
SkipVerify *bool
ReadOnly *bool
NoEnv bool // --no-env: disable the env layer entirely for this run
NoColor bool // --no-color: suppress ANSI color in the TUI
}
// layer is one candidate value plus where it would come from if chosen.
type layer[T any] struct {
val *T
layer string
key string
}
// pick returns the value (written into *dst) and Origin of the first layer
// (in argument order, highest precedence first) whose val is non-nil.
// Layers whose val is nil are "silent" and skipped. If no layer matches,
// dst is left untouched and a zero Origin is returned.
func pick[T any](dst *T, layers ...layer[T]) Origin {
for _, l := range layers {
if l.val != nil {
*dst = *l.val
return Origin{Layer: l.layer, Key: l.key}
}
}
return Origin{}
}
func strLayer(v, layerName, key string) layer[string] {
if v == "" {
return layer[string]{}
}
return layer[string]{val: &v, layer: layerName, key: key}
}
// Resolve builds a Settings for profileName out of f (the loaded config
// file), ov (the flag layer), and the process environment (or an injected
// Getenv in tests). Precedence, for every field independently:
//
// flag > env > profile > defaults block > builtin default
//
// This function — not api.DefaultConfig()'s own ReadEnvironment — is the
// single source of truth for that ordering. api.NewClient applies
// VAULT_TOKEN/VAULT_NAMESPACE itself as soon as a *api.Config is handed to
// it, which would silently place env above every other layer; internal/vault.New
// undoes that (ClearToken + SetNamespace) immediately after construction so
// only values that went through this function ever take effect.
func Resolve(f *File, profileName string, ov Overrides, get Getenv) (*Settings, error) {
if get == nil {
get = OSEnviron
}
name, err := SelectProfile(f, profileName, get)
if err != nil {
return nil, err
}
prof := f.Profiles[name]
if prof == nil {
prof = &Profile{}
}
def := f.Defaults
noEnv := ov.NoEnv
if !noEnv {
if b, ok := envBool(get, EnvTUINoEnv); ok {
noEnv = b
}
}
if prof.IgnoreEnv != nil && *prof.IgnoreEnv {
noEnv = true
}
envGet := get
if noEnv {
envGet = func(string) (string, bool) { return "", false }
}
s := &Settings{Profile: name, Origins: map[string]Origin{}}
set := func(field string, o Origin) {
if o.Layer != "" {
s.Origins[field] = o
}
}
set("address", pick(&s.Address,
strLayer(ov.Address, LayerFlag, "--address"),
envStrLayer(envGet, EnvAddress),
strLayer(prof.Address, LayerProfile, "profiles."+name+".address"),
strLayer(def.Address, LayerDefaults, "defaults.address"),
layer[string]{val: strp("https://127.0.0.1:8200"), layer: LayerBuiltin, key: "builtin"},
))
set("namespace", pick(&s.Namespace,
strLayer(ov.Namespace, LayerFlag, "--namespace"),
envStrLayer(envGet, EnvNamespace),
strLayer(prof.Namespace, LayerProfile, "profiles."+name+".namespace"),
strLayer(def.Namespace, LayerDefaults, "defaults.namespace"),
))
set("ca_cert", pick(&s.CACert,
strLayer(ov.CACert, LayerFlag, "--ca-cert"),
envStrLayer(envGet, EnvCACert),
strLayer(prof.TLS.CACert, LayerProfile, "profiles."+name+".tls.ca_cert"),
strLayer(def.TLS.CACert, LayerDefaults, "defaults.tls.ca_cert"),
))
if v, ok := envGet(EnvCACertBytes); ok && v != "" {
s.CACertPEM = []byte(v)
}
set("ca_path", pick(&s.CAPath,
envStrLayer(envGet, EnvCAPath),
strLayer(prof.TLS.CAPath, LayerProfile, "profiles."+name+".tls.ca_path"),
strLayer(def.TLS.CAPath, LayerDefaults, "defaults.tls.ca_path"),
))
set("client_cert", pick(&s.ClientCert,
strLayer(ov.ClientCert, LayerFlag, "--client-cert"),
envStrLayer(envGet, EnvClientCert),
strLayer(prof.TLS.ClientCert, LayerProfile, "profiles."+name+".tls.client_cert"),
strLayer(def.TLS.ClientCert, LayerDefaults, "defaults.tls.client_cert"),
))
set("client_key", pick(&s.ClientKey,
strLayer(ov.ClientKey, LayerFlag, "--client-key"),
envStrLayer(envGet, EnvClientKey),
strLayer(prof.TLS.ClientKey, LayerProfile, "profiles."+name+".tls.client_key"),
strLayer(def.TLS.ClientKey, LayerDefaults, "defaults.tls.client_key"),
))
set("tls_server_name", pick(&s.ServerName,
envStrLayer(envGet, EnvTLSServerName),
strLayer(prof.TLS.ServerName, LayerProfile, "profiles."+name+".tls.tls_server_name"),
strLayer(def.TLS.ServerName, LayerDefaults, "defaults.tls.tls_server_name"),
))
set("skip_verify", pick(&s.SkipVerify,
boolLayer(ov.SkipVerify, LayerFlag, "--tls-skip-verify"),
envBoolLayer(envGet, EnvSkipVerify),
boolLayer(prof.TLS.SkipVerify, LayerProfile, "profiles."+name+".tls.skip_verify"),
boolLayer(def.TLS.SkipVerify, LayerDefaults, "defaults.tls.skip_verify"),
))
set("timeout", pick(&s.Timeout,
envDurationLayer(envGet, EnvClientTimeout),
durationLayer(prof.Client.Timeout, LayerProfile, "profiles."+name+".client.timeout"),
durationLayer(def.Client.Timeout, LayerDefaults, "defaults.client.timeout"),
durationLayer(durp(60*time.Second), LayerBuiltin, "builtin"),
))
set("max_retries", pick(&s.MaxRetries,
envIntLayer(envGet, EnvMaxRetries),
intLayer(prof.Client.MaxRetries, LayerProfile, "profiles."+name+".client.max_retries"),
intLayer(def.Client.MaxRetries, LayerDefaults, "defaults.client.max_retries"),
intLayer(intp(2), LayerBuiltin, "builtin"),
))
set("min_retry_wait", pick(&s.MinRetryWait,
durationLayer(prof.Client.MinRetryWait, LayerProfile, "profiles."+name+".client.min_retry_wait"),
durationLayer(def.Client.MinRetryWait, LayerDefaults, "defaults.client.min_retry_wait"),
))
set("max_retry_wait", pick(&s.MaxRetryWait,
durationLayer(prof.Client.MaxRetryWait, LayerProfile, "profiles."+name+".client.max_retry_wait"),
durationLayer(def.Client.MaxRetryWait, LayerDefaults, "defaults.client.max_retry_wait"),
))
set("srv_lookup", pick(&s.SRVLookup,
envBoolLayer(envGet, EnvSRVLookup),
boolLayer(prof.Client.SRVLookup, LayerProfile, "profiles."+name+".client.srv_lookup"),
boolLayer(def.Client.SRVLookup, LayerDefaults, "defaults.client.srv_lookup"),
))
set("disable_redirects", pick(&s.DisableRedirects,
envBoolLayer(envGet, EnvDisableRedirects),
boolLayer(prof.Client.DisableRedirects, LayerProfile, "profiles."+name+".client.disable_redirects"),
boolLayer(def.Client.DisableRedirects, LayerDefaults, "defaults.client.disable_redirects"),
))
set("http_proxy", pick(&s.HTTPProxy,
envStrLayer(envGet, EnvHTTPProxy),
envStrLayer(envGet, EnvProxyAddr),
strLayer(prof.Client.HTTPProxy, LayerProfile, "profiles."+name+".client.http_proxy"),
strLayer(def.Client.HTTPProxy, LayerDefaults, "defaults.client.http_proxy"),
))
set("rate_limit", pick(&s.RateLimit,
envStrLayer(envGet, EnvRateLimit),
strLayer(prof.Client.RateLimit, LayerProfile, "profiles."+name+".client.rate_limit"),
strLayer(def.Client.RateLimit, LayerDefaults, "defaults.client.rate_limit"),
))
s.Headers = mergeHeaders(def.Client.Headers, prof.Client.Headers)
s.Auth = mergeAuth(def.Auth, prof.Auth)
s.Token = mergeTokenOpts(def.Token, prof.Token)
set("read_only", pick(&s.ReadOnly,
boolLayer(ov.ReadOnly, LayerFlag, "--read-only"),
envBoolLayer(get, EnvTUIReadOnly), // the read-only guard is NOT subject to --no-env
boolLayer(prof.ReadOnly, LayerProfile, "profiles."+name+".read_only"),
boolLayer(def.ReadOnly, LayerDefaults, "defaults.read_only"),
boolLayer(f.UI.ReadOnly, LayerDefaults, "ui.read_only"),
boolLayer(boolp(true), LayerBuiltin, "builtin"), // safe default: read-only until asked otherwise
))
set("mask_values", pick(&s.MaskValues, boolLayer(f.UI.MaskValues, LayerDefaults, "ui.mask_values"), boolLayer(boolp(true), LayerBuiltin, "builtin")))
set("confirm_destructive", pick(&s.ConfirmDestructive, boolLayer(f.UI.ConfirmDestructive, LayerDefaults, "ui.confirm_destructive"), boolLayer(boolp(true), LayerBuiltin, "builtin")))
set("require_cas", pick(&s.RequireCAS, boolLayer(f.UI.RequireCAS, LayerDefaults, "ui.require_cas"), boolLayer(boolp(true), LayerBuiltin, "builtin")))
set("cache_ttl", pick(&s.CacheTTL, durationLayer(f.UI.CacheTTL, LayerDefaults, "ui.cache_ttl"), durationLayer(durp(60*time.Second), LayerBuiltin, "builtin")))
set("ttl_warn_below", pick(&s.TTLWarnBelow, durationLayer(f.UI.TTLWarnBelow, LayerDefaults, "ui.ttl_warn_below"), durationLayer(durp(5*time.Minute), LayerBuiltin, "builtin")))
set("clipboard_clear_after", pick(&s.ClipboardClear, durationLayer(f.UI.ClipboardClear, LayerDefaults, "ui.clipboard_clear_after")))
set("appearance", pick(&s.Appearance, strLayer(f.UI.Appearance, LayerDefaults, "ui.appearance"), strLayer("auto", LayerBuiltin, "builtin")))
s.BrowserCommand = f.UI.BrowserCommand
// NO_COLOR (https://no-color.org) is a terminal-wide convention, not a
// Vault setting, so — like read_only above — it's read from the raw
// environment even under --no-env.
s.NoColor = ov.NoColor
if !s.NoColor {
if _, isSet := get("NO_COLOR"); isSet {
s.NoColor = true
}
}
return s, nil
}
// SelectProfile applies the profile-selection precedence: --profile flag >
// VAULT_TUI_PROFILE env > current_profile in the file > the sole profile if
// exactly one exists > "default".
func SelectProfile(f *File, flag string, get Getenv) (string, error) {
if flag != "" {
return flag, nil
}
if get == nil {
get = OSEnviron
}
if v, ok := get(EnvTUIProfile); ok && v != "" {
return v, nil
}
if f.CurrentProfile != "" {
return f.CurrentProfile, nil
}
if len(f.Profiles) == 1 {
for name := range f.Profiles {
return name, nil
}
}
if len(f.Profiles) == 0 {
return "default", nil
}
return "", fmt.Errorf("multiple profiles configured and none selected: pass --profile, set %s, or set current_profile in the config file", EnvTUIProfile)
}
func mergeHeaders(a, b map[string]string) map[string]string {
out := map[string]string{}
for k, v := range a {
out[k] = v
}
for k, v := range b {
out[k] = v
}
return out
}
func mergeAuth(def, prof Auth) Auth {
out := def
if prof.Method != "" {
out.Method = prof.Method
}
if prof.Mount != "" {
out.Mount = prof.Mount
}
if len(prof.Params) > 0 {
merged := map[string]string{}
for k, v := range def.Params {
merged[k] = v
}
for k, v := range prof.Params {
merged[k] = v
}
out.Params = merged
}
out.OIDC = mergeOIDCOpts(def.OIDC, prof.OIDC)
return out
}
func mergeOIDCOpts(def, prof OIDCOpts) OIDCOpts {
out := def
if prof.ListenAddress != "" {
out.ListenAddress = prof.ListenAddress
}
if prof.Port != nil {
out.Port = prof.Port
}
if prof.CallbackMethod != "" {
out.CallbackMethod = prof.CallbackMethod
}
if prof.CallbackHost != "" {
out.CallbackHost = prof.CallbackHost
}
if prof.CallbackPort != nil {
out.CallbackPort = prof.CallbackPort
}
if prof.CallbackPath != "" {
out.CallbackPath = prof.CallbackPath
}
if prof.SkipBrowser != nil {
out.SkipBrowser = prof.SkipBrowser
}
if prof.AbortOnBrowserError != nil {
out.AbortOnBrowserError = prof.AbortOnBrowserError
}
if prof.Timeout != nil {
out.Timeout = prof.Timeout
}
return out
}
func mergeTokenOpts(def, prof TokenOpts) TokenOpts {
out := def
if prof.Storage != "" {
out.Storage = prof.Storage
}
if prof.File != "" {
out.File = prof.File
}
if prof.Value != "" {
out.Value = prof.Value
}
if prof.MirrorToVaultCLI != nil {
out.MirrorToVaultCLI = prof.MirrorToVaultCLI
}
if prof.AutoRenew != nil {
out.AutoRenew = prof.AutoRenew
}
if prof.RenewIncrement != nil {
out.RenewIncrement = prof.RenewIncrement
}
if prof.RevokeOnLogout != nil {
out.RevokeOnLogout = prof.RevokeOnLogout
}
if prof.ValidateOnStartup != nil {
out.ValidateOnStartup = prof.ValidateOnStartup
}
return out
}
// --- small helpers -----------------------------------------------------
func envStrLayer(get Getenv, key string) layer[string] {
v, ok := get(key)
if !ok || v == "" {
return layer[string]{}
}
return layer[string]{val: &v, layer: LayerEnv, key: key}
}
func envBoolLayer(get Getenv, key string) layer[bool] {
b, ok := envBool(get, key)
if !ok {
return layer[bool]{}
}
return layer[bool]{val: &b, layer: LayerEnv, key: key}
}
func envDurationLayer(get Getenv, key string) layer[time.Duration] {
d, ok := envDuration(get, key)
if !ok {
return layer[time.Duration]{}
}
return layer[time.Duration]{val: &d, layer: LayerEnv, key: key}
}
func envIntLayer(get Getenv, key string) layer[int] {
n, ok := envInt(get, key)
if !ok {
return layer[int]{}
}
return layer[int]{val: &n, layer: LayerEnv, key: key}
}
func boolLayer(v *bool, layerName, key string) layer[bool] {
if v == nil {
return layer[bool]{}
}
return layer[bool]{val: v, layer: layerName, key: key}
}
func durationLayer(v *time.Duration, layerName, key string) layer[time.Duration] {
if v == nil {
return layer[time.Duration]{}
}
return layer[time.Duration]{val: v, layer: layerName, key: key}
}
func intLayer(v *int, layerName, key string) layer[int] {
if v == nil {
return layer[int]{}
}
return layer[int]{val: v, layer: layerName, key: key}
}
func strp(s string) *string { return &s }
func boolp(b bool) *bool { return &b }
func intp(i int) *int { return &i }
func durp(d time.Duration) *time.Duration { return &d }
+147
View File
@@ -0,0 +1,147 @@
package config
import (
"testing"
)
func envFrom(m map[string]string) Getenv {
return func(k string) (string, bool) {
v, ok := m[k]
return v, ok
}
}
func TestResolvePrecedence_Address(t *testing.T) {
f := &File{
Version: SchemaVersion,
CurrentProfile: "p1",
Defaults: Profile{Address: "https://defaults.example.com"},
Profiles: map[string]*Profile{
"p1": {Address: "https://profile.example.com"},
},
}
cases := []struct {
name string
ov Overrides
env map[string]string
want string
}{
{"builtin only", Overrides{}, nil, "https://profile.example.com"}, // profile beats defaults
{"env beats profile", Overrides{}, map[string]string{EnvAddress: "https://env.example.com"}, "https://env.example.com"},
{"flag beats env", Overrides{Address: "https://flag.example.com"}, map[string]string{EnvAddress: "https://env.example.com"}, "https://flag.example.com"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s, err := Resolve(f, "", c.ov, envFrom(c.env))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if s.Address != c.want {
t.Errorf("Address = %q, want %q", s.Address, c.want)
}
})
}
}
func TestResolve_DefaultsOnlyWhenNoProfile(t *testing.T) {
f := &File{Version: SchemaVersion, Defaults: Profile{Address: "https://defaults.example.com"}}
s, err := Resolve(f, "", Overrides{}, envFrom(nil))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if s.Address != "https://defaults.example.com" {
t.Errorf("Address = %q, want defaults value", s.Address)
}
}
func TestResolve_BuiltinFallback(t *testing.T) {
f := &File{Version: SchemaVersion}
s, err := Resolve(f, "", Overrides{}, envFrom(nil))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if s.Address != "https://127.0.0.1:8200" {
t.Errorf("Address = %q, want builtin default", s.Address)
}
if s.OriginOf("address").Layer != LayerBuiltin {
t.Errorf("origin layer = %q, want %q", s.OriginOf("address").Layer, LayerBuiltin)
}
}
func TestResolve_IgnoreEnvPerProfile(t *testing.T) {
ignore := true
f := &File{
Version: SchemaVersion,
Profiles: map[string]*Profile{
"staging": {Address: "https://staging.example.com", IgnoreEnv: &ignore},
},
}
s, err := Resolve(f, "staging", Overrides{}, envFrom(map[string]string{EnvAddress: "https://env.example.com"}))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if s.Address != "https://staging.example.com" {
t.Errorf("Address = %q, want profile value (env should be ignored)", s.Address)
}
}
func TestResolve_NoEnvOverrideFlag(t *testing.T) {
f := &File{
Version: SchemaVersion,
Profiles: map[string]*Profile{
"p": {Address: "https://profile.example.com"},
},
}
s, err := Resolve(f, "p", Overrides{NoEnv: true}, envFrom(map[string]string{EnvAddress: "https://env.example.com"}))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if s.Address != "https://profile.example.com" {
t.Errorf("Address = %q, want profile value (--no-env should suppress VAULT_ADDR)", s.Address)
}
}
func TestSelectProfile(t *testing.T) {
multi := &File{Profiles: map[string]*Profile{"a": {}, "b": {}}}
if _, err := SelectProfile(multi, "", envFrom(nil)); err == nil {
t.Error("expected error selecting among multiple profiles with no selection")
}
if name, err := SelectProfile(multi, "a", envFrom(nil)); err != nil || name != "a" {
t.Errorf("flag selection: got (%q, %v)", name, err)
}
if name, err := SelectProfile(multi, "", envFrom(map[string]string{EnvTUIProfile: "b"})); err != nil || name != "b" {
t.Errorf("env selection: got (%q, %v)", name, err)
}
single := &File{Profiles: map[string]*Profile{"only": {}}}
if name, err := SelectProfile(single, "", envFrom(nil)); err != nil || name != "only" {
t.Errorf("sole profile selection: got (%q, %v)", name, err)
}
empty := &File{}
if name, err := SelectProfile(empty, "", envFrom(nil)); err != nil || name != "default" {
t.Errorf("empty profiles: got (%q, %v)", name, err)
}
}
func TestResolve_ReadOnlyDefaultsSafe(t *testing.T) {
f := &File{Version: SchemaVersion}
s, err := Resolve(f, "", Overrides{}, envFrom(nil))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !s.ReadOnly {
t.Error("ReadOnly should default to true when nothing overrides it")
}
no := false
s, err = Resolve(f, "", Overrides{ReadOnly: &no}, envFrom(nil))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if s.ReadOnly {
t.Error("--write (ReadOnly=false override) should have taken effect")
}
}
+77
View File
@@ -0,0 +1,77 @@
package config
import "time"
// Layer names used in Origin, most-significant first. Exported as
// constants so callers can compare without typos.
const (
LayerFlag = "flag"
LayerEnv = "env"
LayerProfile = "profile"
LayerDefaults = "defaults"
LayerBuiltin = "builtin"
)
// Origin records which layer produced a resolved value and the key that
// carried it, so the UI/CLI can explain itself (e.g. status bar: "address:
// VAULT_ADDR").
type Origin struct {
Layer string
Key string
}
// Settings is the fully flattened, precedence-resolved connection
// configuration for one profile. internal/vault.New builds an *api.Client
// from exactly this — it never re-reads env or config itself, which is what
// makes the flag>env>profile>defaults>builtin precedence in resolve.go the
// single source of truth (see resolve.go's package doc for why
// api.DefaultConfig()'s own env-reading must not be relied upon instead).
type Settings struct {
Profile string
Address string
Namespace string
CACert string
CAPath string
CACertPEM []byte
ClientCert string
ClientKey string
ServerName string
SkipVerify bool
Timeout time.Duration
MaxRetries int
MinRetryWait time.Duration
MaxRetryWait time.Duration
SRVLookup bool
DisableRedirects bool
HTTPProxy string
RateLimit string
Headers map[string]string
Auth Auth
Token TokenOpts
ReadOnly bool
MaskValues bool
ConfirmDestructive bool
RequireCAS bool
CacheTTL time.Duration
TTLWarnBelow time.Duration
ClipboardClear time.Duration
BrowserCommand string
Appearance string
NoColor bool
Origins map[string]Origin
}
// Fmt returns a short "value (source)" string for display, e.g.
// "https://vault.example.com (VAULT_ADDR)". Falls back to just the key name
// used by that layer when no origin was recorded for field.
func (s *Settings) OriginOf(field string) Origin {
if s.Origins == nil {
return Origin{}
}
return s.Origins[field]
}