feat(cli): vault creds loading from per-user Vault path
This commit is contained in:
parent
6c53ee10b1
commit
cd44ca5921
2 changed files with 124 additions and 2 deletions
|
|
@ -1,6 +1,11 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// vault verbs give each unix user no-HITL access to THEIR OWN Vaultwarden vault.
|
// 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
|
// 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 vaultSetup(args []string) error { return fmt.Errorf("not implemented") }
|
||||||
func vaultStatus(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") }
|
func vaultList(args []string) error { return fmt.Errorf("not implemented") }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestVaultCommandsRegistered(t *testing.T) {
|
func TestVaultCommandsRegistered(t *testing.T) {
|
||||||
want := map[string]Tier{
|
want := map[string]Tier{
|
||||||
|
|
@ -28,3 +32,59 @@ func TestVaultGroupInRegistry(t *testing.T) {
|
||||||
t.Fatal("`vault` group not wired into buildRegistry()")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue