Mapping an EKS ServiceAccount to an AWS IAM Role using OpenTofu

Mapping an EKS ServiceAccount to an AWS IAM Role using OpenTofu

Before you go injecting IAM user Access Key credentials into your EKS pods as secrets, did you know that you can map an EKS ServiceAccount to an IAM role?

It's true! All of my EKS pods are mapped to IAM roles because I mount secrets from AWS Secrets Manager as volumes in my pods. I'll also do it for S3 access, or in the rare case that I do need to interact with an AWS service API.

In this post, I'll show you three ways to do it. As the title suggests, the way I recommend, out of these three, is with OpenTofu (or Terraform, as it were).

tl;dr: Here's a GitHub repo that shows you how to do it: https://github.com/colinjlacy/eks-iam-opentofu.

The Easiest Way

Although I don't recommend it, because it involves a lot of behind-the-scenes stuff, and it's harder to maintain. You'll see why in the next part.

Still, if you just want to get something up and running, you can take this approach. NOTE: The IAM role already needs to exist, and you need to have the ARN handy.

Make sure you're pointed to the AWS environment where your target cluster is running, you have a kubectl context set, and that you have eksctl installed:

$ eksctl create iamserviceaccount \
    --cluster <your-cluster-name> \
    --region <aws-region-for-your-cluster> \
    --name <name-of-service-account> \
    --namespace <target-eks-namespace> \
    --attach-role-arn <arn-of-mapped-role> \
    --approve \
    --override-existing-serviceaccounts

This might look fancy, but as you'll see in the next part, it doesn't do much.

The Manual Way (or, what's happening behind the scenes)

Even though this one's a little less difficult to maintain than the previous approach, I still wouldn't do it this way. This is more for visibility into what eksctl is doing in the background so that we can get to the OpenTofu way.

Again, in this case, your IAM role has to already be created, and you'll need the ARN of that role, as well as AWS access to update its access policy.

Let's start by creating a ServiceAccount:

# ./service-account.yaml
---
kind: ServiceAccount
apiVersion: v1
metadata:
  name: iam-test
  annotations:
    eks.amazonaws.com/role-arn: <iam-role-arn>

The last line of that ServiceAccount config is an annotation that tells EKS what IAM role to map this ServiceAccount to. In the previous section, eksctl would have created this resource for us, with the appropriate annotation based on the --attach-role-arn <arn-of-mapped-role> flag.

Go ahead and apply that config:

$ kubectl apply -f ./service-account.yaml

One more step to go. Right now, the ServiceAccount knows what role to map to. However, we give the ServiceAccount permission to assume that role. For this, we need the EKS cluster OIDC provider details, which you can find in your AWS console:

You're going to use everything after the protocol, and I refer to it below as <oidc-string>, so copy everything but https://. Once you have this, you can add a Trust Relationship policy to your IAM role. You could do this a bunch of ways, but for simplicity, I'll show you the manual way.

In the AWS console, navigate to your IAM role, and click on the Trust Relationships tab. Edit the trust policy, and add the following to the Statement array:

{
  "Effect": "Allow",
  "Principal": {
      "Federated": "arn:aws:iam::<your-aws-account-id>:oidc-provider/<oidc-string>"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
      "StringEquals": {
          "<oidc-string>:sub": "system:serviceaccount:<target-namespace>:<service-account-name>",
          "<oidc-string>:aud": "sts.amazonaws.com"
      }
  }
}

Let's talk about what's happening here:

  • The value for Principal.Federated indicates that this will be an identity authenticated by your OIDC provider.

  • The Action value is where we specify that we're letting the thing authenticated by your OIDC provider to assume this role.

  • The Condition.StringEquals is where we specify that the only principal we'll allow to assume this role is the one whose sub matches the target namespace and name of the ServiceAccount.

That's it! But it was super manual and hard to repeat. So let's automate it with OpenTofu.

The Best/Automated/Declarative/OpenTofu Way

Here, we'll take what we learned in the previous section and build out tofu configs to achieve the same goal, with repeatability in mind. Note that this works with OpenTofu 1.6.0, and Terraform 1.5.7.

The best part is that the IAM role does not need to exist, and you can create it at the same time that you're creating your ServiceAccount.

You'll need the AWS provider, as well as the Kubernetes provider. (Note that these URLs may/will change as the OpenTofu community moves away from Hashicorp.) This block assumes you have OpenTofu installed; if not, switch the required_version back to whatever version of Terraform you have.

terraform {
  required_version = "1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.16.0"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "2.23.0"
    }
  }
}

You'll also need to grab some data references, which you'll use in your provider configurations, and in your resource definitions:

# the cluster object, which we'll use in a second
data "aws_eks_cluster" "selected" {
  name = var.cluster_name
}

# a cluster token, used to configure the K8s provider
data "aws_eks_cluster_auth" "selected" {
  name = var.cluster_name
}

# AWS account info; you'll need the account ID
data "aws_caller_identity" "account" {}

locals {
  # convenience string manipulation to get the slimmed-down OIDC data you'll need
  oidc_string = replace(data.aws_eks_cluster.selected.identity[0].oidc[0].issuer, "https://", "")
  # convenience reference to the AWS account ID
  aws_account_id = data.aws_caller_identity.account.id
}

Configure your providers using the data references you just created:

provider "aws" {
  region = "us-west-2" # replace this with your region, or with a var
}

provider "kubernetes" {
  host = data.aws_eks_cluster.selected.endpoint
  cluster_ca_certificate = base64decode(data.aws_eks_cluster.selected.certificate_authority[0].data)
  token = data.aws_eks_cluster_auth.selected.token
}

And now, create the IAM Role and Kubernetes ServiceAccount, using their respective resource definitions. Note that the role ARN is used in the annotation for the ServiceAccount; the ARN is created after the role, so you won't have that information until you run tofu apply.

This should look like the OpenTofu/Terraform version of what happened in the previous section.

resource "aws_iam_role" "service_role" {
  name = "<service-account-name>" # replace this
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRoleWithWebIdentity"
        Effect = "Allow"
        Principal = {
          Federated = "arn:aws:iam::${local.aws_account_id}:oidc-provider/${local.oidc_string}"
        }
        Condition = {
          StringEquals = {
            # replace the bits at the end here...
            "${local.oidc_string}:sub" : "system:serviceaccount:<target-namespace>:<service_account_name>" 
            "${local.oidc_string}:aud" : "sts.amazonaws.com"
          }
        }
      },
    ]
  })
}

resource "kubernetes_manifest" "service_account" {
  manifest = {
    apiVersion = "v1"
    kind       = "ServiceAccount"
    metadata = {
      name      = "<service-account-name>" # replace this
      namespace = "<target-namespace>" # replace this
      annotations = {
        "eks.amazonaws.com/role-arn": aws_iam_role.service_role.arn
      }
    }
  }
}

And that's it! At this point, you can attach IAM or S3 policies to that role, and your code will have direct access to whatever services your policies specify. You don't have to set any credentials when you connect to AWS through the AWS SDK; the role mapping will do the authentication/authorization for you.

We can do better...

The above blocks of HCL code will run if you slap them all into a single file. But that's not great. Part of the allure of Terraform, and now OpenTofu, is the reusable module syntax.

In order to demonstrate how that would work, I've set up a GitHub repo that puts the above resource definitions into a module folder, which is then reused to create multiple ServiceAccount to IAM Role mappings.

You can also add standardized resources and IAM policies - e.g. create a secret in AWS Secrets Manager for every IAM Role - as part of the module. The module is also set up to export the role object, which can then be used in other resource definitions.

This post is a lot longer than I had originally planned. Sorry about that.