Files
vault-tui/internal/vault/mounts.go
T
f.weber ae30ba1240 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.
2026-08-14 11:09:03 +02:00

146 lines
3.9 KiB
Go

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 })
}