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
This commit is contained in:
Josh Siviour
2026-06-04 11:25:56 +10:00
committed by GitHub
parent b0227e7eeb
commit bfacca7789
32 changed files with 2019 additions and 177 deletions
@@ -1,7 +1,10 @@
#!/sbin/openrc-run
# eks-token-webhook — K8s TokenReview authenticator for IAM-backed kubectl.
# Binds 127.0.0.1:8443 only; kube-apiserver is the sole client (loopback path).
# Stub binary returns 503 on every request until cs-eks-6c lands.
# Decodes `aws eks get-token` presigned URLs, verifies them via Mulga STS over
# NATS, and resolves the principal to a TokenReview via the cluster AccessEntry
# KV. Ordered `before k3s` so its apiserver kubeconfig exists before k3s reads
# --authentication-token-webhook-config-file.
description="EKS Token Review webhook (Mulga)"
command="/usr/local/bin/eks-token-webhook"
@@ -11,7 +14,23 @@ pidfile="/run/eks-token-webhook.pid"
output_log="/var/log/eks-token-webhook.log"
error_log="/var/log/eks-token-webhook.log"
# NATS creds + cluster identity come from the cloud-init-seeded first-boot env.
# Sourced here and exported so start-stop-daemon hands them to the binary, which
# reads SPINIFEX_NATS_* / EKS_ACCOUNT_ID / EKS_CLUSTER_NAME from its environment.
if [ -f /etc/spinifex-eks/first-boot.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/first-boot.env
export SPINIFEX_NATS_URL SPINIFEX_NATS_TOKEN SPINIFEX_NATS_CA EKS_ACCOUNT_ID EKS_CLUSTER_NAME
fi
depend() {
need net
before k3s
}
start_pre() {
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed NATS creds"
return 1
fi
}
+13
View File
@@ -33,4 +33,17 @@ start_pre() {
return 1
fi
fi
# The apiserver is configured with --authentication-token-webhook-config-file
# pointing at the kubeconfig eks-token-webhook writes at startup. `before k3s`
# orders the webhook's start but it backgrounds, so wait (bounded) for the
# kubeconfig to land before letting the apiserver load a missing file.
i=0
while [ ! -f /etc/spinifex-eks/token-webhook.kubeconfig ] && [ "${i}" -lt 30 ]; do
sleep 1
i=$((i + 1))
done
if [ ! -f /etc/spinifex-eks/token-webhook.kubeconfig ]; then
ewarn "eks-token-webhook kubeconfig absent after ${i}s; IAM token auth will be unavailable until the webhook starts"
fi
}
+9 -6
View File
@@ -57,13 +57,13 @@ chmod 0755 /usr/local/sbin/k3s-first-boot
chmod 0755 /etc/periodic/15min/mulga-eks-state-report
chmod 0755 /etc/periodic/daily/mulga-eks-etcd-snapshot
# K3s server config — empty skeleton; cloud-init / first-boot fills in the
# K3s server config — skeleton; cloud-init / first-boot fills in the
# per-cluster fields (cluster-cidr, service-cidr, token-file, etc).
# Webhook-token-auth is NOT wired in the skeleton: enabling it before the
# eks-token-webhook binary is the real implementation (currently a 503 stub,
# replaced by cs-eks-6c) would block /healthz from anonymous callers. The
# Sprint 6c orchestrator drops a webhook-config.yaml + restarts k3s once
# the binary is the real one.
# IAM token-auth is wired here via kube-apiserver-arg: the eks-token-webhook
# service (ordered `before k3s`) writes its kubeconfig to
# /etc/spinifex-eks/token-webhook.kubeconfig before the apiserver reads it.
# This only affects bearer-token requests; anonymous and client-cert paths
# (the first-boot /readyz probe uses the admin kubeconfig) are unaffected.
mkdir -p /etc/rancher/k3s
cat > /etc/rancher/k3s/config.yaml.skel <<'EOF'
# Populated at first boot by cloud-init user-data via k3s-first-boot.sh.
@@ -73,6 +73,9 @@ disable:
- traefik
- servicelb
- local-storage
kube-apiserver-arg:
- "authentication-token-webhook-config-file=/etc/spinifex-eks/token-webhook.kubeconfig"
- "authentication-token-webhook-cache-ttl=5m"
EOF
# Sentinel file marker — k3s-first-boot self-disables after first success by