The product demo took twenty minutes. The security review has taken five weeks, and it's stuck on one sentence in the onboarding doc: "connect your AWS account with read-only access."
That sentence stalls reviews because it's doing a lot of hiding. AWS has thousands of IAM actions, and "read" spans two completely different worlds: reading about your infrastructure (this instance exists, it's an m5.2xlarge, it averaged 4% CPU) and reading your infrastructure's contents (this S3 object, this database row, this secret). A cost tool needs a lot of the first and exactly none of the second. Most permission requests, and most reviews, never make that line explicit.
This post makes it explicit: the exact policy statements a cost tool needs, tier by tier, the deny list that should ride along with them, and the short list of things such a tool should never be able to change no matter what.
The trap: "ReadOnlyAccess" reads your data
Start with the mistake that shows up in real onboarding docs: attaching AWS's managed ReadOnlyAccess policy. The name suggests safety. The contents include s3:GetObject, dynamodb: GetItem, dynamodb:Scan, sqs:ReceiveMessage, and logs:GetLogEvents.
That is: your objects, your rows, your queue messages, your application logs. A vendor role with ReadOnlyAccess can read business data all day and it's all "read-only". If a review approves that policy for a cost tool, the review failed.
AWS's ViewOnlyAccess job-function policy is the metadata-only cousin (List and Describe, no contents), and it's a reasonable starting point. But you can do better than a starting point, because a cost tool's needs are narrow enough to write down exactly.
The principle behind everything below: cost math lives on the control plane. The tool needs to know a bucket exists, how many gigabytes it holds, and its storage class. It never needs a single object inside. It needs the database's instance class, storage, and connection count metric. Never a row.
Tier 1: billing data only
This tier answers "what are we spending, on what, and is it anomalous", org-wide, with no visibility into individual resources:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "BillingRead",
"Effect": "Allow",
"Action": [
"ce:Get*", "ce:List*", "ce:Describe*",
"cur:DescribeReportDefinitions",
"pricing:GetProducts", "pricing:GetAttributeValues", "pricing:DescribeServices",
"budgets:ViewBudget", "budgets:Describe*",
"organizations:ListAccounts", "organizations:DescribeOrganization"
],
"Resource": "*"
}]
}
What it buys: spend, trends, per-service and per-account breakdowns, budget state, anomaly detection at billing granularity. What it can't do: name the idle resource. Billing data says "EC2-Other went up in account 4412"; it takes inventory access to say "it's the NAT gateway in eu-west-1, and here's the route that's abusing it."
If a vendor can't operate at this tier at all, that's worth noticing. Starting billing-only and upgrading later is the natural trust ramp.
Tier 2: inventory and meters
This is where rightsizing, idle detection, and zombie hunting become possible. Metadata and metrics, still zero contents:
{
"Sid": "InventoryAndMeters",
"Effect": "Allow",
"Action": [
"ec2:Describe*",
"rds:Describe*", "rds:ListTagsForResource",
"elasticloadbalancing:Describe*",
"autoscaling:Describe*",
"eks:Describe*", "eks:List*",
"lambda:List*",
"s3:ListAllMyBuckets", "s3:GetBucketLocation", "s3:GetBucketTagging",
"cloudwatch:GetMetricData", "cloudwatch:GetMetricStatistics", "cloudwatch:ListMetrics",
"tag:GetResources"
],
"Resource": "*"
}
One deliberate detail worth copying: lambda:List* and not lambda:GetFunction. GetFunction returns a presigned URL that downloads your function's source code. A cost tool needs your function's memory setting and invocation count, not your code. Small distinctions like this are exactly what a good security reviewer looks for, and exactly what a good vendor has already thought about.
The deny that ends the argument
Allow lists drift. Policies get edited eighteen months later by someone solving an unrelated ticket. So the strongest trust move is an explicit deny on the data plane, attached to the same role, because in IAM an explicit Deny beats any Allow, present or future:
{
"Sid": "NeverYourData",
"Effect": "Deny",
"Action": [
"s3:GetObject*",
"dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan",
"secretsmanager:GetSecretValue",
"ssm:GetParameter*",
"kms:Decrypt",
"lambda:GetFunction",
"logs:GetLogEvents", "logs:FilterLogEvents",
"sqs:ReceiveMessage", "kinesis:GetRecords",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "*"
}
Objects, rows, secrets, parameters, decryption, code, logs, messages, streams, images. One statement, and the question "but could it read our data?" has a provable answer: no, and it stays no even if the allow side is edited carelessly later.
What it can never change
The other half of the title. In the read tiers there is nothing to argue about: no verb in them creates, modifies, or deletes anything. But make the absences explicit, because they're the actual review checklist:
-
No
iam:*. A cost tool that asks for any IAM write can rewrite its own leash. Instant no. - No network or security changes. Nothing touching security groups, route tables, NACLs, or endpoints.
-
No encryption admin. Nothing in
kms:beyond, at most, listing key aliases for cost attribution. -
No data mutations. No
Put,Create,Delete,Modify, orTerminateon anything, anywhere, in a read tier.
If the requested policy for a "visibility" tool contains ec2:TerminateInstances or iam:PutRolePolicy, you're not reviewing a cost tool anymore.
Tier 3: if the tool also acts, give the writes a leash
Some cost tools don't just observe; they park non-prod at night, stop idle databases, apply schedules. That's where real savings live, and it's also where the review should get strict in a very specific way: enumerate the verbs, and scope them with a tag condition.
{
"Sid": "ScopedActions",
"Effect": "Allow",
"Action": [
"ec2:StopInstances", "ec2:StartInstances",
"rds:StopDBInstance", "rds:StartDBInstance",
"autoscaling:UpdateAutoScalingGroup"
],
"Resource": "*",
"Condition": {
"StringEquals": { "aws:ResourceTag/schedule:managed": "true" }
}
}
The condition is the leash: write verbs work only on resources somebody explicitly tagged into management, so the blast radius is opt-in per resource. (EC2 honors aws:ResourceTag conditions; check the same support per service as you extend the list.) Three more rules that make Tier 3 approvable:
-
Reversible verbs only. Stop and start round-trip. Anything containing
DeleteorTerminatebelongs in its own review, months later, if ever. - A separate role for actions. Reads in one role, writes in another, so revoking the write path is one deletion and observability survives it.
- Every action lands in CloudTrail under a session name you can filter on. If the vendor can't tell you what to filter for, that's your answer.
The shape of the connection matters as much as the verbs
However good the policy, reject long-lived access keys. The connection should be a cross-account IAM role the vendor assumes with short-lived STS sessions, carrying an ExternalId: a shared secret in the trust policy that prevents the confused-deputy problem, where another customer of the same vendor tricks it into assuming your role. Revocation should be one step: delete the role, access ends everywhere.
The same split exists off AWS. On GCP, prefer granular grants (roles/billing.viewer, roles/monitoring.viewer, roles/cloudasset.viewer) over the tempting roles/viewer, which includes object-read permissions on some services. On Azure, the built-in Reader role is genuinely control-plane only (data actions are a separate category in Azure's model), and pairs with Cost Management Reader.
Six questions to ask any cost tool
- Can we start billing-only and upgrade tiers later, or is it all-or-nothing on day one?
- Is there a published, action-by-action permission catalog with the reason for each grant?
- Does your onboarding template ship a data-plane deny statement, or do we have to add it?
- Cross-account role with ExternalId, or long-lived keys? (Keys are a no.)
- For actions: which exact verbs, scoped by what condition, gated by what approval?
- What appears in CloudTrail, and what's the one-step revoke?
A vendor that answers these six in writing has done your security review's first month for you. This is also the shape ZopNight commits to in its docs: connection via IAM role, service principal, or workload identity with no agents to install, read-only by default with remediation as a separate later opt-in, stored credentials encrypted with AES-256-GCM under per-account envelope keys, and one hard line worth copying into any vendor conversation: databases (RDS, Aurora, Cloud SQL, ElastiCache, Azure SQL) are never touched.
Trust is a policy document
"Read-only by default" is either a marketing sentence or a verifiable claim, and the difference is whether someone will show you the JSON. The tiers above are the whole negotiation: metadata and meters to see the waste, an explicit deny on your data, tag-scoped reversible verbs if you want the tool to act, and a role you can delete in one motion. Anything a cost tool asks for beyond that isn't for your benefit.
FAQ
Is AWS ReadOnlyAccess safe to give a third-party tool?
No, not as a default. ReadOnlyAccess includes data-plane reads such as s3:GetObject, dynamodb:Scan, and logs:GetLogEvents, so it can read business data, not just metadata. Use ViewOnlyAccess as a baseline or, better, a scoped policy like the tiers above, and add an explicit deny on data-plane actions.
What IAM permissions does a cloud cost tool actually need?
Billing reads (ce:* gets and lists, pricing, budgets, organizations account listing) for spend visibility, plus Describe/List metadata and CloudWatch metrics (GetMetricData, ListMetrics) for per-resource work like rightsizing and idle detection. Nothing that reads object, row, secret, log, or message contents, and no write verbs unless you deliberately enable scheduling actions.
Can a cost tool see my S3 data or database contents?
Only if you over-grant. Bucket-level metadata (ListAllMyBuckets, size and storage-class metrics via CloudWatch) is enough for cost work; s3:GetObject never is. The clean guarantee is an explicit Deny statement on s3:GetObject*, dynamodb:GetItem/Query/Scan, secretsmanager:GetSecretValue, and kms:Decrypt, which wins over any allow.
What is an ExternalId and why does it matter?
A shared secret written into the cross-account role's trust policy that the vendor must present when assuming the role. It prevents the confused-deputy attack, where someone else signs up to the same vendor and points it at your role ARN. Any vendor doing cross-account access should generate a unique ExternalId per customer.
Does read-only access itself cost anything?
Mostly no, with two meters worth knowing (as of early 2026): the Cost Explorer API bills $0.01 per request, and CloudWatch GetMetricData bills about $0.01 per 1,000 metrics requested. A well-built tool batches both; it's worth asking a vendor what their scan adds to your CloudWatch bill.
How do I verify what a vendor actually accessed?
CloudTrail. Filter management events by the vendor role's ARN or session name and review the eventName distribution; it should be all Describe, List, and Get-metadata calls. Access-denied events on that role are a feature: they're your deny statement working in public.
Top comments (0)