diff --git a/cli/README.md b/cli/README.md index 66a92555..fa9ff3ec 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 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 [--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 ` | read | list sub-paths under `` (no values) | +| `vault kv put ` | 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/`); `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 Built from source to `/usr/local/bin/homelab` during devvm provisioning diff --git a/cli/VERSION b/cli/VERSION index c91125db..fd2726c9 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -v0.10.1 +v0.11.0 diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index eb430b63..1a28ff14 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -18,31 +18,41 @@ import ( // decryption is done by the official `bw` CLI. See // docs/runbooks/homelab-vault-onboarding.md. func vaultCommands() []Command { - return []Command{ + cmds := []Command{ + // Vaultwarden — your personal password manager (logins/passwords/TOTP). {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, - 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, - 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, - Summary: "fetch one item: vault get [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet}, + Summary: "[vaultwarden] fetch one login: vault get [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet}, {Path: []string{"vault", "search"}, Tier: TierRead, - Summary: "search your item names: vault search ", Run: vaultSearch}, + Summary: "[vaultwarden] search your item names: vault search ", Run: vaultSearch}, {Path: []string{"vault", "code"}, Tier: TierRead, - Summary: "current TOTP code for an item: vault code ", Run: vaultCode}, + Summary: "[vaultwarden] current TOTP code for an item: vault code ", Run: vaultCode}, {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, - 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 }}, } + // 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 { - 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 status configured / unlocked / reachable (no secrets) homelab vault list [--search Q] list your item names (no secrets) @@ -53,8 +63,13 @@ func vaultHelp() string { homelab vault code current TOTP code 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 -your unix UID. Security model: docs/runbooks/homelab-vault-onboarding.md +── HashiCorp Vault / OpenBao (infra secrets; uses your own OIDC vault token) ── + homelab vault kv get [--field K] read an infra KV secret + homelab vault kv list list sub-paths + homelab vault kv put 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). ` } diff --git a/cli/cmd_vault_kv.go b/cli/cmd_vault_kv.go new file mode 100644 index 00000000..5f70e6b5 --- /dev/null +++ b/cli/cmd_vault_kv.go @@ -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/). 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 [--field K]", Run: vaultKVGet}, + {Path: []string{"vault", "kv", "list"}, Tier: TierRead, + Summary: "[hashicorp-vault] list infra KV sub-paths: vault kv list ", Run: vaultKVList}, + {Path: []string{"vault", "kv", "put"}, Tier: TierWrite, + Summary: "[hashicorp-vault] write one KV key (value via stdin): vault kv put ", 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 [--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 list sub-paths under (no values) + homelab vault kv put 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 `=-` form, so it +// never appears in argv (visible via ps / /proc//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 [--field ]") + } + 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 , 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 ") + } + 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 (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) +} diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 890827bf..fbfd876d 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -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)") + } +} diff --git a/docs/runbooks/homelab-vault-onboarding.md b/docs/runbooks/homelab-vault-onboarding.md index 0d783f80..b4bacced 100644 --- a/docs/runbooks/homelab-vault-onboarding.md +++ b/docs/runbooks/homelab-vault-onboarding.md @@ -1,15 +1,24 @@ -# `homelab vault` onboarding (per-user Vaultwarden access) +# `homelab vault` onboarding (Vaultwarden access + `vault kv` infra secrets) ## Scope -`homelab vault` gives each devvm roster user no-HITL access to **their own** -Vaultwarden vault (and any Organization Collection shared with their account) -from the command line. It shells out to the official `bw` CLI; the user's -Vaultwarden credentials live only in their isolated Vault path -`secret/workstation/claude-users/` and are decrypted as that OS user — -the admin never sees them. +`homelab vault` fronts **two unrelated secret stores** — the name collides, so +the command keeps them clearly separated: + +- **Vaultwarden** — your personal *password manager* (logins/passwords/TOTP). + The verbs below give each devvm roster user no-HITL access to **their own** + 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/` + 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/` path); access is whatever your Vault policy grants. ```text +# Vaultwarden (password manager) homelab vault setup one-time: store VW email + master password + API key homelab vault status configured / unlocked / reachable (no secrets) homelab vault list [--search Q] item names (no secrets) @@ -17,6 +26,11 @@ homelab vault get [--field password|username|uri|notes|totp] [--json] homelab vault get --all all fields (incl. custom) as JSON; pipe it (| jq) homelab vault code current TOTP code homelab vault lock lock / log out the local bw session + +# HashiCorp Vault / OpenBao (infra secrets; uses your own OIDC token) +homelab vault kv get [--field K] read an infra KV secret +homelab vault kv list list sub-paths +homelab vault kv put write one key (value via stdin; merges) ``` ## How auth works (why a non-admin can use it)