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