Python Around Terraform boto3 STS — the role that is not a key
The wrapper does not own the session. STS does. Your environment fallback is not a visa.
<!-- hal:authoritative:yaml -->
The wrapper does not own the session. STS does. Your environment fallback is not a visa.
§I — Frame
Today's Ops lesson puts assume_role on a child provider "aws" and proves the landing with aws_caller_identity. Two processes. One nest. A static access_key in that file is the footgun.
This slot is Python-around-TF. tf_day_dev_counter reads 10, 10 mod 3 is 1. 07-28 wrote the HCP API with terrasnek. 08-09 read terraform show -json and walked resource_changes. 08-18 wrapped terraform apply as a child and refused -lock=false. None of those fires took a role ARN Terraform had already exported and asked STS for a session in Python.
Gift already taught subprocess.run and check (Creating Command-Line Tools, Spawn Processes with the subprocess Module; 08-14 and 08-18 spent those pages). Today those pages are referenced. The child is terraform output -json. The question is the visa.
A wrapper that reads AWS_ACCESS_KEY_ID from the environment when assume_role fails has traded a role for a key. A wrapper that prints the temporary secret is a credential in a log. A wrapper that builds a default boto3 session and never calls STS has not assumed anything, even if a role ARN sat in tfvars the whole time.
Coin the same name the Ops slot coined: the role that is not a key.
This is not a plan-schema lesson. resource_changes stays out of scope. If the wrapper starts walking proposed IAM policy documents inside a plan, it has become 08-09 again. This is not a lock lesson. terraform apply stays out of scope. If the wrapper starts arguing about -lock=false, it has become 08-18 again.
§II — Language Idiom: four facts about the session
Fact one. The ARN arrives as output JSON, not as a string you typed twice.
Ops exported child_account_id and, in any root that created the role in a prior apply, a role_arn. Python must not hard-code that ARN next to the script. The contract is the child process:
import json
import subprocess
from typing import Any
def terraform_outputs(workdir: str) -> dict[str, Any]:
proc = subprocess.run(
["terraform", "output", "-json"],
cwd=workdir,
text=True,
capture_output=True,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "terraform output failed")
raw = json.loads(proc.stdout)
return {name: spec["value"] for name, spec in raw.items()}
Gift's pages show run as the way a Python tool starts another program and notices whether it failed. check=False because the wrapper, not CalledProcessError, decides how a missing state or a backend error is reported. -json is the only flag. No -lock=false. No apply. The command reads outputs from the last apply. 08-21 already taught that those values can be last-apply. A role ARN is last-apply in that sense: it is the ARN Terraform wrote when the role last converged. If the role was renamed and you have not applied, Python will assume the old name and STS will refuse. That refusal is the point. Do not "help" by falling back to a key.
Terraform's output JSON shape is { "name": { "value": ..., "type": ... } }. The wrapper unwraps value. It does not walk resource_changes. It does not parse HCL. It does not import python-hcl2 to hunt assume_role blocks in main.tf. The exported ARN is the public contract. The nest is Ops.
**Fact two. boto3 assume_role returns a session, and a session is four fields.**
The Bootcamp STS notes list them: AccessKeyId, SecretAccessKey, SessionToken, Expiration. boto3 names the same four under Credentials. If/then: if SessionToken is missing, you do not have a visa. If you build a client with only the first two fields, some calls will work against older APIs and some will fail. Always pass the three strings together.
import boto3
from botocore.exceptions import ClientError
def assume(role_arn: str, session_name: str, external_id: str | None = None) -> dict[str, str]:
sts = boto3.client("sts")
kwargs: dict[str, object] = {
"RoleArn": role_arn,
"RoleSessionName": session_name,
}
if external_id:
kwargs["ExternalId"] = external_id
try:
resp = sts.assume_role(**kwargs)
except ClientError as exc:
raise RuntimeError(f"assume_role refused: {exc}") from exc
creds = resp["Credentials"]
token = creds.get("SessionToken")
if not token:
raise RuntimeError("STS returned no SessionToken")
return {
"aws_access_key_id": creds["AccessKeyId"],
"aws_secret_access_key": creds["SecretAccessKey"],
"aws_session_token": token,
}
The starting boto3.client("sts") uses the default chain. That is the same starting principal Ops used for the parent provider. Python does not paste a key to make STS answer. If the chain is empty, assume_role fails, and the wrapper fails. That failure is honest.
RoleSessionName is how CloudTrail will label this assumption. Use a name you can grep. Do not use python. Do not use the operator's first name. tf-output-assume is enough.
Expiration is a datetime. The Bootcamp STS notes say a session can last from fifteen minutes up to hours, and that you cannot cancel it by hand. Python should read the field and refuse to cache the dict across a sleep longer than the remaining life. A module-level global that assumed once at import and is still handing out the same three strings two hours later is a stale visa. Construct the session when you need the client. If a long job must outlive one session, assume again. Do not refresh by concatenating the old access key with a hope.
DurationSeconds is a request. The role's max session duration is the ceiling. If you ask for twelve hours and the role allows one, STS gives you one or refuses. Do not catch that refusal and retry with a key. Retry with a legal duration, or fail.
python-terraform is a library that shells out to the same CLI. It is legal to use for output. It is not a second source of credentials. If a wrapper uses python-terraform to apply, then reads os.environ for keys, it has combined 08-18's child with today's footgun. Stay on subprocess.run for one command, or stay on python-terraform for one command. Do not let the library hide an apply you did not want.
Fact three. A failed assume is not a key-shaped retry.
The footgun lives in the except you have not written yet:
def refuse_static_key() -> None:
import os
if os.environ.get("AWS_ACCESS_KEY_ID") and not os.environ.get("AWS_SESSION_TOKEN"):
raise RuntimeError("static AWS_ACCESS_KEY_ID present without a session token")
If/then: if assume_role raises and the function catches it, then reads os.environ["AWS_ACCESS_KEY_ID"], then returns a default session, the wrapper has become a key. The IAM notes say never write credentials in code. Reading a long-lived key from the environment after a visa refusal is the same class of mistake. The environment is just a file that is not on disk.
A pipeline that "must not be stuck" will want this fallback. Refuse it. The Ops lesson's illegal provider block is the same idea wearing HCL. Today's illegal Python is the except that continues.
Fact four. The session belongs in a boto3 Session object, not in a print.
def client_from_assume(role_arn: str, service: str, session_name: str) -> object:
creds = assume(role_arn, session_name)
sess = boto3.Session(
aws_access_key_id=creds["aws_access_key_id"],
aws_secret_access_key=creds["aws_secret_access_key"],
aws_session_token=creds["aws_session_token"],
)
return sess.client(service)
Print role_arn. Print the caller identity account after the assume. Do not print aws_secret_access_key. Do not print aws_session_token. Do not log the Credentials dict. The Bootcamp STS notes are explicit that these values are similar to an access key. They expire. Until they expire they are a credential. repr of a client is fine. repr of creds is not.
sts.get_caller_identity() on the new client is the Python twin of Ops' data.aws_caller_identity.child. If the account id matches the Terraform output child_account_id, the visa landed. If it matches the parent, you assumed the wrong ARN or you never assumed.
§III — Code Worked Example: output, assume, prove, refuse
A small tool that a runner calls after Ops has applied the parent/child root. It does four things and then exits.
import argparse
import sys
from assume_from_tf import (
client_from_assume,
refuse_static_key,
terraform_outputs,
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="assume-from-tf")
parser.add_argument("--workdir", required=True)
parser.add_argument("--output-name", default="child_role_arn")
parser.add_argument("--session-name", default="tf-output-assume")
args = parser.parse_args(argv)
refuse_static_key()
outputs = terraform_outputs(args.workdir)
if args.output_name not in outputs:
print(f"missing output {args.output_name}", file=sys.stderr)
return 2
role_arn = outputs[args.output_name]
if not isinstance(role_arn, str) or ":role/" not in role_arn:
print("output is not a role ARN", file=sys.stderr)
return 2
sts = client_from_assume(role_arn, "sts", args.session_name)
ident = sts.get_caller_identity()
expected = outputs.get("child_account_id")
print(f"assumed {role_arn}")
print(f"account {ident['Account']} arn {ident['Arn']}")
if expected and ident["Account"] != str(expected):
print("caller identity does not match child_account_id", file=sys.stderr)
return 3
return 0
if __name__ == "__main__":
raise SystemExit(main())
The exit codes are the contract with the runner. 2 means Terraform did not export what the wrapper needs. 3 means STS answered a different account than the output claimed. 0 means the visa matches the last apply. A nonzero exit is a failed job. Do not catch RuntimeError from assume and return 0.
refuse_static_key runs first. A laptop that still exports yesterday's long-lived key, and has no session token, is already the illegal provider block. Fail before the child process starts. The order is the lesson.
A second proof sits next to the account id. ident["Arn"] after a successful assume looks like arn:aws:sts::222222222222:assumed-role/OrganizationAccountAccessRole/tf-output-assume. The role name in that ARN should match the role name in role_arn. The session name should match --session-name. If the ARN is still arn:aws:iam::111111111111:user/someone, you never assumed. You are looking at the starting principal. That is the Python form of two aliases that both print the parent account.
":role/" not in role_arn is a cheap shape check. It is not a parser. An IAM role ARN has that infix. An access key id starts with AKIA and never should appear as this output. If someone exported a key id as child_role_arn, the wrapper refuses. The name of the output is not a type.
Do not add a --fallback-env flag. Do not add a --use-profile that skips STS when a profile already exists. A named profile that itself assumes a role in ~/.aws/config is a different, honest path: the chain does the assume before boto3 sees a client. That path does not need this wrapper. This wrapper exists for the case where Terraform is the source of the ARN.
If the output is sensitive, terraform output -json still prints it. The CLI redacts on the human formatter, not on -json. Treat stdout as a secret when the output was marked sensitive. A role ARN usually is not. An external_id output would be. Do not add that output. Read the external id from a runner secret if the trust policy requires one.
§IV — Connection to Today's Ops Lesson
Ops wrote the nest. Python reads the export. The hinge is one sentence. Terraform asks STS through the provider process. Python asks STS through boto3. Both consume a role. Neither should mint a key.
If this Dev lesson wraps terraform apply, it has repeated 08-18. If it only restates IAM vocabulary, it has skipped the language. The child process is terraform output -json. The language idiom is boto3.Session constructed from a visa, plus the refusal to continue on a static key.
Ops printed two account ids to prove two processes. Python prints one account id to prove one session. The numbers must agree with child_account_id when that output exists. Agreement is not a pretty log line. It is the only reason the runner should proceed to a later step that writes in the child account. A later step that uses the default chain after this wrapper exited has thrown the visa away. Pass the three fields forward as a session, or assume again in the later step. Do not export them as plain environment keys without the token. A helper that writes only AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into the runner environment has minted the illegal provider block for every later process.
external_id is optional in the helper because Ops made it optional on the nest. If the trust policy requires it and you omit it, STS refuses. Pass it from an environment variable that is not AWS_SECRET_ACCESS_KEY. Do not pass it from a tfvars file that is committed.
§V — Prior-Lesson Reach
08-18 taught that subprocess.run around terraform apply must not pass -lock=false, and that try/finally does not release a lock the CLI holds. Today's child is output. The lock is not in scope. Steal the argv discipline. Leave the lock lesson alone.
08-09 taught terraform show -json and the resource_changes schema. Today's JSON is output -json. The schema is { name: { value, type } }. Do not walk planned IAM documents. The plan is a different artifact.
07-28 taught terrasnek against the HCP API. This fire does not open a workspace, trigger a run, or read a state version. The state has already been applied. The wrapper is a local reader plus an STS client.
08-14 taught SIGTERM and a child that never saw the log context. terraform output is short-lived. No signal handling is required. If you start installing a SIGTERM handler around output, you have become 08-14 again.
The 08-24 Dev fire was HCL configuration_aliases. This fire is not that fire. Do not open a .tf file. Do not teach providers = {}. The pair-family supplies HTML shape, not topic. 08-21 Dev was terratest GetProperties in Go. This fire is not that fire. Do not import the Azure SDK. Do not assert on a storage property. Python reads an ARN and asks STS. That is the whole refraction.
§VI — Closing
terraform output -json is how Python learns the ARN. sts.assume_role is how Python asks for the visa. Four fields come back. Three of them go into a boto3.Session. None of them go into a log. A missing SessionToken is a failed assume. A static AWS_ACCESS_KEY_ID without a session token is the illegal provider block wearing an environment variable.
Name it when you see it. The role that is not a key. Read the output. Ask STS. Print the account. Refuse the fallback.
Examine well. The function name will still be pretty. The door is RoleArn. The proof is get_caller_identity. The wrapper is the one who must not mint a key.
Related
- Prior arc: subprocess, the lock error, and the lock you did not hold (2026-08-18)
- Language hub: Cross-References/dev-languages/Python
- Grounding tome: Terraform: Up and Running (Brikman Ch.7, Working with Multiple AWS Accounts) (pp.376-379; ARN surface the wrapper reads)