Module 2: Federated Identity And Least Privilege Iam
5. Hands-on: a least-privilege trust policy
Description
Lesson 4 left modules/oidc-provider/ with "who I trust" declared. This lesson adds "what I allow them to do": an aws_iam_role whose assume_role_policy doesn't accept just any token signed by GitHub, but only one whose sub is, letter for letter, repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main. terraform fmt, validate, and plan run for real again, and this time you'll see something new in the output: a data Terraform can't resolve yet, for a real reason worth understanding.
Connection to the module
With this lesson, modules/oidc-provider/ is complete — the two pieces from lesson 3's diagram, the identity provider and the trust policy, living in the same module. Lesson 6 uses exactly this role, AndesCargoDeployRole, as the target of the sts:AssumeRoleWithWebIdentity call in this module's central experiment.
Step 1 — Extending modules/oidc-provider/variables.tf
Add these two variables to the file that already exists (don't delete anything from lesson 4):
variable "role_name" {
description = "Name of the IAM role that GitHub Actions assumes via OIDC."
type = string
}
variable "github_repo_ref" {
description = "repo:owner/name:ref:refs/heads/branch pattern allowed to assume this role."
type = string
}
Unlike lesson 4's thumbprint_list and tags, these two don't have a default — the same design criterion you already saw in modules/iam-role/: a role with no name, or a trust policy with no repository pattern at all, makes no operational sense. Forcing whoever calls the module to think through these two values is part of what makes this module safe to reuse for a second repository, the day Andes Cargo needs it.
Step 2 — Extending modules/oidc-provider/main.tf
Add this to the end of the file, after lesson 4's resource:
data "aws_iam_policy_document" "trust" {
statement {
sid = "GitHubActionsOIDC"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github_actions.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [var.github_repo_ref]
}
}
}
resource "aws_iam_role" "deploy" {
name = var.role_name
assume_role_policy = data.aws_iam_policy_document.trust.json
tags = var.tags
}
Read it piece by piece, against lesson 3's exact vocabulary:
actions = ["sts:AssumeRoleWithWebIdentity"]— not plainsts:AssumeRole. It's the specific STS variant for federation with an external token, different from the oneLambdaManifestProcessorRole/AppServerRoleuse (which trust an AWSService, not a third-party token).principals { type = "Federated", identifiers = [aws_iam_openid_connect_provider.github_actions.arn] }— the direct reference to lesson 4's identity provider. This line is, literally, the HCL for "I trust tokens that already passed through that identity provider" — without it, there'd be no connection at all between the module's two resources.- Two
conditionblocks, one for each claim that matters:StringEqualsonaud(exact comparison, becausests.amazonaws.comnever varies) andStringLikeonsub(allows a pattern, even though in this case the pattern is, in fact, an exact value with no wildcards —StringLikeis used instead ofStringEqualsby convention in AWS's official documentation for this specific condition, leaving room for a future wildcard without changing the condition type). resource "aws_iam_role" "deploy"— note the local namedeploy, different from thethismodules/iam-role/uses. It's a deliberate decision: this module, unlikemodules/iam-role/, isn't a generic reusable mold for any role — it's a module specific to the federated deploy role, so its internal name can be descriptive without losing real generality.
Step 3 — Extending modules/oidc-provider/outputs.tf
output "role_name" {
description = "Name of the created deploy role."
value = aws_iam_role.deploy.name
}
output "role_arn" {
description = "ARN of the created deploy role."
value = aws_iam_role.deploy.arn
}
Step 4 — Updating the module call in oidc.tf
module "github_oidc" {
source = "./modules/oidc-provider"
role_name = "AndesCargoDeployRole"
github_repo_ref = "repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main"
tags = local.common_tags
}
Two new inputs, with Andes Cargo's exact values: the name a real pipeline's role would use, and the repository/branch pattern scoped to main — not repo:andes-cargo/andes-cargo-infra:*, the misconfiguration cicd-and-gitops-on-aws-guide M4.5 already named as the most common in real implementations.
Step 5 — fmt, validate: no surprises
terraform fmt -recursive
terraform validate
What to expect (literal, executed to write this lesson):
Success! The configuration is valid.
Step 6 — The plan, and something new worth understanding
terraform plan
What to expect (literal, executed to write this lesson):
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
# module.github_oidc.data.aws_iam_policy_document.trust will be read during apply
# (config refers to values not yet known)
<= data "aws_iam_policy_document" "trust" {
+ id = (known after apply)
+ json = (known after apply)
+ minified_json = (known after apply)
+ statement {
+ actions = [
+ "sts:AssumeRoleWithWebIdentity",
]
+ effect = "Allow"
+ sid = "GitHubActionsOIDC"
+ condition {
+ test = "StringEquals"
+ values = [
+ "sts.amazonaws.com",
]
+ variable = "token.actions.githubusercontent.com:aud"
}
+ condition {
+ test = "StringLike"
+ values = [
+ "repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main",
]
+ variable = "token.actions.githubusercontent.com:sub"
}
+ principals {
+ identifiers = [
+ (known after apply),
]
+ type = "Federated"
}
}
}
# module.github_oidc.aws_iam_openid_connect_provider.github_actions will be created
+ resource "aws_iam_openid_connect_provider" "github_actions" {
+ arn = (known after apply)
+ client_id_list = [
+ "sts.amazonaws.com",
]
+ id = (known after apply)
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ thumbprint_list = [
+ "6938fd4d98bab03faadb97b34396831e3780aea1",
]
+ url = "https://token.actions.githubusercontent.com"
}
# module.github_oidc.aws_iam_role.deploy will be created
+ resource "aws_iam_role" "deploy" {
+ arn = (known after apply)
+ assume_role_policy = (known after apply)
+ create_date = (known after apply)
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "AndesCargoDeployRole"
+ name_prefix = (known after apply)
+ path = "/"
+ tags = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ tags_all = {
+ "Environment" = "dev"
+ "ManagedBy" = "terraform"
+ "Project" = "andes-cargo"
}
+ unique_id = (known after apply)
+ inline_policy (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
The new detail worth stopping to understand: data.aws_iam_policy_document.trust shows up marked <= ("will be read during apply"), not resolved directly in the plan, with the explicit note # (config refers to values not yet known). Compare this to terraform-and-iac-guide's data.aws_iam_policy_document.lambda_trust, which did resolve completely in the plan (Read complete after 0s) — the difference is that one's data only depended on a literal ("lambda.amazonaws.com"), while this one depends on aws_iam_openid_connect_provider.github_actions.arn, a value that doesn't exist yet because the resource that produces it is going to be created in the same apply. Terraform can't read the data until that ARN actually exists — so it defers that read until the moment of the apply, and that's why assume_role_policy on aws_iam_role.deploy also shows up (known after apply), not as the complete JSON you did see in terraform-and-iac-guide's Module 6, lesson 4. It's not an error or a warning — it's Terraform being honest about a real dependency between two resources created in the same apply.
Plan: 2 to add — the identity provider and the role; the data never counts toward that number, the same rule you already confirmed with Plan: 2 to add in terraform-and-iac-guide, Module 6, lesson 4.
Step 7 — Applying and verifying (representative)
What to expect (representative), same reason as this entire guide — with no LOCALSTACK_AUTH_TOKEN, the container doesn't start in this environment:
tflocal apply -auto-approve
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creating...
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creation complete after 1s [id=arn:aws:iam::000000000000:oidc-provider/token.actions.githubusercontent.com]
module.github_oidc.data.aws_iam_policy_document.trust: Reading...
module.github_oidc.data.aws_iam_policy_document.trust: Read complete after 0s [id=3924781605]
module.github_oidc.aws_iam_role.deploy: Creating...
module.github_oidc.aws_iam_role.deploy: Creation complete after 1s [id=AndesCargoDeployRole]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Notice the order: the identity provider gets created first, only then can Terraform read data.aws_iam_policy_document.trust (now that the ARN truly exists), and only then does it create the role — exactly the sequence Step 6's implicit dependency predicted.
awslocal iam get-role --role-name AndesCargoDeployRole
What to expect (representative — RoleId and CreateDate vary; everything else is fixed for this exact HCL):
{
"Role": {
"Path": "/",
"RoleName": "AndesCargoDeployRole",
"RoleId": "AROAQZ3EXAMPLEDEPLOYROL",
"Arn": "arn:aws:iam::000000000000:role/AndesCargoDeployRole",
"CreateDate": "2026-08-13T10:14:22+00:00",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "GitHubActionsOIDC",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::000000000000: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:andes-cargo/andes-cargo-infra:ref:refs/heads/main"
}
}
}
]
},
"MaxSessionDuration": 3600
}
}
There it is, complete: Principal.Federated pointing at lesson 4's identity provider by its real ARN (not a literal — Terraform resolved it during the apply), and the two exact conditions you wrote in HCL, now as real JSON inside the account. Anyone reviewing this role in the IAM console, or with this same command, can confirm unambiguously which repository and which branch are allowed to assume it — the same auditability lesson 3 promised as OIDC's central advantage over a shared key.
Common mistakes
Writing repo:andes-cargo/andes-cargo-infra:* instead of the pattern scoped to main (the mistake already named in cicd-and-gitops-on-aws-guide M4.5, now with real HCL consequences). What happens: someone, wanting to "be flexible" so any branch can deploy, drops the ref:refs/heads/main part of the pattern. How to spot it: if your github_repo_ref ends in :* right after the repository name, with no reference to a specific branch. How to fix it: that broad pattern would let any branch of that repository — including a feature branch opened by any collaborator, or a Pull Request — assume a role meant for production deployments. This module's lesson 3 already predicted, in its Exercise 3, that an attempt from a PR should fail with this role — a :* pattern breaks that guarantee.
Using StringEquals instead of StringLike for the condition on sub, and being surprised that "it works the same" (conceptual confusion mistake). What happens: someone swaps StringLike for StringEquals in the sub condition, and the plan/apply work with no error at all. How to spot it: in this specific case, there's no visible symptom — a value with no wildcards behaves the same with either operator. How to fix it: it's not a functional error today, but it is a decision that ties your hands later: if you ever need any branch of a repository to be able to assume a different role (for example, a read-only one for feature branches), StringLike lets you use repo:andes-cargo/andes-cargo-infra:ref:refs/heads/* without changing the condition type; with StringEquals you'd have to rewrite that condition from scratch. AWS's official documentation uses StringLike for sub precisely for this future flexibility.
Forgetting that aws_iam_openid_connect_provider.github_actions.arn only exists inside the same module (name-scope mistake). What happens: someone, writing Step 2's reference, tries to use module.github_oidc.aws_iam_openid_connect_provider.github_actions.arn (with the module.github_oidc. prefix) from inside the module's own main.tf. How to spot it: the Error: Reference to undeclared resource error — inside a module, its own resources are referenced by their local name, with no module prefix at all; the module.github_oidc. prefix only applies outside the module, to access its outputs. How to fix it: inside modules/oidc-provider/main.tf, the correct reference is exactly the one this lesson uses: aws_iam_openid_connect_provider.github_actions.arn, no prefix.
Exercises
Exercise 1 — Explain, in your own words, why data.aws_iam_policy_document.trust shows up <= in this plan but not in terraform-and-iac-guide's. Without looking back at this lesson, explain the exact difference between this data and terraform-and-iac-guide's data.aws_iam_policy_document.lambda_trust, which did resolve completely in the plan.
See solution
data.aws_iam_policy_document.lambda_trust, in terraform-and-iac-guide, only depends on literals ("lambda.amazonaws.com" as the principal) — nothing that data needs depends on any resource being created in the same apply, so Terraform can resolve it completely at plan time, with no real call to AWS. This lesson's data.aws_iam_policy_document.trust, on the other hand, references aws_iam_openid_connect_provider.github_actions.arn inside its principals block — a value that only exists after that resource is actually created. Terraform can't guess that ARN, so it defers the data's full read until the moment of the apply, when the ARN is already real.
Exercise 2 — Predict the exact creation order in the apply, and explain why that order isn't optional. Based on the references inside this lesson's HCL, in what order does Terraform have to create the identity provider, read the data, and create the role? Could Terraform, in theory, create the role first?
See solution
The order has to be: (1) create aws_iam_openid_connect_provider.github_actions, (2) read data.aws_iam_policy_document.trust (which can now resolve the real ARN), (3) create aws_iam_role.deploy with assume_role_policy = data.aws_iam_policy_document.trust.json. No, Terraform couldn't create the role first — its assume_role_policy depends directly on the data's result, which in turn depends on the identity provider's ARN. It's a three-link chain of implicit dependencies, computed automatically by Terraform's dependency graph, the same mechanics you already saw with terraform graph in terraform-and-iac-guide, Module 8, lesson 2 — nobody wrote depends_on anywhere in this module; the direct references already tell Terraform everything it needs.
Exercise 3 — Rewrite the sub condition to allow deployments from main or from a release/* branch. Using StringLike (not StringEquals), write what values would be in the condition on sub if Andes Cargo wanted this same role to be assumable both from main and from any branch starting with release/.
See solution
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main",
"repo:andes-cargo/andes-cargo-infra:ref:refs/heads/release/*",
]
}
values accepts a list, not just one string — StringLike evaluates each pattern in the list independently, and the whole condition passes if the token's sub matches at least one of them (OR behavior within the same condition operator). The * wildcard in release/* is exactly the mechanism that justified, in this lesson's Common mistakes, preferring StringLike over StringEquals from the start.
Summary and next step
In this lesson you completed modules/oidc-provider/: the AndesCargoDeployRole role, with a trust policy conditioned, with repository and branch precision, on the JWT's aud and sub claims. You saw, in a real plan, why a data that depends on a resource created in the same apply gets deferred until that moment (<=, "will be read during apply") — honest Terraform behavior, not an error. You confirmed (representative) that the apply creates both resources in the correct order, and that get-role returns exactly the trust policy you wrote, now auditable by anyone with IAM access.
Before moving on you should be able to: write the complete data "aws_iam_policy_document" "trust" from memory, with its two conditions; explain why this specific data doesn't resolve in the plan; and modify the sub pattern to allow an additional branch, using StringLike.
Lesson 6 uses this same role — AndesCargoDeployRole, with the trust policy you just built — as the target of a real experiment: building a test JWT with Python and PyJWT, and observing live what of all this LocalStack Hobby does verify, and what it doesn't.
Resources
- AWS Docs — Creating a role for web identity or OpenID Connect Federation — complete official documentation for the trust policy declared in this lesson, including
Conditionsyntax. - GitHub Docs — Configuring OpenID Connect in Amazon Web Services — the same source
cicd-and-gitops-on-aws-guideM4.5 already cited for the YAML; this lesson builds, on the AWS side, exactly the trust policy that page documents. - Terraform Registry —
aws_iam_role— complete reference for the resource, already used sinceterraform-and-iac-guide. - Terraform Registry —
data.aws_iam_policy_document— complete reference for thedata source, includingprincipalsandcondition. - AWS CLI —
iam get-role— complete reference for Step 7's verification command. cicd-and-gitops-on-aws-guide, Module 4, lesson 5 — the original source of therepo:andes-cargo/andes-cargo-infra:ref:refs/heads/mainpattern and the misconfiguration (:*with no branch) this lesson avoids.