Files
spinifex/cmd/eks-token-webhook/main.go
T
Josh Siviour bfacca7789 feat(eks): 6c token webhook + AccessEntry (IAM auth → kubectl) (#284)
* feat(eks): 6c AccessEntry CRUD + access-policy association

Replace the nine notImpl() AccessEntry/AccessPolicy stubs with real
KV-backed handlers (Q9, API auth mode):

- AccessEntry CRUD persisted at clusters/{name}/access-entries/{hash};
  the principal ARN is hashed into the key (ARNs carry ':', illegal in
  NATS KV keys) while the record keeps the plaintext ARN.
- AssociateAccessPolicy/Disassociate/ListAssociated against the four
  supported AWS-managed policies; unsupported policy ARN or bad access
  scope -> InvalidParameterValue.
- ListAccessPolicies returns the static supported catalogue.
- Cluster-existence gate -> ResourceNotFoundException; duplicate create
  -> ResourceInUseException; node-type entries rejected until 6d.

Updates the daemon routing test for the now-implemented handlers.

* feat(eks): plumb caller principal ARN + bootstrap-creator-admin

Thread the caller's resolved IAM principal ARN from the gateway to the
EKS control plane via a new X-Principal-ARN NATS header, so CreateCluster
can mint the bootstrapClusterCreatorAdminPermissions AccessEntry (Q9):

- utils.NATSRequest takes optional extra headers; PrincipalARNHeader +
  PrincipalARNFromMsg added.
- Gateway resolves the caller ARN best-effort (buildCallerARN) and passes
  it down the EKS route table; only CreateCluster consumes it.
- CreateCluster signature gains callerPrincipalARN; default-true bootstrap
  flag mints a system:masters entry keyed by the caller's exact ARN (the
  token webhook resolves the same ARN from STS). '' degrades gracefully.
- Dedicated daemon handler reads both headers.

* feat(eks): IAM token webhook + STS verify-over-NATS + apiserver wiring

The eks-token-webhook (baked into the eks-server AMI) authenticates kubectl
requests that present an `aws eks get-token` bearer token: decode the presigned
STS GetCallerIdentity URL, verify it, resolve the principal ARN to its cluster
AccessEntry, and answer the apiserver TokenReview with the mapped username +
groups.

STS is hosted in-process in the awsgw service and is not otherwise on the bus,
so awsgw now serves an `eks.VerifyToken` NATS subject that wraps
VerifyPresignedGetCallerIdentity (the cluster name is forwarded so STS enforces
the x-k8s-aws-id anti-replay pin). The webhook reads the AccessEntry KV directly
over its own NATS connection.

Apiserver wiring: the k3s config skeleton sets
authentication-token-webhook-config-file; the webhook writes its own self-signed
serving cert + the apiserver kubeconfig at startup (ordered `before k3s`, with a
bounded wait in the k3s start_pre so the apiserver never loads a missing file).

* fix(eks): omit empty spec from token webhook TokenReview response

* fix(sts): presigned GetCallerIdentity must hash empty body, not UNSIGNED-PAYLOAD

VerifyPresignedGetCallerIdentity built the SigV4 canonical request with
"UNSIGNED-PAYLOAD" as the payload hash. That is an S3-only convention; botocore
and the aws-sdk presign non-S3 requests (sts:GetCallerIdentity, the shape `aws
eks get-token` produces) with the SHA256 of the empty string. Every real
get-token therefore failed signature verification with InvalidIdentityToken,
breaking the entire EKS IAM token-auth flow end to end.

Use the empty-string SHA256. The existing hand-rolled presign tests masked the
bug by signing with the same wrong constant; they now sign correctly, and a new
test presigns with the real aws-sdk-go v4 signer (the same canonicalisation the
CLI uses) so this can't regress silently.

Verified live: a real `aws eks get-token --cluster-name dev1` now resolves to
the caller principal over the awsgw eks.VerifyToken surface; an otherwise-valid
token presented for a different cluster is still rejected (anti-replay intact).

