Cloud 10 — swap py-10's model for Bedrock, called with the task's own IAM role, never a key

In Java you call a third-party API with an API key: pasted into a config file, injected as an env var, rotated by hand when someone leaves. Bedrock is called with SigV4-signed IAM credentials — there is no Bedrock API key to paste anywhere. The caller's identity is a role, and a role's permissions are a policy, so "this service may call this one model" is a normal least-privilege statement instead of a secret to protect. This page adds one IAM policy to the task role cloud-09 already created, writes a ~20-line class that satisfies py-10's Model protocol over Bedrock instead of the stub, and measures what a real call actually costs — which, unlike cloud-09's RDS or cloud-12's SageMaker endpoint, is nothing while nobody is asking it anything.

~30 min1 IAM policy no new infra~$0.08–$0.88 / 1,000 requests (approx.) cloud-10

What this creates

One new resource: aws_iam_role_policy.task_bedrock, attached to aws_iam_role.task — the task role cloud-09's step 1 already added to modules/fargate-service/ for ECS Exec. It grants exactly two actions, bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream, on exactly one Resource: the ARN of Anthropic's Claude 3 Haiku on Bedrock in ap-south-1 — not bedrock:*, not Resource: "*". Bedrock bills on-demand InvokeModel calls per 1,000 tokens, split input/output, and nothing else: no per-hour charge for the model existing, no charge for a task that never calls it. The figures on this page (Claude 3 Haiku on-demand: ~$0.25 / 1M input tokens, ~$1.25 / 1M output tokens) are from the Bedrock pricing page as published, not re-verified live in this session — approx., verify on the pricing page today before you rely on them. Three prompt sizes below turn that per-token price into a per-1,000-request line; a fourth calls a second model this policy does not name, so you can see the deny, not just read about it.

Preconditions

Do it

1. Request model access — console, once per account per region. Bedrock console → Model access → Modify model access → check Anthropic · Claude 3 Haiku → Submit. The row usually reads Access granted within a minute; if the console asks for a one-screen use-case form first (it does for some accounts, and sometimes not at all any more), fill it — that is the whole gate. If Claude 3 Haiku shows as legacy in your region, pick the current Haiku instead and use its ARN in step 2; the policy shape and the client do not change, only the price and the model id. Nothing here is Terraform — model access is an account-level grant, not a resource this page's state can create or destroy.

2. The IAM policy — modules/fargate-service/main.tf, next to cloud-09's task_exec policy on the same role. A second variable so the module never hardcodes a model id:

# modules/fargate-service/variables.tf — add
variable "bedrock_model_arn" {
  type        = string
  default     = ""
  description = "Full ARN of the one Bedrock model this task may invoke; \"\" attaches no policy"
}

# modules/fargate-service/main.tf — add, only when the variable is set
resource "aws_iam_role_policy" "task_bedrock" {
  count = var.bedrock_model_arn == "" ? 0 : 1
  name  = "bedrock-invoke-one-model"
  role  = aws_iam_role.task.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
      Resource = var.bedrock_model_arn # exactly one model — never bedrock:* or Resource "*"
    }]
  })
}

# 06-fargate/main.tf — the root that instantiates the module, add the two values
module "fargate" {
  # ...existing arguments unchanged...
  bedrock_model_arn = "arn:aws:bedrock:ap-south-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0"
  environment = {
    ASKAPI_MODEL      = "bedrock"
    BEDROCK_MODEL_ARN = "arn:aws:bedrock:ap-south-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0"
  }
}

terraform apply in 06-fargate: 1 to add (the policy), the task definition replaced (new env vars), the service updated to roll one new task. No aws_iam_role is created here — cloud-09 already made the identity; this step only widens what it may do.

3. The ~20-line client swap — a new file, src/askapi/bedrock_model.py, next to model.py. py-10's tests build their own app around an unloaded StubModel and never import this file, so nothing graded changes:

# src/askapi/bedrock_model.py — new file; satisfies model.Model's load/answer/stream shape
import json
import boto3


