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
+83
View File
@@ -0,0 +1,83 @@
// Command agentvault runs deterministic Vault flows for agents in a single
// invocation, so credentials are never plumbed through a shell. It authenticates
// with the same Vault AppRole as agentpr (role_id only, no secret_id).
//
// agentvault seed-outpost --outpost <name> --dest-path <kv/path>
package main
import (
"fmt"
"os"
"git.unkin.net/unkin/agent-tools/internal/agent"
"github.com/spf13/cobra"
)
var version = "dev"
func main() {
if err := newRootCmd().Execute(); err != nil {
os.Exit(1)
}
}
// newRootCmd builds the agentvault command tree. Separated from main so tests
// can execute it against httptest servers.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "agentvault",
Short: "Run deterministic Vault flows as the agent AppRole.",
Long: "agentvault performs self-contained Vault flows for agents: it logs in with the\nagent AppRole and moves secret material between systems without ever printing it.",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newSeedOutpostCmd(), newVersionCmd())
return root
}
func newSeedOutpostCmd() *cobra.Command {
opts := agent.SeedOutpostOptions{}
cmd := &cobra.Command{
Use: "seed-outpost",
Short: "Copy an Authentik outpost token into Vault KV",
Long: "Read the Authentik API token from Vault KV, resolve the named outpost's\n" +
"token_identifier, fetch its key and write it to a Vault KV path. Re-running\n" +
"writes a new KV version. The token value is never printed or logged.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts.VaultAddr = agent.VaultAddr()
opts.RoleID = agent.RoleID()
res, err := agent.SeedOutpost(opts)
if err != nil {
return err
}
out := cmd.OutOrStdout()
_, _ = fmt.Fprintf(out, "outpost: %s\n", res.Outpost)
_, _ = fmt.Fprintf(out, "token_identifier: %s\n", res.TokenIdentifier)
_, _ = fmt.Fprintf(out, "dest: %s/%s\n", res.KVMount, res.DestPath)
_, _ = fmt.Fprintf(out, "version: %d\n", res.Version)
return nil
},
}
f := cmd.Flags()
f.StringVar(&opts.Outpost, "outpost", "", "Authentik outpost name (required)")
f.StringVar(&opts.DestPath, "dest-path", "", "KV-v2 path to write the token to, e.g. kubernetes/namespace/authentik/default/outpost-token (required)")
f.StringVar(&opts.DestKey, "dest-key", agent.DefaultDestKey, "Field to write the token under")
f.StringVar(&opts.KVMount, "kv-mount", agent.DefaultKVMount, "KV-v2 mount holding both the API token and the destination")
f.StringVar(&opts.TokenPath, "token-path", agent.DefaultOutpostTokenPath, "KV-v2 path of the Authentik API token")
f.StringVar(&opts.AuthentikURL, "authentik-url", agent.AuthentikURL(), "Authentik base URL")
_ = cmd.MarkFlagRequired("outpost")
_ = cmd.MarkFlagRequired("dest-path")
return cmd
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
}
+96
View File
@@ -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")
}
})
}
}