* fix(eks): match access-entry routes on escaped path so slashed principal ARNs resolve

IAM principal ARNs (user/admin, role/foo) embed a '/' that the AWS CLI
percent-encodes in the request path. The dispatcher matched r.URL.Path
(decoded), so %2F became a literal '/' and split the ARN across the
([^/]+) capture, dropping every describe/update/delete-access-entry and
associate/disassociate/list-access-policy call to InvalidActionException.

Match r.URL.EscapedPath() so the encoded ARN stays one segment, then
url.PathUnescape each captured param before it reaches the handler.

* test(eks): unit-cover access-entry scope validation, AWS converters, store guards
2026-06-04 11:25:56 +10:00

258 lines
8.0 KiB
Go

// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
// the eks-server AMI. It decodes the SigV4-presigned GetCallerIdentity URL
// produced by `aws eks get-token`, calls Mulga STS over NATS (which enforces
// the x-k8s-aws-id cross-cluster pin against this cluster's name), and resolves
// the principal ARN to a TokenReview response via the cluster's AccessEntry KV.
//
// It binds 127.0.0.1 only; the kube-apiserver is the sole client (loopback).
// On startup it writes the apiserver webhook kubeconfig (with its self-signed
// serving CA) so k3s can be pointed at it via
// --authentication-token-webhook-config-file.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"time"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// config is the webhook's runtime configuration, sourced from the first-boot
// env (/etc/spinifex-eks/first-boot.env) cloud-init seeds onto the VM.
type config struct {
addr string
natsURL string
natsToken string
natsCA string
accountID string
clusterName string
certPath string
keyPath string
kubeconfig string
verifyTO time.Duration
}
func loadConfig() (config, error) {
c := config{
addr: flagAddr,
natsURL: os.Getenv("SPINIFEX_NATS_URL"),
natsToken: os.Getenv("SPINIFEX_NATS_TOKEN"),
natsCA: os.Getenv("SPINIFEX_NATS_CA"),
accountID: os.Getenv("EKS_ACCOUNT_ID"),
clusterName: os.Getenv("EKS_CLUSTER_NAME"),
certPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
keyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
verifyTO: 5 * time.Second,
}
if c.natsURL == "" {
return c, errors.New("SPINIFEX_NATS_URL not set")
}
if c.accountID == "" {
return c, errors.New("EKS_ACCOUNT_ID not set")
}
if c.clusterName == "" {
return c, errors.New("EKS_CLUSTER_NAME not set")
}
return c, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
var flagAddr string
func main() {
flag.StringVar(&flagAddr, "addr", "127.0.0.1:8443", "listen address (loopback only)")
flag.Parse()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
cfg, err := loadConfig()
if err != nil {
slog.Error("eks-token-webhook config error", "err", err)
os.Exit(1)
}
if err := run(cfg); err != nil {
slog.Error("eks-token-webhook fatal", "err", err)
os.Exit(1)
}
}
func run(cfg config) error {
// Serving cert + apiserver kubeconfig. Persisted and reused across restarts
// so a webhook crash does not invalidate the CA the apiserver already loaded.
tlsCert, certPEM, err := ensureServingCert(cfg.certPath, cfg.keyPath)
if err != nil {
return fmt.Errorf("serving cert: %w", err)
}
if err := writeAPIServerKubeconfig(cfg.kubeconfig, cfg.addr, certPEM); err != nil {
return fmt.Errorf("write apiserver kubeconfig: %w", err)
}
nc, err := utils.ConnectNATSWithRetry(cfg.natsURL, cfg.natsToken, cfg.natsCA)
if err != nil {
return fmt.Errorf("connect NATS: %w", err)
}
defer nc.Close()
kv, err := openAccountKV(nc, cfg.accountID)
if err != nil {
return fmt.Errorf("open account KV: %w", err)
}
authr := &authenticator{
nc: nc,
kv: kv,
clusterName: cfg.clusterName,
verifyTO: cfg.verifyTO,
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/authenticate", authr.handle)
server := newServer(cfg.addr, mux, tlsCert)
slog.Info("eks-token-webhook listening", "addr", cfg.addr, "cluster", cfg.clusterName)
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
return nil
}
// openAccountKV attaches to the per-account EKS KV bucket. The bucket already
// exists by the time a control-plane VM is running (CreateCluster created it),
// but JetStream may briefly lag at boot, so retry a few times.
func openAccountKV(nc *nats.Conn, accountID string) (nats.KeyValue, error) {
js, err := nc.JetStream()
if err != nil {
return nil, fmt.Errorf("jetstream: %w", err)
}
bucket := handlers_eks.AccountBucketName(accountID)
var lastErr error
for range 30 {
kv, kvErr := js.KeyValue(bucket)
if kvErr == nil {
return kv, nil
}
lastErr = kvErr
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("kv bucket %s not available: %w", bucket, lastErr)
}
// authenticator resolves a TokenReview against STS (over NATS) + AccessEntry KV.
type authenticator struct {
nc *nats.Conn
kv nats.KeyValue
clusterName string
verifyTO time.Duration
}
// verify resolves a presigned get-token URL into the caller principal via the
// awsgw-hosted STS verify subject, binding the signature to this cluster name.
func (a *authenticator) verify(presignedURL string) (*handlers_eks.TokenVerifyResponse, error) {
req := handlers_eks.TokenVerifyRequest{
PresignedURL: presignedURL,
ClusterName: a.clusterName,
}
// accountID header is unused by the verify responder; pass empty.
return utils.NATSRequest[handlers_eks.TokenVerifyResponse](
a.nc, handlers_eks.TokenVerifySubject, req, a.verifyTO, "")
}
// lookup reads the AccessEntry for a principal ARN from the cluster KV.
func (a *authenticator) lookup(principalARN string) (*handlers_eks.AccessEntryRecord, error) {
return handlers_eks.GetAccessEntryRecord(a.kv, a.clusterName, principalARN)
}
func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var review tokenReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
status := authenticate(review.Spec.Token, a.verify, a.lookup)
resp := tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Status: status,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.Error("encode TokenReview response", "err", err)
}
}
// authenticate is the pure decision core: decode token → STS verify → KV
// AccessEntry lookup → TokenReview status. Any failure yields an unauthenticated
// status (never a 5xx) so a forged/unknown token is a clean K8s 401, not a
// webhook outage. verify and lookup are injected so the logic is unit-testable.
func authenticate(
token string,
verify func(presignedURL string) (*handlers_eks.TokenVerifyResponse, error),
lookup func(principalARN string) (*handlers_eks.AccessEntryRecord, error),
) tokenReviewStatus {
presignedURL, err := handlers_eks.DecodeGetToken(token)
if err != nil {
return tokenReviewStatus{Authenticated: false}
}
ident, err := verify(presignedURL)
if err != nil {
slog.Debug("token verify rejected", "err", err)
return tokenReviewStatus{Authenticated: false}
}
rec, err := lookup(ident.ARN)
if err != nil {
// No AccessEntry for an otherwise-valid IAM principal: authenticated to
// AWS, but not granted any K8s identity on this cluster.
slog.Debug("no access entry for principal", "arn", ident.ARN, "err", err)
return tokenReviewStatus{Authenticated: false}
}
uid := ident.UserID
if uid == "" {
uid = ident.ARN
}
return tokenReviewStatus{
Authenticated: true,
User: userInfo{
Username: rec.KubernetesUsername,
UID: uid,
Groups: rec.KubernetesGroups,
},
}
}
// newServer builds the loopback TLS server. Split out so tests can construct it
// without binding a port.
func newServer(addr string, handler http.Handler, cert tlsCertificate) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: cert.serverTLSConfig(),
}
}