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.
94 lines
2.9 KiB
Go
94 lines
2.9 KiB
Go
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/<identifier>/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
|
|
}
|