Add agentvault seed-oauth for oauth2-proxy credentials
Seeding an oauth2-proxy secret by hand means an agent shell-plumbing a client secret and a cookie secret, which the classifier blocks. seed-oauth does it in one self-contained invocation: it reads the KV path, fills in only the keys that are missing, preserves everything else and prints key names and the new version, never a value. - Add SeedOAuth in internal/agent: read-modify-write of the client_id, client_secret and cookie_secret keys with per-key created/kept/rotated actions and a no-op when nothing changed. - Generate secrets from 32 crypto/rand bytes; cookie_secret is base64url so it decodes to exactly the 32 bytes oauth2-proxy requires. - Add ReadKVOptional (missing secret = empty) and WriteKVAny (non-string fields survive a round trip) to the KV-v2 client. - Wire the seed-oauth subcommand and document it in README and AGENTS.md. - Cover fresh create, patch-preserves-client_secret, other-key preservation, --rotate, idempotence, denial errors and secret leakage.
This commit is contained in:
+45
-1
@@ -3,11 +3,13 @@
|
||||
// with the same Vault AppRole as agentpr (role_id only, no secret_id).
|
||||
//
|
||||
// agentvault seed-outpost --outpost <name> --dest-path <kv/path>
|
||||
// agentvault seed-oauth --path <kv/path> --client-id <id>
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.unkin.net/unkin/agent-tools/internal/agent"
|
||||
|
||||
@@ -33,7 +35,7 @@ func newRootCmd() *cobra.Command {
|
||||
SilenceUsage: true,
|
||||
}
|
||||
root.SetVersionTemplate("{{.Version}}\n")
|
||||
root.AddCommand(newSeedOutpostCmd(), newVersionCmd())
|
||||
root.AddCommand(newSeedOutpostCmd(), newSeedOAuthCmd(), newVersionCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -73,6 +75,48 @@ func newSeedOutpostCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSeedOAuthCmd() *cobra.Command {
|
||||
opts := agent.SeedOAuthOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "seed-oauth",
|
||||
Short: "Seed an oauth2-proxy credential set into Vault KV",
|
||||
Long: "Make a Vault KV-v2 path hold a complete oauth2-proxy credential set: the\n" +
|
||||
"given client_id, plus a client_secret and a 32-byte cookie_secret that are\n" +
|
||||
"generated only when missing (or with --rotate). Existing keys are preserved\n" +
|
||||
"and nothing is written when the secret is already correct. Secret values are\n" +
|
||||
"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.SeedOAuth(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintf(out, "path: %s/%s\n", res.KVMount, res.Path)
|
||||
_, _ = fmt.Fprintf(out, "keys: %s\n", strings.Join(res.KeyNames(), ", "))
|
||||
for _, k := range res.Keys {
|
||||
_, _ = fmt.Fprintf(out, " %-14s %s\n", k.Name+":", k.Action)
|
||||
}
|
||||
if res.Changed {
|
||||
_, _ = fmt.Fprintf(out, "version: %d\n", res.Version)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(out, "version: unchanged\n")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&opts.Path, "path", "", "KV-v2 path holding the credentials, e.g. kubernetes/namespace/repospawner/default/oauth-credentials (required)")
|
||||
f.StringVar(&opts.ClientID, "client-id", "", "OIDC client id to store (required)")
|
||||
f.StringVar(&opts.KVMount, "kv-mount", agent.DefaultKVMount, "KV-v2 mount holding the path")
|
||||
f.BoolVar(&opts.Rotate, "rotate", false, "Regenerate client_secret and cookie_secret even when they already exist")
|
||||
_ = cmd.MarkFlagRequired("path")
|
||||
_ = cmd.MarkFlagRequired("client-id")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newVersionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -78,6 +79,89 @@ func TestSeedOutpostOutputHasNoSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const oauthPath = "kubernetes/namespace/repospawner/default/oauth-credentials"
|
||||
|
||||
// fakeOAuthVault serves approle login plus a KV-v2 path that already holds a
|
||||
// client_secret, and records what gets written back.
|
||||
func fakeOAuthVault(t *testing.T, existing map[string]string) (vaultURL string, written *map[string]string) {
|
||||
t.Helper()
|
||||
writes := map[string]string{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
|
||||
})
|
||||
mux.HandleFunc("/v1/kv/data/"+oauthPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
for k, v := range body.Data {
|
||||
writes[k] = v
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"data":{"version":4}}`)
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"data": map[string]any{"data": existing}})
|
||||
_, _ = w.Write(payload)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL, &writes
|
||||
}
|
||||
|
||||
// The command prints key names and the KV version only — never a value.
|
||||
func TestSeedOAuthOutputHasNoSecrets(t *testing.T) {
|
||||
const existingSecret = "existing-client-secret-value"
|
||||
vaultURL, written := fakeOAuthVault(t, map[string]string{"client_secret": existingSecret})
|
||||
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-oauth", "--path", oauthPath, "--client-id", "mediamark-client-id"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{"kv/" + oauthPath, "client_id, client_secret, cookie_secret", "client_secret: kept", "cookie_secret: created", "version: 4"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for key, value := range *written {
|
||||
if key == "client_id" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(got, value) {
|
||||
t.Fatalf("output leaks the %s value:\n%s", key, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, existingSecret) {
|
||||
t.Fatalf("output leaks the existing client_secret:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedOAuthRequiresFlags(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"no path": {"seed-oauth", "--client-id", "mediamark-client-id"},
|
||||
"no client-id": {"seed-oauth", "--path", oauthPath},
|
||||
} {
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedOutpostRequiresFlags(t *testing.T) {
|
||||
for name, args := range map[string][]string{
|
||||
"no outpost": {"seed-outpost", "--dest-path", destPath},
|
||||
|
||||
Reference in New Issue
Block a user