Manage EKS with Terraform›06 · IAM as code: access entries, Pod Identity & IRSA
Learning Hub / Cloud — OpenStack, AWS & EKS / Manage EKS with Terraform

Lesson 06 of 17 · Part 2 — Build as code

IAM as code: access entries, Pod Identity & IRSA

Express all EKS identity in Terraform: namespace-scoped team access with access entries, Pod Identity roles and associations for controllers and apps, IRSA where still needed, reusable least-privilege policies, and permissions boundaries for delegated roles.

Advanced
Key wordsTerraformaws_iam_roleaws_iam_policy_documenttrust policypermissions boundaryaws_eks_access_entryaws_eks_access_policy_associationaws_eks_pod_identity_associationIRSAaws_iam_openid_connect_providerleast privilegeABAC

What this lesson puts in code

Identity Resource(s)
Admins and teams on the Kubernetes API aws_eks_access_entry, aws_eks_access_policy_association
Controllers (CNI, EBS CSI, Load Balancer Controller, Karpenter, ExternalDNS) aws_iam_role + policy + aws_eks_pod_identity_association
Applications Same pattern, one role per ServiceAccount, created through a small module
Legacy / Fargate / non-EKS IRSA: aws_iam_openid_connect_provider + web-identity trust
Guard-rails Permissions boundary policy, SCP (in the organisation's repo)

The concepts are explained in "Production EKS Platform", lesson 06; here it's all Terraform.

A guest list at the door (access entries) and a key for each guest's own locker (pod roles), both written in the same book that everyone can read and nobody can change without a second signature (code review).

Team access, scoped to namespaces

variable "teams" {
  type = map(object({ role_arn = string, namespaces = list(string), level = string }))
  # e.g. shop = { role_arn = "arn:aws:iam::111122223333:role/...ShopDev...", namespaces = ["shop"], level = "edit" }
}

locals {
  access_policy = {
    view  = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
    edit  = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy"
    admin = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy"
  }
}

resource "aws_eks_access_entry" "team" {
  for_each      = var.teams
  cluster_name  = var.cluster_name
  principal_arn = each.value.role_arn
}

resource "aws_eks_access_policy_association" "team" {
  for_each      = var.teams
  cluster_name  = var.cluster_name
  principal_arn = each.value.role_arn
  policy_arn    = local.access_policy[each.value.level]
  access_scope {
    type       = "namespace"
    namespaces = each.value.namespaces
  }
  depends_on = [aws_eks_access_entry.team]
}

Adding a team is now a two-line change in tfvars, reviewed like any other code.

A reusable Pod Identity role module

Every controller and app needs the same four things: a role trusting Pod Identity, a policy, an attachment, and an association. Wrap them once:

# modules/pod-role/main.tf
variable "name"            { type = string }
variable "cluster_name"    { type = string }
variable "namespace"       { type = string }
variable "service_account" { type = string }
variable "policy_json"     { type = string }
variable "boundary_arn"    { type = string }

data "aws_iam_policy_document" "trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole", "sts:TagSession"]
    principals {
      type        = "Service"
      identifiers = ["pods.eks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "this" {
  name                 = var.name
  assume_role_policy   = data.aws_iam_policy_document.trust.json
  permissions_boundary = var.boundary_arn
}

resource "aws_iam_role_policy" "this" {
  role   = aws_iam_role.this.id
  policy = var.policy_json
}

resource "aws_eks_pod_identity_association" "this" {
  cluster_name    = var.cluster_name
  namespace       = var.namespace
  service_account = var.service_account
  role_arn        = aws_iam_role.this.arn
}

output "role_arn" { value = aws_iam_role.this.arn }

Using it for an application that reads one S3 prefix:

data "aws_iam_policy_document" "orders" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["arn:aws:s3:::shop-assets-prod/orders/*"]
  }
}

