Stop copying AWS managed policies — deny what you don’t want instead
I needed to give a developer full CloudWatch read access — metrics, alarms, dashboards, log groups — but deny access to three categories of log groups containing security-sensitive data: WorkSpaces OS event logs, VPC flow logs, and WAF request logs.
The reflex is to copy CloudWatchReadOnlyAccess into a custom policy and delete the parts you don’t want. I’ve seen this in every organization I’ve worked in. It produces a policy with 50+ actions that you now own. Every time AWS ships a new CloudWatch feature, your policy is stale. You won’t update it. It’ll rot.
Deny always wins
IAM policy evaluation has a simple hierarchy: explicit deny beats everything. A deny statement overrides any number of allow statements, regardless of where they appear — managed policies, inline policies, permission boundaries, SCPs.
This means you can keep CloudWatchReadOnlyAccess attached as a managed policy — AWS maintains it, new features get picked up automatically — and layer an inline deny on top:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenySensitiveLogGroups",
"Effect": "Deny",
"Action": [
"logs:GetLogEvents",
"logs:FilterLogEvents",
"logs:DescribeLogStreams",
"logs:StartQuery",
"logs:GetQueryResults"
],
"Resource": [
"arn:aws:logs:us-east-1:ACCOUNT:log-group:/workspaces/windows/*",
"arn:aws:logs:us-east-1:ACCOUNT:log-group:StackSet-AWSControlTowerBP-*",
"arn:aws:logs:us-east-1:ACCOUNT:log-group:aws-waf-logs-*"
]
}
]
}
The managed policy allows everything. The inline policy carves out three exceptions. I maintain five actions and three resource patterns instead of the entire CloudWatch API surface.
The boundary is data classification, not API surface
The deny targets specific log group ARN patterns, not CloudWatch actions globally. The developer can still call GetLogEvents — just not on these three prefixes. Metrics, alarms, dashboards, and every other log group are unaffected.
This matters because GetLogEvents on /apps/tenants/* is debugging. GetLogEvents on /workspaces/windows/Security is endpoint forensics. Same API call, different data classification. The boundary belongs on the data, not the action.
Why five actions and not all of logs:*
These five are the read paths into log group contents:
GetLogEvents— read log entriesFilterLogEvents— search log entriesDescribeLogStreams— enumerate streams within the log groupStartQuery— CloudWatch Logs Insights queriesGetQueryResults— retrieve Insights query output
DescribeLogGroups is deliberately not denied. The developer can still see that the log groups exist — they just can’t read their contents. Visible but inaccessible is better than invisible. If a log group disappears from the console, that’s confusing. If it shows access denied when you click into it, that’s clear.
The general pattern
This works anywhere you have a broad managed policy that’s 90% right and need to carve out exceptions:
ReadOnlyAccess+ deny on S3 buckets containing PIIViewOnlyAccess+ deny on specific Secrets Manager secretsSecurityAudit+ deny on specific CloudTrail trails
The principle: let AWS maintain the allow surface, you maintain the deny surface. Your deny list is small, stable, and tied to data classification decisions that change slowly. When AWS adds logs:GetSomethingNew next quarter, your developer gets it automatically. When you need to protect a new log group, you add one ARN pattern.
The maintenance burden is proportional to the number of things you need to protect, not the number of things you need to allow. In any non-trivial AWS account, that’s a better ratio.