61bb464e32
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.
99 lines
3.5 KiB
Go
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
|
|
}
|