Testing a Kafka service that signs in to MSK with IAM

kafkamskaws iamblack-box testing
Contents

A service on EKS that produces to Amazon MSK with IAM authentication has no Kafka username or password. Its pod is given a role and a web identity token (IRSA), the AWS SDK trades the token at STS for a short-lived session, the Kafka client signs its handshake with that session, and MSK lets it in if the role’s IAM policy allows it.

Tests usually turn all of that off and connect to a plaintext broker, which leaves the code that most often breaks in a new environment untested: the SASL mechanism, the credential chain, the role. tomato keeps it on. Its aws resource serves STS and hands the service a token and a role, and its kafka preset adds a listener that speaks MSK’s AWS_MSK_IAM handshake. Step through a run:

The service exchanges a web identity token for a role session at STS, signs an AWS_MSK_IAM handshake with it, and the broker lets the role's sessions in. Under tomato, the aws resource serves STS and the kafka preset's listener admits exactly the sessions of the configured role.tomato run✓POST /orders✓receives from orders✓role was assumedordersyour serviceKafka client · AWS_MSK_IAMAWS_ROLE_ARN=AWS_WEB_IDENTITY_TOKEN_FILEAWS_ENDPOINT_URL_STS=KAFKA_BROKERS=✓✗ Access deniedtopic ordersord-1 · {"status":"placed"}web identity tokenfallback identity:TOMATOAMBFB4060D1171AssumeRoleWithWebIdentity→ AccessKeyId=never asked: no role to assumeHTTPSASLthe kafka resource reads orders on the plain listener

On AWS the service has no keys of its own. EKS gives its pod a web identity token and a role (IRSA); the SDK trades the token at STS for a short-lived session, signs the Kafka handshake with it, and MSK lets it in if the role's IAM policy allows kafka-cluster:Connect.

Under tomato every piece has a stand-in. The aws resource writes the token file, serves STS on localhost and puts both in the service's environment. The kafka preset with auth: aws_msk_iam adds a listener on 9098 that speaks the same handshake. The service's code and client settings stay as they are.

Credentials. The SDK reads the token file and calls AssumeRoleWithWebIdentity at tomato's STS, which records the role and returns a session. Its access key id is derived from the role ARN, so it is the same on every run.

Handshake. The client sends MSK's signed kafka-cluster:Connect payload. The listener reads the access key id out of it and lets it in only if it is a session of the role in tomato.yml: the kafka preset derives the same key from the same ARN, so the two agree without talking to each other.

A scenario calls the service over HTTP, the service produces to orders over the IAM listener, and tomato's kafka resource reads the topic on the plain listener to check the message. The aws resource can also assert which role the service assumed.

Wrong identity. When the pod is not given the role (the service account lost its annotation, say), the SDK's credential chain keeps looking and settles on another identity, like the EKS node's. With ambient_identity: true tomato leaves such an identity where the chain looks; it is not a session of the role, so the listener answers Access denied, as MSK would.

The service

orders takes an order over HTTP and produces it to the orders topic. The Kafka client is configured from the environment only, and takes its credentials from the AWS SDK’s default chain; nothing in it knows about tomato. These are the parts that matter, in four languages:

awsCfg, err := config.LoadDefaultConfig(ctx) // web identity, via AWS_ROLE_ARN + token file
if err != nil {
	log.Fatal(err)
}
opts := []kgo.Opt{
	kgo.SeedBrokers(strings.Split(os.Getenv("KAFKA_BROKERS"), ",")...),
	kgo.RecordDeliveryTimeout(10 * time.Second), // fail the request instead of retrying forever
	kgo.SASL(aws.ManagedStreamingIAM(func(ctx context.Context) (aws.Auth, error) {
		c, err := awsCfg.Credentials.Retrieve(ctx)
		return aws.Auth{AccessKey: c.AccessKeyID, SecretKey: c.SecretAccessKey, SessionToken: c.SessionToken}, err
	})),
}
if os.Getenv("KAFKA_TLS") == "true" {
	opts = append(opts, kgo.DialTLSConfig(&tls.Config{}))
}
kafka, err := kgo.NewClient(opts...)

franz-go v1.22 with the AWS SDK for Go v2. Build it first (go build -o orders .); tomato runs the binary. Without RecordDeliveryTimeout franz-go retries a refused handshake forever, and a request that should fail hangs instead.

The one switch is KAFKA_TLS. MSK’s IAM port is SASL_SSL; tomato’s is SASL_PLAINTEXT, the same handshake without TLS.

The suite

