vault: distinguish Vaultwarden vs HashiCorp Vault, add vault kv

`homelab vault` only spoke to Vaultwarden (the password manager), but the
name reads as HashiCorp Vault (the infra secrets store — actually OpenBao
here). Make the two unmistakable and support both.

Distinction (no breakage — the existing Vaultwarden verbs are unchanged):
- bare `homelab vault` help now LEADS with the two-stores split;
- every verb summary is tagged `[vaultwarden]` or `[hashicorp-vault]`;
- HashiCorp Vault/OpenBao lives under a clearly-named `vault kv` group.

New `vault kv` (HashiCorp Vault / OpenBao, the secret/… KV store):
- `kv get <path> [--field K]` — read; --field → one value (TTY-aware
  clipboard/stdout), no field → full secret JSON (refuses a bare TTY).
- `kv list <path>` — list sub-paths (no values).
- `kv put <path> <key>` — write one key; value via stdin (piped or
  no-echo prompt, never argv); creates the path or merges (never
  clobbers siblings; uses kv patch -method=rw so no `patch` cap needed).

Critical: `kv` uses the caller's OWN Vault token (OIDC ~/.vault-token /
$VAULT_TOKEN), NOT the per-user scoped Vaultwarden token (bound only to
claude-users/<user>, which would 403 elsewhere) — handlers set VAULT_ADDR
but never inject the scoped token. Access is whatever the policy grants.

