Role-Based Access Control in Terraform
Infrastructure-as-Code doesn’t automatically mean secure infrastructure. Terraform must be given credentials to provision resources, and those credentials need to follow least-privilege principles. Getting RBAC right for Terraform pipelines prevents both security breaches and accidental mass-destruction of infrastructure.
The Principle of Least Privilege
Terraform should have exactly the permissions it needs — no more. Two separate roles for plan and apply:
| Operation | Permissions Needed | Risk Level |
|---|---|---|
terraform plan | Read-only: describe, list, get | Low — no changes made |
terraform apply | Read + Write: create, modify, delete specific resources | High — creates real changes |
terraform destroy | Same as apply + delete permissions | Very High |
AWS: IAM Roles for Terraform
Plan Role (Read-Only)
resource "aws_iam_role" "terraform_plan" { name = "terraform-plan-role"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com" } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } StringLike = { "token.actions.githubusercontent.com:sub" = "repo:myorg/infra-repo:pull_request" } } }] })}
resource "aws_iam_role_policy_attachment" "terraform_plan_readonly" { role = aws_iam_role.terraform_plan.name policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"}Apply Role (Scoped Write Access)
resource "aws_iam_role" "terraform_apply" { name = "terraform-apply-role"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com" } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" "token.actions.githubusercontent.com:sub" = "repo:myorg/infra-repo:ref:refs/heads/main" } } }] })}
resource "aws_iam_policy" "terraform_apply_scoped" { name = "terraform-apply-scoped"
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:*", "ecs:*", "rds:*", "s3:*", "elasticloadbalancing:*" ] Resource = "*" Condition = { StringEquals = { "aws:RequestedRegion" = "us-east-1" } } }, { # Allow Terraform state management Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] Resource = "arn:aws:s3:::my-terraform-state/*" }, { Effect = "Allow" Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"] Resource = "arn:aws:dynamodb:us-east-1:*:table/terraform-locks" } ] })}AWS IAM Resources via Terraform
# Create an application IAM roleresource "aws_iam_role" "app_role" { name = "${var.service_name}-${var.environment}-role"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } Action = "sts:AssumeRole" }] })
tags = local.common_tags}
# Attach a managed policyresource "aws_iam_role_policy_attachment" "ssm" { role = aws_iam_role.app_role.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"}
# Custom inline policyresource "aws_iam_role_policy" "app_s3" { name = "s3-access" role = aws_iam_role.app_role.id
policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject", "s3:PutObject"] Resource = "${aws_s3_bucket.app_data.arn}/*" }] })}
# Instance profile (attaches role to EC2 instances)resource "aws_iam_instance_profile" "app" { name = "${var.service_name}-instance-profile" role = aws_iam_role.app_role.name}Azure RBAC via Terraform
# Get current subscriptiondata "azurerm_subscription" "current" {}
# Create a custom roleresource "azurerm_role_definition" "terraform_deployer" { name = "Terraform Deployer" scope = data.azurerm_subscription.current.id
permissions { actions = [ "Microsoft.Resources/subscriptions/resourceGroups/*", "Microsoft.Compute/virtualMachines/*", "Microsoft.Network/*", "Microsoft.Storage/storageAccounts/*" ] not_actions = [ "Microsoft.Authorization/*/Delete", "Microsoft.Authorization/*/Write" ] }
assignable_scopes = [data.azurerm_subscription.current.id]}
# Assign role to service principalresource "azurerm_role_assignment" "ci_deployer" { scope = data.azurerm_subscription.current.id role_definition_name = azurerm_role_definition.terraform_deployer.name principal_id = var.service_principal_object_id}GCP IAM via Terraform
# Create service account for Terraformresource "google_service_account" "terraform" { account_id = "terraform-deployer" display_name = "Terraform Deployment Service Account" project = var.project_id}
# Grant permissionsresource "google_project_iam_member" "terraform_compute" { project = var.project_id role = "roles/compute.admin" member = "serviceAccount:${google_service_account.terraform.email}"}
resource "google_project_iam_member" "terraform_storage" { project = var.project_id role = "roles/storage.admin" member = "serviceAccount:${google_service_account.terraform.email}"}
# Workload Identity for GKE → no service account keysresource "google_service_account_iam_binding" "workload_identity" { service_account_id = google_service_account.app.name role = "roles/iam.workloadIdentityUser" members = [ "serviceAccount:${var.project_id}.svc.id.goog[${var.namespace}/${var.k8s_service_account}]" ]}Team Access Patterns
# Different permission levels for different teamslocals { team_access = { "platform-team" = "full" "dev-team" = "plan-only" "security-team" = "readonly" }}
resource "aws_iam_group" "terraform_users" { for_each = local.team_access name = "terraform-${each.key}"}
resource "aws_iam_group_policy_attachment" "terraform_users" { for_each = local.team_access group = aws_iam_group.terraform_users[each.key].name policy_arn = each.value == "full" ? aws_iam_policy.terraform_apply_scoped.arn : each.value == "plan-only" ? "arn:aws:iam::aws:policy/ReadOnlyAccess" : "arn:aws:iam::aws:policy/SecurityAudit"}