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 / // KV v2: LIST /metadata/ // READ /data/?version=N // WRITE /data/ {data: {...}, options: {cas: N}} // delete family on /{delete,undelete,destroy}/ // and /metadata/ (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 }