diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index fab86f52..70aa5cd3 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -1,6 +1,11 @@ package main -import "fmt" +import ( + "fmt" + "os" + "os/exec" + "strings" +) // vault verbs give each unix user no-HITL access to THEIR OWN Vaultwarden vault. // Identity is the kernel UID; per-user creds live in that user's isolated Vault @@ -26,6 +31,63 @@ func vaultCommands() []Command { } } +const vwUserPathPrefix = "secret/workstation/claude-users/" + +// vwCreds is one user's Vaultwarden auth material, read from their Vault path. +type vwCreds struct { + Email string + MasterPassword string + ClientID string + ClientSecret string +} + +// cmdRunner shells out to an external command with an explicit environment and +// returns trimmed stdout. Secrets are passed via envv, NEVER argv. Tests inject +// a fake; realRunner is the production implementation. +type cmdRunner func(name string, argv, envv []string) (string, error) + +func realRunner(name string, argv, envv []string) (string, error) { + cmd := exec.Command(name, argv...) + if envv != nil { + cmd.Env = envv + } + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +func vwCredsPath(user string) string { return vwUserPathPrefix + user } + +func bwAppDataDir(uid string) string { return "/run/user/" + uid + "/homelab-bw" } + +// readVaultField returns one field from a KV-v2 path, "" if absent/error. +func readVaultField(run cmdRunner, field, path string) string { + out, err := run("vault", []string{"kv", "get", "-field=" + field, path}, nil) + if err != nil { + return "" + } + return out +} + +// loadCreds reads the four vaultwarden_* keys from the user's isolated path. +// A missing master password means the user hasn't onboarded. +func loadCreds(run cmdRunner, user string) (vwCreds, error) { + p := vwCredsPath(user) + c := vwCreds{ + Email: readVaultField(run, "vaultwarden_email", p), + MasterPassword: readVaultField(run, "vaultwarden_master_password", p), + ClientID: readVaultField(run, "vaultwarden_client_id", p), + ClientSecret: readVaultField(run, "vaultwarden_client_secret", p), + } + if c.MasterPassword == "" { + return vwCreds{}, fmt.Errorf("vault not configured for this user — run `homelab vault setup`") + } + return c, nil +} + +// vaultCurrentUser/vaultCurrentUID are seams for tests (avoid conflict with repo.go's currentUser func). +var vaultCurrentUser = func() string { return os.Getenv("USER") } +var vaultCurrentUID = func() string { return fmt.Sprintf("%d", os.Getuid()) } + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } func vaultList(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 42a4f21e..76448484 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "reflect" + "strings" + "testing" +) func TestVaultCommandsRegistered(t *testing.T) { want := map[string]Tier{ @@ -28,3 +32,59 @@ func TestVaultGroupInRegistry(t *testing.T) { t.Fatal("`vault` group not wired into buildRegistry()") } } + +func TestVaultCredsPath(t *testing.T) { + if got := vwCredsPath("emo"); got != "secret/workstation/claude-users/emo" { + t.Fatalf("vwCredsPath = %q", got) + } +} + +func TestBwAppDataDir(t *testing.T) { + if got := bwAppDataDir("1001"); got != "/run/user/1001/homelab-bw" { + t.Fatalf("bwAppDataDir = %q", got) + } +} + +// fakeRunner records calls and returns canned stdout/err keyed by argv[0]+first arg. +type fakeRunner struct { + calls [][]string + out map[string]string // key: name+" "+strings.Join(argv," ") prefix-matched + err map[string]error + lastEnv []string +} + +func (f *fakeRunner) run(name string, argv, envv []string) (string, error) { + f.calls = append(f.calls, append([]string{name}, argv...)) + f.lastEnv = envv + key := name + " " + strings.Join(argv, " ") + for k, v := range f.out { + if strings.HasPrefix(key, k) { + return v, f.err[k] + } + } + return "", f.err[key] +} + +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_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", + }} + c, err := loadCreds(f.run, "emo") + if err != nil { + t.Fatalf("loadCreds: %v", err) + } + want := vwCreds{Email: "emo@x.me", MasterPassword: "hunter2", ClientID: "user.abc", ClientSecret: "sek"} + if !reflect.DeepEqual(c, want) { + t.Fatalf("loadCreds = %+v want %+v", c, want) + } +} + +func TestLoadCredsUnconfigured(t *testing.T) { + f := &fakeRunner{out: map[string]string{}} // every field empty + if _, err := loadCreds(f.run, "emo"); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("want 'not configured' error, got %v", err) + } +}