class BedrockModel:
    loaded = True  # the call itself is the "load" — nothing to warm up first

    def __init__(self, model_arn: str, region: str = "ap-south-1") -> None:
        # boto3.client() with no key/secret args: it walks the default credential chain,
        # which inside an ECS task means the container credentials endpoint the task role
        # backs — the same temporary credentials aws_iam_role.task.arn grants, auto-refreshed.
        self._client = boto3.client("bedrock-runtime", region_name=region)
        self._model_arn = model_arn

    def load(self) -> None:
        pass

    def answer(self, prompt: str) -> str:
        body = {"anthropic_version": "bedrock-2023-05-31", "max_tokens": 512,
                "messages": [{"role": "user", "content": prompt}]}
        resp = self._client.invoke_model(modelId=self._model_arn, body=json.dumps(body))
        return json.loads(resp["body"].read())["content"][0]["text"]

    async def stream(self, prompt: str):
        body = {"anthropic_version": "bedrock-2023-05-31", "max_tokens": 512,
                "messages": [{"role": "user", "content": prompt}]}
        resp = self._client.invoke_model_with_response_stream(modelId=self._model_arn, body=json.dumps(body))
        for event in resp["body"]:  # boto3's stream is a sync iterator — blocking, one Bedrock
            chunk = json.loads(event["chunk"]["bytes"])  # call stalls this task's whole event loop;
            if chunk.get("type") == "content_block_delta":  # py-12 (Q4) is what fixes that, not this page.
                yield chunk["delta"]["text"]

And two lines in main.py, the only place StubModel is constructed:

# src/askapi/main.py — was: model = StubModel()
from askapi.bedrock_model import BedrockModel

model = (
    BedrockModel(os.environ["BEDROCK_MODEL_ARN"])
    if os.environ.get("ASKAPI_MODEL") == "bedrock"
    else StubModel()
)

Rebuild and push cloud-05's image as a new tag, lbv-api:v3, and point 06-fargate's container definition at it. Re-apply — this is the same terraform apply as step 2; do both edits together and one new task revision carries the policy, the env vars and the image tag at once.

4. Drive it — 100 requests at each size, then py-10's own test.

ALB=<your ALB DNS from cloud-06/07>
for i in $(seq 1 100); do
  curl -s -X POST "http://$ALB/ask" -H 'content-type: application/json' \
    -d '{"prompt": "Summarize what a Fargate task role is in one sentence."}' > /dev/null
done
# repeat with a ~500-token prompt, then a ~2,000-token one built from a few pasted paragraphs —
# the three sizes chip 1-3 show. Then prove the contract still holds, now served by Bedrock:
BASE_URL="http://$ALB" uv run pytest -q -m integration
# 3 passed

Verify

Teardown

Nothing to destroy — an IAM policy statement costs nothing sitting unused, and Bedrock never bills for a model that no request called. Unlike cloud-09's RDS instance (bills by the hour, running or idle) or cloud-12's SageMaker endpoint (the box the plan explicitly warns bills while idle), Bedrock's whole bill is the tokens in the requests you actually sent — stop sending them and the line stops growing, with no destroy step required. If you want the task to lose Bedrock access entirely, delete the task_bedrock policy block and re-apply; revoking model access in the console is optional and does not affect the bill either way, since access without a call still costs $0.00.

There is no teardown checkbox on this page, because there is nothing left running that bills. Press the button once the five Verify lines above are true.

This is self-attestation — the site cannot see your AWS account, so pressing the button is you telling The Path the policy names one ARN, /ask answered for real, and the second model was denied.

Takeaways: an IAM role is what an API key was trying to be — an identity the caller can prove without anyone typing a secret into a file that might leak. Scoping the policy's Resource to one model ARN is not paranoia: it is the same "keep 5" instinct as cloud-05's lifecycle policy, applied to blast radius instead of storage — a bug that loops and calls whatever model id a prompt-injected string names cannot, because the role was never granted permission to call anything else. And because Bedrock bills per token instead of per hour, "no teardown" is not a shortcut this page is taking; it is the actual shape of the bill.