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.
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)
- Add two teams to
var.teamswith different namespaces and levels; apply and test each role withkubectl auth can-i. - Create the
pod-rolemodule and a role forshop/orders; verify from a pod withaws sts get-caller-identity. - Attach an over-broad inline policy (
s3:*on*) to the role and useaws iam simulate-principal-policyto show the boundary still deniesiam:CreateUser. - 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
subcondition 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.