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
+76
View File
@@ -0,0 +1,76 @@
// Package vault wraps github.com/hashicorp/vault/api behind a small
// Service that the UI and CLI layers talk to. Nothing in this package
// imports a TUI toolkit, so it is fully unit-testable with httptest and
// reusable by the headless commands in internal/cli.
package vault
import (
"fmt"
"github.com/hashicorp/vault/api"
"git.morlana.online/f.weber/vault-tui/internal/config"
)
// NewClient builds an *api.Client from a fully resolved config.Settings.
//
// api.DefaultConfig() already calls ReadEnvironment(), and api.NewClient
// additionally picks up VAULT_TOKEN/VAULT_NAMESPACE/VAULT_HEADERS as soon as
// it sees a *api.Config — all of which would bypass the flag>env>profile>
// defaults precedence that config.Resolve already computed into s. So this
// function overwrites every field env may have set, then explicitly clears
// the token and re-sets the namespace from s: ClearToken()+SetNamespace()
// right after NewClient is the load-bearing pair of calls here.
func NewClient(s *config.Settings) (*api.Client, error) {
cfg := api.DefaultConfig()
if cfg.Error != nil {
return nil, fmt.Errorf("building base client config: %w", cfg.Error)
}
cfg.Address = s.Address
cfg.Timeout = s.Timeout
cfg.MaxRetries = s.MaxRetries
if s.MinRetryWait > 0 {
cfg.MinRetryWait = s.MinRetryWait
}
if s.MaxRetryWait > 0 {
cfg.MaxRetryWait = s.MaxRetryWait
}
cfg.SRVLookup = s.SRVLookup
cfg.DisableRedirects = s.DisableRedirects
cfg.CloneHeaders = true
tls := &api.TLSConfig{
CACert: s.CACert,
CACertBytes: s.CACertPEM,
CAPath: s.CAPath,
ClientCert: s.ClientCert,
ClientKey: s.ClientKey,
TLSServerName: s.ServerName,
Insecure: s.SkipVerify,
}
if err := cfg.ConfigureTLS(tls); err != nil {
return nil, fmt.Errorf("configuring TLS: %w", err)
}
c, err := api.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("creating vault client: %w", err)
}
c.ClearToken()
c.SetNamespace(s.Namespace)
c.SetCloneHeaders(true)
if len(s.Headers) > 0 {
h := c.Headers()
if h == nil {
h = make(map[string][]string)
}
for k, v := range s.Headers {
h.Set(k, v)
}
c.SetHeaders(h)
}
return c, nil
}
+77
View File
@@ -0,0 +1,77 @@
package vault
import (
"context"
"errors"
"net"
"strings"
"github.com/hashicorp/vault/api"
)
// ErrKind classifies a Vault API error into a small, UI-actionable set.
// See internal/ui's error surfacing tiers: Cancelled is dropped silently,
// NotFound/Forbidden/CAS become a toast plus an inline hint, Unauthorized
// sends the user back to the auth screen, Sealed/Network get a full-body
// retry panel.
type ErrKind uint8
const (
ErrUnknown ErrKind = iota
ErrForbidden
ErrNotFound
ErrUnauthorized
ErrSealed
ErrCAS
ErrNetwork
ErrCancelled
)
// Classify inspects err (typically returned from a Logical() call) and
// returns its kind plus a short human-readable message.
func Classify(err error) (ErrKind, string) {
if err == nil {
return ErrUnknown, ""
}
if errors.Is(err, context.Canceled) {
return ErrCancelled, "cancelled"
}
var respErr *api.ResponseError
if errors.As(err, &respErr) {
msg := strings.Join(respErr.Errors, "; ")
if msg == "" {
msg = err.Error()
}
switch respErr.StatusCode {
case 403:
if isTokenInvalid(msg) {
return ErrUnauthorized, "token is invalid or expired"
}
return ErrForbidden, "permission denied"
case 404:
return ErrNotFound, "not found"
case 400:
if strings.Contains(strings.ToLower(msg), "check-and-set") {
return ErrCAS, "changed underneath you (check-and-set mismatch)"
}
return ErrUnknown, msg
case 503:
return ErrSealed, "vault is sealed or in standby"
default:
return ErrUnknown, msg
}
}
var netErr net.Error
if errors.As(err, &netErr) {
return ErrNetwork, netErr.Error()
}
return ErrUnknown, err.Error()
}
func isTokenInvalid(msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "permission denied") && strings.Contains(m, "token")
}
+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
}
+231
View File
@@ -0,0 +1,231 @@
package vault
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/hashicorp/vault/api"
)
// newTestClient builds a real *api.Client pointed at srv, so these tests
// exercise the exact path construction KV sends over the wire — this is
// the single most bug-prone part of any Vault client (see kv.go's doc
// comment), so it gets exhaustive request-shape assertions rather than
// mocking KV itself.
func newTestClient(t *testing.T, srv *httptest.Server) *api.Client {
t.Helper()
cfg := api.DefaultConfig()
cfg.Address = srv.URL
c, err := api.NewClient(cfg)
if err != nil {
t.Fatalf("api.NewClient: %v", err)
}
c.SetToken("test-token")
return c
}
func jsonBody(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func TestKV_List_PathMapping(t *testing.T) {
cases := []struct {
name string
mount Mount
dir string
wantPath string
}{
{"kv2 root", Mount{Path: "secret/", Kind: EngineKVv2}, "", "/v1/secret/metadata"},
{"kv2 nested", Mount{Path: "secret/", Kind: EngineKVv2}, "team/prod", "/v1/secret/metadata/team/prod"},
{"kv1 root", Mount{Path: "kv1/", Kind: EngineKVv1}, "", "/v1/kv1"},
{"kv1 nested", Mount{Path: "kv1/", Kind: EngineKVv1}, "team", "/v1/kv1/team"},
{"cubbyhole", Mount{Path: "cubbyhole/", Kind: EngineKVv1}, "", "/v1/cubbyhole"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"keys": []string{"a", "b/"}}})
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
_, err := kv.List(context.Background(), c.mount, c.dir)
if err != nil {
t.Fatalf("List: %v", err)
}
if gotMethod != "LIST" && gotMethod != "GET" {
t.Errorf("method = %q, want LIST or GET", gotMethod)
}
if gotPath != c.wantPath {
t.Errorf("path = %q, want %q", gotPath, c.wantPath)
}
})
}
}
func TestKV_List_SplitsDirsAndLeaves(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"keys": []string{"leaf1", "dir1/", "leaf2", "dir2/"}}})
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
l, err := kv.List(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "")
if err != nil {
t.Fatalf("List: %v", err)
}
if len(l.Dirs) != 2 || len(l.Leaves) != 2 {
t.Fatalf("got %d dirs, %d leaves; want 2/2 (dirs=%v leaves=%v)", len(l.Dirs), len(l.Leaves), l.Dirs, l.Leaves)
}
}
func TestKV_Read_PathMapping(t *testing.T) {
cases := []struct {
name string
mount Mount
path string
version int
wantPath string
wantQS string
}{
{"kv2 latest", Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", 0, "/v1/secret/data/team/db", ""},
{"kv2 versioned", Mount{Path: "secret/", Kind: EngineKVv2}, "team/db", 3, "/v1/secret/data/team/db", "version=3"},
{"kv1", Mount{Path: "kv1/", Kind: EngineKVv1}, "team/db", 0, "/v1/kv1/team/db", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var gotPath, gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
if c.mount.Kind == EngineKVv2 {
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{
"data": map[string]interface{}{"k": "v"},
"metadata": map[string]interface{}{"version": 1, "created_time": "now"},
}})
} else {
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"k": "v"}})
}
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
_, err := kv.Read(context.Background(), c.mount, c.path, c.version)
if err != nil {
t.Fatalf("Read: %v", err)
}
if gotPath != c.wantPath {
t.Errorf("path = %q, want %q", gotPath, c.wantPath)
}
if c.wantQS != "" && gotQuery != c.wantQS {
t.Errorf("query = %q, want %q", gotQuery, c.wantQS)
}
})
}
}
func TestKV_Write_PathAndCAS(t *testing.T) {
var gotPath string
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
jsonBody(w, map[string]interface{}{"data": map[string]interface{}{"version": 2}})
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
ack, err := kv.Write(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "team/db",
map[string]interface{}{"k": "v"}, true, 1)
if err != nil {
t.Fatalf("Write: %v", err)
}
if gotPath != "/v1/secret/data/team/db" {
t.Errorf("path = %q, want /v1/secret/data/team/db", gotPath)
}
opts, ok := gotBody["options"].(map[string]interface{})
if !ok {
t.Fatalf("request body missing options (cas): %v", gotBody)
}
if cas, _ := opts["cas"].(float64); cas != 1 {
t.Errorf("cas = %v, want 1", opts["cas"])
}
if ack.Version != 2 {
t.Errorf("ack.Version = %d, want 2 (json.Number decoding regression check)", ack.Version)
}
}
func TestKV_ReadOnly_RefusesWrites(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should never be called in read-only mode; got %s %s", r.Method, r.URL.Path)
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv), ReadOnly: true}
if _, err := kv.Write(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", nil, false, 0); err != ErrReadOnly {
t.Errorf("Write err = %v, want ErrReadOnly", err)
}
if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", OpSoftDelete, nil); err != ErrReadOnly {
t.Errorf("Delete err = %v, want ErrReadOnly", err)
}
}
func TestKV_Delete_OpPaths(t *testing.T) {
cases := []struct {
name string
op DeleteOp
versions []int
wantPath string
wantMeth string
}{
// Soft-deleting the current version (no explicit versions) has no
// shorthand on the "delete" endpoint, so it goes through the plain
// data endpoint instead — the same request "vault kv delete"
// (without -versions) makes.
{"soft delete current version", OpSoftDelete, nil, "/v1/secret/data/x", "DELETE"},
{"soft delete specific version", OpSoftDelete, []int{2}, "/v1/secret/delete/x", "PUT"},
{"undelete", OpUndelete, []int{2}, "/v1/secret/undelete/x", "PUT"},
{"destroy", OpDestroy, []int{2}, "/v1/secret/destroy/x", "PUT"},
{"delete metadata", OpDeleteMetadata, nil, "/v1/secret/metadata/x", "DELETE"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var gotPath, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotMethod = r.URL.Path, r.Method
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", c.op, c.versions); err != nil {
t.Fatalf("Delete: %v", err)
}
if gotPath != c.wantPath {
t.Errorf("path = %q, want %q", gotPath, c.wantPath)
}
if gotMethod != c.wantMeth {
t.Errorf("method = %q, want %q", gotMethod, c.wantMeth)
}
})
}
}
func TestKV_Delete_RequiresVersionsForUndeleteAndDestroy(t *testing.T) {
for _, op := range []DeleteOp{OpUndelete, OpDestroy} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("server should never be called without explicit versions; got %s %s", r.Method, r.URL.Path)
}))
defer srv.Close()
kv := &KV{Client: newTestClient(t, srv)}
if _, err := kv.Delete(context.Background(), Mount{Path: "secret/", Kind: EngineKVv2}, "x", op, nil); err == nil {
t.Errorf("Delete(%v, nil versions): want error, got nil", op)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
package vault
import (
"context"
"fmt"
"sort"
"github.com/hashicorp/vault/api"
)
// EngineKind is what the UI needs to know to talk to a mount's data plane.
type EngineKind uint8
const (
EngineKVv1 EngineKind = iota
EngineKVv2
EngineOther
)
// Mount describes one secret engine mount that the current token can see.
type Mount struct {
Path string // e.g. "secret/" — always slash-terminated, as Vault returns it
Type string // "kv", "cubbyhole", "ssh", ...
Kind EngineKind
Description string
Accessor string
Local bool
Options map[string]string
}
// Supported reports whether internal/vault.KV (see kv.go) knows how to
// browse/read/write this mount. Cubbyhole is included: it behaves like a
// single-version KV v1 mount (no /data or /metadata split, no versioning).
func (m Mount) Supported() bool {
switch m.Type {
case "kv", "cubbyhole":
return true
default:
return false
}
}
// ListMounts returns every secret engine mount the current token is
// permitted to see.
//
// sys/mounts requires broad "sys" access and commonly returns 403 for
// ordinary tokens (verified against a real Vault instance in this
// project — see the plan's "Erkenntnisse aus der Zielumgebung"). The Vault
// web UI itself falls back to sys/internal/ui/mounts, which is scoped to
// exactly what the caller's token may use and is unauthenticated-safe to
// call broadly. We do the same: try sys/mounts first (it has richer
// `local`/accessor detail when it works), and fall back on any error.
func ListMounts(ctx context.Context, c *api.Client) ([]Mount, error) {
mounts, err := listViaSysMounts(ctx, c)
if err == nil {
return mounts, nil
}
mounts, ferr := listViaUIMounts(ctx, c)
if ferr != nil {
// Report the original sys/mounts error: it is usually the more
// informative one (e.g. "permission denied" vs. a generic parse
// failure), and callers use Classify() on it.
return nil, err
}
return mounts, nil
}
func listViaSysMounts(ctx context.Context, c *api.Client) ([]Mount, error) {
sec, err := c.Logical().ReadWithContext(ctx, "sys/mounts")
if err != nil {
return nil, err
}
if sec == nil {
return nil, fmt.Errorf("sys/mounts: empty response")
}
out := make([]Mount, 0, len(sec.Data))
for path, raw := range sec.Data {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
out = append(out, mountFromMap(path, entry))
}
sortMounts(out)
return out, nil
}
func listViaUIMounts(ctx context.Context, c *api.Client) ([]Mount, error) {
sec, err := c.Logical().ReadWithContext(ctx, "sys/internal/ui/mounts")
if err != nil {
return nil, err
}
if sec == nil {
return nil, fmt.Errorf("sys/internal/ui/mounts: empty response")
}
secretRaw, _ := sec.Data["secret"].(map[string]interface{})
out := make([]Mount, 0, len(secretRaw))
for path, raw := range secretRaw {
entry, ok := raw.(map[string]interface{})
if !ok {
continue
}
out = append(out, mountFromMap(path, entry))
}
sortMounts(out)
return out, nil
}
func mountFromMap(path string, entry map[string]interface{}) Mount {
m := Mount{Path: path}
m.Type, _ = entry["type"].(string)
m.Description, _ = entry["description"].(string)
m.Accessor, _ = entry["accessor"].(string)
m.Local, _ = entry["local"].(bool)
if opts, ok := entry["options"].(map[string]interface{}); ok {
m.Options = make(map[string]string, len(opts))
for k, v := range opts {
if s, ok := v.(string); ok {
m.Options[k] = s
}
}
}
m.Kind = classifyEngine(m)
return m
}
func classifyEngine(m Mount) EngineKind {
switch m.Type {
case "kv":
if m.Options != nil && m.Options["version"] == "2" {
return EngineKVv2
}
return EngineKVv1
case "cubbyhole":
// Cubbyhole has no /data or /metadata split and no versioning — it
// behaves like a single-version KV v1 mount for our purposes.
return EngineKVv1
default:
return EngineOther
}
}
func sortMounts(m []Mount) {
sort.Slice(m, func(i, j int) bool { return m[i].Path < m[j].Path })
}
+32
View File
@@ -0,0 +1,32 @@
package vault
import (
"context"
"github.com/hashicorp/vault/api"
)
// Service is the single entry point the UI and headless CLI use for all
// Vault data-plane operations. It exists so callers never touch *api.Client
// or KV directly — this is the dependency boundary internal/ui relies on to
// stay free of the hashicorp/vault/api import.
type Service struct {
Client *api.Client
KV *KV
ReadOnly bool
}
// New wires a Service around an already-authenticated client. readOnly is
// enforced here (via KV.ReadOnly), not just in the UI's disabled
// keybindings — this is the boundary that actually blocks writes.
func New(c *api.Client, readOnly bool) *Service {
return &Service{
Client: c,
KV: &KV{Client: c, ReadOnly: readOnly},
ReadOnly: readOnly,
}
}
func (s *Service) Mounts(ctx context.Context) ([]Mount, error) {
return ListMounts(ctx, s.Client)
}