Add agentvault with a seed-outpost subcommand
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Interactive agents are classifier-blocked from plumbing credentials through
a shell, so seeding an Authentik outpost token into Vault KV needs to happen
inside one binary invocation that never exposes the secret.

- Add cmd/agentvault, a fourth CLI sharing the agentpr Vault AppRole login
  (role_id only, VAULT_ADDR/AGENT_APPROLE_ROLE_ID defaults unchanged).
- Add `agentvault seed-outpost`: read the Authentik API token from
  kv/service/authentik/agent-api-token (field `token`, falling back to
  `api_token`), exact-match the outpost by name via the instances search,
  fetch its key from /api/v3/core/tokens/<identifier>/view_key/ and write it
  to --dest-path under --dest-key.
- Print only the outpost name, token identifier, dest path and new KV
  version; keep secret material out of results, errors and logs.
- Distinguish the failure stages (login, KV read denied, outpost missing,
  view_key, KV write denied) with ErrVaultDenied/ErrVaultNotFound/
  ErrOutpostNotFound sentinels and actionable messages.
- Add internal/agent vaultkv.go (AppRole-authenticated KV-v2 client) and
  authentik.go (outpost search + view_key) for reuse by future flows.
- Cover the happy path, idempotent re-run, field fallback and every failure
  mode with httptest servers, including a leak check on error strings.
- Wire agentvault into the Makefile, build-rpm.sh, nfpm contents, release
  cross-builds/assets, README and AGENTS.md.