Logic in cmd_vault_kv.go (pure cores extractKVData/parseKVList/arg
builders/kvGet/List/Put; file header documents the credential split).
CLI v0.11.0. Tests: no value in put argv, create-then-merge, KV-v2
envelope strip, help names both systems. Verified e2e against live Vault
(read key-names-only + a scratch put/merge/cleanup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-28 11:09:33 +00:00
parent a1cf7ccaf6
commit e03e4719ad
6 changed files with 492 additions and 21 deletions

View file

@ -882,3 +882,176 @@ func TestReadSucceedsWhenSyncFails(t *testing.T) {
t.Fatalf("read must succeed despite a sync failure: val=%q err=%v", val, err)
}
}
// --- vault kv (HashiCorp Vault / OpenBao infra secrets) --------------------
func TestVaultKVCommandsRegistered(t *testing.T) {
want := map[string]Tier{
"vault kv get": TierRead,
"vault kv list": TierRead,
"vault kv put": TierWrite,
}
got := map[string]Tier{}
for _, c := range vaultCommands() {
got[c.name()] = c.Tier
}
for name, tier := range want {
if got[name] != tier {
t.Errorf("command %q: tier=%q, want %q", name, got[name], tier)
}
}
}
func TestVaultKVArgs(t *testing.T) {
if got := vaultKVGetFieldArgs("secret/viktor", "github_pat"); !reflect.DeepEqual(got, []string{"kv", "get", "-field=github_pat", "secret/viktor"}) {
t.Fatalf("vaultKVGetFieldArgs = %v", got)
}
if got := vaultKVGetJSONArgs("secret/viktor"); !reflect.DeepEqual(got, []string{"kv", "get", "-format=json", "secret/viktor"}) {
t.Fatalf("vaultKVGetJSONArgs = %v", got)
}
if got := vaultKVListArgs("secret/"); !reflect.DeepEqual(got, []string{"kv", "list", "-format=json", "secret/"}) {
t.Fatalf("vaultKVListArgs = %v", got)
}
// create (path absent) → put; merge (path present) → patch -method=rw. Either
// way the VALUE travels via the `key=-` stdin form, never argv.
create := vaultKVPutArgs(false, "secret/x", "api_key")
if !reflect.DeepEqual(create, []string{"kv", "put", "secret/x", "api_key=-"}) {
t.Fatalf("vaultKVPutArgs(create) = %v", create)
}
merge := vaultKVPutArgs(true, "secret/x", "api_key")
if !reflect.DeepEqual(merge, []string{"kv", "patch", "-method=rw", "secret/x", "api_key=-"}) {
t.Fatalf("vaultKVPutArgs(merge) = %v", merge)
}
for _, args := range [][]string{create, merge} {
for _, a := range args {
if strings.Contains(a, "SECRETVALUE") || strings.HasSuffix(a, "=SECRETVALUE") {
t.Fatalf("value must not appear in argv: %v", args)
}
}
}
}
func TestExtractKVData(t *testing.T) {
// `vault kv get -format=json` wraps the secret in {"data":{"data":{...},"metadata":{...}}}.
env := `{"request_id":"x","data":{"data":{"github_pat":"ghp_abc","email":"e@x.me"},"metadata":{"version":3}}}`
out, err := extractKVData(env)
if err != nil {
t.Fatalf("extractKVData: %v", err)
}
// Round-trip to a map so key order doesn't matter.
var m map[string]string
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("result not a JSON object: %q (%v)", out, err)
}
if m["github_pat"] != "ghp_abc" || m["email"] != "e@x.me" {
t.Fatalf("extractKVData inner data wrong: %v", m)
}
// metadata must NOT leak into the output.
if strings.Contains(out, "metadata") || strings.Contains(out, "request_id") {
t.Fatalf("envelope internals leaked: %s", out)
}
if _, err := extractKVData("not json"); err == nil {
t.Fatal("malformed envelope must error")
}
}
func TestParseKVList(t *testing.T) {
keys, err := parseKVList(`["app1","app2/","viktor"]`)
if err != nil {
t.Fatalf("parseKVList: %v", err)
}
if !reflect.DeepEqual(keys, []string{"app1", "app2/", "viktor"}) {
t.Fatalf("parseKVList = %v", keys)
}
if _, err := parseKVList("not json"); err == nil {
t.Fatal("malformed list must error")
}
}
func TestKVGetFieldFlow(t *testing.T) {
f := &fakeRunner{out: map[string]string{
"vault kv get -field=github_pat secret/viktor": "ghp_secret",
}}
val, err := kvGetField(f.run, "secret/viktor", "github_pat")
if err != nil || val != "ghp_secret" {
t.Fatalf("kvGetField = %q, %v", val, err)
}
}
func TestKVListFlow(t *testing.T) {
f := &fakeRunner{out: map[string]string{
"vault kv list -format=json secret/": `["app1","app2/"]`,
}}
keys, err := kvList(f.run, "secret/")
if err != nil || !reflect.DeepEqual(keys, []string{"app1", "app2/"}) {
t.Fatalf("kvList = %v, %v", keys, err)
}
}
// kvPut creates the path on first write and merges thereafter, with the value on
// stdin only (mirrors writeCreds). Never plain `kv patch` (needs the patch cap).
func TestKVPutCreatesThenMerges(t *testing.T) {
for _, tc := range []struct {
name string
exists bool
wantCreate bool
}{
{"absent path → create (put)", false, true},
{"present path → merge (patch -rw)", true, false},
} {
t.Run(tc.name, func(t *testing.T) {
var stdinCalls []recStdin
run := func(name string, argv, envv []string) (string, error) {
if len(argv) >= 2 && argv[0] == "kv" && argv[1] == "get" {
if tc.exists {
return `{"data":{"data":{}}}`, nil
}
return "", fmt.Errorf("No value found at secret/x")
}
return "", nil
}
runStdin := func(name string, argv, envv []string, stdin string) (string, error) {
stdinCalls = append(stdinCalls, recStdin{append([]string{name}, argv...), stdin})
return "", nil
}
if err := kvPut(run, runStdin, "secret/x", "api_key", "SECRETVALUE"); err != nil {
t.Fatalf("kvPut: %v", err)
}
if len(stdinCalls) != 1 {
t.Fatalf("want exactly 1 stdin write, got %d", len(stdinCalls))
}
sc := stdinCalls[0]
joined := strings.Join(sc.argv, " ")
if tc.wantCreate && !strings.Contains(joined, "kv put") {
t.Fatalf("absent path must use `kv put`: %v", sc.argv)
}
if !tc.wantCreate && !strings.Contains(joined, "kv patch -method=rw") {
t.Fatalf("present path must merge via `kv patch -method=rw`: %v", sc.argv)
}
if strings.Contains(joined, "kv patch") && !strings.Contains(joined, "-method=rw") {
t.Fatalf("must never use plain `kv patch`: %v", sc.argv)
}
if sc.stdin != "SECRETVALUE" {
t.Fatalf("value must travel via stdin, got %q", sc.stdin)
}
for _, a := range sc.argv {
if strings.Contains(a, "SECRETVALUE") {
t.Fatalf("value leaked into argv: %v", sc.argv)
}
}
})
}
}
func TestVaultHelpMentionsBothSystems(t *testing.T) {
h := vaultHelp()
for _, want := range []string{"Vaultwarden", "vault kv"} {
if !strings.Contains(h, want) {
t.Errorf("vault help must mention %q (distinguish the two systems)", want)
}
}
// Must name the infra-secrets system so the distinction is unambiguous.
if !strings.Contains(h, "HashiCorp") && !strings.Contains(h, "OpenBao") {
t.Error("vault help must name HashiCorp Vault / OpenBao (the infra secrets store)")
}
}