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:
parent
a1cf7ccaf6
commit
e03e4719ad
6 changed files with 492 additions and 21 deletions
|
|
@ -244,6 +244,27 @@ vault wouldn't show up until the next login. The sync is **best-effort**: a
|
||||||
transient failure warns on stderr and falls back to the cached vault rather than
|
transient failure warns on stderr and falls back to the cached vault rather than
|
||||||
failing the read.
|
failing the read.
|
||||||
|
|
||||||
|
### v0.11 — `vault kv` (HashiCorp Vault / OpenBao infra secrets)
|
||||||
|
|
||||||
|
`homelab vault` now fronts **two unrelated stores**, made explicit in the bare
|
||||||
|
`homelab vault` help and via `[vaultwarden]` / `[hashicorp-vault]` summary tags:
|
||||||
|
|
||||||
|
- **Vaultwarden** — your personal password manager (`vault get/list/code/…`, unchanged).
|
||||||
|
- **HashiCorp Vault / OpenBao** — homelab infra secrets, the `secret/…` KV store, under `vault kv`.
|
||||||
|
|
||||||
|
| Command | Tier | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `vault kv get <path> [--field K]` | read | read a secret: `--field K` → one value (TTY-aware clipboard/stdout); no field → all fields as JSON (refuses a bare TTY) |
|
||||||
|
| `vault kv list <path>` | read | list sub-paths under `<path>` (no values) |
|
||||||
|
| `vault kv put <path> <key>` | write | write one key; **value via stdin** (piped or no-echo prompt, never argv); creates the path or **merges** (never clobbers siblings) |
|
||||||
|
|
||||||
|
**Different credentials:** the Vaultwarden verbs use the per-user *scoped* token
|
||||||
|
(bound to `claude-users/<user>`); `vault kv` uses your **own** Vault token
|
||||||
|
(`vault login -method=oidc` → `~/.vault-token`, or `$VAULT_TOKEN`) — the kv
|
||||||
|
handlers set `VAULT_ADDR` but never inject the scoped token (which would 403 off
|
||||||
|
its own path). Access is whatever your policy grants. Writes are merge-only;
|
||||||
|
`put` (replace) / `delete` are out of scope — use the raw `vault` CLI.
|
||||||
|
|
||||||
## Build / install
|
## Build / install
|
||||||
|
|
||||||
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
Built from source to `/usr/local/bin/homelab` during devvm provisioning
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
v0.10.1
|
v0.11.0
|
||||||
|
|
|
||||||
|
|
@ -18,31 +18,41 @@ import (
|
||||||
// decryption is done by the official `bw` CLI. See
|
// decryption is done by the official `bw` CLI. See
|
||||||
// docs/runbooks/homelab-vault-onboarding.md.
|
// docs/runbooks/homelab-vault-onboarding.md.
|
||||||
func vaultCommands() []Command {
|
func vaultCommands() []Command {
|
||||||
return []Command{
|
cmds := []Command{
|
||||||
|
// Vaultwarden — your personal password manager (logins/passwords/TOTP).
|
||||||
{Path: []string{"vault", "setup"}, Tier: TierWrite,
|
{Path: []string{"vault", "setup"}, Tier: TierWrite,
|
||||||
Summary: "one-time: store your Vaultwarden master password + API key in your Vault path", Run: vaultSetup},
|
Summary: "[vaultwarden] one-time: store your master password + API key in your Vault path", Run: vaultSetup},
|
||||||
{Path: []string{"vault", "status"}, Tier: TierRead,
|
{Path: []string{"vault", "status"}, Tier: TierRead,
|
||||||
Summary: "show whether your vault is configured/reachable (no secrets)", Run: vaultStatus},
|
Summary: "[vaultwarden] show whether your vault is configured/reachable (no secrets)", Run: vaultStatus},
|
||||||
{Path: []string{"vault", "list"}, Tier: TierRead,
|
{Path: []string{"vault", "list"}, Tier: TierRead,
|
||||||
Summary: "list your item names: vault list [--search Q]", Run: vaultList},
|
Summary: "[vaultwarden] list your item names: vault list [--search Q]", Run: vaultList},
|
||||||
{Path: []string{"vault", "get"}, Tier: TierRead,
|
{Path: []string{"vault", "get"}, Tier: TierRead,
|
||||||
Summary: "fetch one item: vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet},
|
Summary: "[vaultwarden] fetch one login: vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet},
|
||||||
{Path: []string{"vault", "search"}, Tier: TierRead,
|
{Path: []string{"vault", "search"}, Tier: TierRead,
|
||||||
Summary: "search your item names: vault search <query>", Run: vaultSearch},
|
Summary: "[vaultwarden] search your item names: vault search <query>", Run: vaultSearch},
|
||||||
{Path: []string{"vault", "code"}, Tier: TierRead,
|
{Path: []string{"vault", "code"}, Tier: TierRead,
|
||||||
Summary: "current TOTP code for an item: vault code <name>", Run: vaultCode},
|
Summary: "[vaultwarden] current TOTP code for an item: vault code <name>", Run: vaultCode},
|
||||||
{Path: []string{"vault", "lock"}, Tier: TierWrite,
|
{Path: []string{"vault", "lock"}, Tier: TierWrite,
|
||||||
Summary: "lock/log out the local bw session", Run: vaultLock},
|
Summary: "[vaultwarden] lock/log out the local bw session", Run: vaultLock},
|
||||||
{Path: []string{"vault"}, Tier: TierRead,
|
{Path: []string{"vault"}, Tier: TierRead,
|
||||||
Summary: "Vaultwarden access for your own vault (run `homelab vault` for help)",
|
Summary: "two stores: Vaultwarden (logins) + HashiCorp Vault/OpenBao kv (infra secrets) — run `homelab vault` for help",
|
||||||
Run: func([]string) error { fmt.Print(vaultHelp()); return nil }},
|
Run: func([]string) error { fmt.Print(vaultHelp()); return nil }},
|
||||||
}
|
}
|
||||||
|
// HashiCorp Vault / OpenBao — homelab INFRA secrets (the secret/… KV store).
|
||||||
|
return append(cmds, vaultKVCommands()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// vaultHelp is shown for bare `homelab vault`.
|
// vaultHelp is shown for bare `homelab vault`. It LEADS with the distinction
|
||||||
|
// between the two unrelated "vaults" this command fronts, because the name
|
||||||
|
// collides: Vaultwarden (a password manager) vs HashiCorp Vault / OpenBao (the
|
||||||
|
// infra secrets store).
|
||||||
func vaultHelp() string {
|
func vaultHelp() string {
|
||||||
return `homelab vault — read YOUR OWN Vaultwarden logins (no-HITL after one-time setup)
|
return `homelab vault — two different secret stores under one command:
|
||||||
|
|
||||||
|
• Vaultwarden your personal PASSWORD MANAGER (logins / passwords / TOTP)
|
||||||
|
• HashiCorp Vault / OpenBao homelab INFRA secrets (the secret/… KV store) → 'vault kv …'
|
||||||
|
|
||||||
|
── Vaultwarden (reads YOUR OWN vault; no-HITL after one-time setup) ──
|
||||||
homelab vault setup one-time: store your master password + API key in your Vault path
|
homelab vault setup one-time: store your master password + API key in your Vault path
|
||||||
homelab vault status configured / unlocked / reachable (no secrets)
|
homelab vault status configured / unlocked / reachable (no secrets)
|
||||||
homelab vault list [--search Q] list your item names (no secrets)
|
homelab vault list [--search Q] list your item names (no secrets)
|
||||||
|
|
@ -53,8 +63,13 @@ func vaultHelp() string {
|
||||||
homelab vault code <name> current TOTP code
|
homelab vault code <name> current TOTP code
|
||||||
homelab vault lock lock / log out the local bw session
|
homelab vault lock lock / log out the local bw session
|
||||||
|
|
||||||
Creds live only in your own Vault path; the admin never sees them. Identity is
|
── HashiCorp Vault / OpenBao (infra secrets; uses your own OIDC vault token) ──
|
||||||
your unix UID. Security model: docs/runbooks/homelab-vault-onboarding.md
|
homelab vault kv get <path> [--field K] read an infra KV secret
|
||||||
|
homelab vault kv list <path> list sub-paths
|
||||||
|
homelab vault kv put <path> <key> write one key (value via stdin)
|
||||||
|
|
||||||
|
Vaultwarden creds live only in your own Vault path; the admin never sees them.
|
||||||
|
Security model: docs/runbooks/homelab-vault-onboarding.md
|
||||||
(note: anything running as your user can decrypt your vault — the accepted no-HITL trade).
|
(note: anything running as your user can decrypt your vault — the accepted no-HITL trade).
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
248
cli/cmd_vault_kv.go
Normal file
248
cli/cmd_vault_kv.go
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The `vault kv` verbs talk to HashiCorp Vault / OpenBao — the homelab INFRA
|
||||||
|
// secrets store (the `secret/…` KV-v2 mount at vault.viktorbarzin.me) — NOT
|
||||||
|
// Vaultwarden. They are a thin, TTY-aware wrapper over the `vault` CLI that adds
|
||||||
|
// the same conveniences as the Vaultwarden verbs: a self-defaulted VAULT_ADDR
|
||||||
|
// (so non-login agent shells work) and clipboard/refuse-on-TTY secret handling.
|
||||||
|
//
|
||||||
|
// CREDENTIALS DIFFER FROM THE VAULTWARDEN VERBS. Those use the per-user *scoped*
|
||||||
|
// token (bound only to secret/workstation/claude-users/<user>). A general kv read
|
||||||
|
// of e.g. secret/viktor must use the caller's OWN Vault token (the OIDC
|
||||||
|
// ~/.vault-token or an explicit $VAULT_TOKEN) — the scoped token has `deny`
|
||||||
|
// everywhere else and would 403. So the kv handlers call ensureVaultAddr() to
|
||||||
|
// guarantee VAULT_ADDR but deliberately do NOT call ensureVaultToken() (which
|
||||||
|
// injects the scoped token). Access is then whatever the caller's policy grants.
|
||||||
|
func vaultKVCommands() []Command {
|
||||||
|
return []Command{
|
||||||
|
{Path: []string{"vault", "kv", "get"}, Tier: TierRead,
|
||||||
|
Summary: "[hashicorp-vault] read an infra KV secret: vault kv get <path> [--field K]", Run: vaultKVGet},
|
||||||
|
{Path: []string{"vault", "kv", "list"}, Tier: TierRead,
|
||||||
|
Summary: "[hashicorp-vault] list infra KV sub-paths: vault kv list <path>", Run: vaultKVList},
|
||||||
|
{Path: []string{"vault", "kv", "put"}, Tier: TierWrite,
|
||||||
|
Summary: "[hashicorp-vault] write one KV key (value via stdin): vault kv put <path> <key>", Run: vaultKVPut},
|
||||||
|
{Path: []string{"vault", "kv"}, Tier: TierRead,
|
||||||
|
Summary: "[hashicorp-vault] infra secrets (run `homelab vault kv` for help)",
|
||||||
|
Run: func([]string) error { fmt.Print(vaultKVHelp()); return nil }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vaultKVHelp() string {
|
||||||
|
return `homelab vault kv — HashiCorp Vault / OpenBao (homelab INFRA secrets, the secret/… KV store)
|
||||||
|
|
||||||
|
homelab vault kv get <path> [--field K] read a secret
|
||||||
|
--field K → one value (TTY → clipboard; piped → stdout)
|
||||||
|
no --field → all fields as JSON (piped only)
|
||||||
|
homelab vault kv list <path> list sub-paths under <path> (no values)
|
||||||
|
homelab vault kv put <path> <key> write one key; value read from stdin
|
||||||
|
(piped, or no-echo prompt); merges — never clobbers siblings
|
||||||
|
|
||||||
|
Uses YOUR Vault token (vault login -method=oidc → ~/.vault-token); access is
|
||||||
|
whatever your policy grants. This is NOT Vaultwarden — for your personal logins
|
||||||
|
use 'homelab vault get' (see 'homelab vault').
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- arg builders (pure; values never travel via argv) --------------------
|
||||||
|
|
||||||
|
func vaultKVGetFieldArgs(path, field string) []string {
|
||||||
|
return []string{"kv", "get", "-field=" + field, path}
|
||||||
|
}
|
||||||
|
func vaultKVGetJSONArgs(path string) []string { return []string{"kv", "get", "-format=json", path} }
|
||||||
|
func vaultKVListArgs(path string) []string { return []string{"kv", "list", "-format=json", path} }
|
||||||
|
|
||||||
|
// vaultKVPutArgs builds the write argv. merge=true → `kv patch -method=rw`
|
||||||
|
// (read-modify-write: merges, needs only read+update — not the `patch` capability
|
||||||
|
// — and preserves sibling keys); merge=false → `kv put` (creates the path on
|
||||||
|
// first write). The value is ALWAYS read from stdin via the `<key>=-` form, so it
|
||||||
|
// never appears in argv (visible via ps / /proc/<pid>/cmdline to same-UID procs).
|
||||||
|
func vaultKVPutArgs(merge bool, path, key string) []string {
|
||||||
|
return append(kvWriteVerb(merge), path, key+"=-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure parsers ----------------------------------------------------------
|
||||||
|
|
||||||
|
// extractKVData returns the inner secret object from a `vault kv get -format=json`
|
||||||
|
// envelope (`{"data":{"data":{…},"metadata":{…}}}`), dropping the metadata/request
|
||||||
|
// wrapper so only the secret's own key→value data is emitted.
|
||||||
|
func extractKVData(jsonOut string) (string, error) {
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(jsonOut), &env); err != nil {
|
||||||
|
return "", fmt.Errorf("parse vault kv json: %w", err)
|
||||||
|
}
|
||||||
|
if len(env.Data.Data) == 0 {
|
||||||
|
return "", fmt.Errorf("no secret data at that path")
|
||||||
|
}
|
||||||
|
return string(env.Data.Data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKVList parses the JSON array `vault kv list -format=json` prints.
|
||||||
|
func parseKVList(jsonOut string) ([]string, error) {
|
||||||
|
var keys []string
|
||||||
|
if err := json.Unmarshal([]byte(jsonOut), &keys); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse vault kv list json: %w", err)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- testable cores (injected cmdRunner) -----------------------------------
|
||||||
|
|
||||||
|
func kvGetField(run cmdRunner, path, field string) (string, error) {
|
||||||
|
return run("vault", vaultKVGetFieldArgs(path, field), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvGetJSON(run cmdRunner, path string) (string, error) {
|
||||||
|
out, err := run("vault", vaultKVGetJSONArgs(path), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return extractKVData(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kvList(run cmdRunner, path string) ([]string, error) {
|
||||||
|
out, err := run("vault", vaultKVListArgs(path), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseKVList(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvPathExists reports whether the KV path already holds data, to pick create
|
||||||
|
// (`kv put`) vs merge (`kv patch -method=rw`) — so a write never clobbers
|
||||||
|
// sibling keys on an existing path.
|
||||||
|
func kvPathExists(run cmdRunner, path string) bool {
|
||||||
|
_, err := run("vault", vaultKVGetJSONArgs(path), nil)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvPut writes one key, creating the path when absent and merging when present.
|
||||||
|
// The value travels on stdin only (never argv).
|
||||||
|
func kvPut(run cmdRunner, runStdin cmdRunnerStdin, path, key, value string) error {
|
||||||
|
merge := kvPathExists(run, path)
|
||||||
|
_, err := runStdin("vault", vaultKVPutArgs(merge, path, key), nil, value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- handlers --------------------------------------------------------------
|
||||||
|
|
||||||
|
func vaultKVGet(args []string) error {
|
||||||
|
hardenProcess()
|
||||||
|
ensureVaultAddr() // own token, NOT the scoped one (see file header)
|
||||||
|
var path, field string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
a := args[i]
|
||||||
|
switch {
|
||||||
|
case a == "--field" && i+1 < len(args):
|
||||||
|
field = args[i+1]
|
||||||
|
i++
|
||||||
|
case strings.HasPrefix(a, "--field="):
|
||||||
|
field = strings.TrimPrefix(a, "--field=")
|
||||||
|
case !strings.HasPrefix(a, "-") && path == "":
|
||||||
|
path = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("usage: homelab vault kv get <path> [--field <key>]")
|
||||||
|
}
|
||||||
|
if field != "" {
|
||||||
|
val, err := kvGetField(realRunner, path, field)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
emitSecret(val) // TTY-aware: clipboard on a terminal, stdout when piped
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// No --field → the whole secret. All values, so refuse a bare TTY (like
|
||||||
|
// `vault get --json`): pick a --field for the clipboard path, or pipe it.
|
||||||
|
if !jsonToStdoutOK(stdoutIsTTY()) {
|
||||||
|
return fmt.Errorf("refusing to print all KV fields as JSON to a terminal; use --field <key>, or pipe it (e.g. | jq)")
|
||||||
|
}
|
||||||
|
out, err := kvGetJSON(realRunner, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vaultKVList(args []string) error {
|
||||||
|
ensureVaultAddr()
|
||||||
|
var path string
|
||||||
|
for _, a := range args {
|
||||||
|
if !strings.HasPrefix(a, "-") {
|
||||||
|
path = a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("usage: homelab vault kv list <path>")
|
||||||
|
}
|
||||||
|
keys, err := kvList(realRunner, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Println(k)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vaultKVPut(args []string) error {
|
||||||
|
hardenProcess()
|
||||||
|
ensureVaultAddr()
|
||||||
|
var path, key string
|
||||||
|
for _, a := range args {
|
||||||
|
if strings.HasPrefix(a, "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case path == "":
|
||||||
|
path = a
|
||||||
|
case key == "":
|
||||||
|
key = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if path == "" || key == "" {
|
||||||
|
return fmt.Errorf("usage: homelab vault kv put <path> <key> (value read from stdin)")
|
||||||
|
}
|
||||||
|
value, err := readSecretValue("Value for " + key + ": ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("empty value; aborting (nothing written)")
|
||||||
|
}
|
||||||
|
if err := kvPut(realRunner, realRunnerStdin, path, key, value); err != nil {
|
||||||
|
return fmt.Errorf("writing %q to %s failed (does your token have write access? path correct?): %w", key, path, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "wrote "+key+" to "+path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSecretValue obtains a secret value WITHOUT putting it in argv: piped stdin
|
||||||
|
// is read verbatim (trailing newline trimmed, internal newlines preserved so
|
||||||
|
// multi-line values like PEM keys survive); an interactive TTY is prompted
|
||||||
|
// without echo.
|
||||||
|
func readSecretValue(prompt string) (string, error) {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err == nil && fi.Mode()&os.ModeCharDevice == 0 {
|
||||||
|
b, rerr := io.ReadAll(os.Stdin)
|
||||||
|
if rerr != nil {
|
||||||
|
return "", rerr
|
||||||
|
}
|
||||||
|
return strings.TrimRight(string(b), "\r\n"), nil
|
||||||
|
}
|
||||||
|
return promptNoEcho(prompt)
|
||||||
|
}
|
||||||
|
|
@ -882,3 +882,176 @@ func TestReadSucceedsWhenSyncFails(t *testing.T) {
|
||||||
t.Fatalf("read must succeed despite a sync failure: val=%q err=%v", val, err)
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
# `homelab vault` onboarding (per-user Vaultwarden access)
|
# `homelab vault` onboarding (Vaultwarden access + `vault kv` infra secrets)
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
`homelab vault` gives each devvm roster user no-HITL access to **their own**
|
`homelab vault` fronts **two unrelated secret stores** — the name collides, so
|
||||||
Vaultwarden vault (and any Organization Collection shared with their account)
|
the command keeps them clearly separated:
|
||||||
from the command line. It shells out to the official `bw` CLI; the user's
|
|
||||||
Vaultwarden credentials live only in their isolated Vault path
|
- **Vaultwarden** — your personal *password manager* (logins/passwords/TOTP).
|
||||||
`secret/workstation/claude-users/<os-user>` and are decrypted as that OS user —
|
The verbs below give each devvm roster user no-HITL access to **their own**
|
||||||
the admin never sees them.
|
Vaultwarden vault (and any Organization Collection shared with their account).
|
||||||
|
It shells out to the official `bw` CLI; the user's Vaultwarden credentials live
|
||||||
|
only in their isolated Vault path `secret/workstation/claude-users/<os-user>`
|
||||||
|
and are decrypted as that OS user — the admin never sees them.
|
||||||
|
- **HashiCorp Vault / OpenBao** — the homelab *infra* secrets store (the
|
||||||
|
`secret/…` KV mount at `vault.viktorbarzin.me`), under `homelab vault kv`.
|
||||||
|
These use the caller's **own** Vault token (`vault login -method=oidc` →
|
||||||
|
`~/.vault-token`), **not** the scoped Vaultwarden token (which only reads the
|
||||||
|
`claude-users/<user>` path); access is whatever your Vault policy grants.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
# Vaultwarden (password manager)
|
||||||
homelab vault setup one-time: store VW email + master password + API key
|
homelab vault setup one-time: store VW email + master password + API key
|
||||||
homelab vault status configured / unlocked / reachable (no secrets)
|
homelab vault status configured / unlocked / reachable (no secrets)
|
||||||
homelab vault list [--search Q] item names (no secrets)
|
homelab vault list [--search Q] item names (no secrets)
|
||||||
|
|
@ -17,6 +26,11 @@ homelab vault get <name> [--field password|username|uri|notes|totp] [--json]
|
||||||
homelab vault get <name> --all all fields (incl. custom) as JSON; pipe it (| jq)
|
homelab vault get <name> --all all fields (incl. custom) as JSON; pipe it (| jq)
|
||||||
homelab vault code <name> current TOTP code
|
homelab vault code <name> current TOTP code
|
||||||
homelab vault lock lock / log out the local bw session
|
homelab vault lock lock / log out the local bw session
|
||||||
|
|
||||||
|
# HashiCorp Vault / OpenBao (infra secrets; uses your own OIDC token)
|
||||||
|
homelab vault kv get <path> [--field K] read an infra KV secret
|
||||||
|
homelab vault kv list <path> list sub-paths
|
||||||
|
homelab vault kv put <path> <key> write one key (value via stdin; merges)
|
||||||
```
|
```
|
||||||
|
|
||||||
## How auth works (why a non-admin can use it)
|
## How auth works (why a non-admin can use it)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue