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.
This commit is contained in:
@@ -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/<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
|
||||
}
|
||||
Reference in New Issue
Block a user