This commit is contained in:
2026-08-29 21:03:31 +10:00
parent 60b08f1198
commit 61bb464e32
13 changed files with 982 additions and 16 deletions
+93
View File
@@ -0,0 +1,93 @@
package agent
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// ErrOutpostNotFound marks a search that returned no exactly-named outpost.
var ErrOutpostNotFound = errors.New("outpost not found")
// AuthentikClient talks to the Authentik REST API with a bearer API token. The
// internal CA is in the OS trust store, so the default transport suffices.
type AuthentikClient struct {
BaseURL string
Token string
HTTP *http.Client
}
// NewAuthentikClient builds a client for the given Authentik base URL.
func NewAuthentikClient(baseURL, token string) *AuthentikClient {
return &AuthentikClient{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: httpClient}
}
// Outpost is the subset of Authentik's outpost object we need.
type Outpost struct {
PK string `json:"pk"`
Name string `json:"name"`
TokenIdentifier string `json:"token_identifier"`
}
// get issues an authenticated GET and decodes into out. Error text never
// includes a successful response body, which may carry key material.
func (c *AuthentikClient) get(path string, out any) error {
req, err := http.NewRequest(http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("authentik GET %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("authentik GET %s: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("authentik GET %s: decoding response: %w", path, err)
}
return nil
}
// FindOutpost searches outpost instances and returns the one whose name matches
// exactly (search is a substring match, so the exact name is re-checked here).
func (c *AuthentikClient) FindOutpost(name string) (Outpost, error) {
var out struct {
Results []Outpost `json:"results"`
}
path := "/api/v3/outposts/instances/?search=" + url.QueryEscape(name)
if err := c.get(path, &out); err != nil {
return Outpost{}, err
}
for _, o := range out.Results {
if o.Name == name {
return o, nil
}
}
return Outpost{}, fmt.Errorf("authentik outpost %q: %w (searched %d result(s))", name, ErrOutpostNotFound, len(out.Results))
}
// TokenKey returns the key behind a token identifier
// (GET /api/v3/core/tokens/<identifier>/view_key/).
func (c *AuthentikClient) TokenKey(identifier string) (string, error) {
var out struct {
Key string `json:"key"`
}
path := "/api/v3/core/tokens/" + url.PathEscape(identifier) + "/view_key/"
if err := c.get(path, &out); err != nil {
return "", err
}
if out.Key == "" {
return "", fmt.Errorf("authentik view_key for %q: response has no key field", identifier)
}
return out.Key, nil
}
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"errors"
"fmt"
)
const (
// DefaultOutpostTokenPath is the KV-v2 path holding the Authentik API token
// the agent uses to read outpost tokens.
DefaultOutpostTokenPath = "service/authentik/agent-api-token"
// DefaultDestKey is the KV field the outpost token is written to.
DefaultDestKey = "token"
)
// SeedOutpostOptions configures SeedOutpost. Every field is required; the CLI
// supplies the defaults.
type SeedOutpostOptions struct {
VaultAddr string
RoleID string
AuthentikURL string
Outpost string
KVMount string
TokenPath string
DestPath string
DestKey string
}
// SeedOutpostResult is the non-secret summary of a successful seed.
type SeedOutpostResult struct {
Outpost string
TokenIdentifier string
KVMount string
DestPath string
Version int
}
// SeedOutpost copies an Authentik outpost's token into Vault KV-v2. It reads an
// Authentik API token from Vault, resolves the outpost's token identifier,
// fetches the key and writes it to the destination path. The token value never
// leaves this function: results and errors carry only identifiers.
func SeedOutpost(o SeedOutpostOptions) (SeedOutpostResult, error) {
var res SeedOutpostResult
vc, err := NewVaultClient(o.VaultAddr, o.RoleID)
if err != nil {
return res, fmt.Errorf("vault approle login failed against %s (check VAULT_ADDR and AGENT_APPROLE_ROLE_ID): %w", o.VaultAddr, err)
}
secret, err := vc.ReadKV(o.KVMount, o.TokenPath)
if err != nil {
switch {
case errors.Is(err, ErrVaultDenied):
return res, fmt.Errorf("reading %s/%s denied: the agent AppRole policy does not grant read on this path (apply the terraform-vault policy change): %w", o.KVMount, o.TokenPath, err)
case errors.Is(err, ErrVaultNotFound):
return res, fmt.Errorf("secret %s/%s does not exist: seed the Authentik API token there first: %w", o.KVMount, o.TokenPath, err)
}
return res, fmt.Errorf("reading %s/%s: %w", o.KVMount, o.TokenPath, err)
}
apiToken := StringField(secret, "token", "api_token")
if apiToken == "" {
return res, fmt.Errorf("secret %s/%s has neither a 'token' nor an 'api_token' field", o.KVMount, o.TokenPath)
}
ac := NewAuthentikClient(o.AuthentikURL, apiToken)
outpost, err := ac.FindOutpost(o.Outpost)
if err != nil {
if errors.Is(err, ErrOutpostNotFound) {
return res, fmt.Errorf("no outpost named %q at %s: has the terraform-authentik outpost been applied?: %w", o.Outpost, o.AuthentikURL, err)
}
return res, fmt.Errorf("looking up outpost %q: %w", o.Outpost, err)
}
if outpost.TokenIdentifier == "" {
return res, fmt.Errorf("outpost %q has an empty token_identifier", outpost.Name)
}
key, err := ac.TokenKey(outpost.TokenIdentifier)
if err != nil {
return res, fmt.Errorf("fetching the key for token identifier %q (the API token needs view_key on it): %w", outpost.TokenIdentifier, err)
}
version, err := vc.WriteKV(o.KVMount, o.DestPath, map[string]string{o.DestKey: key})
if err != nil {
if errors.Is(err, ErrVaultDenied) {
return res, fmt.Errorf("writing %s/%s denied: the agent AppRole policy does not grant create/update on this path (apply the terraform-vault policy change): %w", o.KVMount, o.DestPath, err)
}
return res, fmt.Errorf("writing %s/%s: %w", o.KVMount, o.DestPath, err)
}
return SeedOutpostResult{
Outpost: outpost.Name,
TokenIdentifier: outpost.TokenIdentifier,
KVMount: o.KVMount,
DestPath: o.DestPath,
Version: version,
}, nil
}
+369
View File
@@ -0,0 +1,369 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
const (
testAPIToken = "ak-api-token-secret"
testOutpostKey = "outpost-key-secret"
testTokenPath = "service/authentik/agent-api-token"
testDestPath = "kubernetes/namespace/authentik/default/outpost-token"
testOutpostName = "k8s-outpost"
testTokenIdent = "ak-outpost-k8s-outpost"
testVaultClientT = "s.vaulttoken"
)
// vaultStub is a KV-v2 stand-in whose per-path behaviour tests can override.
type vaultStub struct {
readStatus int
writeStatus int
tokenField string // field name the API token is stored under
writes []map[string]string
writeCount int
}
func newVaultStub() *vaultStub {
return &vaultStub{readStatus: http.StatusOK, writeStatus: http.StatusOK, tokenField: "token"}
}
func (v *vaultStub) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["secret_id"]; ok {
t.Errorf("secret_id must not be sent")
}
_, _ = io.WriteString(w, `{"auth":{"client_token":"`+testVaultClientT+`"}}`)
})
mux.HandleFunc("/v1/kv/data/"+testTokenPath, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-Vault-Token"); got != testVaultClientT {
t.Errorf("X-Vault-Token = %q, want %q", got, testVaultClientT)
}
if v.readStatus != http.StatusOK {
w.WriteHeader(v.readStatus)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
return
}
_, _ = io.WriteString(w, `{"data":{"data":{"`+v.tokenField+`":"`+testAPIToken+`"},"metadata":{"version":1}}}`)
})
mux.HandleFunc("/v1/kv/data/"+testDestPath, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("write method = %s, want POST", r.Method)
}
if v.writeStatus != http.StatusOK {
w.WriteHeader(v.writeStatus)
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
return
}
var body struct {
Data map[string]string `json:"data"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
v.writes = append(v.writes, body.Data)
v.writeCount++
_, _ = io.WriteString(w, `{"data":{"version":`+strconv.Itoa(v.writeCount+2)+`}}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// authentikStub serves the outpost search and view_key endpoints.
type authentikStub struct {
results string // JSON array body for .results
viewKeyStatus int
viewKeyBody string
sawBearer string
sawSearchQuery string
}
func newAuthentikStub() *authentikStub {
return &authentikStub{
results: `{"pk":"1","name":"` + testOutpostName + `","token_identifier":"` + testTokenIdent + `"}`,
viewKeyStatus: http.StatusOK,
viewKeyBody: `{"key":"` + testOutpostKey + `"}`,
}
}
func (a *authentikStub) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v3/outposts/instances/", func(w http.ResponseWriter, r *http.Request) {
a.sawBearer = r.Header.Get("Authorization")
a.sawSearchQuery = r.URL.Query().Get("search")
_, _ = io.WriteString(w, `{"results":[`+a.results+`]}`)
})
mux.HandleFunc("/api/v3/core/tokens/"+testTokenIdent+"/view_key/", func(w http.ResponseWriter, r *http.Request) {
if a.viewKeyStatus != http.StatusOK {
w.WriteHeader(a.viewKeyStatus)
_, _ = io.WriteString(w, `{"detail":"boom"}`)
return
}
_, _ = io.WriteString(w, a.viewKeyBody)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func opts(vaultURL, authentikURL string) SeedOutpostOptions {
return SeedOutpostOptions{
VaultAddr: vaultURL,
RoleID: "role-xyz",
AuthentikURL: authentikURL,
Outpost: testOutpostName,
KVMount: DefaultKVMount,
TokenPath: testTokenPath,
DestPath: testDestPath,
DestKey: DefaultDestKey,
}
}
func TestSeedOutpostHappyPath(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
res, err := SeedOutpost(opts(vs.URL, as.URL))
if err != nil {
t.Fatalf("SeedOutpost: %v", err)
}
if res.Outpost != testOutpostName {
t.Errorf("Outpost = %q, want %q", res.Outpost, testOutpostName)
}
if res.TokenIdentifier != testTokenIdent {
t.Errorf("TokenIdentifier = %q, want %q", res.TokenIdentifier, testTokenIdent)
}
if res.DestPath != testDestPath || res.KVMount != DefaultKVMount {
t.Errorf("dest = %s/%s, want kv/%s", res.KVMount, res.DestPath, testDestPath)
}
if res.Version != 3 {
t.Errorf("Version = %d, want 3", res.Version)
}
if a.sawBearer != "Bearer "+testAPIToken {
t.Errorf("Authorization = %q, want the API token as a bearer", a.sawBearer)
}
if a.sawSearchQuery != testOutpostName {
t.Errorf("search = %q, want %q", a.sawSearchQuery, testOutpostName)
}
if len(v.writes) != 1 || v.writes[0][DefaultDestKey] != testOutpostKey {
t.Fatalf("written data = %v, want {%s: outpost key}", v.writes, DefaultDestKey)
}
}
// Re-running writes a new KV version rather than failing.
func TestSeedOutpostIdempotentNewVersion(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
first, err := SeedOutpost(opts(vs.URL, as.URL))
if err != nil {
t.Fatalf("first SeedOutpost: %v", err)
}
second, err := SeedOutpost(opts(vs.URL, as.URL))
if err != nil {
t.Fatalf("second SeedOutpost: %v", err)
}
if second.Version != first.Version+1 {
t.Errorf("versions = %d then %d, want consecutive", first.Version, second.Version)
}
}
// The API token may be stored under api_token instead of token.
func TestSeedOutpostAPITokenFallbackField(t *testing.T) {
v := newVaultStub()
v.tokenField = "api_token"
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
if _, err := SeedOutpost(opts(vs.URL, as.URL)); err != nil {
t.Fatalf("SeedOutpost with api_token field: %v", err)
}
if a.sawBearer != "Bearer "+testAPIToken {
t.Errorf("Authorization = %q, want the api_token value", a.sawBearer)
}
}
func TestSeedOutpostCustomDestKey(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
o := opts(vs.URL, as.URL)
o.DestKey = "outpost-token"
if _, err := SeedOutpost(o); err != nil {
t.Fatalf("SeedOutpost: %v", err)
}
if len(v.writes) != 1 || v.writes[0]["outpost-token"] != testOutpostKey {
t.Errorf("written data = %v, want the key under outpost-token", v.writes)
}
}
func TestSeedOutpostLoginFailure(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"errors":["invalid role ID"]}`)
})
vs := httptest.NewServer(mux)
defer vs.Close()
a := newAuthentikStub()
_, err := SeedOutpost(opts(vs.URL, a.server(t).URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an approle login error")
}
if !strings.Contains(err.Error(), "approle login failed") {
t.Errorf("error = %v, want it to name the approle login", err)
}
}
func TestSeedOutpostKVReadDenied(t *testing.T) {
v := newVaultStub()
v.readStatus = http.StatusForbidden
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a KV read error")
}
msg := err.Error()
if !strings.Contains(msg, testTokenPath) || !strings.Contains(msg, "policy") {
t.Errorf("error = %v, want it to name the token path and point at the policy", err)
}
if strings.Contains(msg, "does not exist") {
t.Errorf("error = %v, denied must not be reported as missing", err)
}
}
func TestSeedOutpostKVReadNotFound(t *testing.T) {
v := newVaultStub()
v.readStatus = http.StatusNotFound
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a missing-secret error")
}
if !strings.Contains(err.Error(), "does not exist") {
t.Errorf("error = %v, want it to say the secret does not exist", err)
}
}
func TestSeedOutpostMissingTokenField(t *testing.T) {
v := newVaultStub()
v.tokenField = "password"
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an error for a secret with no token field")
}
if !strings.Contains(err.Error(), "api_token") {
t.Errorf("error = %v, want it to name the accepted fields", err)
}
}
// A substring hit that is not the exact name must not be accepted.
func TestSeedOutpostNotFound(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
a.results = `{"pk":"1","name":"` + testOutpostName + `-staging","token_identifier":"other"}`
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an outpost-not-found error")
}
msg := err.Error()
if !strings.Contains(msg, "no outpost named") || !strings.Contains(msg, "terraform") {
t.Errorf("error = %v, want it to report the missing outpost and mention terraform", err)
}
if len(v.writes) != 0 {
t.Errorf("wrote %v, want no KV write when the outpost is missing", v.writes)
}
}
func TestSeedOutpostViewKeyFailure(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
a.viewKeyStatus = http.StatusForbidden
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a view_key error")
}
if !strings.Contains(err.Error(), testTokenIdent) {
t.Errorf("error = %v, want it to name the token identifier", err)
}
if len(v.writes) != 0 {
t.Errorf("wrote %v, want no KV write when view_key fails", v.writes)
}
}
func TestSeedOutpostViewKeyEmpty(t *testing.T) {
v := newVaultStub()
a := newAuthentikStub()
a.viewKeyBody = `{}`
vs, as := v.server(t), a.server(t)
if _, err := SeedOutpost(opts(vs.URL, as.URL)); err == nil {
t.Fatal("SeedOutpost() = nil, want an error when view_key returns no key")
}
}
func TestSeedOutpostKVWriteDenied(t *testing.T) {
v := newVaultStub()
v.writeStatus = http.StatusForbidden
a := newAuthentikStub()
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want a KV write error")
}
msg := err.Error()
if !strings.Contains(msg, testDestPath) || !strings.Contains(msg, "create/update") {
t.Errorf("error = %v, want it to name the dest path and the missing capability", err)
}
}
// No failure path may leak the API token or the outpost key into the error.
func TestSeedOutpostErrorsNeverLeakSecrets(t *testing.T) {
cases := map[string]func(*vaultStub, *authentikStub){
"read denied": func(v *vaultStub, a *authentikStub) { v.readStatus = http.StatusForbidden },
"write denied": func(v *vaultStub, a *authentikStub) { v.writeStatus = http.StatusForbidden },
"view_key fail": func(v *vaultStub, a *authentikStub) { a.viewKeyStatus = http.StatusInternalServerError },
"outpost gone": func(v *vaultStub, a *authentikStub) { a.results = "" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
v, a := newVaultStub(), newAuthentikStub()
mutate(v, a)
vs, as := v.server(t), a.server(t)
_, err := SeedOutpost(opts(vs.URL, as.URL))
if err == nil {
t.Fatal("SeedOutpost() = nil, want an error")
}
for _, secret := range []string{testAPIToken, testOutpostKey} {
if strings.Contains(err.Error(), secret) {
t.Errorf("error %q leaks a secret", err)
}
}
})
}
}
+17 -5
View File
@@ -1,8 +1,8 @@
// Package agent holds the plumbing shared by the agent-tools CLIs (agentpr and
// watchpr): obtaining a Gitea token via Vault AppRole, talking to the Gitea
// API, parsing PR references, and deciding when a watched PR changed
// meaningfully. Both tools acquire their Gitea token the same way, so that
// logic lives here once.
// Package agent holds the plumbing shared by the agent-tools CLIs (agentpr,
// watchpr, agentws and agentvault): the Vault AppRole login and its KV-v2
// client, talking to the Gitea and Authentik APIs, parsing PR references, and
// deciding when a watched PR changed meaningfully. Every tool authenticates to
// Vault the same way, so that logic lives here once.
package agent
import (
@@ -23,6 +23,9 @@ const (
// DefaultAgentLogin is the Gitea login of the agent whose own comments are
// ignored by watchpr. Overridable via AGENT_LOGIN.
DefaultAgentLogin = "unkin-agent"
// DefaultAuthentikURL is the Authentik base URL used when AUTHENTIK_URL is
// unset. identity.unkin.net has no DNS record; the k8s name is the real one.
DefaultAuthentikURL = "https://identity.k8s.syd1.au.unkin.net"
)
// VaultAddr returns the configured Vault address (env VAULT_ADDR or the default).
@@ -59,6 +62,15 @@ func AgentLogin() string {
return DefaultAgentLogin
}
// AuthentikURL returns the configured Authentik base URL (env AUTHENTIK_URL or
// the default).
func AuthentikURL() string {
if v := os.Getenv("AUTHENTIK_URL"); v != "" {
return v
}
return DefaultAuthentikURL
}
var (
tokenOnce sync.Once
tokenValue string
+134
View File
@@ -0,0 +1,134 @@
package agent
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
const (
// DefaultKVMount is the KV-v2 mount holding agent-facing secrets.
DefaultKVMount = "kv"
)
var (
// ErrVaultDenied marks a 403 from Vault (the AppRole policy lacks the capability).
ErrVaultDenied = errors.New("permission denied")
// ErrVaultNotFound marks a 404 from Vault (mount or secret does not exist).
ErrVaultNotFound = errors.New("not found")
)
// VaultClient issues authenticated requests against Vault/OpenBao using a token
// obtained from the agent AppRole.
type VaultClient struct {
Addr string
Token string
HTTP *http.Client
}
// NewVaultClient performs the AppRole login (role_id only, no secret_id) and
// returns a client bound to the resulting client_token.
func NewVaultClient(addr, roleID string) (*VaultClient, error) {
token, err := approleLogin(addr, roleID)
if err != nil {
return nil, err
}
return &VaultClient{Addr: addr, Token: token, HTTP: httpClient}, nil
}
func (c *VaultClient) do(method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
url := strings.TrimRight(c.Addr, "/") + path
req, err := http.NewRequest(method, url, reader)
if err != nil {
return err
}
req.Header.Set("X-Vault-Token", c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("vault %s %s: %w", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
switch {
case resp.StatusCode == http.StatusForbidden:
return fmt.Errorf("vault %s %s: %w", method, path, ErrVaultDenied)
case resp.StatusCode == http.StatusNotFound:
return fmt.Errorf("vault %s %s: %w", method, path, ErrVaultNotFound)
case resp.StatusCode < 200 || resp.StatusCode >= 300:
return fmt.Errorf("vault %s %s: HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out != nil && len(data) > 0 {
// Response bodies here carry secret material, so decode failures never
// echo the body.
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("vault %s %s: decoding response: %w", method, path, err)
}
}
return nil
}
// kvDataPath builds the KV-v2 data path for a mount and secret path.
func kvDataPath(mount, path string) string {
return "/v1/" + strings.Trim(mount, "/") + "/data/" + strings.Trim(path, "/")
}
// ReadKV returns the data map of a KV-v2 secret.
func (c *VaultClient) ReadKV(mount, path string) (map[string]any, error) {
var out struct {
Data struct {
Data map[string]any `json:"data"`
} `json:"data"`
}
if err := c.do(http.MethodGet, kvDataPath(mount, path), nil, &out); err != nil {
return nil, err
}
if out.Data.Data == nil {
return nil, fmt.Errorf("vault read %s/%s: secret has no data", mount, path)
}
return out.Data.Data, nil
}
// WriteKV writes a KV-v2 secret and returns the version it created.
func (c *VaultClient) WriteKV(mount, path string, data map[string]string) (int, error) {
var out struct {
Data struct {
Version int `json:"version"`
} `json:"data"`
}
body := map[string]any{"data": data}
if err := c.do(http.MethodPost, kvDataPath(mount, path), body, &out); err != nil {
return 0, err
}
if out.Data.Version == 0 {
return 0, fmt.Errorf("vault write %s/%s: no version in response", mount, path)
}
return out.Data.Version, nil
}
// StringField returns the first non-empty string value among the given keys.
func StringField(data map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := data[k].(string); ok && s != "" {
return s
}
}
return ""
}