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,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
|
||||
}
|
||||
Reference in New Issue
Block a user