Module 3: Infrastructure As Code For An Ai Endpoint

4. Hands-on: `BedrockManifestExtractorRole`, least privilege

Description

A guardrail controls what the model can read and say. This lesson's role controls who can invoke the model, and which one — a different layer, just as necessary as the first. BedrockManifestExtractorRole is the role extract-shipment-manifest-fields assumes to call bedrock:InvokeModel, scoped, with real terraform validate, to a single model's ARN — never the entire service. This lesson's code ran for real; the apply against IAM and the awslocal read are marked precisely, with the exact reason why, in this specific environment.

Connection to the module

This is the role Module 5, lesson 3 verifies with a dedicated Rego policy (bedrock-least-privilege.rego), and the same one this module's lesson 7 picks back up to trace the exact boundary between what can actually be applied in this lab and what can't. Everything that follows — guardrails, the security gate, the cost gate — assumes this role, not bedrock:*, is the only gateway to the model.


Analogy: a room's key, not the building's master key

An office building gives a new employee a key that opens exactly the room where they work — not the master key that opens every door in the building, even though that master key is, technically, "simpler to manage" (one key for everything, nothing to remember about who needs what). The reason for giving the scoped key, not the master, isn't distrust of that specific employee: it's that when a key gets lost, cloned, or used from a compromised account, the possible damage is limited to exactly what that key opens. bedrock:InvokeModel scoped to a specific model's ARN is the room's key. bedrock:* — or bedrock:InvokeModel on "*", any ARN — is the master key: if something goes wrong with extract-shipment-manifest-fields's credentials, the difference between those two policies is the difference between "can invoke Nova Lite" and "can invoke, manage, delete, or reconfigure any Bedrock resource in the entire account."


Step 1 — The exact model ARN, and why it matters that it's exact

GENAI-COST-PROFILE.md (Module 2, section 2) already fixed the decision: Amazon Nova Lite (amazon.nova-lite-v1:0), on-demand. A Bedrock base model's ARN follows a fixed format, with no AWS account in the path — because base models are a shared AWS resource, not something each account owns individually:

arn:aws:bedrock:{region}::foundation-model/{model-id}

For Andes Cargo, in us-east-1, with the chosen model:

arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-lite-v1:0

This gets declared as a local, not a value repeated by hand everywhere it's needed — the same locals.tf discipline andes-cargo-infra/ has already followed for common_tags since terraform-and-iac-guide's Module 1:

locals {
  common_tags = {
    Project     = "andes-cargo"
    Environment = var.environment
    ManagedBy   = "terraform"
  }

  # The exact Bedrock model BedrockManifestExtractorRole is allowed to invoke.
  # GENAI-COST-PROFILE.md (Module 2, section 2) chose Amazon Nova Lite. Scoping
  # bedrock:InvokeModel to this single ARN -- never "bedrock:*", never a wildcard
  # ARN -- is the least-privilege decision this lesson builds.
  manifest_extractor_model_arn = "arn:aws:bedrock:${var.aws_region}::foundation-model/amazon.nova-lite-v1:0"
}

var.aws_region already exists in variables.tf since terraform-and-iac-guide, with default = "us-east-1" — it's reused here, with no value duplicated.


Step 2 — The role, with modules/iam-role/ inherited, no rewrite

modules/iam-role/ already exists, built in terraform-and-iac-guide Module 5, lesson 7, and already used twice in andes-cargo-infra/ (LambdaManifestProcessorRole, AppServerRole). This role is the third use, with no change to the module itself — proof the mold genuinely generalizes to a domain that didn't exist when it was written:

data "aws_iam_policy_document" "bedrock_manifest_extractor_trust" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

data "aws_iam_policy_document" "bedrock_manifest_extractor_permissions" {
  statement {
    sid    = "InvokeManifestExtractionModelOnly"
    effect = "Allow"

    actions = [
      "bedrock:InvokeModel",
    ]

    # Scoped to exactly one model ARN -- never "bedrock:*", never a wildcard ARN:
    # Module 5, lesson 3 tests this exact statement against bedrock-least-privilege.rego.
    resources = [
      local.manifest_extractor_model_arn,
    ]
  }
}

module "bedrock_manifest_extractor_role" {
  source = "./modules/iam-role"

  role_name               = "BedrockManifestExtractorRole"
  trust_policy_json       = data.aws_iam_policy_document.bedrock_manifest_extractor_trust.json
  permissions_policy_json = data.aws_iam_policy_document.bedrock_manifest_extractor_permissions.json
  tags                    = local.common_tags
}

Notice the trust policy (bedrock_manifest_extractor_trust): the principal that can assume this role is lambda.amazonaws.com, not Bedrock. This isn't a mistake — BedrockManifestExtractorRole is the execution role for the extract-shipment-manifest-fields Lambda function (inherited from aws-serverless-and-containers-guide), not a role Bedrock assumes on its own. The Lambda function assumes this role on startup, and from there, with those temporary credentials, calls bedrock:InvokeModel — the exact same pattern LambdaManifestProcessorRole already uses so process-shipment-manifest can read from S3 and write to DynamoDB.


Step 3 — terraform validate, real, on the complete role

terraform validate

What to expect (literal — executed in this environment while writing this lesson, on the complete andes-cargo-infra/, including this role and lesson 3's module):

Success! The configuration is valid.

terraform validate doesn't distinguish "validate only this" from "validate the whole project" — it always reviews the complete project. To confirm, in isolation, that this specific role produces the correct plan without dragging in the rest of andes-cargo-infra/, use -target, the same flag this module's lesson 5 explains in detail:

terraform plan -input=false -no-color -target=module.bedrock_manifest_extractor_role

What to expect (literal — executed in this environment):

  # module.bedrock_manifest_extractor_role.aws_iam_role.this will be created
  + resource "aws_iam_role" "this" {
      + name                  = "BedrockManifestExtractorRole"
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "lambda.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      ... (other "known after apply" attributes)
    }

  # module.bedrock_manifest_extractor_role.aws_iam_role_policy.this will be created
  + resource "aws_iam_role_policy" "this" {
      + name        = "BedrockManifestExtractorRole-policy"
      + policy      = jsonencode(
            {
              + Statement = [
                  + {
                      + Action   = "bedrock:InvokeModel"
                      + Effect   = "Allow"
                      + Resource = "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-lite-v1:0"
                      + Sid      = "InvokeManifestExtractionModelOnly"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      ... (other "known after apply" attributes)
    }

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

Two resources — the role and its inline policy —, and this lesson's central point visible, literal, in the output: "Resource": "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-lite-v1:0", not "bedrock:*", not "*". This is exactly the JSON that would get installed in IAM if this plan were actually applied.


Step 4 — The boundary: what's real here, what's representative, and exactly why

This is where this lesson has to be precise, not approximate. IAM, as a service, is confirmed on LocalStack's free Hobby plan since aws-core-services-guide — none of that changed. On a machine with LocalStack running and a real Hobby token exported, the following two commands would run for real and produce real results:

tflocal apply -target=module.bedrock_manifest_extractor_role -auto-approve
awslocal iam get-role-policy --role-name BedrockManifestExtractorRole --policy-name BedrockManifestExtractorRole-policy

In this specific writing environment, neither of the two commands above ran. Not because IAM is outside Hobby — it isn't —, but for the same reason, already confirmed by direct execution, this guide's Module 1, lesson 7 documented with the real exit code 55: this sandbox has no LOCALSTACK_AUTH_TOKEN exported, so the LocalStack container never gets to start for any service — not Bedrock (outside Hobby either way), not IAM (inside Hobby, but still blocked here by the missing token).

   THIS LESSON'S EXACT BOUNDARY

   terraform validate / terraform plan
        │
        │  Run REAL, no exception -- confirmed above,
        │  Plan: 2 to add, with no LocalStack, no token.
        ▼
   tflocal apply -target=module.bedrock_manifest_extractor_role
        │
        │  On YOUR machine, with LocalStack + a real Hobby token:
        │  would run for real -- IAM IS in Hobby.
        │
        │  In THIS writing sandbox: didn't run -- exit code 55,
        │  the same "no token" limit Module 1, lesson 7
        │  already confirmed, unrelated to which LocalStack plan
        │  includes which service.
        ▼
   awslocal iam get-role-policy ...
        │
        │  On YOUR machine: would return the policy's real JSON,
        │  identical to what "terraform plan" already showed above.
        │
        │  In THIS sandbox: representative -- built precisely
        │  from the real JSON the plan already produced,
        │  never invented.

What to expect (representative — built, precisely, from the exact JSON terraform plan already showed in Step 3, never actually invoked in this environment):

{
    "RoleName": "BedrockManifestExtractorRole",
    "PolicyName": "BedrockManifestExtractorRole-policy",
    "PolicyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Sid": "InvokeManifestExtractionModelOnly",
                "Effect": "Allow",
                "Action": "bedrock:InvokeModel",
                "Resource": "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-lite-v1:0"
            }
        ]
    }
}

This module's lesson 7 revisits this same boundary in more depth — including what would happen if you tried applying the guardrail alongside the role in the same command; here, the exact distinction is enough: this specific role's limit belongs to this writing environment, not to the service. Anyone with a free Hobby token, on their own machine, runs these two commands for real.


Common mistakes

Writing resources = ["arn:aws:bedrock:*:*:foundation-model/*"] or resources = ["*"], thinking "I'll scope it later" (postponed-least-privilege mistake). What happens: someone, to "get it working first," uses a broad wildcard intending to restrict it in a future iteration that never arrives. How to spot it: if your terraform plan shows "Resource": "*" in BedrockManifestExtractorRole's policy, instead of this lesson's specific ARN. How to fix it: Module 5, lesson 3 builds a Rego policy (bedrock-least-privilege.rego) that does exactly this check automatically and blocks a plan with a wildcard like that before it reaches apply — but the correct discipline is to write the specific ARN from the first draft, not trust that a later gate is going to catch it.

Confusing the trust policy (assume_role_policy, who can assume the role) with the permissions policy (permissions_policy_json, what it can do once it's assumed it) (the two JSON documents of an IAM role mistake). What happens: someone tries putting bedrock:InvokeModel inside data.aws_iam_policy_document.bedrock_manifest_extractor_trust, or tries putting lambda.amazonaws.com as a resource inside the permissions policy. How to spot it: a terraform validate that does pass (both documents are syntactically valid policy JSON), but a role that, actually applied, wouldn't give Lambda the correct permission, or wouldn't let anyone assume it. How to fix it: the trust policy answers "who can become this role?" (here, the Lambda service); the permissions policy answers "what can it do once it already is this role?" (here, invoke exactly one model). They're two different questions, with two different JSON documents, exactly as you already saw with LambdaManifestProcessorRole in iam.tf.

Thinking that, because IAM is on Hobby, this role COULD actually be applied in this sandbox (generalizing "the service is available" to "the command ran here" mistake). What happens: someone reads that IAM is a Hobby service and concludes this lesson should have been able to run tflocal apply for real. How to spot it: if your question is "why does the lesson say representative if IAM is free?" How to fix it: these are two independent questions, the same distinction Module 1, lesson 7 already made for Bedrock — there, with two independent obstacles (the sandbox's token, LocalStack's plan). Here there's only one obstacle, but it's real: this guide's writing sandbox specifically has no LOCALSTACK_AUTH_TOKEN exported, so no service — not even the ones that are on Hobby — starts in this environment. With your own free, registered token, this same role does get applied and read for real.


Exercises

Exercise 1 — Rewrite, from memory, the complete ARN for the model BedrockManifestExtractorRole can invoke. Without looking at this lesson, write the exact ARN, including the region and the double-colon format before foundation-model/.

See solution

arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-lite-v1:0. The double colon (::) marks the deliberate absence of an account ID in the path — Bedrock base models don't belong to any individual account, they're a shared AWS resource, unlike, for example, a DynamoDB table's or an S3 bucket's ARN, which do include the owning account's ID.

Exercise 2 — Explain why the trust policy's principal is lambda.amazonaws.com and not bedrock.amazonaws.com. A colleague, seeing the name BedrockManifestExtractorRole, assumes Bedrock itself should be the principal assuming this role. Correct them.

See solution

The role's name describes what it's used for (extracting manifests via Bedrock), not who assumes it. The one assuming this role is the extract-shipment-manifest-fields Lambda function — the exact pattern LambdaManifestProcessorRole already uses for process-shipment-manifest —; once Lambda assumes the role, it uses the resulting temporary credentials to call bedrock:InvokeModel as part of its own code. Bedrock never "assumes" anyone's role in this flow; it's the service the code, already running with the role's credentials, makes an outgoing call to. Confusing this would lead to putting bedrock.amazonaws.com as the principal, which would leave the role with nobody who could actually use it from Lambda.

Exercise 3 — Predict what terraform plan -target=module.bedrock_manifest_extractor_role would show if, by mistake, someone added a second model to the ARN scope, like this: resources = [local.manifest_extractor_model_arn, "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0"]. Would terraform validate still be able to catch that this violates the least-privilege principle?

See solution

terraform validate would NOT catch it — syntactically, a list with two valid ARNs is perfectly correct HCL, and the plan would simply show two entries in the "Resource" array instead of one. Least privilege isn't a type or syntax property validate can verify by definition — it's a business-scope decision ("how many models does this function really need to invoke?"). This is exactly why Module 5, lesson 3 builds a dedicated Rego policy (bedrock-least-privilege.rego): a separate check, written on purpose for this specific question, that a generic terraform validate could never answer on its own.


Summary and next step

In this lesson you built BedrockManifestExtractorRole by reusing modules/iam-role/ with no change to the mold, with a permissions policy scoping bedrock:InvokeModel to a single model's ARN — Amazon Nova Lite, the decision already fixed by GENAI-COST-PROFILE.md. You confirmed terraform validate and a targeted plan, both real, with Plan: 2 to add and the policy's exact JSON visible, literal, in the output. You traced this role's exact boundary: IAM is indeed a Hobby service, but this specific writing sandbox has no LocalStack token exported, so the apply and the awslocal read stay representative here — not because of the service, because of the environment.

Before moving on you should be able to: write from memory a Bedrock base model ARN's format; explain the difference between trust policy and permissions policy; and explain why terraform validate could never, on its own, catch a business-scope least-privilege violation.

Lesson 5 combines this role with lesson 3's module into a single terraform plan — Andes Cargo's complete AI infrastructure, planned end to end, with the real number of resources to create.

Resources

  1. Terraform Registry — aws_iam_role_policy — official reference for the inline policy this role uses.
  2. AWS Docs — Amazon Bedrock foundation model ARNs — official format for the ARN cited in this lesson.
  3. cloud-security-and-guardrails-guide, Module 2 — the IAM least-privilege discipline this lesson applies for the first time to a model, not a service.
  4. This guide's Module 1, lesson 7 (07-hands-on-the-honest-attempt-against-bedrock-on-localstack.md) — the source of the exit code 55 finding, reused here to explain why this lesson's apply is representative.