Module 2: Federated Identity And Least Privilege Iam

8. Project: Andes Cargo's federated identity

Description

This project brings this module's four "hands-on" lessons together into one verifiable piece: the complete modules/oidc-provider/ (identity provider + federated role), Andes Cargo's two real roles trimmed to least privilege, a combined plan running over the entire project, and a short — honestly representative — document on what would change, and what would not, if Andes Cargo ever federated against a real AWS account.

Connection to the module

With this project, RISK-MAP.md closes its first two rows: TM-01 (lesson 5) and TM-07 (lesson 7). Module 3 opens the third — TM-05, plaintext secrets — on exactly this same foundation: an already-hardened identity, ready for a secret to live only where someone with the correct identity can read it.


Step 1 — modules/oidc-provider/, complete

The three files, as they stood at the close of lesson 5 — nothing changes in the rest of this project.

modules/oidc-provider/variables.tf:

variable "thumbprint_list" {
  description = "SHA-1 thumbprints of the GitHub Actions OIDC issuer's TLS certificate chain."
  type        = list(string)
  default     = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

variable "tags" {
  description = "Tags applied to the OIDC provider and the role this module creates."
  type        = map(string)
  default     = {}
}

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
}

modules/oidc-provider/main.tf:

resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  thumbprint_list = var.thumbprint_list

  tags = var.tags
}

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
}

modules/oidc-provider/outputs.tf:

output "provider_arn" {
  description = "ARN of the GitHub Actions OIDC identity provider."
  value       = aws_iam_openid_connect_provider.github_actions.arn
}

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
}

And, at the root of andes-cargo-infra/, 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
}

Step 2 — The combined plan: new identity + trimmed roles, in a single run

terraform fmt -recursive
terraform init
terraform validate

What to expect (literal, executed to write this lesson):

Success! The configuration is valid.
terraform plan

