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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user