homelab vault: make it work for non-admin workstation users
`homelab vault` was effectively admin-only: two bugs blocked every non-admin (e.g. emo) from using it for their own Vaultwarden vault. 1. Token: the CLI relied purely on ambient `vault` auth (~/.vault-token / $VAULT_TOKEN), which only admins have. Non-admins carry a scoped token at ~/.config/claude-auth-sync/vault-token (policy workstation-claude-<user>). Add ensureVaultToken(): explicit env > ~/.vault-token > scoped fallback, wired into every vault verb. Admins are unaffected (their ambient token wins). 2. Write capability: `homelab vault setup` used plain `vault kv patch`, which needs the `patch` capability the scoped policy does not grant (only create/read/update) — so setup 403'd for non-admins. Switch to `kv patch -method=rw` (read-modify-write; same approach claude-auth-sync already uses), with `kv put` only when the path doesn't exist yet. Preserves co-located keys (claude_ai_oauth_json). Enables onboarding emo onto the per-user Vaultwarden access tool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
006f97ef58
commit
51dc5d031c
2 changed files with 283 additions and 35 deletions
|
|
@ -70,7 +70,7 @@ func (f *fakeRunner) run(name string, argv, envv []string) (string, error) {
|
|||
|
||||
func TestLoadCredsReadsFourFields(t *testing.T) {
|
||||
f := &fakeRunner{out: map[string]string{
|
||||
"vault kv get -field=vaultwarden_email secret/workstation/claude-users/emo": "emo@x.me",
|
||||
"vault kv get -field=vaultwarden_email secret/workstation/claude-users/emo": "emo@x.me",
|
||||
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "hunter2",
|
||||
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.abc",
|
||||
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "sek",
|
||||
|
|
@ -233,12 +233,96 @@ func TestStatusSummaryUnconfigured(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVaultPatchPublicArgs(t *testing.T) {
|
||||
got := vaultPatchPublicArgs("emo", "e@x.me", "user.ci")
|
||||
want := []string{"kv", "patch", "secret/workstation/claude-users/emo",
|
||||
func TestEnsureVaultTokenSetsScopedFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := dir + "/.config/claude-auth-sync"
|
||||
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cfg+"/vault-token", []byte("SCOPED-TOK\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("VAULT_TOKEN", "") // no ambient token
|
||||
|
||||
ensureVaultToken()
|
||||
if got := os.Getenv("VAULT_TOKEN"); got != "SCOPED-TOK" {
|
||||
t.Fatalf("VAULT_TOKEN = %q, want scoped fallback to be exported", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureVaultTokenKeepsExplicitEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := dir + "/.config/claude-auth-sync"
|
||||
if err := os.MkdirAll(cfg, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cfg+"/vault-token", []byte("SCOPED-TOK"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("VAULT_TOKEN", "ADMIN-TOK")
|
||||
|
||||
ensureVaultToken()
|
||||
if got := os.Getenv("VAULT_TOKEN"); got != "ADMIN-TOK" {
|
||||
t.Fatalf("VAULT_TOKEN = %q, must not override an explicit token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedTokenPath(t *testing.T) {
|
||||
if got := scopedTokenPath("/home/emo"); got != "/home/emo/.config/claude-auth-sync/vault-token" {
|
||||
t.Fatalf("scopedTokenPath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultTokenSource(t *testing.T) {
|
||||
// Precedence: explicit $VAULT_TOKEN > ~/.vault-token (vault CLI native) >
|
||||
// the claude-auth-sync per-user scoped token. This is what lets a non-admin
|
||||
// workstation user (no ambient token) reach their own Vault path.
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
haveVaultToken bool
|
||||
scoped string
|
||||
wantTok, wantSrc string
|
||||
}{
|
||||
{"explicit env wins", "abc", true, "S", "", "env"},
|
||||
{"vault-token file used natively", "", true, "S", "", "file"},
|
||||
{"scoped fallback for non-admin", "", false, "S-TOK", "S-TOK", "scoped"},
|
||||
{"scoped value is trimmed", "", false, " S-TOK\n", "S-TOK", "scoped"},
|
||||
{"whitespace-only scoped is no token", "", false, " \n", "", "none"},
|
||||
{"nothing configured", "", false, "", "", "none"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
tok, src := vaultTokenSource(c.env, c.haveVaultToken, c.scoped)
|
||||
if tok != c.wantTok || src != c.wantSrc {
|
||||
t.Errorf("%s: vaultTokenSource(%q,%v,%q) = (%q,%q), want (%q,%q)",
|
||||
c.name, c.env, c.haveVaultToken, c.scoped, tok, src, c.wantTok, c.wantSrc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKvWriteVerb(t *testing.T) {
|
||||
// merge=true → read-modify-write patch (needs only read+update, NOT the
|
||||
// `patch` capability the scoped workstation policy lacks).
|
||||
if got := kvWriteVerb(true); !reflect.DeepEqual(got, []string{"kv", "patch", "-method=rw"}) {
|
||||
t.Fatalf("kvWriteVerb(true) = %v", got)
|
||||
}
|
||||
// merge=false → put (creates the path on first use)
|
||||
if got := kvWriteVerb(false); !reflect.DeepEqual(got, []string{"kv", "put"}) {
|
||||
t.Fatalf("kvWriteVerb(false) = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultWritePublicArgs(t *testing.T) {
|
||||
got := vaultWritePublicArgs(true, "emo", "e@x.me", "user.ci")
|
||||
want := []string{"kv", "patch", "-method=rw", "secret/workstation/claude-users/emo",
|
||||
"vaultwarden_email=e@x.me", "vaultwarden_client_id=user.ci"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("vaultPatchPublicArgs = %v", got)
|
||||
t.Fatalf("vaultWritePublicArgs(merge) = %v", got)
|
||||
}
|
||||
if got := vaultWritePublicArgs(false, "emo", "e@x.me", "user.ci"); got[0] != "kv" || got[1] != "put" {
|
||||
t.Fatalf("vaultWritePublicArgs(create) must use `kv put`, got %v", got)
|
||||
}
|
||||
for _, a := range got {
|
||||
if strings.Contains(a, "master_password") || strings.Contains(a, "client_secret") {
|
||||
|
|
@ -247,12 +331,12 @@ func TestVaultPatchPublicArgs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestVaultPatchSecretArgsNoValueInArgv(t *testing.T) {
|
||||
func TestVaultWriteSecretArgsNoValueInArgv(t *testing.T) {
|
||||
for _, key := range []string{"vaultwarden_master_password", "vaultwarden_client_secret"} {
|
||||
got := vaultPatchSecretArgs("emo", key)
|
||||
want := []string{"kv", "patch", "secret/workstation/claude-users/emo", key + "=-"}
|
||||
got := vaultWriteSecretArgs(true, "emo", key)
|
||||
want := []string{"kv", "patch", "-method=rw", "secret/workstation/claude-users/emo", key + "=-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("vaultPatchSecretArgs(%q) = %v", key, got)
|
||||
t.Fatalf("vaultWriteSecretArgs(%q) = %v", key, got)
|
||||
}
|
||||
if got[len(got)-1] != key+"=-" {
|
||||
t.Fatalf("secret value must be read from stdin (`%s=-`), got %v", key, got)
|
||||
|
|
@ -260,6 +344,90 @@ func TestVaultPatchSecretArgsNoValueInArgv(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// recStdin records a stdin-bearing call for assertions.
|
||||
type recStdin struct {
|
||||
argv []string
|
||||
stdin string
|
||||
}
|
||||
|
||||
// TestWriteCredsCreatesThenMerges: when the path is ABSENT the first (public)
|
||||
// write must `kv put` (create), and the two secrets must merge via patch -rw
|
||||
// with values on stdin only — never the buggy plain `kv patch` (needs `patch`).
|
||||
func TestWriteCredsCreatesThenMerges(t *testing.T) {
|
||||
var calls [][]string
|
||||
var stdinCalls []recStdin
|
||||
run := func(name string, argv, envv []string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, argv...))
|
||||
if len(argv) >= 2 && argv[0] == "kv" && argv[1] == "get" {
|
||||
return "", fmt.Errorf("no value found") // path absent
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
runStdin := func(name string, argv, envv []string, stdin string) (string, error) {
|
||||
stdinCalls = append(stdinCalls, recStdin{append([]string{name}, argv...), stdin})
|
||||
return "", nil
|
||||
}
|
||||
c := vwCreds{Email: "e@x.me", MasterPassword: "PW", ClientID: "user.ci", ClientSecret: "CS"}
|
||||
if err := writeCreds(run, runStdin, "emo", c); err != nil {
|
||||
t.Fatalf("writeCreds: %v", err)
|
||||
}
|
||||
var sawPut, sawPlainPatch bool
|
||||
for _, cl := range calls {
|
||||
j := strings.Join(cl, " ")
|
||||
if strings.Contains(j, "kv put") {
|
||||
sawPut = true
|
||||
}
|
||||
if strings.Contains(j, "kv patch") && !strings.Contains(j, "-method=rw") {
|
||||
sawPlainPatch = true
|
||||
}
|
||||
}
|
||||
if !sawPut {
|
||||
t.Fatalf("path absent → public write must be `kv put`; calls=%v", calls)
|
||||
}
|
||||
if sawPlainPatch {
|
||||
t.Fatalf("must never use plain `kv patch` (needs `patch` capability); calls=%v", calls)
|
||||
}
|
||||
if len(stdinCalls) != 2 {
|
||||
t.Fatalf("want 2 stdin secret writes, got %d", len(stdinCalls))
|
||||
}
|
||||
for _, sc := range stdinCalls {
|
||||
if !strings.Contains(strings.Join(sc.argv, " "), "kv patch -method=rw") {
|
||||
t.Errorf("secret write must use patch -method=rw: %v", sc.argv)
|
||||
}
|
||||
for _, a := range sc.argv {
|
||||
if strings.Contains(a, "PW") || strings.Contains(a, "CS") {
|
||||
t.Errorf("secret leaked into argv: %v", sc.argv)
|
||||
}
|
||||
}
|
||||
}
|
||||
if stdinCalls[0].stdin != "PW" || stdinCalls[1].stdin != "CS" {
|
||||
t.Errorf("stdin values wrong: %q,%q", stdinCalls[0].stdin, stdinCalls[1].stdin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteCredsMergesWhenPresent: when the path EXISTS, every write must merge
|
||||
// (patch -rw) — a `kv put` would wipe sibling keys (e.g. claude_ai_oauth_json).
|
||||
func TestWriteCredsMergesWhenPresent(t *testing.T) {
|
||||
var calls [][]string
|
||||
run := func(name string, argv, envv []string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, argv...))
|
||||
return "{}", nil // get succeeds → path exists
|
||||
}
|
||||
runStdin := func(name string, argv, envv []string, stdin string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, argv...))
|
||||
return "", nil
|
||||
}
|
||||
c := vwCreds{Email: "e@x.me", MasterPassword: "PW", ClientID: "user.ci", ClientSecret: "CS"}
|
||||
if err := writeCreds(run, runStdin, "emo", c); err != nil {
|
||||
t.Fatalf("writeCreds: %v", err)
|
||||
}
|
||||
for _, cl := range calls {
|
||||
if strings.Contains(strings.Join(cl, " "), "kv put") {
|
||||
t.Fatalf("path exists → must NOT `kv put` (wipes siblings): %v", cl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoSecretInArgvAcrossFlow is the load-bearing security test: across the
|
||||
// whole get flow (vault reads, bw config/status/login/unlock/get) NO secret
|
||||
// value may appear in any command's argv — secrets travel via env/stdin only.
|
||||
|
|
@ -267,8 +435,8 @@ func TestNoSecretInArgvAcrossFlow(t *testing.T) {
|
|||
uid := fmt.Sprintf("%d", os.Getuid())
|
||||
f := &fakeRunner{out: map[string]string{
|
||||
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "SUPERSECRETPW",
|
||||
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "CLIENTSEKRET",
|
||||
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "CLIENTSEKRET",
|
||||
"bw status": `{"status":"locked"}`,
|
||||
"bw unlock": "SESSIONXYZ",
|
||||
"bw get password github": "p@ss",
|
||||
|
|
@ -353,8 +521,8 @@ func TestVaultBareGroupRegistered(t *testing.T) {
|
|||
func TestGetValueFlow(t *testing.T) {
|
||||
f := &fakeRunner{out: map[string]string{
|
||||
"vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw",
|
||||
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs",
|
||||
"vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x",
|
||||
"vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs",
|
||||
"bw status": `{"status":"locked"}`,
|
||||
"bw unlock": "SESS",
|
||||
"bw get password github": "p@ss",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue