Scoping IAM Least-Privilege for Cost Explorer
A cost pipeline that reads AWS Cost Explorer should hold exactly three or four billing read actions and nothing else, and this page builds that minimal IAM policy, gates it with condition keys, and wires a cross-account read-only role the pipeline assumes with STS.
Back to: Security & Access Control for Cost Data
The default failure mode for cost tooling is over-permissioning: a pipeline gets Billing or, worse, a broad ReadOnlyAccess managed policy, and now a job whose entire purpose is to call ce:GetCostAndUsage can also enumerate every S3 bucket and RDS snapshot in the account. AWS IAM is deny-by-default — an action is forbidden unless something explicitly allows it — so the discipline is to enumerate the precise Cost Explorer, Savings Plans, and Cost and Usage Report actions your extractor calls and grant only those. This is the access-control counterpart to encrypting stored cost records with KMS envelope encryption: one page locks down who may read billing data, the other locks down the bytes at rest. Together they bound the blast radius of a leaked pipeline credential.
Cost Explorer actions are global (they live in us-east-1 and carry no resource ARNs), which changes how you scope them — you cannot restrict ce:GetCostAndUsage to a specific resource, so the meaningful controls are the action list itself, request conditions, and which principal holds the permission. That last lever is the strongest: put the billing account behind a dedicated read-only role in a separate account and make the pipeline assume it with scoped short-lived credentials, so the long-lived identity carries no standing billing access at all.
Prerequisites
Before wiring the role, confirm the following are in place.
Two accounts (recommended): a billing/payer account
Athat owns the Cost Explorer data, and a pipeline accountBwhose compute assumes intoA. Single-account setups work too — drop the cross-account trust and attach the permission policy directly to the pipeline role.Cost Explorer enabled in account
A. It must be turned on once in the Billing console; until thence:*calls return an empty result rather than an error, which is a common source of “my policy works but I get no data” confusion.IAM permission to create roles/policies in both accounts for the operator running this setup (not for the pipeline itself).
Python 3.10+ and boto3.
pip install "boto3>=1.34"The pipeline’s base identity. On EC2/ECS/Lambda this is the task or instance role ARN; that ARN is the principal you will trust and later simulate.
Step-by-Step Implementation
You will author a minimal permission policy, attach it to a role in account A whose trust policy admits only the pipeline principal in B, assume that role from boto3, and finally prove the grant with the IAM policy simulator.
Step 1 — Write the minimal permission policy
Grant only the read actions your extractor actually calls. A typical Cost Explorer pipeline needs GetCostAndUsage, GetCostForecast, and Savings Plans coverage; if you also read Cost and Usage Reports from S3, add the cur:Describe* descriptors (never cur:PutReportDefinition). Everything is scoped with condition keys so the grant only holds from your egress network and only in us-east-1, where Cost Explorer lives.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CostExplorerRead",
"Effect": "Allow",
"Action": [
"ce:GetCostAndUsage",
"ce:GetCostForecast",
"ce:GetSavingsPlansCoverage",
"ce:GetSavingsPlansUtilization",
"ce:GetDimensionValues"
],
"Resource": "*",
"Condition": {
"StringEquals": { "aws:RequestedRegion": "us-east-1" },
"IpAddress": { "aws:SourceIp": ["203.0.113.0/24"] }
}
},
{
"Sid": "CurDescribeOnly",
"Effect": "Allow",
"Action": ["cur:DescribeReportDefinitions"],
"Resource": "*"
},
{
"Sid": "DenyEverythingWrite",
"Effect": "Deny",
"NotAction": ["ce:Get*", "ce:Describe*", "ce:List*", "cur:Describe*"],
"Resource": "*"
}
]
}
The trailing explicit Deny is belt-and-braces: even if a future attach accidentally adds a broad allow to this role, the NotAction deny still blocks anything outside the billing read verbs. Explicit deny always wins over any allow, which is what makes this pattern safe against later drift. Keep the aws:SourceIp CIDR pinned to your NAT gateway or VPC egress range so a leaked credential is useless off-network.
Step 2 — Trust only the pipeline principal
The role in account A carries the permission policy above plus a trust policy that names exactly which principal in account B may assume it. Scope the trust to the specific task/instance role ARN, not the whole account root, and require an sts:ExternalId to defend against the confused-deputy problem.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::222222222222:role/cost-pipeline-task"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "db-cost-quota-prod" }
}
}
]
}
Trusting the role ARN rather than arn:aws:iam::222222222222:root means only that one task role can assume in — not every principal in account B. This is the single most important line in the whole setup.
Step 3 — Assume the role from boto3
The pipeline calls sts:AssumeRole to exchange its base identity for short-lived credentials scoped to the read-only role, then builds a Cost Explorer client from those credentials. Request the shortest session duration your job needs — the default is one hour; 15 minutes (900) is plenty for a scheduled pull.
The sequence below traces the credential exchange and the scoped Cost Explorer call across the two accounts.
import os
import boto3
ROLE_ARN = os.environ["COST_READ_ROLE_ARN"] # arn:aws:iam::111111111111:role/cost-explorer-ro
EXTERNAL_ID = os.environ["COST_READ_EXTERNAL_ID"] # db-cost-quota-prod
def assume_cost_reader(duration_seconds: int = 900) -> boto3.Session:
"""Exchange the task's base identity for short-lived read-only creds."""
sts = boto3.client("sts")
resp = sts.assume_role(
RoleArn=ROLE_ARN,
RoleSessionName="cost-explorer-pull",
ExternalId=EXTERNAL_ID,
DurationSeconds=duration_seconds, # keep sessions short
)
c = resp["Credentials"]
# A Session built from temp creds; no long-lived secret ever touches disk.
return boto3.Session(
aws_access_key_id=c["AccessKeyId"],
aws_secret_access_key=c["SecretAccessKey"],
aws_session_token=c["SessionToken"],
region_name="us-east-1", # Cost Explorer is global -> us-east-1
)
def pull_cost(start: str, end: str) -> list[dict]:
"""Call GetCostAndUsage with the assumed, scoped session."""
session = assume_cost_reader()
ce = session.client("ce")
resp = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end}, # end is exclusive
Granularity="DAILY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
return resp["ResultsByTime"]
if __name__ == "__main__":
rows = pull_cost("2026-06-01", "2026-07-01")
print(f"pulled {len(rows)} day(s)")
print(rows[0]["Groups"][0])
Expected output for a payer account with daily granularity:
pulled 30 day(s)
{'Keys': ['Amazon Relational Database Service'], 'Metrics': {'UnblendedCost': {'Amount': '184.20', 'Unit': 'USD'}}}
Step 4 — Verify with the IAM policy simulator
Never assume a policy grants what you intended — prove it. simulate_principal_policy evaluates the effective permissions of a principal (role plus all attached and inline policies) against a list of actions, returning allowed or implicitDeny/explicitDeny for each, and it honours condition keys when you pass a matching context.
import boto3
iam = boto3.client("iam")
resp = iam.simulate_principal_policy(
PolicySourceArn="arn:aws:iam::111111111111:role/cost-explorer-ro",
ActionNames=[
"ce:GetCostAndUsage", # expect: allowed
"ce:GetReservationPurchaseRecommendation", # expect: implicitDeny
"rds:DescribeDBInstances", # expect: implicitDeny (proves blast radius is bounded)
"cur:PutReportDefinition", # expect: explicitDeny (write verb)
],
ContextEntries=[
{
"ContextKeyName": "aws:RequestedRegion",
"ContextKeyType": "string",
"ContextKeyValues": ["us-east-1"],
},
{
"ContextKeyName": "aws:SourceIp",
"ContextKeyType": "ip",
"ContextKeyValues": ["203.0.113.10"],
},
],
)
for r in resp["EvaluationResults"]:
print(f"{r['EvalActionName']:45} -> {r['EvalDecision']}")
Expected output:
ce:GetCostAndUsage -> allowed
ce:GetReservationPurchaseRecommendation -> implicitDeny
rds:DescribeDBInstances -> implicitDeny
cur:PutReportDefinition -> explicitDeny
The implicitDeny on rds:DescribeDBInstances is the proof that matters: the role cannot reach into database resources at all. Re-run the simulation with a SourceIp outside your CIDR and confirm ce:GetCostAndUsage flips to implicitDeny — that verifies the condition key is actually enforcing.
Verification
Confirm the grant end-to-end before scheduling the job.
Simulate first, call second. Wire the
simulate_principal_policycheck from Step 4 into CI so a policy change that widens the grant fails the build. Assert on the decisions:decisions = {r["EvalActionName"]: r["EvalDecision"] for r in resp["EvaluationResults"]} assert decisions["ce:GetCostAndUsage"] == "allowed" assert decisions["rds:DescribeDBInstances"] != "allowed", "blast radius leaked!"Inspect the assumed identity. After
assume_cost_reader(), callsession.client("sts").get_caller_identity()and confirm theArnshowsassumed-role/cost-explorer-ro/cost-explorer-pull— not the pipeline’s base task role. If you still see the base role, the assume silently fell back.Confirm the record shape. A successful pull returns
ResultsByTimeentries whoseGroups[].Metrics.UnblendedCost.Amountare decimal strings (not floats) in the account’s billing currency — carry them asDecimaldownstream, exactly as the billing schema contract requires.
Gotchas & Edge Cases
- Cost Explorer is not regional. Every
ce:*call must go tous-east-1regardless of where your compute runs; a client built foreu-west-1throws an endpoint error. Theaws:RequestedRegioncondition both documents and enforces this. aws:SourceIpbreaks for in-VPC calls over a VPC endpoint. If your traffic reaches STS/CE through an interface VPC endpoint rather than a NAT gateway,aws:SourceIpis the private VPC address, not your public egress. Useaws:SourceVpcoraws:VpcSourceIpinstead, or the condition silently denies every call.- SCPs can override your allow. In an AWS Organization, a Service Control Policy on the OU is an outer boundary — if it does not permit
ce:*, no role-level allow matters. The simulator does not evaluate SCPs, so a policy that simulatesallowedcan still be denied in production. Check the OU’s SCPs separately. - Session duration ceilings.
DurationSecondscannot exceed the role’sMaxSessionDuration(default 3600s). Requesting more throwsValidationError; role chaining caps you at one hour regardless. ce:GetCostAndUsageis a billed API call. Each request costs USD 0.01. A tight retry loop against a throttled endpoint can quietly run up charges — apply the same retry-with-backoff discipline used across cost pipelines rather than a hot loop.- ExternalId is not a secret, but omit it and you invite the confused deputy. It is an identifier, not a credential; its value is that a third party who tricks your pipeline into assuming a role still cannot supply the right
ExternalId. Set it whenever the trusting account is not one you control end-to-end.
Frequently Asked Questions
Why assume a cross-account role instead of just attaching the policy to the pipeline?
Separation of accounts means the pipeline’s standing identity carries zero billing access — it only gains read permission for the minutes it holds an assumed session, and only from your egress network. A leaked long-lived key is then useless for reading cost data. In a single-account estate the trade-off is smaller, and attaching the permission policy directly is acceptable; across accounts, the assume-role hop is the cleaner boundary.
Do I need ce:* wildcards to make Cost Explorer work?
No. Enumerate the specific Get* actions your code calls — GetCostAndUsage, GetCostForecast, GetSavingsPlansCoverage, and so on. A wildcard ce:* silently includes write-adjacent actions like ce:CreateAnomalyMonitor and ce:UpdatePreferences that a read pipeline never needs. Grow the list deliberately when you add a new API, and let the simulator prove the addition.
Why does the simulator say allowed but the real call is denied?
The two most common causes are an Organization SCP that the simulator does not evaluate, and a condition key whose real request context differs from what you passed to ContextEntries — most often aws:SourceIp resolving to a VPC endpoint address instead of your NAT egress. Re-run get_caller_identity on the assumed session and check the OU’s SCPs before suspecting the role policy.
How short should the assumed session be?
As short as the job’s longest single run. A scheduled pull that finishes in seconds should request DurationSeconds=900 (the 15-minute minimum) so a captured session token expires almost immediately. Long-lived sessions exist mainly for interactive use; automation should treat credentials as disposable per-run.
Can I restrict Cost Explorer to specific linked accounts?
Not through the resource ARN — ce:* actions take Resource: "*". You filter linked-account data inside the request instead, with a LINKED_ACCOUNT dimension filter on GetCostAndUsage. Access control there is about which principal holds the read grant, not per-account resource scoping; use separate roles per consuming team if you need hard isolation.
Related
- Encrypting Cost Data at Rest with KMS Envelope Encryption — the companion control that protects the cost records once this policy has read them.
- Tracking Savings Plan coverage and utilization — a downstream consumer of the exact
ce:GetSavingsPlansCoveragegrant scoped here. - Security & Access Control for Cost Data — the parent topic covering least-privilege, encryption, and credential rotation across the cost estate.