Add vctl and vctx Vault token CLIs
Introduces the vault-tools monorepo: two Go CLIs that share a config file (~/.config/vault) and token cache (~/.cache/vault) for working with multiple Vault instances (contexts). - add shared/ library: config parsing (vctl.yaml/config.yaml, per-context overrides, slash contexts), token cache (0600/0700, atomic writes, path- traversal guards), and a small hand-rolled Vault HTTP client (login/renew) - add vctl: login/renew (single or --all), list, --method/--user overrides, no-echo password/token prompts, dynamic context completion - add vctx: resolve a context, set VAULT_ADDR/VAULT_TOKEN/VAULT_NAMESPACE and exec the vault CLI, passing remaining args through untouched - add unit tests across shared/, vctl and vctx command layers (config resolution, cache paths, vault client, --all iteration + error aggregation, vctx arg pass-through and env construction via fakeable exec/prompt seams) - add Makefile (build/test/completions/rpm, patch|minor|major version bumps), nfpm RPM packaging bundling bash/zsh/fish completions for both binaries - add Woodpecker pipelines: build/test/pre-commit on PRs, and a tag release that cross-compiles, builds+uploads the RPM to artifactapi, and cuts a Gitea release (serviceAccountName default, k8s resources on every step) - add README, per-command docs (docs/vctl.md, docs/vctx.md), AGENTS.md and an example config Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
+146
@@ -0,0 +1,146 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token is the cached result of a successful login. It stores enough detail to
|
||||
// later inspect or revoke the token (accessor, policies) and to reason about
|
||||
// its lifetime (issued/expiry, renewable) — not just the secret itself.
|
||||
type Token struct {
|
||||
// Context is the name of the context this token belongs to.
|
||||
Context string `json:"context"`
|
||||
// Address is the Vault address the token is valid against.
|
||||
Address string `json:"address"`
|
||||
// Namespace is the Vault namespace the token was issued in (may be empty).
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
|
||||
// Token is the client token secret.
|
||||
Token string `json:"token"`
|
||||
// Accessor identifies the token without exposing it — enough to revoke it
|
||||
// via /auth/token/revoke-accessor.
|
||||
Accessor string `json:"accessor,omitempty"`
|
||||
// Policies are the policies attached to the token.
|
||||
Policies []string `json:"policies,omitempty"`
|
||||
|
||||
// Renewable reports whether the token can be renewed.
|
||||
Renewable bool `json:"renewable"`
|
||||
// LeaseDurationSeconds is the TTL granted at issue/renew time.
|
||||
LeaseDurationSeconds int `json:"lease_duration_seconds,omitempty"`
|
||||
|
||||
// IssuedAt is when this token was obtained (login) or last renewed.
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
// ExpiresAt is IssuedAt + LeaseDuration; zero for non-expiring tokens.
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// CacheDir returns the XDG_CACHE_HOME/vault directory that holds token files.
|
||||
func CacheDir() string {
|
||||
base := os.Getenv("XDG_CACHE_HOME")
|
||||
if base == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
base = filepath.Join(home, ".cache")
|
||||
}
|
||||
return filepath.Join(base, appDir)
|
||||
}
|
||||
|
||||
// TokenPath returns the on-disk path for a context's cached token. A context
|
||||
// name with slashes (e.g. "staging/sydney") maps to a nested path under
|
||||
// CacheDir. The name is validated to prevent escaping the cache directory.
|
||||
func TokenPath(context string) (string, error) {
|
||||
if err := validateContextName(context); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Use forward slashes as path separators regardless of platform, matching
|
||||
// how contexts are written in the config file.
|
||||
rel := filepath.FromSlash(context)
|
||||
return filepath.Join(CacheDir(), rel), nil
|
||||
}
|
||||
|
||||
// validateContextName rejects names that could escape the cache directory or
|
||||
// are otherwise unusable as a relative path.
|
||||
func validateContextName(context string) error {
|
||||
if context == "" {
|
||||
return fmt.Errorf("empty context name")
|
||||
}
|
||||
if strings.HasPrefix(context, "/") || filepath.IsAbs(context) {
|
||||
return fmt.Errorf("context name %q must not be absolute", context)
|
||||
}
|
||||
for _, seg := range strings.Split(context, "/") {
|
||||
if seg == "" {
|
||||
return fmt.Errorf("context name %q has an empty path segment", context)
|
||||
}
|
||||
if seg == "." || seg == ".." {
|
||||
return fmt.Errorf("context name %q must not contain %q segments", context, seg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveToken writes a token to its cache path as JSON, creating parent
|
||||
// directories (0700) as needed and writing the file with 0600 permissions.
|
||||
func SaveToken(t *Token) error {
|
||||
path, err := TokenPath(t.Context)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("creating cache dir: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(t, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding token: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
// Write via a temp file + rename so a token file is never left partially
|
||||
// written, and create it 0600 from the start (never briefly world-readable).
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("writing token: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("saving token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadToken reads and decodes a cached token for a context. A missing token
|
||||
// returns an error wrapping os.ErrNotExist so callers can detect "not logged
|
||||
// in" with errors.Is.
|
||||
func LoadToken(context string) (*Token, error) {
|
||||
path, err := TokenPath(context)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("no cached token for context %q (run 'vctl login %s'): %w", context, context, os.ErrNotExist)
|
||||
}
|
||||
return nil, fmt.Errorf("reading token %s: %w", path, err)
|
||||
}
|
||||
var t Token
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return nil, fmt.Errorf("parsing token %s: %w", path, err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// DeleteToken removes a context's cached token file, if present.
|
||||
func DeleteToken(context string) error {
|
||||
path, err := TokenPath(context)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("removing token %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTokenPathSimpleAndSlash(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", base)
|
||||
|
||||
cases := map[string]string{
|
||||
"sydney": filepath.Join(base, appDir, "sydney"),
|
||||
"staging/sydney": filepath.Join(base, appDir, "staging", "sydney"),
|
||||
"a/b/c": filepath.Join(base, appDir, "a", "b", "c"),
|
||||
}
|
||||
for ctx, want := range cases {
|
||||
got, err := TokenPath(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("TokenPath(%q): %v", ctx, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("TokenPath(%q) = %q, want %q", ctx, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPathRejectsTraversal(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
bad := []string{"", "/etc/passwd", "../escape", "a/../../b", "a//b", "foo/", "./x"}
|
||||
for _, ctx := range bad {
|
||||
if _, err := TokenPath(ctx); err == nil {
|
||||
t.Errorf("TokenPath(%q) expected error, got nil", ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundTripSlashContext(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Setenv("XDG_CACHE_HOME", base)
|
||||
|
||||
tok := &Token{
|
||||
Context: "staging/sydney",
|
||||
Address: "https://vault-staging.syd1.au.unkin.net",
|
||||
Namespace: "staging",
|
||||
Token: "s.abcdef123456",
|
||||
Accessor: "acc-123",
|
||||
Policies: []string{"default", "kv-read"},
|
||||
Renewable: true,
|
||||
LeaseDurationSeconds: 3600,
|
||||
IssuedAt: time.Now().UTC().Truncate(time.Second),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour).Truncate(time.Second),
|
||||
}
|
||||
if err := SaveToken(tok); err != nil {
|
||||
t.Fatalf("SaveToken: %v", err)
|
||||
}
|
||||
|
||||
// Parent dir for a slash context must be created 0700, file 0600.
|
||||
path, _ := TokenPath("staging/sydney")
|
||||
if runtime.GOOS != "windows" {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("token file perm = %o, want 600", perm)
|
||||
}
|
||||
di, err := os.Stat(filepath.Dir(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := di.Mode().Perm(); perm != 0o700 {
|
||||
t.Errorf("token dir perm = %o, want 700", perm)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := LoadToken("staging/sydney")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToken: %v", err)
|
||||
}
|
||||
if got.Token != tok.Token || got.Accessor != tok.Accessor || got.Namespace != tok.Namespace {
|
||||
t.Errorf("round-trip mismatch: %+v vs %+v", got, tok)
|
||||
}
|
||||
if len(got.Policies) != 2 || got.Policies[1] != "kv-read" {
|
||||
t.Errorf("policies mismatch: %v", got.Policies)
|
||||
}
|
||||
if !got.Renewable {
|
||||
t.Error("renewable lost in round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTokenMissingIsNotExist(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
_, err := LoadToken("never-logged-in")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing token")
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("error should wrap os.ErrNotExist, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteToken(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
tok := &Token{Context: "sydney", Address: "https://v", Token: "s.x"}
|
||||
if err := SaveToken(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DeleteToken("sydney"); err != nil {
|
||||
t.Fatalf("DeleteToken: %v", err)
|
||||
}
|
||||
// Deleting again is a no-op (not-exist tolerated).
|
||||
if err := DeleteToken("sydney"); err != nil {
|
||||
t.Errorf("second DeleteToken: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package shared holds the plumbing common to the vault-tools CLIs (vctl and
|
||||
// vctx): config-file parsing, the on-disk token cache, and a small Vault HTTP
|
||||
// API client. Both tools read the SAME config file and token cache so a single
|
||||
// ~/.config/vault/ configures every tool in the family.
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultMethod is the auth method used when neither the context nor the
|
||||
// file-level defaults specify one.
|
||||
DefaultMethod = "ldap"
|
||||
|
||||
// appDir is the per-user config/cache subdirectory both tools live under.
|
||||
appDir = "vault"
|
||||
)
|
||||
|
||||
// configFileNames are the accepted config file basenames, tried in order. The
|
||||
// first one that exists wins.
|
||||
var configFileNames = []string{"vctl.yaml", "config.yaml"}
|
||||
|
||||
// Context is a single vault instance the tools can target. Every field except
|
||||
// Address is optional and, when empty, falls back to the file-level Defaults
|
||||
// and finally the built-in defaults.
|
||||
type Context struct {
|
||||
// Address is the Vault API base URL, e.g. https://vault.syd1.au.unkin.net.
|
||||
Address string `yaml:"address"`
|
||||
// Method is the auth method (ldap, userpass, okta, radius, token, ...).
|
||||
Method string `yaml:"method,omitempty"`
|
||||
// User is the login username (LDAP/userpass/...); ignored for token auth.
|
||||
User string `yaml:"user,omitempty"`
|
||||
// Namespace is the Vault namespace (X-Vault-Namespace) for the context.
|
||||
Namespace string `yaml:"namespace,omitempty"`
|
||||
// Path overrides the auth mount path (defaults to Method), e.g. "ldap2".
|
||||
Path string `yaml:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Defaults holds file-level fallbacks applied to every context that does not
|
||||
// set its own value.
|
||||
type Defaults struct {
|
||||
Method string `yaml:"method,omitempty"`
|
||||
User string `yaml:"user,omitempty"`
|
||||
Namespace string `yaml:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the parsed config file: file-level defaults plus a map of named
|
||||
// contexts. Context names may contain slashes (e.g. "staging/sydney"), which
|
||||
// map to nested cache paths.
|
||||
type Config struct {
|
||||
Defaults Defaults `yaml:"defaults"`
|
||||
Contexts map[string]Context `yaml:"contexts"`
|
||||
|
||||
// path records the file this config was loaded from (empty if none found).
|
||||
path string
|
||||
}
|
||||
|
||||
// ResolvedContext is a Context with all defaults applied, ready to use. Method
|
||||
// and User are always populated.
|
||||
type ResolvedContext struct {
|
||||
Name string
|
||||
Address string
|
||||
Method string
|
||||
User string
|
||||
Namespace string
|
||||
Path string
|
||||
}
|
||||
|
||||
// ConfigDir returns the XDG_CONFIG_HOME/vault directory.
|
||||
func ConfigDir() string {
|
||||
base := os.Getenv("XDG_CONFIG_HOME")
|
||||
if base == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
base = filepath.Join(home, ".config")
|
||||
}
|
||||
return filepath.Join(base, appDir)
|
||||
}
|
||||
|
||||
// ConfigPath returns the path of the first existing config file, or the path
|
||||
// the file would take (the first candidate) when none exists yet.
|
||||
func ConfigPath() string {
|
||||
dir := ConfigDir()
|
||||
for _, name := range configFileNames {
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, configFileNames[0])
|
||||
}
|
||||
|
||||
// Load reads and parses the first config file found in ConfigDir. A missing
|
||||
// config file is not an error: an empty Config is returned so callers can give
|
||||
// a helpful "no contexts configured" message.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{Contexts: map[string]Context{}}
|
||||
|
||||
dir := ConfigDir()
|
||||
for _, name := range configFileNames {
|
||||
p := filepath.Join(dir, name)
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return cfg, fmt.Errorf("reading config %s: %w", p, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parsing config %s: %w", p, err)
|
||||
}
|
||||
if cfg.Contexts == nil {
|
||||
cfg.Contexts = map[string]Context{}
|
||||
}
|
||||
cfg.path = p
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Path returns the file this config was loaded from, or "" if none was found.
|
||||
func (c *Config) Path() string { return c.path }
|
||||
|
||||
// ContextNames returns the configured context names, sorted. Used to drive
|
||||
// shell completion for the --context flag / context arguments.
|
||||
func (c *Config) ContextNames() []string {
|
||||
names := make([]string, 0, len(c.Contexts))
|
||||
for n := range c.Contexts {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// Resolve looks up a context by name and applies the fallback chain for each
|
||||
// field: context value < file defaults < built-in default. Method and User are
|
||||
// guaranteed non-empty in the result (User defaults to $USER).
|
||||
func (c *Config) Resolve(name string) (ResolvedContext, error) {
|
||||
ctx, ok := c.Contexts[name]
|
||||
if !ok {
|
||||
return ResolvedContext{}, fmt.Errorf("no context %q in %s", name, displayPath(c.path))
|
||||
}
|
||||
if ctx.Address == "" {
|
||||
return ResolvedContext{}, fmt.Errorf("context %q has no address", name)
|
||||
}
|
||||
|
||||
method := firstNonEmpty(ctx.Method, c.Defaults.Method, DefaultMethod)
|
||||
user := firstNonEmpty(ctx.User, c.Defaults.User, os.Getenv("USER"))
|
||||
namespace := firstNonEmpty(ctx.Namespace, c.Defaults.Namespace)
|
||||
path := firstNonEmpty(ctx.Path, method)
|
||||
|
||||
return ResolvedContext{
|
||||
Name: name,
|
||||
Address: ctx.Address,
|
||||
Method: method,
|
||||
User: user,
|
||||
Namespace: namespace,
|
||||
Path: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveWithOverrides is Resolve plus explicit CLI-flag overrides for method
|
||||
// and user; an empty override leaves the resolved value untouched. When the
|
||||
// method is overridden and the context did not pin an explicit auth path, the
|
||||
// auth path follows the new method.
|
||||
func (c *Config) ResolveWithOverrides(name, method, user string) (ResolvedContext, error) {
|
||||
rc, err := c.Resolve(name)
|
||||
if err != nil {
|
||||
return rc, err
|
||||
}
|
||||
if method != "" {
|
||||
pinnedPath := c.Contexts[name].Path != ""
|
||||
rc.Method = method
|
||||
if !pinnedPath {
|
||||
rc.Path = method
|
||||
}
|
||||
}
|
||||
if user != "" {
|
||||
rc.User = user
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func displayPath(p string) string {
|
||||
if p == "" {
|
||||
return ConfigPath() + " (not found)"
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withConfigDir points XDG_CONFIG_HOME at a temp dir and writes the given
|
||||
// config file into <tmp>/vault/<name>, returning the temp base.
|
||||
func withConfigDir(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", base)
|
||||
dir := filepath.Join(base, appDir)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const sampleConfig = `
|
||||
defaults:
|
||||
method: ldap
|
||||
user: ben
|
||||
contexts:
|
||||
sydney:
|
||||
address: https://vault.syd1.au.unkin.net
|
||||
staging/sydney:
|
||||
address: https://vault-staging.syd1.au.unkin.net
|
||||
namespace: staging
|
||||
user: svc-ben
|
||||
legacy:
|
||||
address: https://vault-legacy.example.net
|
||||
method: userpass
|
||||
path: userpass2
|
||||
`
|
||||
|
||||
func TestLoadAndContextNames(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
got := cfg.ContextNames()
|
||||
want := []string{"legacy", "staging/sydney", "sydney"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("ContextNames = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ContextNames[%d] = %q, want %q (%v)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
if cfg.Path() == "" {
|
||||
t.Error("Path() is empty after loading a config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPathPrefersVctlYaml(t *testing.T) {
|
||||
base := withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
// also write a config.yaml; vctl.yaml should win
|
||||
if err := os.WriteFile(filepath.Join(base, appDir, "config.yaml"), []byte(sampleConfig), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := ConfigPath(), filepath.Join(base, appDir, "vctl.yaml"); got != want {
|
||||
t.Errorf("ConfigPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPathFallsBackToConfigYaml(t *testing.T) {
|
||||
base := withConfigDir(t, "config.yaml", sampleConfig)
|
||||
if got, want := ConfigPath(), filepath.Join(base, appDir, "config.yaml"); got != want {
|
||||
t.Errorf("ConfigPath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingConfigIsNotError(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load with no file: %v", err)
|
||||
}
|
||||
if len(cfg.ContextNames()) != 0 {
|
||||
t.Errorf("expected no contexts, got %v", cfg.ContextNames())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesDefaults(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// sydney: inherits method+user from defaults, no namespace, path == method.
|
||||
rc, err := cfg.Resolve("sydney")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != "ldap" || rc.User != "ben" || rc.Namespace != "" || rc.Path != "ldap" {
|
||||
t.Errorf("sydney resolved = %+v", rc)
|
||||
}
|
||||
|
||||
// staging/sydney: overrides user + namespace, inherits method.
|
||||
rc, err = cfg.Resolve("staging/sydney")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != "ldap" || rc.User != "svc-ben" || rc.Namespace != "staging" {
|
||||
t.Errorf("staging/sydney resolved = %+v", rc)
|
||||
}
|
||||
|
||||
// legacy: explicit method + custom auth path.
|
||||
rc, err = cfg.Resolve("legacy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != "userpass" || rc.Path != "userpass2" {
|
||||
t.Errorf("legacy resolved = %+v", rc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUnknownContext(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
cfg, _ := Load()
|
||||
if _, err := cfg.Resolve("nope"); err == nil {
|
||||
t.Error("expected error for unknown context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMethodDefaultWhenUnset(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", `
|
||||
contexts:
|
||||
bare:
|
||||
address: https://vault.example.net
|
||||
`)
|
||||
t.Setenv("USER", "alice")
|
||||
cfg, _ := Load()
|
||||
rc, err := cfg.Resolve("bare")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != DefaultMethod {
|
||||
t.Errorf("method = %q, want %q", rc.Method, DefaultMethod)
|
||||
}
|
||||
if rc.User != "alice" {
|
||||
t.Errorf("user = %q, want alice ($USER fallback)", rc.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWithOverrides(t *testing.T) {
|
||||
withConfigDir(t, "vctl.yaml", sampleConfig)
|
||||
cfg, _ := Load()
|
||||
|
||||
// Overriding the method also moves the auth path (context did not pin one).
|
||||
rc, err := cfg.ResolveWithOverrides("sydney", "okta", "otheruser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != "okta" || rc.Path != "okta" || rc.User != "otheruser" {
|
||||
t.Errorf("override resolved = %+v", rc)
|
||||
}
|
||||
|
||||
// legacy pins path=userpass2, so a method override must NOT change the path.
|
||||
rc, err = cfg.ResolveWithOverrides("legacy", "okta", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != "okta" || rc.Path != "userpass2" {
|
||||
t.Errorf("pinned-path override resolved = %+v", rc)
|
||||
}
|
||||
|
||||
// Empty overrides leave resolved values untouched.
|
||||
rc, err = cfg.ResolveWithOverrides("sydney", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rc.Method != "ldap" || rc.User != "ben" {
|
||||
t.Errorf("no-op override resolved = %+v", rc)
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// httpClient is the shared client for Vault API calls with a sane timeout.
|
||||
var httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// passwordMethods are auth methods whose login takes a username in the path and
|
||||
// a password in the body (POST auth/<path>/login/<user> {"password": ...}).
|
||||
var passwordMethods = map[string]bool{
|
||||
"ldap": true,
|
||||
"userpass": true,
|
||||
"okta": true,
|
||||
"radius": true,
|
||||
}
|
||||
|
||||
// NeedsPassword reports whether an auth method prompts for a password.
|
||||
func NeedsPassword(method string) bool { return passwordMethods[method] }
|
||||
|
||||
// IsTokenMethod reports whether the method authenticates with a raw token the
|
||||
// user pastes in, rather than a username/password login.
|
||||
func IsTokenMethod(method string) bool { return method == "token" }
|
||||
|
||||
// authResponse models the /auth block returned by a Vault login/renew call.
|
||||
type authResponse struct {
|
||||
Auth struct {
|
||||
ClientToken string `json:"client_token"`
|
||||
Accessor string `json:"accessor"`
|
||||
Policies []string `json:"policies"`
|
||||
TokenPolicies []string `json:"token_policies"`
|
||||
LeaseDuration int `json:"lease_duration"`
|
||||
Renewable bool `json:"renewable"`
|
||||
} `json:"auth"`
|
||||
}
|
||||
|
||||
// lookupResponse models the /auth/token/lookup-self data block, used when the
|
||||
// method is a raw token (no /auth block is returned by a login call).
|
||||
type lookupResponse struct {
|
||||
Data struct {
|
||||
Accessor string `json:"accessor"`
|
||||
Policies []string `json:"policies"`
|
||||
TTL int `json:"ttl"`
|
||||
Renewable bool `json:"renewable"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// vaultError decodes Vault's {"errors": [...]} response body into a message.
|
||||
func vaultError(status int, body []byte) error {
|
||||
var e struct {
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if json.Unmarshal(body, &e) == nil && len(e.Errors) > 0 {
|
||||
return fmt.Errorf("vault returned HTTP %d: %s", status, strings.Join(e.Errors, "; "))
|
||||
}
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if msg == "" {
|
||||
return fmt.Errorf("vault returned HTTP %d", status)
|
||||
}
|
||||
return fmt.Errorf("vault returned HTTP %d: %s", status, msg)
|
||||
}
|
||||
|
||||
// doRequest performs a Vault API request and returns the response body on 2xx.
|
||||
func doRequest(method, address, path, namespace, token string, payload any) ([]byte, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encoding request: %w", err)
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
url := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/")
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-Vault-Token", token)
|
||||
}
|
||||
if namespace != "" {
|
||||
req.Header.Set("X-Vault-Namespace", namespace)
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request to %s failed: %w", url, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, vaultError(resp.StatusCode, data)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// tokenFromAuth builds a cache Token from a resolved context and an /auth block.
|
||||
func tokenFromAuth(rc ResolvedContext, ar authResponse) *Token {
|
||||
policies := ar.Auth.TokenPolicies
|
||||
if len(policies) == 0 {
|
||||
policies = ar.Auth.Policies
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
t := &Token{
|
||||
Context: rc.Name,
|
||||
Address: rc.Address,
|
||||
Namespace: rc.Namespace,
|
||||
Token: ar.Auth.ClientToken,
|
||||
Accessor: ar.Auth.Accessor,
|
||||
Policies: policies,
|
||||
Renewable: ar.Auth.Renewable,
|
||||
LeaseDurationSeconds: ar.Auth.LeaseDuration,
|
||||
IssuedAt: now,
|
||||
}
|
||||
if ar.Auth.LeaseDuration > 0 {
|
||||
t.ExpiresAt = now.Add(time.Duration(ar.Auth.LeaseDuration) * time.Second)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Login authenticates against a context and returns a Token ready to cache.
|
||||
// For password methods, secret is the password; for the token method, secret is
|
||||
// the raw client token to adopt (verified via lookup-self).
|
||||
func Login(rc ResolvedContext, secret string) (*Token, error) {
|
||||
if IsTokenMethod(rc.Method) {
|
||||
return loginWithToken(rc, secret)
|
||||
}
|
||||
if !NeedsPassword(rc.Method) {
|
||||
return nil, fmt.Errorf("unsupported auth method %q", rc.Method)
|
||||
}
|
||||
if rc.User == "" {
|
||||
return nil, fmt.Errorf("context %q: no user for %s login", rc.Name, rc.Method)
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("auth/%s/login/%s", rc.Path, rc.User)
|
||||
data, err := doRequest(http.MethodPost, rc.Address, path, rc.Namespace, "", map[string]string{"password": secret})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ar authResponse
|
||||
if err := json.Unmarshal(data, &ar); err != nil {
|
||||
return nil, fmt.Errorf("decoding login response: %w", err)
|
||||
}
|
||||
if ar.Auth.ClientToken == "" {
|
||||
return nil, fmt.Errorf("login for context %q returned no token", rc.Name)
|
||||
}
|
||||
return tokenFromAuth(rc, ar), nil
|
||||
}
|
||||
|
||||
// loginWithToken adopts a raw client token, verifying it and filling in details
|
||||
// via /auth/token/lookup-self.
|
||||
func loginWithToken(rc ResolvedContext, token string) (*Token, error) {
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("context %q: empty token", rc.Name)
|
||||
}
|
||||
data, err := doRequest(http.MethodGet, rc.Address, "auth/token/lookup-self", rc.Namespace, token, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lr lookupResponse
|
||||
if err := json.Unmarshal(data, &lr); err != nil {
|
||||
return nil, fmt.Errorf("decoding token lookup: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
t := &Token{
|
||||
Context: rc.Name,
|
||||
Address: rc.Address,
|
||||
Namespace: rc.Namespace,
|
||||
Token: token,
|
||||
Accessor: lr.Data.Accessor,
|
||||
Policies: lr.Data.Policies,
|
||||
Renewable: lr.Data.Renewable,
|
||||
LeaseDurationSeconds: lr.Data.TTL,
|
||||
IssuedAt: now,
|
||||
}
|
||||
if lr.Data.TTL > 0 {
|
||||
t.ExpiresAt = now.Add(time.Duration(lr.Data.TTL) * time.Second)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Renew renews the given cached token against its context and returns the
|
||||
// updated Token (new lease/expiry), preserving the accessor from the prior
|
||||
// token when the renew response omits it.
|
||||
func Renew(rc ResolvedContext, prev *Token) (*Token, error) {
|
||||
if prev == nil || prev.Token == "" {
|
||||
return nil, fmt.Errorf("context %q: no token to renew", rc.Name)
|
||||
}
|
||||
data, err := doRequest(http.MethodPost, rc.Address, "auth/token/renew-self", rc.Namespace, prev.Token, map[string]string{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ar authResponse
|
||||
if err := json.Unmarshal(data, &ar); err != nil {
|
||||
return nil, fmt.Errorf("decoding renew response: %w", err)
|
||||
}
|
||||
t := tokenFromAuth(rc, ar)
|
||||
// renew-self echoes the same client token; guard against an empty echo and
|
||||
// carry over the accessor if the response omitted it.
|
||||
if t.Token == "" {
|
||||
t.Token = prev.Token
|
||||
}
|
||||
if t.Accessor == "" {
|
||||
t.Accessor = prev.Accessor
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNeedsPasswordAndTokenMethod(t *testing.T) {
|
||||
for _, m := range []string{"ldap", "userpass", "okta", "radius"} {
|
||||
if !NeedsPassword(m) {
|
||||
t.Errorf("NeedsPassword(%q) = false, want true", m)
|
||||
}
|
||||
}
|
||||
if NeedsPassword("token") {
|
||||
t.Error("NeedsPassword(token) should be false")
|
||||
}
|
||||
if !IsTokenMethod("token") {
|
||||
t.Error("IsTokenMethod(token) should be true")
|
||||
}
|
||||
if IsTokenMethod("ldap") {
|
||||
t.Error("IsTokenMethod(ldap) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPasswordMethod(t *testing.T) {
|
||||
var gotPath, gotNS string
|
||||
var gotBody map[string]string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotNS = r.Header.Get("X-Vault-Namespace")
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &gotBody)
|
||||
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.tok","accessor":"acc","token_policies":["default","kv"],"lease_duration":3600,"renewable":true}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rc := ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben", Namespace: "team-a"}
|
||||
tok, err := Login(rc, "hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if gotPath != "/v1/auth/ldap/login/ben" {
|
||||
t.Errorf("login path = %q", gotPath)
|
||||
}
|
||||
if gotNS != "team-a" {
|
||||
t.Errorf("namespace header = %q", gotNS)
|
||||
}
|
||||
if gotBody["password"] != "hunter2" {
|
||||
t.Errorf("password body = %v", gotBody)
|
||||
}
|
||||
if tok.Token != "s.tok" || tok.Accessor != "acc" || !tok.Renewable {
|
||||
t.Errorf("token = %+v", tok)
|
||||
}
|
||||
if len(tok.Policies) != 2 || tok.Policies[0] != "default" {
|
||||
t.Errorf("policies = %v", tok.Policies)
|
||||
}
|
||||
if tok.ExpiresAt.IsZero() {
|
||||
t.Error("ExpiresAt should be set from lease_duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCustomAuthPath(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.x","lease_duration":60}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rc := ResolvedContext{Name: "legacy", Address: srv.URL, Method: "userpass", Path: "userpass2", User: "svc"}
|
||||
if _, err := Login(rc, "pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPath != "/v1/auth/userpass2/login/svc" {
|
||||
t.Errorf("login path = %q, want custom auth path", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenMethodUsesLookupSelf(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/auth/token/lookup-self" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Vault-Token") != "s.raw" {
|
||||
t.Errorf("token header = %q", r.Header.Get("X-Vault-Token"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"data":{"accessor":"acc2","policies":["root"],"ttl":0,"renewable":false}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rc := ResolvedContext{Name: "root-ctx", Address: srv.URL, Method: "token"}
|
||||
tok, err := Login(rc, "s.raw")
|
||||
if err != nil {
|
||||
t.Fatalf("Login(token): %v", err)
|
||||
}
|
||||
if tok.Token != "s.raw" || tok.Accessor != "acc2" {
|
||||
t.Errorf("token = %+v", tok)
|
||||
}
|
||||
if !tok.ExpiresAt.IsZero() {
|
||||
t.Error("ttl=0 should leave ExpiresAt zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPreservesAccessor(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/auth/token/renew-self" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
// renew response omits accessor; Renew should carry it from prev.
|
||||
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.tok","lease_duration":7200,"renewable":true}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rc := ResolvedContext{Name: "sydney", Address: srv.URL}
|
||||
prev := &Token{Context: "sydney", Address: srv.URL, Token: "s.tok", Accessor: "acc-prev"}
|
||||
tok, err := Renew(rc, prev)
|
||||
if err != nil {
|
||||
t.Fatalf("Renew: %v", err)
|
||||
}
|
||||
if tok.Accessor != "acc-prev" {
|
||||
t.Errorf("accessor = %q, want carried-over acc-prev", tok.Accessor)
|
||||
}
|
||||
if tok.LeaseDurationSeconds != 7200 {
|
||||
t.Errorf("lease = %d, want 7200", tok.LeaseDurationSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSurfacesVaultError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"errors":["ldap operation failed"]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rc := ResolvedContext{Name: "sydney", Address: srv.URL, Method: "ldap", Path: "ldap", User: "ben"}
|
||||
_, err := Login(rc, "bad")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ldap operation failed") {
|
||||
t.Errorf("error should surface vault message, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user