What to expect (literal, executed to write this lesson — this module's complete project, oidc.tf + trimmed iam.tf, in a single run):

Terraform will perform the following actions:

  # module.app_server_role.aws_iam_role.this will be created
  # module.app_server_role.aws_iam_role_policy.this will be created
  # module.github_oidc.data.aws_iam_policy_document.trust will be read during apply
  # module.github_oidc.aws_iam_openid_connect_provider.github_actions will be created
  # module.github_oidc.aws_iam_role.deploy will be created
  # module.lambda_manifest_processor_role.aws_iam_role.this will be created
  # module.lambda_manifest_processor_role.aws_iam_role_policy.this will be created

Plan: 6 to add, 0 to change, 0 to destroy.

Six managed resources — the data never counts, same rule as always — two for each of the project's three roles (the role itself + its inline policy), plus the identity provider. Notice something that confirms everything you built in this module, in a single view: AppServerRole and LambdaManifestProcessorRole no longer share any identical policy — the reason TM-07 gets resolved — and AndesCargoDeployRole is a third role, with a third kind of trust policy (Federated, not Service), that neither of the other two has — three identities, three different purposes, none with more scope than it needs.


Step 3 — Applying the complete project (representative)

What to expect (representative), same reason as always:

tflocal apply -auto-approve
module.app_server_role.aws_iam_role.this: Creating...
module.lambda_manifest_processor_role.aws_iam_role.this: Creating...
module.github_oidc.aws_iam_openid_connect_provider.github_actions: Creating...
module.app_server_role.aws_iam_role.this: Creation complete after 1s [id=AppServerRole]
module.lambda_manifest_processor_role.aws_iam_role.this: Creation complete after 1s [id=LambdaManifestProcessorRole]
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.app_server_role.aws_iam_role_policy.this: Creating...
module.lambda_manifest_processor_role.aws_iam_role_policy.this: Creating...
module.github_oidc.data.aws_iam_policy_document.trust: Reading...
module.app_server_role.aws_iam_role_policy.this: Creation complete after 0s [id=AppServerRole:AppServerRole-policy]
module.lambda_manifest_processor_role.aws_iam_role_policy.this: Creation complete after 0s [id=LambdaManifestProcessorRole:LambdaManifestProcessorRole-policy]
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: 6 added, 0 changed, 0 destroyed.

Notice the parallelism: the graph's three starting points — AppServerRole, LambdaManifestProcessorRole, and the identity provider — get created together, none waiting on another (none depends on another), exactly the same behavior you already saw with terraform graph in terraform-and-iac-guide, Module 8, lesson 2. AndesCargoDeployRole is, of the three roles, the only one with a real dependency chain: it waits for the identity provider, then for the data that reads its ARN, and only then gets created.

awslocal iam list-roles --query 'Roles[].RoleName'

What to expect (representative):

["AppServerRole", "LambdaManifestProcessorRole", "AndesCargoDeployRole"]

Three roles — the two inherited ones, now trimmed, plus the new federated role. andes-cargo-infra/ never had, before this module, a single role meant to be assumed by anything other than an AWS service (lambda.amazonaws.com, ec2.amazonaws.com) — AndesCargoDeployRole is the first meant for an external identity, with a completely different trust mechanism.


Step 4 — The short document: what would change against a real AWS account

At the root of andes-cargo-infra/, create FEDERATION-TO-REAL-AWS.md — a short document, honestly labeled representative from its first line, answering a single question: if Andes Cargo stopped being a lab case and actually federated against a real AWS account, how much of what this module built would have to change?

# FEDERATION-TO-REAL-AWS.md — What Changes Against a Real AWS Account

**Status:** Representative (not executed) · **Scope:** `modules/oidc-provider/` and its call site
**Why representative:** this repository's Terraform, applied against LocalStack Hobby, cannot be
applied against a real AWS account without an actual account to target — this document is the
honest map of that gap, not a promise this guide tested.

## What does NOT change: all of it

Every file inside `modules/oidc-provider/``main.tf`, `variables.tf`, `outputs.tf` — is
account-agnostic HCL. `aws_iam_openid_connect_provider`, the trust policy's two conditions, and
`aws_iam_role.deploy` reference no LocalStack-specific value anywhere. The two tightened policies
in `iam.tf` (Lesson 7) are equally account-agnostic. This is not a coincidence: it is the direct
result of never hardcoding the account ID `000000000000` inside any `resource` or `data` block in
this module — every ARN that needs an account ID is either resolved by the provider at apply time,
or, in the trust policy's `sub` condition, refers to a GitHub repository, never to an AWS account.

## What changes: exactly one thing, the target account

**The `provider "aws"` block.** Every LocalStack-specific argument this ecosystem has used since
`terraform-and-iac-guide` Module 1 — `access_key = "test"`, `secret_key = "test"`,
`skip_credentials_validation`, `skip_metadata_api_check`, `skip_requesting_account_id`, and the
entire `endpoints { ... }` block pointing at `http://localhost:4566` — disappears entirely. A
provider block targeting real AWS needs none of them: Terraform resolves real credentials from the
standard credential chain (an IAM user's access key via `aws configure`, an assumed role, or —
appropriately, given this module's own subject — OIDC federation for the operator's own CI/CD, the
same mechanism this module builds for Andes Cargo's pipeline).

```hcl
# LocalStack (this guide, all modules)          # Real AWS (representative, not executed here)
provider "aws" {                                 provider "aws" {
  region             = "us-east-1"                 region = "us-east-1"
  access_key         = "test"                       # credentials resolved from the standard
  secret_key         = "test"                       # chain -- no access_key/secret_key here
  skip_credentials_validation = true               }
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
  endpoints { ... http://localhost:4566 ... }
}
```

## What changes on the GitHub side: the account number inside one ARN

`cicd-and-gitops-on-aws-guide` Module 4, lesson 5 already showed the exact YAML a real pipeline
would use — `role-to-assume: arn:aws:iam::123456789012:role/AndesCargoDeployRole`. The only
account-specific value in that entire workflow is the twelve-digit account number inside that one
ARN, replacing the placeholder `123456789012` with Andes Cargo's real account ID. Nothing else in
that YAML, and nothing in this module's HCL, changes.

## What becomes executable, not just representative

With a real AWS account behind `AndesCargoDeployRole`, `IAM Policy Enforcement` is no longer a paid
LocalStack feature that this guide has to work around — it is simply how IAM always works on a real
account, no plan tier required. The exact experiment of Lesson 6 (presenting a JWT to
`sts:AssumeRoleWithWebIdentity`) would, for the first time, produce a real, enforced result: a
genuine GitHub Actions-signed token would succeed, and this lesson's test JWT — signed with a
symmetric test key, using an algorithm (`HS256`) real AWS does not even accept for this operation —
would fail outright.

## What this document is not

This is not a migration runbook, a cost estimate, or a security review of a specific AWS account —
those are out of scope for a $0 lab guide. It is a scoped answer to one question: how much of the
HCL this module built would survive, unchanged, a move to a real account. The answer, verified
against every file this module touched, is: all of it except the `provider` block itself.

Step 5 — RISK-MAP.md, with the complete module

With lessons 5 and 7 already updated, RISK-MAP.md's first two rows now read:

OrderIDSTRIDERiskControlModuleStatus
1TM-01SpoofingLong-lived static pipeline credentialsOIDC federation + scoped trust policyM2Resolved (M2.5)
2TM-07Elevation of privilegeAppServerRole broader than its actual usageLeast-privilege role tighteningM2.7Resolved (M2.7)

Five rows remain OpenTM-05 (plaintext secrets, Module 3), TM-04 and TM-06 (Module 4), TM-02 (Module 6), TM-03 (Module 7) — exactly the order that document's Decision section already justified.


Module 2's close

With modules/oidc-provider/ applied, the two real roles trimmed, and FEDERATION-TO-REAL-AWS.md honestly documenting this lab's exact limit, this module delivers what it promised in its first lesson: federated identity actually built, not just named — down to the exact limit LocalStack Hobby allows, with that limit turned into lesson 6's pedagogical experiment, not hidden. andes-cargo-infra/ now has three roles, each with exactly the scope it needs, and zero long-lived credentials added by this module.


Common mistakes

Publishing FEDERATION-TO-REAL-AWS.md as if it were a proven migration guide (document scope mistake). What happens: someone takes this document and presents it as "this is how Andes Cargo federates against real AWS, already verified." How to spot it: if your description of the document omits the word "representative" from its own heading. How to fix it: the document is explicit, from its first line, about what it is and isn't — an honest map of the necessary change, not an executed migration. The difference matters: anyone using it as a real checklist should still run terraform plan against the real account before any apply, exactly the same discipline this entire guide teaches.

Thinking this project added some new business resource (expectation mistake, revisited from this module's lesson 1). What happens: someone reviews the six-resource plan and assumes one of them is part of Andes Cargo's business infrastructure (the bucket, the table, the function). How to spot it: if you go looking for aws_s3_bucket, aws_dynamodb_table, or aws_lambda_function in this lesson's plan. How to fix it: all six resources, without exception, belong to the identity layer — three roles (one new, two trimmed) and their policies, plus an identity provider. Not a single business resource was touched in this entire module, exactly as this guide's design promised.

Confusing the plan's resource count (6) with the role count (3). What happens: someone, seeing Plan: 6 to add, concludes this module created six different roles. How to spot it: if your count of new roles doesn't match Step 3's awslocal iam list-roles. How to fix it: count the six resources one by one against Step 2's plan: AppServerRole contributes two (the role + its inline policy), LambdaManifestProcessorRole contributes two more, and AndesCargoDeployRole contributes just one — the identity provider is the sixth resource, a resource of its own, not a role. Two trimmed roles × two resources each (four) + one new role (one) + the identity provider (one) = six, with exactly three real roles behind that number. The correct way to verify how many roles exist, without doing arithmetic on the plan, is always awslocal iam list-roles, not the raw Plan: N to add number.


Exercises

Exercise 1 — Rebuild from memory the three andes-cargo-infra/ roles at this module's close, with their trust policy and permission scope. Without looking back, list the three roles, what each can assume, and which S3 prefix (if applicable) each has access to.

See solution

LambdaManifestProcessorRole — trusts lambda.amazonaws.com; reads manifests/* (s3:GetObject + conditioned s3:ListBucket), writes to Shipments (inherited, no changes from this module). AppServerRole — trusts ec2.amazonaws.com; reads photos/* (same pattern, different prefix). AndesCargoDeployRole — trusts the GitHub Actions identity provider (Federated), conditioned on aud == sts.amazonaws.com and sub LIKE repo:andes-cargo/andes-cargo-infra:ref:refs/heads/main; with no permission policy declared yet in this module (that's, deliberately, work outside this specific module's scope — the trust policy resolves "who can assume this role," not "what it can do once inside").

Exercise 2 — Explain why FEDERATION-TO-REAL-AWS.md concludes "all the HCL survives" the account change. Using the document's own evidence, explain why no file in modules/oidc-provider/ ever needed, at any point in this module, to write account 000000000000 literally.

See solution

Because every reference that would, in theory, need an account number resolves in one of two ways that never require writing it by hand: either Terraform computes it automatically from the provider's active credentials (the identity provider's ARN, each role's ARN, both known after apply), or the trust policy's condition needs no account number at all — the sub pattern references a GitHub repository, not an AWS account. The only 000000000000 account that appears anywhere in this guide lives in the provider block, in the reconstructed ARNs of representative sections (so the example is readable), and in LocalStack's real state — never inside a resource or data block of the module itself.

Exercise 3 — Defend, to a technical interviewer, why this module counts as evidence of OIDC mastery even though it never ran against real AWS. An interviewer asks: "did you test this against a real AWS account?" How would you respond, using this module's complete structure as evidence?

See solution

A complete answer sounds, roughly, like this: "Not against a real account, and I can explain exactly why, with the technical source cited: LocalStack Hobby, this whole project's free lab, doesn't include IAM Policy Enforcement — it's documented as a paid-plan feature. What I can show is that the complete HCL — the identity provider, the trust policy with its two exact conditions, the two existing roles trimmed to least privilege — passes validate and produces the correct plan against Terraform's real engine, and that I understand, precisely, the exact difference between what I built and what it would take to prove it end to end: a real AWS account, nothing more, because the HCL itself has no pending change. That level of honesty about the exact limit of what I tested is, in itself, part of what I'm demonstrating."


Summary and next step

With this project you closed the complete Module 2: modules/oidc-provider/ applied end to end, with the identity provider and trust policy lessons 4 and 5 built; Andes Cargo's two real roles trimmed to exact least privilege, closing TM-07; and FEDERATION-TO-REAL-AWS.md, the representative document that precisely traces the single real change that would separate this module from federating against a genuine AWS account — the provider block, nothing in the identity HCL itself.

Before closing this module you should be able to: recite the three andes-cargo-infra/ roles with their trust policy and exact scope; explain why modules/oidc-provider/ is account-agnostic, with no account number written by hand; and defend, with cited technical evidence, why this module is real evidence of OIDC mastery even though the final apply stays representative.

Module 3 opens RISK-MAP.md's third row: TM-05, the plaintext .secrets inherited from cicd-and-gitops-on-aws-guide, replaced by SSM Parameter Store and Secrets Manager — on exactly the already-hardened identity this module left ready.

Resources

  1. This module, lessons 4, 5, and 7 — the complete source for the HCL brought together in this project.
  2. cicd-and-gitops-on-aws-guide, Module 4, lesson 5 — the complete YAML FEDERATION-TO-REAL-AWS.md references as the piece already ready on the GitHub Actions side.
  3. AWS Docs — IAM Roles for GitHub Actions — complete official reference for the mechanism this project leaves built.
  4. This course, Module 1, lesson 8 (RISK-MAP.md) — the document this project updates with the first two rows resolved.