Files
spinifex/cmd/eks-token-webhook/main_test.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

124 lines
3.9 KiB
Go

package main
import (
"encoding/base64"
"errors"
"os"
"path/filepath"
"testing"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validToken(url string) string {
return "k8s-aws-v1." + base64.RawURLEncoding.EncodeToString([]byte(url))
}
const testARN = "arn:aws:iam::111122223333:role/admin"
func okVerify(_ string) (*handlers_eks.TokenVerifyResponse, error) {
return &handlers_eks.TokenVerifyResponse{
AccountID: "111122223333",
ARN: testARN,
UserID: "AROAEXAMPLE:session",
PrincipalType: "AssumedRole",
}, nil
}
func TestAuthenticate_GrantsWhenEntryExists(t *testing.T) {
lookup := func(arn string) (*handlers_eks.AccessEntryRecord, error) {
assert.Equal(t, testARN, arn)
return &handlers_eks.AccessEntryRecord{
KubernetesUsername: testARN,
KubernetesGroups: []string{"system:masters"},
}, nil
}
st := authenticate(validToken("https://sts.amazonaws.com/?Action=GetCallerIdentity"), okVerify, lookup)
require.True(t, st.Authenticated)
assert.Equal(t, testARN, st.User.Username)
assert.Equal(t, "AROAEXAMPLE:session", st.User.UID)
assert.Equal(t, []string{"system:masters"}, st.User.Groups)
}
func TestAuthenticate_DeniesMalformedToken(t *testing.T) {
called := false
verify := func(string) (*handlers_eks.TokenVerifyResponse, error) { called = true; return nil, nil }
lookup := func(string) (*handlers_eks.AccessEntryRecord, error) { return nil, nil }
st := authenticate("not-a-k8s-aws-token", verify, lookup)
assert.False(t, st.Authenticated)
assert.False(t, called, "must not call STS for a token that fails to decode")
}
func TestAuthenticate_DeniesWhenVerifyFails(t *testing.T) {
verify := func(string) (*handlers_eks.TokenVerifyResponse, error) {
return nil, errors.New("signature mismatch")
}
lookup := func(string) (*handlers_eks.AccessEntryRecord, error) {
t.Fatal("lookup must not run when verify fails")
return nil, nil
}
st := authenticate(validToken("https://sts/?x=1"), verify, lookup)
assert.False(t, st.Authenticated)
}
func TestAuthenticate_DeniesWhenNoAccessEntry(t *testing.T) {
lookup := func(string) (*handlers_eks.AccessEntryRecord, error) {
return nil, handlers_eks.ErrAccessEntryNotFound
}
st := authenticate(validToken("https://sts/?x=1"), okVerify, lookup)
assert.False(t, st.Authenticated)
assert.Empty(t, st.User.Username)
}
func TestAuthenticate_FallsBackUIDToARN(t *testing.T) {
verify := func(string) (*handlers_eks.TokenVerifyResponse, error) {
return &handlers_eks.TokenVerifyResponse{ARN: testARN}, nil // no UserID
}
lookup := func(string) (*handlers_eks.AccessEntryRecord, error) {
return &handlers_eks.AccessEntryRecord{KubernetesUsername: testARN, KubernetesGroups: []string{"system:masters"}}, nil
}
st := authenticate(validToken("https://sts/?x=1"), verify, lookup)
require.True(t, st.Authenticated)
assert.Equal(t, testARN, st.User.UID)
}
func TestEnsureServingCert_PersistsAndReuses(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "wh.crt")
keyPath := filepath.Join(dir, "wh.key")
c1, pem1, err := ensureServingCert(certPath, keyPath)
require.NoError(t, err)
require.NotEmpty(t, pem1)
require.NotNil(t, c1.serverTLSConfig())
// Second call reuses the persisted pair (same cert bytes).
_, pem2, err := ensureServingCert(certPath, keyPath)
require.NoError(t, err)
assert.Equal(t, pem1, pem2)
}
func TestWriteAPIServerKubeconfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wh.kubeconfig")
certPEM := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n")
require.NoError(t, writeAPIServerKubeconfig(path, "127.0.0.1:8443", certPEM))
data, err := os.ReadFile(path)
require.NoError(t, err)
body := string(data)
assert.Contains(t, body, "server: https://127.0.0.1:8443/authenticate")
assert.Contains(t, body, base64.StdEncoding.EncodeToString(certPEM))
assert.Contains(t, body, "current-context: webhook")
}