Files
unkin-agent 61bb464e32
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-08-29 21:03:31 +10:00

99 lines
3.5 KiB
Go

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
}