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
+381
View File
@@ -0,0 +1,381 @@
package vault
import (
"context"
"encoding/json"
"fmt"
"path"
"sort"
"github.com/hashicorp/vault/api"
)
// AsInt extracts an int from a decoded-JSON numeric value. api.Secret is
// parsed with json.Decoder.UseNumber() (see api.ParseSecret), so numbers in
// Secret.Data arrive as json.Number, not float64 — a plain `.(float64)`
// type assertion silently fails on every such field. Exported because
// internal/token.infoFromSecret hits the same issue on lookup-self's
// creation_ttl.
func AsInt(v interface{}) (int, bool) {
switch n := v.(type) {
case json.Number:
i, err := n.Int64()
if err != nil {
return 0, false
}
return int(i), true
case float64:
return int(n), true
case int:
return n, true
default:
return 0, false
}
}
// Listing is the result of listing one logical directory inside a mount.
// Dirs and Leaves are both relative to Path and never overlap; a Vault LIST
// response marks directories with a trailing "/", which is how this split
// is made.
type Listing struct {
Mount string
Path string
Dirs []string
Leaves []string
}
// VersionMeta is one KV v2 version's metadata, as returned by a data read or
// by the metadata endpoint's versions map.
type VersionMeta struct {
Version int
CreatedTime string
DeletionTime string // "" if not deleted
Destroyed bool
}
// Secret is the engine-agnostic result of a KV read: the key/value data
// plus, for KV v2, which version it is and that version's metadata.
type Secret struct {
Mount string
Path string
Data map[string]interface{}
Version int // 0 for KV v1 / cubbyhole, which are unversioned
Meta *VersionMeta // nil for KV v1 / cubbyhole
}
// WriteAck is returned after a successful write.
type WriteAck struct {
Mount string
Path string
Version int // 0 for KV v1 / cubbyhole
}
// DeleteOp identifies which of KV v2's four distinct delete-ish operations
// was requested; see Confirm() in the plan for why each needs a different
// confirmation prompt.
type DeleteOp uint8
const (
// OpSoftDelete marks the current (or given) versions deleted; recoverable.
OpSoftDelete DeleteOp = iota
// OpUndelete reverses OpSoftDelete for the given versions.
OpUndelete
// OpDestroy permanently destroys the given versions' data. Irreversible.
OpDestroy
// OpDeleteMetadata removes the path and all versions/metadata. Irreversible.
OpDeleteMetadata
// OpDeleteV1 is the plain, irreversible KV v1 / cubbyhole delete.
OpDeleteV1
)
func (op DeleteOp) String() string {
switch op {
case OpSoftDelete:
return "deleted"
case OpUndelete:
return "undeleted"
case OpDestroy:
return "destroyed"
case OpDeleteMetadata:
return "deleted (all versions)"
case OpDeleteV1:
return "deleted"
default:
return "unknown op"
}
}
// DeleteAck is returned after a successful delete-family operation.
type DeleteAck struct {
Mount string
Path string
Op DeleteOp
Versions []int // populated for OpSoftDelete/OpUndelete/OpDestroy
}
// KV is the engine-agnostic client the UI and headless commands use for all
// secret data operations. It hides the KV v1 vs v2 path mapping entirely —
// callers only ever pass a Mount and a logical path.
//
// KV v1 / cubbyhole: LIST/READ/WRITE/DELETE <mount>/<path>
// KV v2: LIST <mount>/metadata/<path>
// READ <mount>/data/<path>?version=N
// WRITE <mount>/data/<path> {data: {...}, options: {cas: N}}
// delete family on <mount>/{delete,undelete,destroy}/<path>
// and <mount>/metadata/<path> (DELETE = delete-metadata)
type KV struct {
Client *api.Client
ReadOnly bool
}
// ErrReadOnly is returned by every mutating KV method when ReadOnly is set.
// This is the real enforcement point for read-only mode — the UI layer's
// disabled keybindings are a courtesy, this is the boundary that actually
// matters (see the plan's "Schreibsicherheit" section).
var ErrReadOnly = fmt.Errorf("vault-tui is in read-only mode")
// List returns the immediate children of dir within mount ("" = mount root).
func (kv *KV) List(ctx context.Context, m Mount, dir string) (Listing, error) {
var p string
if m.Kind == EngineKVv2 {
p = path.Join(m.Path, "metadata", dir)
} else {
p = path.Join(m.Path, dir)
}
sec, err := kv.Client.Logical().ListWithContext(ctx, p)
if err != nil {
return Listing{}, err
}
l := Listing{Mount: m.Path, Path: dir}
if sec == nil || sec.Data == nil {
return l, nil
}
keys, _ := sec.Data["keys"].([]interface{})
for _, k := range keys {
s, ok := k.(string)
if !ok {
continue
}
if len(s) > 0 && s[len(s)-1] == '/' {
l.Dirs = append(l.Dirs, s)
} else {
l.Leaves = append(l.Leaves, s)
}
}
sort.Strings(l.Dirs)
sort.Strings(l.Leaves)
return l, nil
}
// Read fetches a secret. version == 0 means "latest" (KV v2) or is ignored
// (KV v1 / cubbyhole).
func (kv *KV) Read(ctx context.Context, m Mount, p string, version int) (*Secret, error) {
switch m.Kind {
case EngineKVv2:
full := path.Join(m.Path, "data", p)
var data map[string][]string
if version > 0 {
data = map[string][]string{"version": {fmt.Sprint(version)}}
}
sec, err := kv.Client.Logical().ReadWithDataWithContext(ctx, full, data)
if err != nil {
return nil, err
}
if sec == nil || sec.Data == nil {
return nil, nil // caller maps this to ErrNotFound via Classify's caller-side check
}
inner, _ := sec.Data["data"].(map[string]interface{})
out := &Secret{Mount: m.Path, Path: p, Data: inner}
if meta, ok := sec.Data["metadata"].(map[string]interface{}); ok {
out.Meta = versionMetaFromMap(meta)
if out.Meta != nil {
out.Version = out.Meta.Version
}
}
return out, nil
default:
full := path.Join(m.Path, p)
sec, err := kv.Client.Logical().ReadWithContext(ctx, full)
if err != nil {
return nil, err
}
if sec == nil || sec.Data == nil {
return nil, nil
}
return &Secret{Mount: m.Path, Path: p, Data: sec.Data}, nil
}
}
// Write creates or updates a secret. For KV v2, casVersion is the expected
// current version (0 means "must not exist yet", matching Vault's own
// check-and-set semantics); pass useCAS=false to write unconditionally.
func (kv *KV) Write(ctx context.Context, m Mount, p string, data map[string]interface{}, useCAS bool, casVersion int) (*WriteAck, error) {
if kv.ReadOnly {
return nil, ErrReadOnly
}
switch m.Kind {
case EngineKVv2:
full := path.Join(m.Path, "data", p)
body := map[string]interface{}{"data": data}
if useCAS {
body["options"] = map[string]interface{}{"cas": casVersion}
}
sec, err := kv.Client.Logical().WriteWithContext(ctx, full, body)
if err != nil {
return nil, err
}
ack := &WriteAck{Mount: m.Path, Path: p}
if sec != nil && sec.Data != nil {
if v, ok := AsInt(sec.Data["version"]); ok {
ack.Version = v
}
}
return ack, nil
default:
full := path.Join(m.Path, p)
_, err := kv.Client.Logical().WriteWithContext(ctx, full, data)
if err != nil {
return nil, err
}
return &WriteAck{Mount: m.Path, Path: p}, nil
}
}
// Delete performs one of the DeleteOp variants. versions is required for
// OpSoftDelete/OpUndelete/OpDestroy when targeting specific versions; pass
// nil to soft-delete the current version (KV v2's normal "d" behaviour).
func (kv *KV) Delete(ctx context.Context, m Mount, p string, op DeleteOp, versions []int) (*DeleteAck, error) {
if kv.ReadOnly {
return nil, ErrReadOnly
}
ack := &DeleteAck{Mount: m.Path, Path: p, Op: op, Versions: versions}
if m.Kind != EngineKVv2 {
if op != OpDeleteV1 {
return nil, fmt.Errorf("delete operation %v is only valid for KV v2 mounts", op)
}
full := path.Join(m.Path, p)
if _, err := kv.Client.Logical().DeleteWithContext(ctx, full); err != nil {
return nil, err
}
return ack, nil
}
var full string
var body map[string]interface{}
useDelete := false // true => DELETE with no body; false => PUT/POST with body
switch op {
case OpSoftDelete:
if len(versions) > 0 {
full = path.Join(m.Path, "delete", p)
body = map[string]interface{}{"versions": versions}
} else {
// The "delete" endpoint always requires an explicit versions
// array — it has no "current version" shorthand — so soft-
// deleting the current version (nil versions) has to go
// through the plain data endpoint instead, the same way the
// real Vault CLI's "vault kv delete" (no -versions flag) does.
full = path.Join(m.Path, "data", p)
useDelete = true
}
case OpUndelete:
if len(versions) == 0 {
return nil, fmt.Errorf("undelete requires at least one version number")
}
full = path.Join(m.Path, "undelete", p)
body = map[string]interface{}{"versions": versions}
case OpDestroy:
if len(versions) == 0 {
return nil, fmt.Errorf("destroy requires at least one version number")
}
full = path.Join(m.Path, "destroy", p)
body = map[string]interface{}{"versions": versions}
case OpDeleteMetadata:
full = path.Join(m.Path, "metadata", p)
useDelete = true
default:
return nil, fmt.Errorf("unsupported delete operation %v for KV v2", op)
}
if useDelete {
if _, err := kv.Client.Logical().DeleteWithContext(ctx, full); err != nil {
return nil, err
}
return ack, nil
}
if _, err := kv.Client.Logical().WriteWithContext(ctx, full, body); err != nil {
return nil, err
}
return ack, nil
}
// Versions returns the version history of a KV v2 secret, newest first.
func (kv *KV) Versions(ctx context.Context, m Mount, p string) ([]VersionMeta, error) {
if m.Kind != EngineKVv2 {
return nil, fmt.Errorf("version history is only available for KV v2 mounts")
}
full := path.Join(m.Path, "metadata", p)
sec, err := kv.Client.Logical().ReadWithContext(ctx, full)
if err != nil {
return nil, err
}
if sec == nil || sec.Data == nil {
return nil, nil
}
versionsRaw, _ := sec.Data["versions"].(map[string]interface{})
out := make([]VersionMeta, 0, len(versionsRaw))
for k, raw := range versionsRaw {
vm, ok := raw.(map[string]interface{})
if !ok {
continue
}
var n int
fmt.Sscanf(k, "%d", &n)
meta := versionMetaFromMap(vm)
if meta == nil {
meta = &VersionMeta{}
}
meta.Version = n
out = append(out, *meta)
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// Rollback creates a new version whose content is a copy of an older
// version's data — the KV v2-recommended way to "revert" without losing
// history. It is intentionally implemented as Read(old)+Write(new,CAS),
// exactly what `vault kv rollback` does, rather than any special API.
func (kv *KV) Rollback(ctx context.Context, m Mount, p string, toVersion int) (*WriteAck, error) {
if kv.ReadOnly {
return nil, ErrReadOnly
}
old, err := kv.Read(ctx, m, p, toVersion)
if err != nil {
return nil, err
}
if old == nil {
return nil, fmt.Errorf("version %d of %s not found", toVersion, p)
}
current, err := kv.Read(ctx, m, p, 0)
if err != nil {
return nil, err
}
cas := 0
if current != nil {
cas = current.Version
}
return kv.Write(ctx, m, p, old.Data, true, cas)
}
func versionMetaFromMap(m map[string]interface{}) *VersionMeta {
vm := &VersionMeta{}
vm.CreatedTime, _ = m["created_time"].(string)
vm.DeletionTime, _ = m["deletion_time"].(string)
vm.Destroyed, _ = m["destroyed"].(bool)
if v, ok := AsInt(m["version"]); ok {
vm.Version = v
}
return vm
}