The short version
- One IAM role, created by a CloudFormation template you read first. No agent, no software installed, no VPC peering, no network path into your infrastructure.
- Describe, List, and read-metric permissions only. There is not one create, modify, write, or delete action anywhere in the policy. Count them: it is about twenty-five actions in total.
- No data-plane access at all. Not one of
GetItem,Query,Scan,rds-data,redshift-data,es:ESHttp*,rds-db:connect, orrds:Download*is granted. Nor are Secrets Manager, SSM, KMS, S3, or IAM. - Only one specific auditor role can assume it — not an account, a single named role ARN — and only when it presents an external ID unique to your engagement.
- Every call is in your CloudTrail, under the role
name
db-cost-audit-readonly. - Revoke by deleting the stack. One step, immediate, nothing left behind.
What the role can and cannot do
The audit needs to know what exists, how hard it is working, and what it costs. All three are metadata. None of them require touching data.
Can
- Describe database resources — instance classes, engine versions, storage config, replicas, snapshots, reservations — across RDS, Aurora, DocumentDB, Neptune, Redshift, ElastiCache, OpenSearch, MemoryDB, DAX and DMS
- List DynamoDB tables and describe their configuration, backups, and TTL settings
- Read CloudWatch metrics:
CPUUtilization,FreeableMemory,DatabaseConnectionsand similar, hourly, over 60 days - Read Performance Insights resource metrics (read the caveat on this one — it is the only permission here that deserves a second look)
- Read Cost Explorer aggregates and reservation utilization
- Read the public AWS Price List, which contains nothing about you at all
Cannot
- Read data in any database —
GetItem,Query,ScanandPartiQLSelectare simply not in the policy, and neither is any SQL execution API - Open a database connection — no
rds-db:connect, nords-data, noredshift-data, noes:ESHttp*, noneptune-db, nocassandra - Download database log files —
rds:Download*is not granted (note thatrds:Describe*does not include it) - Read secrets, parameters, or keys — no Secrets Manager, no SSM, no KMS
- Create, modify, restart, resize, or delete anything. There is not one mutating action in the template
- Touch S3, IAM, EC2, VPC, CloudTrail, or anything else outside databases, metrics, cost, and pricing
Why there is no explicit Deny block:
IAM denies everything by default. A policy that lists twenty-five allowed
actions grants those twenty-five actions and nothing else — the
absence of dynamodb:Query from the list is the
guarantee it cannot be called. If your reviewer would rather see the
denial written out explicitly as well, say so and I will add a
Deny statement to your copy before you deploy it. It changes
nothing about what the role can do; it is a readability preference, and a
reasonable one.
Every permission, and why it exists
If a permission cannot be justified by a finding it produces, it does not belong in the policy. Here is that mapping.
| Permissions | Why it is needed | What it cannot reach |
|---|---|---|
rds:Describe*rds:ListTagsForResource |
The inventory for RDS, Aurora, DocumentDB and Neptune, which all
answer on the rds: API. You cannot right-size an
instance class you cannot see. Tags are how non-production is
identified, which is how idle scheduling is found at all. |
Configuration only: names, classes, engine versions, storage
settings, replicas, snapshots, reservations.
Note rds:Download* is a separate prefix and is
not granted — log files are out of reach. |
redshift:Describe*elasticache:Describe*es:Describe*, es:List*memorydb:Describe*dax:Describe* |
The same inventory for the rest of the estate. Cluster shapes, node counts, engine versions, reserved nodes. | Metadata. es:ESHttp* — the OpenSearch data plane —
is a different prefix entirely and is not granted. |
dynamodb:ListTablesdynamodb:DescribeTabledynamodb:DescribeContinuousBackupsdynamodb:DescribeTimeToLivedynamodb:ListTagsOfResource |
Table capacity mode, provisioned throughput, index configuration, PITR state, and TTL — enough to find over-provisioned capacity, unused indexes, and PITR left on in non-production. | Five named actions, not dynamodb:Describe*
and not a wildcard. GetItem,
BatchGetItem, Query, Scan and
PartiQLSelect are absent, so they cannot be called. |
dms:DescribeReplicationInstancesdms:DescribeReplicationTasks |
Leftover DMS replication instances from a finished migration are one of the most reliably wasteful things in an AWS estate — they page nobody, so nobody deletes them. | Two named actions. No endpoint credentials, no task data, no
dms:Describe* wildcard. |
cloudwatch:GetMetricDatacloudwatch:ListMetrics |
60 days of hourly metrics is what makes a downsize safe rather than a guess — p95 of hourly maxima, memory headroom, connection counts, and idle windows in your business timezone. | CloudWatch stores numbers about resources, never their contents. There is no API in this pair that could return data if it wanted to. |
pi:GetResourceMetricspi:DescribeDimensionKeys |
Performance Insights load metrics, used to tell a genuinely busy database from an idle one before recommending a downsize, and to spot instances where the fix is a query problem rather than a sizing problem. | The one permission worth a second look, so here it is plainly: Performance Insights dimensions can include tokenized SQL statement text — the shape of a query, with literal values stripped. That is query metadata, not row data, and no result set is ever reachable. If your reviewer would rather not grant it, delete both lines before deploying: the scan still runs, and you lose only the load-based checks. Say so and I will send you the version with them removed. |
ce:GetCostAndUsagece:GetDimensionValuesce:GetReservationUtilizationce:GetReservationPurchaseRecommendationce:GetSavingsPlansUtilization |
To separate reservable compute from storage, backups and I/O — the single most common error in reservation recommendations, and the reason this practice exists at all. | Billing aggregates for the account. No resource contents, no user activity, no access to the billing console or payment details. |
pricing:GetProducts |
Every claimed saving is priced against the live AWS Price List. Where a rate cannot be verified, the report prints TBD instead of a number. | AWS's public product catalogue. It contains nothing about your account, and anyone can read it without any role at all. |
The template, in full
This is the whole thing. Nothing is omitted here, and nothing is added at deploy time.
AWSTemplateFormatVersion: "2010-09-09"
Description: >
Read-only IAM role for the AWS Database Cost Audit.
Grants access to cost data, RDS/Redshift metadata, and CloudWatch
metrics only. No write permissions of any kind. Delete this stack
after the audit to revoke access instantly.
Parameters:
AuditorRoleArn:
Type: String
Description: Dedicated auditor role ARN permitted to assume this role
ExternalId:
Type: String
Description: Unique external ID provided by the auditor (confused-deputy guard)
NoEcho: true
Resources:
DbCostAuditRole:
Type: AWS::IAM::Role
Properties:
RoleName: db-cost-audit-readonly
# One named role may assume this — not an account, not a wildcard —
# and only when it presents the external ID.
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Ref AuditorRoleArn
Action: sts:AssumeRole
Condition:
StringEquals:
sts:ExternalId: !Ref ExternalId
# No ManagedPolicyArns. Deliberately not ReadOnlyAccess — that
# AWS-managed policy is orders of magnitude broader than this needs.
Policies:
- PolicyName: db-cost-audit-readonly
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: CostData
Effect: Allow
Action:
- ce:GetCostAndUsage
- ce:GetDimensionValues
- ce:GetReservationUtilization
- ce:GetReservationPurchaseRecommendation
- ce:GetSavingsPlansUtilization
Resource: "*"
- Sid: RdsRead # RDS, Aurora, DocumentDB, Neptune
Effect: Allow
Action:
- rds:Describe*
- rds:ListTagsForResource
Resource: "*"
- Sid: PublicPricing
Effect: Allow
Action:
- pricing:GetProducts
Resource: "*"
- Sid: RedshiftRead
Effect: Allow
Action:
- redshift:Describe*
Resource: "*"
- Sid: ElastiCacheRead
Effect: Allow
Action:
- elasticache:Describe*
Resource: "*"
- Sid: Metrics
Effect: Allow
Action:
- cloudwatch:GetMetricData
- cloudwatch:ListMetrics
Resource: "*"
- Sid: PerformanceInsights # see the note on pi: above
Effect: Allow
Action:
- pi:GetResourceMetrics
- pi:DescribeDimensionKeys
Resource: "*"
# --- v2 additions (Phase 3 services). Existing deployments:
# update the stack in place; RoleArn and ExternalId are
# unchanged, no re-onboarding needed.
- Sid: OpenSearchRead
Effect: Allow
Action:
- es:Describe*
- es:List*
Resource: "*"
- Sid: DynamoDBRead # five named actions, no wildcard
Effect: Allow
Action:
- dynamodb:ListTables
- dynamodb:DescribeTable
- dynamodb:DescribeContinuousBackups
- dynamodb:DescribeTimeToLive
- dynamodb:ListTagsOfResource
Resource: "*"
- Sid: FutureDatabaseReads
Effect: Allow
Action:
- memorydb:Describe*
- dax:Describe*
Resource: "*"
- Sid: DmsRead
Effect: Allow
Action:
- dms:DescribeReplicationInstances
- dms:DescribeReplicationTasks
Resource: "*"
Outputs:
RoleArn:
Description: Share this ARN with the auditor
Value: !GetAtt DbCostAuditRole.Arn
The file linked above is byte-for-byte the template deployed for every engagement. If this page and the YAML ever disagree, the YAML is correct — and please tell me, because that is a bug.
Deploying it — about 15 minutes
-
Read it, or have your security team read it
Download the YAML above. If your reviewer wants a call before anything is deployed, that call is free and I would rather have it than not — I will walk the policy line by line with them.
-
Deploy the stack in one account
CloudFormation → Create stack → upload the template. Two parameters, both from my email:
AuditorRoleArn(check it matches character for character) andExternalId. Nothing else. The stack creates exactly one resource. -
Send me the role ARN
It is in the stack's
Outputstab. That is the entire handover — no key, no password, no credential of any kind is ever exchanged in either direction. -
Watch it in CloudTrail, then delete the stack
Filter CloudTrail on
db-cost-audit-readonlyto see every call the scan made, with timestamps. When you are done, delete the stack: access ends immediately and completely.
What security teams ask
Can you see the data inside our databases?
No. There is no data-plane permission in the policy — not
GetItem, Query, Scan,
rds-data, redshift-data,
es:ESHttp*, rds-db:connect, or
rds:Download*. The scan sees names, instance classes,
engine versions, configuration, tags, pricing, and utilization metrics.
The one nuance worth knowing about is Performance Insights, which is
covered in its own answer below. Every API call is logged in your
CloudTrail, so none of this has to be taken on faith.
What about the Performance Insights permissions?
This is the one place the answer is longer than "no", so here it is
in full. pi:GetResourceMetrics and
pi:DescribeDimensionKeys return database load metrics,
broken down by dimensions. Some of those dimensions can include
tokenized SQL statement text — the shape of a query with
literal values stripped out. That is query metadata; it is not row data,
and no result set is reachable through this API. It is used to tell a
genuinely busy database from an idle one before recommending a downsize.
If you would rather not grant it, delete those two lines before
deploying. The scan runs fine without them; you lose only the
load-based checks, and I would rather you deploy a template you are
completely comfortable with.
Why not just use the AWS-managed ReadOnlyAccess policy?
Because it is orders of magnitude broader than this audit needs. It grants read across most of AWS, including services with nothing to do with databases and some that return genuinely sensitive content. A scoped allow-list of roughly twenty-five actions is both safer for you and far easier for a reviewer to sign off than an AWS-managed policy nobody can read end to end.
What is the external ID actually for?
It prevents the "confused deputy" problem. Without it, anyone who learned your role ARN and could get code running in the auditor account might be able to assume your role. The external ID is a secret shared only between us and required on every assume-role call, so the ARN alone is useless to anyone else. It is AWS's own recommended pattern for third-party access. Note also that the trust policy names a specific auditor role ARN, not an account root — so even inside that account, only one role can make the call.
Can we restrict it further?
Yes, and it is a reasonable thing to want. Common additions: an
aws:RequestedRegion condition limiting the scan to regions
you actually use, a MaxSessionDuration cap, a permissions
boundary, an IP condition, or an explicit Deny block
spelling out the data-plane actions. Dropping the Performance Insights
statement is the most common one. Send me what you want and I will tell
you honestly which findings each restriction switches off — usually the
answer is "none".
What happens to the metadata you collect?
It is used to produce your report, delivered to you, and deleted on request after the engagement. The report itself is a single self-contained HTML file: it makes no network requests, phones nothing home, and works offline. You can verify that by opening it with your network disconnected.
How do we revoke access?
Delete the CloudFormation stack. That is the whole procedure — access ends immediately, and nothing is left behind to clean up. You can do it mid-scan if you change your mind; the scan simply stops.
Does anything get installed in our environment?
No. No agent, no daemon, no Lambda, no VPC peering, no network path into your infrastructure. The scan runs entirely from outside, using the AWS APIs, through the role you created. Deleting one IAM role removes every trace of it.
Cleared it with your security team?
The free scan runs 45+ checks against one AWS account and returns an evidence-first report in 48 hours — every finding with the account, the resource, the numbers, and the risk. Yours to keep whatever you decide afterwards. If you would rather see the output before the paperwork, the sample report is open and complete.
Request a free scan See the sample report