diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 22841c0..3d89a92 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -17,7 +17,7 @@ steps: memory: 2Gi cpu: 2 - # Build both binaries into dist/ (consumed by the RPM step) plus the + # Build every binary into dist/ (consumed by the RPM step) plus the # cross-platform binaries attached to the Gitea release. Each tool is a # separate main package, so they are built individually per os/arch. - name: build @@ -28,7 +28,7 @@ steps: # for the shell instead of substituting them (as pipeline vars) at parse # time. ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$. - | - for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr" "agentws:./cmd/agentws"; do + for entry in "agentpr:./cmd/agentpr" "watchpr:./cmd/watchpr" "agentws:./cmd/agentws" "agentvault:./cmd/agentvault"; do name="$${entry%%:*}"; pkg="$${entry##*:}" for osarch in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do os="$${osarch%/*}"; arch="$${osarch#*/}" @@ -135,7 +135,7 @@ steps: # root; the package step writes the RPM to dist/. Generate a checksums # manifest over everything we attach so downloads can be verified. RPM=$$(ls dist/*.rpm 2>/dev/null | head -1) - ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64 agentws-linux-amd64 agentws-linux-arm64 agentws-darwin-amd64 agentws-darwin-arm64" + ASSETS="agentpr-linux-amd64 agentpr-linux-arm64 agentpr-darwin-amd64 agentpr-darwin-arm64 watchpr-linux-amd64 watchpr-linux-arm64 watchpr-darwin-amd64 watchpr-darwin-arm64 agentws-linux-amd64 agentws-linux-arm64 agentws-darwin-amd64 agentws-darwin-arm64 agentvault-linux-amd64 agentvault-linux-arm64 agentvault-darwin-amd64 agentvault-darwin-arm64" [ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM" sha256sum $$ASSETS > sha256sums.txt tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \ diff --git a/AGENTS.md b/AGENTS.md index c6b218c..e30517d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ parsing, watch-state comparison, git worktree helpers). cmd/agentpr/main.go # agentpr CLI (pr create / pr comment / whoami) cmd/watchpr/main.go # watchpr CLI (poll + meaningful-change exit) cmd/agentws/main.go # agentws CLI (new / list / rm / clean / token / credential) +cmd/agentvault/main.go # agentvault CLI (seed-outpost) internal/agent/ # shared plumbing: token.go # env config + in-process Gitea-token cache vault.go # AppRole login + read gitea/creds/unkin-agent @@ -36,6 +37,9 @@ internal/agent/ # shared plumbing: parse.go # owner/repo#N and owner/repo parsing watch.go # PRState snapshot + MeaningfulChange comparison git.go # git worktree/clone/fetch helpers (os/exec, no go-git) + vaultkv.go # AppRole-authenticated Vault client + KV-v2 read/write + authentik.go # Authentik REST client (outpost search, token view_key) + seedoutpost.go # seed-outpost flow (Authentik token -> Vault KV) go.mod # module git.unkin.net/unkin/agent-tools Makefile # build / test / lint / completions / rpm / version-bump packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM (all binaries) @@ -66,6 +70,7 @@ Config via env (all have defaults): | `AGENTWS_SRC_ROOT` | `~/src/prodenv` | agentws source-of-truth checkout root | | `AGENTWS_ROOT` | `~/.cache/agentws` | agentws worktree root | | `AGENTWS_OWNER` | `unkin` | Gitea org that owns agentws-managed repos | +| `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) | ### agentws git auth (ephemeral credential helper) @@ -85,7 +90,7 @@ to keep the default branch current. ## Build ```bash -make build # -> dist/agentpr, dist/watchpr, dist/agentws (CGO disabled, static) +make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault (CGO disabled, static) ``` Requires Go 1.21+. Dependency: `github.com/spf13/cobra` (CLI). @@ -97,8 +102,8 @@ make rpm # build all binaries + package into dist/*.rpm via nfpm ``` `scripts/build-rpm.sh` generates bash/zsh/fish completions from the built -binaries and bundles them alongside `/usr/bin/agentpr`, `/usr/bin/watchpr` and -`/usr/bin/agentws`. +binaries and bundles them alongside `/usr/bin/agentpr`, `/usr/bin/watchpr`, +`/usr/bin/agentws` and `/usr/bin/agentvault`. On a `v*` tag the release pipeline builds the RPM and `PUT`s it to the artifactapi `rpm-internal` repo, then cuts a Gitea release. @@ -120,10 +125,30 @@ alerting transitions), request-body construction, and the Vault+Gitea client against `httptest` servers (fake AppRole login + gitea creds + PR create / comment / whoami / status). No live Vault/Gitea access is required for tests. +## agentvault seed-outpost + +`agentvault seed-outpost --outpost --dest-path ` does the whole +flow in-process: + +1. AppRole login (shared `approleLogin`), then KV-v2 read of + `kv/service/authentik/agent-api-token` (field `token`, falling back to + `api_token`). +2. `GET /api/v3/outposts/instances/?search=` — Authentik's `search` is a + substring match, so the exact `name` is re-checked client-side. +3. `GET /api/v3/core/tokens//view_key/` for the key. +4. KV-v2 write to `--dest-path` under `--dest-key` (default `token`). + +Only the outpost name, token identifier, dest path and new KV version are +printed. Errors are wrapped per stage (login / read denied / outpost missing / +view_key / write denied) via the `ErrVaultDenied`, `ErrVaultNotFound` and +`ErrOutpostNotFound` sentinels. + ## Gotchas - `watchpr` exits 0 with no output changes on `--once` (just prints state). - The token cache is process-wide (`sync.Once`); tests call the unexported `fetchGiteaToken` to avoid it. +- `agentvault` never puts a secret in an error string: Vault decode failures and + Authentik `view_key` responses are reported without their bodies. - CI "combined status" comes from `/commits/{sha}/status`; an empty head SHA yields an empty state without an API call. diff --git a/Makefile b/Makefile index 0ba2c0a..0a253d1 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # All shipped binaries and the package path each is built from. Both tools live # under cmd/; the module root ships no binary of its own. -BINARIES := agentpr watchpr agentws +BINARIES := agentpr watchpr agentws agentvault DIST := dist VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" diff --git a/README.md b/README.md index 831eaf5..91c05b7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ happens to run the command. acting on. - **`agentws`** — manage per-branch git worktrees for `unkin-agent`, cloning into Ben's source checkout and isolating agent work under the XDG cache. +- **`agentvault`** — run deterministic Vault flows in one invocation, so agents + never plumb secret material through a shell. ## How it gets a token @@ -28,6 +30,7 @@ Everything is configured by environment variables, all with defaults: | `AGENTWS_SRC_ROOT` | `~/src/prodenv` | source-of-truth checkout root (`agentws`) | | `AGENTWS_ROOT` | `~/.cache/agentws` | worktree root (`agentws`) | | `AGENTWS_OWNER` | `unkin` | Gitea org that owns the repos (`agentws`) | +| `AUTHENTIK_URL` | `https://identity.k8s.syd1.au.unkin.net` | Authentik base URL (`agentvault`) | ## agentpr @@ -121,10 +124,45 @@ the **per-worktree** config. Clone/fetch use the same helper via a transient removal `agentws` fetches in `~/src/prodenv/` so its default branch stays current. +## agentvault + +Deterministic Vault flows, each a single self-contained invocation: the tool +reads and writes the secrets itself, and prints only identifiers. + +### seed-outpost + +Copy an Authentik outpost's token into Vault KV-v2. `agentvault` reads the +Authentik API token from `kv/service/authentik/agent-api-token`, resolves the +named outpost's `token_identifier`, fetches its key via +`/api/v3/core/tokens//view_key/` and writes it to the destination +KV path. The token value is never printed or logged. + +```bash +agentvault seed-outpost \ + --outpost k8s-outpost \ + --dest-path kubernetes/namespace/authentik/default/outpost-token +``` + +``` +outpost: k8s-outpost +token_identifier: ak-outpost-k8s-outpost +dest: kv/kubernetes/namespace/authentik/default/outpost-token +version: 3 +``` + +Re-running is safe: it writes a new KV version. Flags: `--outpost` and +`--dest-path` are required; `--dest-key` (default `token`), `--kv-mount` +(default `kv`), `--token-path` (default `service/authentik/agent-api-token`) and +`--authentik-url` override the rest. + +Errors name the failing stage: AppRole login, KV read denied (policy not +applied), outpost not found (terraform not applied), `view_key` failure, or KV +write denied. + ## Build & package ```bash -make build # -> dist/agentpr, dist/watchpr, dist/agentws +make build # -> dist/agentpr, dist/watchpr, dist/agentws, dist/agentvault make test # go test -race ./... make rpm # build + package dist/agent-tools--1.x86_64.rpm ``` diff --git a/cmd/agentvault/main.go b/cmd/agentvault/main.go new file mode 100644 index 0000000..72fab30 --- /dev/null +++ b/cmd/agentvault/main.go @@ -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 --dest-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, + } +} diff --git a/cmd/agentvault/main_test.go b/cmd/agentvault/main_test.go new file mode 100644 index 0000000..7a027c1 --- /dev/null +++ b/cmd/agentvault/main_test.go @@ -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") + } + }) + } +} diff --git a/internal/agent/authentik.go b/internal/agent/authentik.go new file mode 100644 index 0000000..29a9c6f --- /dev/null +++ b/internal/agent/authentik.go @@ -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//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 +} diff --git a/internal/agent/seedoutpost.go b/internal/agent/seedoutpost.go new file mode 100644 index 0000000..8fa89f0 --- /dev/null +++ b/internal/agent/seedoutpost.go @@ -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 +} diff --git a/internal/agent/seedoutpost_test.go b/internal/agent/seedoutpost_test.go new file mode 100644 index 0000000..055db41 --- /dev/null +++ b/internal/agent/seedoutpost_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/agent/token.go b/internal/agent/token.go index b2ef3f6..9ebca7b 100644 --- a/internal/agent/token.go +++ b/internal/agent/token.go @@ -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 diff --git a/internal/agent/vaultkv.go b/internal/agent/vaultkv.go new file mode 100644 index 0000000..5587a67 --- /dev/null +++ b/internal/agent/vaultkv.go @@ -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 "" +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index fe20d22..d4b94da 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -42,6 +42,12 @@ contents: mode: 0755 owner: root group: root + - src: dist/agentvault + dst: /usr/bin/agentvault + file_info: + mode: 0755 + owner: root + group: root # Shell completions (generated by scripts/build-rpm.sh before packaging). - src: dist/completions/agentpr.bash @@ -80,3 +86,15 @@ contents: dst: /usr/share/fish/vendor_completions.d/agentws.fish file_info: mode: 0644 + - src: dist/completions/agentvault.bash + dst: /usr/share/bash-completion/completions/agentvault + file_info: + mode: 0644 + - src: dist/completions/_agentvault + dst: /usr/share/zsh/site-functions/_agentvault + file_info: + mode: 0644 + - src: dist/completions/agentvault.fish + dst: /usr/share/fish/vendor_completions.d/agentvault.fish + file_info: + mode: 0644 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index 588f6e0..bc445c5 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Package the (already built) agentpr, watchpr and agentws binaries into an RPM +# Package the (already built) agentpr, watchpr, agentws and agentvault binaries into an RPM # with nfpm, bundling generated bash/zsh/fish shell completions. # Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG) # @@ -12,7 +12,7 @@ cd "${ROOT_DIR}" VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}" VERSION="${VERSION#v}" # strip a leading v PACKAGE="agent-tools" -BINARIES=(agentpr watchpr agentws) +BINARIES=(agentpr watchpr agentws agentvault) DIST="dist" for b in "${BINARIES[@]}"; do @@ -37,7 +37,7 @@ export PACKAGE_VERSION="${VERSION}" export PACKAGE_RELEASE="1" export PACKAGE_ARCH="amd64" export PACKAGE_PLATFORM="linux" -export PACKAGE_DESCRIPTION="CLI tools for Gitea automation as the unkin-agent user: agentpr (create PRs/comments), watchpr (poll PRs and alert on meaningful change) and agentws (manage per-branch git worktrees)" +export PACKAGE_DESCRIPTION="CLI tools for automation as the unkin-agent user: agentpr (create PRs/comments), watchpr (poll PRs and alert on meaningful change), agentws (manage per-branch git worktrees) and agentvault (deterministic Vault flows)" export PACKAGE_MAINTAINER="Ben Vincent " export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/agent-tools" export PACKAGE_LICENSE="MIT"