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