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
+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 }