Add agentvault with a seed-outpost subcommand
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:
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
apiToken = "ak-api-token-secret"
|
||||
outpostKey = "outpost-key-secret"
|
||||
destPath = "kubernetes/namespace/authentik/default/outpost-token"
|
||||
)
|
||||
|
||||
// fakeEstate serves the Vault (approle + KV-v2 read/write) and Authentik
|
||||
// (outpost search + view_key) endpoints the seed flow needs.
|
||||
func fakeEstate(t *testing.T) (vaultURL, authentikURL string) {
|
||||
t.Helper()
|
||||
vmux := http.NewServeMux()
|
||||
vmux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
|
||||
})
|
||||
vmux.HandleFunc("/v1/kv/data/service/authentik/agent-api-token", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"data":{"data":{"token":"`+apiToken+`"}}}`)
|
||||
})
|
||||
vmux.HandleFunc("/v1/kv/data/"+destPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"data":{"version":7}}`)
|
||||
})
|
||||
vs := httptest.NewServer(vmux)
|
||||
t.Cleanup(vs.Close)
|
||||
|
||||
amux := http.NewServeMux()
|
||||
amux.HandleFunc("/api/v3/outposts/instances/", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"results":[{"pk":"1","name":"k8s-outpost","token_identifier":"ak-outpost-k8s"}]}`)
|
||||
})
|
||||
amux.HandleFunc("/api/v3/core/tokens/ak-outpost-k8s/view_key/", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"key":"`+outpostKey+`"}`)
|
||||
})
|
||||
as := httptest.NewServer(amux)
|
||||
t.Cleanup(as.Close)
|
||||
|
||||
return vs.URL, as.URL
|
||||
}
|
||||
|
||||
// The command prints identifiers and the KV version only — never a secret.
|
||||
func TestSeedOutpostOutputHasNoSecrets(t *testing.T) {
|
||||
vaultURL, authentikURL := fakeEstate(t)
|
||||
t.Setenv("VAULT_ADDR", vaultURL)
|
||||
t.Setenv("AGENT_APPROLE_ROLE_ID", "role-xyz")
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := newRootCmd()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{
|
||||
"seed-outpost",
|
||||
"--outpost", "k8s-outpost",
|
||||
"--dest-path", destPath,
|
||||
"--authentik-url", authentikURL,
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"k8s-outpost", "ak-outpost-k8s", "kv/" + destPath, "version: 7"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, secret := range []string{apiToken, outpostKey} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("output leaks a secret:\n%s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedOutpostRequiresFlags(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"no outpost": {"seed-outpost", "--dest-path", destPath},
|
||||
"no dest-path": {"seed-outpost", "--outpost", "k8s-outpost"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := newRootCmd()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("Execute() = nil, want a missing-required-flag error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user