155392a809
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.
181 lines
5.6 KiB
Go
181 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const (
|
|
apiToken = "ak-api-token-secret"
|
|
outpostKey = "outpost-key-secret"
|
|
destPath = "kubernetes/namespace/authentik/default/outpost-token"
|
|
)
|
|
|
|
// fakeEstate serves the Vault (approle + KV-v2 read/write) and Authentik
|
|
// (outpost search + view_key) endpoints the seed flow needs.
|
|
func fakeEstate(t *testing.T) (vaultURL, authentikURL string) {
|
|
t.Helper()
|
|
vmux := http.NewServeMux()
|
|
vmux.HandleFunc("/v1/auth/approle/login", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"auth":{"client_token":"s.vaulttoken"}}`)
|
|
})
|
|
vmux.HandleFunc("/v1/kv/data/service/authentik/agent-api-token", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"data":{"data":{"token":"`+apiToken+`"}}}`)
|
|
})
|
|
vmux.HandleFunc("/v1/kv/data/"+destPath, func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"data":{"version":7}}`)
|
|
})
|
|
vs := httptest.NewServer(vmux)
|
|
t.Cleanup(vs.Close)
|
|
|
|
amux := http.NewServeMux()
|
|
amux.HandleFunc("/api/v3/outposts/instances/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"results":[{"pk":"1","name":"k8s-outpost","token_identifier":"ak-outpost-k8s"}]}`)
|
|
})
|
|
amux.HandleFunc("/api/v3/core/tokens/ak-outpost-k8s/view_key/", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"key":"`+outpostKey+`"}`)
|
|
})
|
|
as := httptest.NewServer(amux)
|
|
t.Cleanup(as.Close)
|
|
|
|
return vs.URL, as.URL
|
|
}
|
|
|
|
// The command prints identifiers and the KV version only — never a secret.
|
|
func TestSeedOutpostOutputHasNoSecrets(t *testing.T) {
|
|
vaultURL, authentikURL := fakeEstate(t)
|
|
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-outpost",
|
|
"--outpost", "k8s-outpost",
|
|
"--dest-path", destPath,
|
|
"--authentik-url", authentikURL,
|
|
})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
got := out.String()
|
|
for _, want := range []string{"k8s-outpost", "ak-outpost-k8s", "kv/" + destPath, "version: 7"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, secret := range []string{apiToken, outpostKey} {
|
|
if strings.Contains(got, secret) {
|
|
t.Fatalf("output leaks a secret:\n%s", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
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},
|
|
"no dest-path": {"seed-outpost", "--outpost", "k8s-outpost"},
|
|
} {
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|