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
+110
View File
@@ -0,0 +1,110 @@
package token
import (
"context"
"fmt"
"time"
"github.com/hashicorp/vault/api"
)
// RenewEvent is emitted by AutoRenewer as the watched token's lifecycle
// changes. Err set + Terminal true means the watcher has given up and the
// token will eventually expire without further notice.
type RenewEvent struct {
At time.Time
TTL time.Duration
Err error
Terminal bool
}
// AutoRenewer wraps api.LifetimeWatcher with the two special cases that
// matter for a token we did not necessarily just mint ourselves: batch
// tokens are not renewable, and root tokens have TTL == 0 (never expire).
// NewAutoRenewer returns (nil, nil) for either case — callers should treat
// a nil renewer as "nothing to do", not an error.
type AutoRenewer struct {
client *api.Client
w *api.LifetimeWatcher
events chan RenewEvent
}
// NewAutoRenewer builds a renewer from a *Info (e.g. from Lookup) rather
// than requiring the original login *api.Secret, since a token resolved
// from ~/.vault-token has no such Secret — this synthesises an equivalent
// one from the lookup data.
func NewAutoRenewer(c *api.Client, info *Info, increment time.Duration) (*AutoRenewer, error) {
if !info.Renewable || info.ExpireTime == nil {
return nil, nil
}
sec := &api.Secret{
Auth: &api.SecretAuth{
ClientToken: c.Token(),
LeaseDuration: int(info.TTL.Seconds()),
Renewable: info.Renewable,
},
}
w, err := c.NewLifetimeWatcher(&api.LifetimeWatcherInput{
Secret: sec,
Increment: int(increment.Seconds()),
RenewBuffer: api.DefaultLifetimeWatcherRenewBuffer,
RenewBehavior: api.RenewBehaviorIgnoreErrors,
})
if err != nil {
return nil, fmt.Errorf("creating lifetime watcher: %w", err)
}
return &AutoRenewer{client: c, w: w, events: make(chan RenewEvent, 8)}, nil
}
// Start runs the watcher until ctx is cancelled or Stop is called.
func (a *AutoRenewer) Start(ctx context.Context) {
go a.w.Start()
go func() {
defer close(a.events)
for {
select {
case <-ctx.Done():
a.w.Stop()
return
case out, ok := <-a.w.RenewCh():
if !ok {
return
}
a.emit(RenewEvent{At: time.Now(), TTL: leaseDuration(out)})
case err, ok := <-a.w.DoneCh():
if !ok {
return
}
a.emit(RenewEvent{At: time.Now(), Err: err, Terminal: true})
return
}
}
}()
}
func (a *AutoRenewer) emit(e RenewEvent) {
select {
case a.events <- e:
default:
}
}
// Events returns the channel of renewal notifications; closed when the
// watcher stops.
func (a *AutoRenewer) Events() <-chan RenewEvent { return a.events }
// Stop releases the underlying watcher.
func (a *AutoRenewer) Stop() { a.w.Stop() }
// leaseDuration reads the renewed TTL out of a RenewOutput. Token renewals
// (our only use case) carry it on Secret.Auth, not the top-level
// Secret.LeaseDuration field that non-auth lease renewals use.
func leaseDuration(out *api.RenewOutput) time.Duration {
if out == nil || out.Secret == nil {
return 0
}
if out.Secret.Auth != nil {
return time.Duration(out.Secret.Auth.LeaseDuration) * time.Second
}
return time.Duration(out.Secret.LeaseDuration) * time.Second
}
+141
View File
@@ -0,0 +1,141 @@
package token
import (
"context"
"errors"
"fmt"
"time"
"github.com/hashicorp/vault/api"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
// ErrTokenInvalid means the token was resolved from storage but Vault
// rejects it (expired, revoked, or simply wrong) — distinct from a
// transport failure, so callers can route straight back to the auth
// screen instead of showing a generic error.
var ErrTokenInvalid = errors.New("token is invalid or expired")
// Info is the normalised result of auth/token/lookup-self.
type Info struct {
Accessor string
DisplayName string
EntityID string
Policies []string
IdentityPolicies []string
Type string // "service" | "batch"
Path string
NamespacePath string
Meta map[string]string
Renewable bool
Orphan bool
NumUses int
TTL time.Duration
CreationTTL time.Duration
IssueTime time.Time
ExpireTime *time.Time // nil => root token / never expires
}
// String never includes the token value itself (Info never carries the raw
// token in the first place), but is defined for consistent %v behaviour
// alongside Resolved.
func (i *Info) String() string {
if i == nil {
return "<no token info>"
}
return fmt.Sprintf("<token accessor=%s policies=%v>", i.Accessor, i.Policies)
}
// Lookup calls auth/token/lookup-self and normalises the response. A 403
// is reported as ErrTokenInvalid rather than propagated as a raw API error.
func Lookup(ctx context.Context, c *api.Client) (*Info, error) {
sec, err := c.Logical().ReadWithContext(ctx, "auth/token/lookup-self")
if err != nil {
kind, _ := vault.Classify(err)
if kind == vault.ErrForbidden || kind == vault.ErrUnauthorized {
return nil, ErrTokenInvalid
}
return nil, err
}
if sec == nil || sec.Data == nil {
return nil, ErrTokenInvalid
}
return infoFromSecret(sec), nil
}
func infoFromSecret(sec *api.Secret) *Info {
i := &Info{}
i.Accessor, _ = sec.TokenAccessor()
i.Policies, _ = sec.TokenPolicies()
i.Renewable, _ = sec.TokenIsRenewable()
i.TTL, _ = sec.TokenTTL()
i.NumUses, _ = sec.TokenRemainingUses()
i.Meta, _ = sec.TokenMetadata()
d := sec.Data
i.DisplayName, _ = d["display_name"].(string)
i.EntityID, _ = d["entity_id"].(string)
i.Type, _ = d["type"].(string)
i.Path, _ = d["path"].(string)
i.NamespacePath, _ = d["namespace_path"].(string)
i.Orphan, _ = d["orphan"].(bool)
if idp, ok := d["identity_policies"].([]interface{}); ok {
for _, p := range idp {
if s, ok := p.(string); ok {
i.IdentityPolicies = append(i.IdentityPolicies, s)
}
}
}
if ct, ok := vault.AsInt(d["creation_ttl"]); ok {
i.CreationTTL = time.Duration(ct) * time.Second
}
if it, ok := d["issue_time"].(string); ok {
if t, err := time.Parse(time.RFC3339, it); err == nil {
i.IssueTime = t
}
}
// expire_time is absent/null for root tokens; handle that explicitly so
// callers can render "never" instead of the zero time.
if et, ok := d["expire_time"].(string); ok && et != "" {
if t, err := time.Parse(time.RFC3339Nano, et); err == nil {
i.ExpireTime = &t
}
}
return i
}
// RenewSelf calls auth/token/renew-self. increment == 0 lets Vault choose
// its own TTL extension.
func RenewSelf(ctx context.Context, c *api.Client, increment time.Duration) (*Info, error) {
body := map[string]interface{}{}
if increment > 0 {
body["increment"] = int(increment.Seconds())
}
sec, err := c.Logical().WriteWithContext(ctx, "auth/token/renew-self", body)
if err != nil {
return nil, err
}
if sec == nil {
return nil, fmt.Errorf("renew-self: empty response")
}
// renew-self's response shape is an auth response (sec.Auth), not a
// lookup-self data map; re-lookup so callers get one consistent Info shape.
return Lookup(ctx, c)
}
// RevokeSelf calls auth/token/revoke-self and then erases the given store
// (best-effort: erase runs even if the API call itself already invalidated
// the token from Vault's perspective).
func RevokeSelf(ctx context.Context, c *api.Client, s Store) error {
_, err := c.Logical().WriteWithContext(ctx, "auth/token/revoke-self", nil)
if s != nil {
if eraseErr := s.Erase(ctx); eraseErr != nil && err == nil {
err = eraseErr
}
}
return err
}
+88
View File
@@ -0,0 +1,88 @@
package token
import (
"context"
"strings"
"git.morlana.online/f.weber/vault-tui/internal/config"
)
// Source identifies which layer a resolved token came from.
type Source uint8
const (
SourceNone Source = iota
SourceFlag
SourceEnv
SourceConfig
SourceStore
)
func (s Source) String() string {
switch s {
case SourceFlag:
return "flag"
case SourceEnv:
return "env"
case SourceConfig:
return "config"
case SourceStore:
return "store"
default:
return "none"
}
}
// Resolved is the outcome of Resolve: the token plus enough provenance for
// the status bar to explain itself (a common source of confusion for any
// tool that shadows the Vault CLI's own ~/.vault-token).
//
// String/GoString are overridden so an accidental %v/%+v never leaks the
// token value into a log line.
type Resolved struct {
Token string
Source Source
Origin string // e.g. "VAULT_TOKEN", "~/.vault-token", "/usr/local/bin/vault-token-helper"
}
func (r Resolved) String() string {
if r.Token == "" {
return "<no token>"
}
return "<token via " + r.Origin + ">"
}
func (r Resolved) GoString() string { return r.String() }
// Options is the input to Resolve.
type Options struct {
Flag string // --token
Env string // VAULT_TOKEN, pre-read by the caller
Profile *config.Profile
Store Store // resolved by internal/cli from profile.token.storage
Getenv func(string) (string, bool)
}
// Resolve applies the precedence: --token flag > VAULT_TOKEN env >
// profile.token.value (discouraged) > the configured Store.
func Resolve(ctx context.Context, o Options) (Resolved, error) {
if v := strings.TrimSpace(o.Flag); v != "" {
return Resolved{Token: v, Source: SourceFlag, Origin: "--token"}, nil
}
if v := strings.TrimSpace(o.Env); v != "" {
return Resolved{Token: v, Source: SourceEnv, Origin: "VAULT_TOKEN"}, nil
}
if o.Profile != nil && o.Profile.Token.Value != "" {
return Resolved{Token: o.Profile.Token.Value, Source: SourceConfig, Origin: "config token.value"}, nil
}
if o.Store != nil {
tok, err := o.Store.Get(ctx)
if err != nil {
return Resolved{}, err
}
if tok != "" {
return Resolved{Token: tok, Source: SourceStore, Origin: o.Store.Location()}, nil
}
}
return Resolved{Source: SourceNone}, nil
}
+31
View File
@@ -0,0 +1,31 @@
// Package token owns everything about where a Vault token lives between
// runs: resolving one from flag/env/config/disk, validating it, keeping it
// renewed, and writing it back out — including in a form the real Vault CLI
// can read (see store_vaultcli.go).
package token
import "context"
// Store persists a single token value. Implementations must not log the
// token value anywhere.
type Store interface {
// Kind identifies the implementation for display: "vault-cli", "profile",
// or "none".
Kind() string
// Location is a display string: the token file path, or the external
// helper binary path.
Location() string
Get(ctx context.Context) (string, error)
Store(ctx context.Context, token string) error
Erase(ctx context.Context) error
}
// noneStore never persists anything; used when token.storage: none.
type noneStore struct{}
func NewNoneStore() Store { return noneStore{} }
func (noneStore) Kind() string { return "none" }
func (noneStore) Location() string { return "" }
func (noneStore) Get(context.Context) (string, error) { return "", nil }
func (noneStore) Store(context.Context, string) error { return nil }
func (noneStore) Erase(context.Context) error { return nil }
+68
View File
@@ -0,0 +1,68 @@
package token
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/natefinch/atomic"
)
// profileStore keeps one token per profile under the XDG state directory.
// It exists because vault-cli storage is single-valued (~/.vault-token):
// logging into "prod" would silently clobber "dev". Uses the same
// write-then-atomic-rename pattern as Vault's own InternalTokenHelper.
type profileStore struct {
path string
}
// NewProfileStore returns a Store scoped to one profile name, rooted at
// stateDir (see config.StateDir), e.g. stateDir/tokens/<profile>.token.
func NewProfileStore(stateDir, profile string) Store {
return profileStore{path: filepath.Join(stateDir, "tokens", sanitize(profile)+".token")}
}
// NewFileStore is a profileStore pinned to an explicit path, used for
// token.file in the config.
func NewFileStore(path string) Store {
return profileStore{path: path}
}
func sanitize(name string) string {
return strings.NewReplacer("/", "_", "\\", "_", "..", "_").Replace(name)
}
func (s profileStore) Kind() string { return "profile" }
func (s profileStore) Location() string { return s.path }
func (s profileStore) Get(context.Context) (string, error) {
b, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return "", nil
}
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
func (s profileStore) Store(_ context.Context, tok string) error {
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating token directory %q: %w", dir, err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, []byte(tok), 0o600); err != nil {
return err
}
return atomic.ReplaceFile(tmp, s.path)
}
func (s profileStore) Erase(context.Context) error {
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package token
import (
"context"
"fmt"
"github.com/hashicorp/vault/api/cliconfig"
"github.com/hashicorp/vault/api/tokenhelper"
)
// vaultCLIStore delegates to the exact packages the Vault CLI itself uses,
// so ~/.vault-token stays byte-compatible and any token_helper configured in
// ~/.vault (HCL file, overridable via VAULT_CONFIG_PATH) is honoured. Both
// api/cliconfig and api/tokenhelper ship inside the api module we already
// depend on, so this costs zero additional dependencies.
type vaultCLIStore struct {
h tokenhelper.TokenHelper
}
// NewVaultCLIStore resolves the configured token helper (falling back to
// the internal ~/.vault-token helper when none is configured).
func NewVaultCLIStore() (Store, error) {
h, err := cliconfig.DefaultTokenHelper()
if err != nil {
return nil, fmt.Errorf("resolving vault token helper: %w", err)
}
return vaultCLIStore{h: h}, nil
}
func (s vaultCLIStore) Kind() string { return "vault-cli" }
// Location calls Get first: InternalTokenHelper.Path() is only populated
// after its first Get/Store/Erase call (it lazily resolves the home
// directory internally), so an unconditional Path() call before any other
// operation would return "".
func (s vaultCLIStore) Location() string {
_, _ = s.h.Get()
return s.h.Path()
}
func (s vaultCLIStore) Get(context.Context) (string, error) {
return s.h.Get()
}
func (s vaultCLIStore) Store(_ context.Context, tok string) error {
return s.h.Store(tok)
}
func (s vaultCLIStore) Erase(context.Context) error {
return s.h.Erase()
}