tomato.yml is the same for every language except app.command:

version: 2

containers:
  kafka:
    preset: kafka
    auth: aws_msk_iam          # adds the AWS_MSK_IAM listener on 9098

app:
  command: ./orders
  port: 8097
  ready:
    type: http
    path: /health
    timeout: 120s
  env:
    PORT: "8097"
    KAFKA_BROKERS: "{{.kafka.host}}:{{.kafka.port.9098}}"

resources:
  orders-identity:             # STS, a token file and a role, in the app's environment
    type: aws
    options:
      region: eu-central-1
      role_arn: arn:aws:iam::000000000000:role/orders

  orders-events:               # reads the topic on the plain listener
    type: kafka
    container: kafka
    options:
      topics: [orders]

  orders-client:
    type: http-client
    base_url: "http://localhost:8097"

./orders is the Go binary. For Java the command is java -cp lib/* Orders.java, for Python uv run orders.py, and for Node.js node orders.mjs.

The service is never handed a Kafka credential or an AWS key. The aws resource sets AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ENDPOINT_URL_STS, and removes any AWS keys the service would otherwise inherit from your shell or CI, so it can only get in the way it does on EKS.

Feature: placing an order publishes it to Kafka

  Scenario: a placed order is published
    Given "orders-events" consumes from "orders"
    When "orders-client" sends "POST" to "/orders" with json:
      """
      {"id": "ord-1", "total": 1999}
      """
    Then "orders-client" response status is "201"
    And "orders-events" receives from "orders" within "10s":
      """
      {"id":"ord-1","total":1999,"status":"placed"}
      """
    And "orders-events" last message has key "ord-1"
    And "orders-identity" role "arn:aws:iam::000000000000:role/orders" was assumed

  Scenario: a bad order publishes nothing
    Given "orders-events" consumes from "orders"
    When "orders-client" sends "POST" to "/orders" with json:
      """
      {"total": 1999}
      """
    Then "orders-client" response status is "400"
    And "orders-events" topic "orders" is empty
$ tomato run
2 scenarios (2 passed)
10 steps (10 passed)

All four services pass it unchanged. The last step of the first scenario is the one a plaintext test cannot have: the service got in as the role it was meant to.

In production

The same variables, set by EKS and your deployment instead of tomato:

VariableUnder tomatoOn EKS
KAFKA_BROKERSlocalhost:<port>, the IAM listenerMSK’s IAM bootstrap brokers, port 9098
KAFKA_TLSunset: SASL_PLAINTEXTtrue: SASL_SSL
AWS_ROLE_ARN, AWS_WEB_IDENTITY_TOKEN_FILEset by the aws resourceset by EKS from the service account’s role annotation
AWS_ENDPOINT_URL_STStomato’s STS on 127.0.0.1unset: the SDK’s regional STS
AWS_REGIONregion of the aws resourcethe cluster’s region

When the service has the wrong identity

The failure this setup catches is the one that only shows up on AWS: the service authenticates as someone other than the role with the Kafka policy. The usual cause is a pod that was not given the role, so the SDK’s credential chain keeps looking and finds the node’s identity instead. Reproduce it by leaving the role out of the service’s environment, and giving the chain something to fall back to:

app:
  env:
    AWS_ROLE_ARN: ""                 # the service account lost its role annotation
    AWS_WEB_IDENTITY_TOKEN_FILE: ""

resources:
  orders-identity:
    type: aws
    options:
      region: eu-central-1
      role_arn: arn:aws:iam::000000000000:role/orders
      ambient_identity: true         # a long-term identity where the chain looks next

Every SDK falls back to it, and the listener refuses it with MSK’s own answer:

SASL_AUTHENTICATION_FAILED: SASL Authentication failed.: [<request id>]: Access denied

How that reaches the scenario depends on the client. Go and Java keep running and answer the order with a 502. Python and Node.js connect when they start, so the service never becomes ready and tomato stops with app not ready. Either way the run fails, as the deployment would.

What tomato checks, and what it doesn’t

The listener checks who the client is: it reads the access key id out of the signed kafka-cluster:Connect payload and lets in only the sessions the aws resource issued for the configured role. Both sides derive that key from the role ARN, so they agree without talking to each other. That covers the mechanism, the credential chain, the STS call and the role.

It does not verify the signature, and it does not evaluate IAM policies, so a policy that allows Connect but not WriteData on the topic passes here and fails on MSK. It has no TLS. Those belong to the cluster’s own configuration, and are worth a check in a real AWS environment before the first deploy.