module "orders_role" {
  source          = "../../modules/pod-role"
  name            = "prod-shop-orders"
  cluster_name    = var.cluster_name
  namespace       = "shop"
  service_account = "orders"
  policy_json     = data.aws_iam_policy_document.orders.json
  boundary_arn    = aws_iam_policy.workload_boundary.arn
}

For controllers, attach AWS-managed policies instead of an inline document where they exist: AmazonEKS_CNI_Policy for the VPC CNI and AmazonEBSCSIDriverPolicy for the EBS CSI driver (the module above can take a list of managed policy ARNs as an extra variable). The Load Balancer Controller and Karpenter publish their own policy documents; the community EKS module's submodules build them for you.

ABAC: one policy for every namespace

Pod Identity session tags let a single policy follow the namespace:

data "aws_iam_policy_document" "team_data" {
  statement {
    actions   = ["s3:GetObject", "s3:PutObject"]
    resources = ["arn:aws:s3:::team-data-prod/$${aws:PrincipalTag/kubernetes-namespace}/*"]
  }
}

($${...} escapes the interpolation so Terraform passes ${aws:PrincipalTag/...} through to IAM literally.)

The permissions boundary

data "aws_iam_policy_document" "workload_boundary" {
  statement {
    sid       = "AllowOnlyAppServices"
    actions   = ["s3:*", "sqs:*", "sns:*", "dynamodb:*", "secretsmanager:GetSecretValue", "kms:Decrypt"]
    resources = ["*"]
  }
  statement {
    sid       = "NeverIAMOrOrgs"
    effect    = "Deny"
    actions   = ["iam:*", "organizations:*", "eks:*", "ec2:*"]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "workload_boundary" {
  name   = "eks-workload-boundary"
  policy = data.aws_iam_policy_document.workload_boundary.json
}

Whatever a team attaches to its role, the effective permissions can never exceed the boundary. Pair it with an SCP (in the organisation repository) that denies iam:CreateRole unless the boundary is attached.

IRSA, when you still need it

data "tls_certificate" "oidc" {
  url = aws_eks_cluster.this.identity[0].oidc[0].issuer
}

resource "aws_iam_openid_connect_provider" "this" {
  url             = aws_eks_cluster.this.identity[0].oidc[0].issuer
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.oidc.certificates[0].sha1_fingerprint]
}

locals { issuer = replace(aws_iam_openid_connect_provider.this.url, "https://", "") }

data "aws_iam_policy_document" "irsa_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.this.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.issuer}:sub"
      values   = ["system:serviceaccount:batch:reporter"]
    }
    condition {
      test     = "StringEquals"
      variable = "${local.issuer}:aud"
      values   = ["sts.amazonaws.com"]
    }
  }
}

The ServiceAccount then needs the eks.amazonaws.com/role-arn annotation, usually set in its Helm values from a Terraform output.

Keep the sub condition exact

A StringLike with system:serviceaccount:* lets every ServiceAccount in the cluster assume the role. Always pin namespace and name, and review trust policies as carefully as permission policies.

Try it: identity in code (sandbox)

  1. Add two teams to var.teams with different namespaces and levels; apply and test each role with kubectl auth can-i.
  2. Create the pod-role module and a role for shop/orders; verify from a pod with aws sts get-caller-identity.
  3. Attach an over-broad inline policy (s3:* on *) to the role and use aws iam simulate-principal-policy to show the boundary still denies iam:CreateUser.
  4. Delete an access entry by hand in the console, then run plan: Terraform shows the drift and puts it back.

Recap

  • Team access from a map of teams: access entries + namespace-scoped access policies.
  • A pod-role module: trust pods.eks.amazonaws.com, least-privilege policy, permissions boundary, association.
  • ABAC with session tags for one policy across namespaces (escape $${} in Terraform).
  • IRSA with an exact sub condition where Pod Identity doesn't fit.
  • Identity changes are pull requests; out-of-band changes show up as drift.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.