infra/docs/runbooks/homelab-vault-onboarding.md
Viktor Barzin e03e4719ad 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>
2026-06-28 11:09:33 +00:00

8.2 KiB

homelab vault onboarding (Vaultwarden access + vault kv infra secrets)

Scope

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/<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.
# 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)
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 code <name>       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 <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)

homelab vault runs vault as the calling user. It resolves a Vault token in this order (ensureVaultToken, cli/cmd_vault.go):

  1. an explicit $VAULT_TOKEN (a deliberate override), then
  2. the per-user scoped token that claude-auth-sync maintains at ~/.config/claude-auth-sync/vault-token (policy workstation-claude-<user>), then
  3. a native ~/.vault-token (admins who carry one; non-admins usually don't).

The scoped token deliberately beats ~/.vault-token. This tool only touches your own secret/workstation/claude-users/<user> path, and a power-user who ran vault login -method=oidc carries a read-only ~/.vault-token (capability deny on that path); letting it win would shadow the scoped token and fail every op with 403 permission denied (this is exactly what bit emo, 2026-06-28). The CLI also self-defaults VAULT_ADDR to https://vault.viktorbarzin.me when unset, so it works from non-login shells (tmux panes, AFK agent subprocesses) that never sourced /etc/environment — otherwise every vault child hits the 127.0.0.1:8200 default and fails connection refused (exit 2).

That scoped policy grants exactly create/read/update on the user's own secret/workstation/claude-users/<user> path — no patch capability — so the tool writes with vault kv patch -method=rw (read-modify-write), falling back to kv put only when the path does not exist yet. This preserves the claude_ai_oauth_json key that claude-auth-sync co-locates there. (The admin-only bugs were fixed 2026-06-27; the VAULT_ADDR/token-precedence bugs above were fixed 2026-06-28.)

Prerequisites (per user)

  • The user is in scripts/workstation/roster.yaml and the vault stack has been applied → their workstation-claude-<user> policy exists.
  • The user's workstation was provisioned (setup-devvm.sh) → their scoped Vault token exists at ~/.config/claude-auth-sync/vault-token.
  • bw is installed system-wide at /usr/bin/bw (see below).
  • The user has a Vaultwarden account at https://vaultwarden.viktorbarzin.me (self-service signup is open; admin panel is disabled).

One-time admin steps (devvm)

bw must be system-wide so every user resolves it (it is a Node script, and node is already system-wide at /usr/bin/node). setup-devvm.sh installs it to the npm /usr prefix; the guard checks the system path, not command -v bw (an admin's own ~/.local/bin/bw used to mask the system install, leaving non-admins with no backend). To install on a running box:

sudo npm install -g --prefix /usr "@bitwarden/cli@^2024"
bw --version            # confirm /usr/bin/bw resolves

After landing a cli/ change, rebuild the binary so users pick it up:

# version is stamped from cli/VERSION, exactly as setup-devvm.sh does it
sudo bash -c 'cd /home/wizard/code/infra/cli && \
  go build -ldflags "-X main.version=$(cat VERSION 2>/dev/null || echo dev)" \
  -o /usr/local/bin/homelab .'

(or just re-run scripts/workstation/setup-devvm.sh as root, which rebuilds it.)

User onboarding

The user runs these as themselves. The master password / API key are entered interactively (never on the command line) and stored only in the user's Vault path.

  1. In the Vaultwarden web vault → Settings → Security → Keys → View API key, copy the client_id (user.xxxx) and client_secret.

  2. Configure:

    homelab vault setup        # prompts: VW email, API client_id/secret, master password
    homelab vault status       # → "vault: configured, unlocked, reachable ✓"
    homelab vault list         # item names (own vault + any shared Collections)
    

Shared-Collection access (sharing passwords with a user)

homelab vault surfaces Organization Collection items automatically once the user's Vaultwarden account is a confirmed member. These steps are done by the vault owner in the Vaultwarden web UI (they need the owner's master password — not an infra/Terraform operation):

  1. Create or reuse an Organization and a Collection of shared logins.
  2. Invite the user's Vaultwarden account to the Organization, granting "Can view" on that Collection (least privilege).
  3. The user accepts the email invite and confirms membership.
  4. The user runs homelab vault list — the shared items now appear alongside their own (a homelab vault status sync picks them up).

Security model (the no-HITL trade)

Identity is the kernel UID. Anything running as the user can decrypt the user's vault — this is the accepted trade for no-human-in-the-loop fetches. Secrets never appear in argv (passed via env or stdin), core dumps are disabled, TOTP fetches are logged to syslog/Loki, and on a TTY values go to the clipboard (auto-clearing) rather than scrollback. The admin's Vault token is never used by a non-admin: each user authenticates with their own scoped token.

Verification

# the scoped token carries the right policy
VAULT_TOKEN="$(sudo cat /home/<user>/.config/claude-auth-sync/vault-token)" \
  vault token lookup -format=json | jq '.data.display_name, .data.policies'
#   → "token-devvm-claude-auth-<user>", [..., "workstation-claude-<user>"]

sudo -u <user> -i bw --version        # /usr/bin/bw resolves for the user
sudo -u <user> -i homelab vault status

Troubleshooting

homelab vault setup (or any verb) fails with exit status 2 — older binaries swallowed the underlying vault error; the message now includes it. Two historical causes (both fixed in-CLI 2026-06-28, kept here for diagnosis):

  • ... connection refused to 127.0.0.1:8200VAULT_ADDR wasn't set in the caller's shell. The CLI now self-defaults it, but if you see this on an old binary: export VAULT_ADDR=https://vault.viktorbarzin.me.
  • 403 permission denied on PUT .../secret/data/workstation/claude-users/<user> → a stale read-only ~/.vault-token (e.g. from vault login -method=oidc, policy default, capability deny on that path) was shadowing the scoped token. The CLI now prefers the scoped token; on an old binary, rm ~/.vault-token (or unset VAULT_TOKEN) and retry. Confirm with VAULT_TOKEN="$(sudo cat /home/<user>/.config/claude-auth-sync/vault-token)" vault token capabilities secret/data/workstation/claude-users/<user> → must be create, read, update.