diff --git a/docs/runbooks/claude-auth-renew-workstation.md b/docs/runbooks/claude-auth-renew-workstation.md index f5ce6625..727b0da4 100644 --- a/docs/runbooks/claude-auth-renew-workstation.md +++ b/docs/runbooks/claude-auth-renew-workstation.md @@ -11,6 +11,11 @@ inference every six hours and backs up only the `claudeAiOauth` object to: secret/workstation/claude-users/ ``` +The backup **merges** into that path (`vault kv patch -method=rw`, falling back to +`kv put` only when the path does not exist yet), so keys that other tools +co-locate there — notably `homelab vault`'s `vaultwarden_*` credentials — survive. +A blind `kv put` here silently wiped them on every six-hourly run (fixed 2026-06-26). + The user's unrelated `mcpOAuth` credentials never leave their home directory. Each renewal service has a distinct 32-day periodic Vault token, mode `0600`, at `~/.config/claude-auth-sync/vault-token`. Its policy can access only that user's diff --git a/scripts/test-claude-auth-sync.sh b/scripts/test-claude-auth-sync.sh index 10f07746..62c54e8b 100755 --- a/scripts/test-claude-auth-sync.sh +++ b/scripts/test-claude-auth-sync.sh @@ -28,5 +28,61 @@ ok "accept own scoped Vault token" cas_vault_identity_ok token-devvm-claude-auth no "reject another user's token" cas_vault_identity_ok token-devvm-claude-auth-anca default,workstation-claude-anca no "reject wrong policy" cas_vault_identity_ok token-devvm-claude-auth-emo default,workstation-claude-anca +# --- Regression: cas_backup must MERGE into the shared Vault path, preserving +# sibling keys that other tools co-locate there (e.g. `homelab vault`'s +# vaultwarden_* creds) — NOT overwrite the whole KV document. A blind `kv put` +# wiped them every 6h (claude-auth-sync clobber, 2026-06-26). +fakebin="$tmp/bin"; mkdir -p "$fakebin" +store="$tmp/vault-store.json" +cat > "$fakebin/vault" <<'FAKE' +#!/usr/bin/env bash +# Minimal KV-v2 fake backed by $VAULT_FAKE_STORE (a flat JSON object). +[[ "$1" == kv ]] || { echo '{}'; exit 0; } # token lookup etc. -> ignore +op="$2"; shift 2 +store="$VAULT_FAKE_STORE" +case "$op" in + get) + for a in "$@"; do [[ "$a" == -field=* ]] && field="${a#-field=}"; done + if [[ "$*" == *-format=json* ]]; then + [[ -f "$store" ]] || { echo "No value found"; exit 2; } + jq -n --argjson d "$(cat "$store")" '{data:{data:$d}}'; exit 0 + fi + [[ -f "$store" ]] || exit 2 # bare get == existence check + if [[ -n "${field:-}" ]]; then + v="$(jq -r --arg k "$field" '.[$k] // empty' "$store")"; [[ -n "$v" ]] || exit 1 + printf '%s' "$v"; exit 0 + fi + exit 0 ;; + put) echo '{}' > "$store" ;; # full replace + patch) [[ -f "$store" ]] || { echo "No value found"; exit 2; } ;; # merge (rw) + *) exit 1 ;; +esac +for a in "$@"; do + case "$a" in + -*|secret/*) continue ;; # flags + the path arg + *=*) k="${a%%=*}"; v="${a#*=}" + t="$(mktemp)"; jq --arg k "$k" --arg v "$v" '.[$k]=$v' "$store" > "$t" && mv "$t" "$store" ;; + esac +done +exit 0 +FAKE +chmod +x "$fakebin/vault" + +CAS_VAULT_PATH="secret/workstation/claude-users/test" +CAS_CREDENTIALS="$tmp/credentials.json" +CAS_STATE_DIR="$tmp/state" +_oldpath="$PATH"; PATH="$fakebin:$PATH"; export VAULT_FAKE_STORE="$store" + +printf '{"vaultwarden_master_password":"keep-me"}\n' > "$store" # pretend `homelab vault setup` ran +ok "backup succeeds (existing doc)" cas_backup +eq "merge preserves sibling key" keep-me "$(jq -r '.vaultwarden_master_password' "$store")" +eq "merge writes claude oauth" access "$(jq -r '.claude_ai_oauth_json|fromjson|.accessToken' "$store")" + +rm -f "$store" # fresh user: no doc yet +ok "backup succeeds (creates doc)" cas_backup +eq "create writes claude oauth" access "$(jq -r '.claude_ai_oauth_json|fromjson|.accessToken' "$store")" + +PATH="$_oldpath"; unset VAULT_FAKE_STORE + printf '\n%d passed, %d failed\n' "$pass" "$fail" (( fail == 0 )) diff --git a/scripts/workstation/claude-auth-sync.sh b/scripts/workstation/claude-auth-sync.sh index dc3d780d..0ea94f48 100755 --- a/scripts/workstation/claude-auth-sync.sh +++ b/scripts/workstation/claude-auth-sync.sh @@ -82,7 +82,17 @@ cas_backup() { return 1 } expires="$(jq -r '.expiresAt' <<<"$oauth")" - vault kv put "$CAS_VAULT_PATH" \ + # MERGE into the shared path so sibling keys other tools co-locate there + # (e.g. `homelab vault`'s vaultwarden_* creds) survive. `kv patch -method=rw` + # is read+update (needs no `patch` capability) but requires the secret to + # already exist, so create it with `kv put` on the very first backup only. + local -a write_cmd + if vault kv get "$CAS_VAULT_PATH" >/dev/null 2>&1; then + write_cmd=(vault kv patch -method=rw "$CAS_VAULT_PATH") + else + write_cmd=(vault kv put "$CAS_VAULT_PATH") + fi + "${write_cmd[@]}" \ claude_ai_oauth_json="$oauth" \ credential_expires_at_ms="$expires" \ backed_up_at="$(date -Is)" >/dev/null || {