Data & logs
Read, search and query one CloudWatch Logs log group
Let an on-call engineer search, tail and run Logs Insights queries on one application's log group, not every group in the account.
Published · Updated .
The problem
On-call engineers usually get CloudWatchLogsReadOnlyAccess. It is a reasonable default for a platform team, but it grants logs:Describe*, logs:Get*, logs:StartQuery, logs:FilterLogEvents and live tail on "*": every log group in the account, including groups with customer data, security tooling output or other teams' payloads. Application logs regularly contain identifiers, tokens and request bodies that someone logged by mistake, so read access to a log group is read access to production data.
This policy lets a troubleshooting role read the /app/orders-api group in eu-west-1: fetch and filter events, tail live, and run Logs Insights queries. It also has to deal with three CloudWatch Logs details that trip people up: log group and log stream ARNs are different resource types, some Logs Insights and live tail actions cannot be scoped to a group, and the console expects DescribeLogGroups.
The policy
Read and query /app/orders-api
IAM identity-based policy. Attach to: A troubleshooting role for the orders application, assumed by the on-call engineers
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadAndQueryOneLogGroup",
"Effect": "Allow",
"Action": [
"logs:DescribeLogStreams",
"logs:GetLogEvents",
"logs:FilterLogEvents",
"logs:GetLogGroupFields",
"logs:GetLogRecord",
"logs:StartQuery",
"logs:GetQueryResults",
"logs:StartLiveTail"
],
"Resource": [
"arn:aws:logs:eu-west-1:111122223333:log-group:/app/orders-api",
"arn:aws:logs:eu-west-1:111122223333:log-group:/app/orders-api:*"
]
},
{
"Sid": "ActionsWithoutResourceScope",
"Effect": "Allow",
"Action": [
"logs:DescribeLogGroups",
"logs:StopQuery",
"logs:StopLiveTail"
],
"Resource": "*"
}
]
}Statement by statement
- ReadAndQueryOneLogGroup: most of these actions authorize against the log-group resource type. GetLogEvents is the exception: it authorizes against a log-stream ARN, log-group:/app/orders-api:log-stream:<name>, which only the :* entry matches. AWS documents both spellings of a group ARN: the LogGroup API type calls the :* form the one to use in IAM policies for most actions, while the CloudWatch Logs user guide shows the plain ARN for group-level actions. Listing both covers both without widening access, because log group names cannot contain a colon. The Service Authorization Reference lists log-group as the resource type for StartQuery and GetQueryResults, so both Logs Insights calls are scoped here.
- ActionsWithoutResourceScope: StopQuery and StopLiveTail act on a query or live tail session, which CloudWatch Logs does not treat as resources, and AWS documents that they need Resource "*". DescribeLogGroups has no resource type either, and its API reference notes that it cannot be limited with aws:ResourceTag. It returns names and metadata for every group, not their events. DescribeQueries is left out on purpose, as explained in the steps.
Before you start
- The log group /app/orders-api in eu-west-1 of account 111122223333.
- A role for the on-call engineers, for example orders-oncall, that they assume through your identity provider.
- AWS CLI v2 for the checks. Logs Insights start and end times are in epoch seconds; filter-log-events times are in epoch milliseconds.
Deploy it step by step
- Validate and attach. Access Analyzer catches grammar problems and some risky patterns. It does not know which resource type each Logs action uses, so a missing :* passes validation and only shows up as an AccessDeniedException on GetLogEvents.
Commands (replace <placeholders>) aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://policy.json aws iam put-role-policy --role-name orders-oncall --policy-name read-orders-api-logs --policy-document file://policy.json - Decide what the console needs. From the CLI, engineers can name the group directly. The console's group pickers need DescribeLogGroups, which is why it is included. Saved queries need logs:DescribeQueryDefinitions, and query history uses logs:DescribeQueries. Think twice before adding DescribeQueries: it has no resource type, and its API reference shows each recent query in the account returned with its query string, its log group and the caller's identity. The CloudWatch Logs documentation suggests CloudWatchReadOnlyAccess for full console use, which grants far more than this example aims to.
- Run a Logs Insights query. StartQuery returns a query ID. GetQueryResults takes that ID and returns rows once the query completes, with Status showing Running until then. Queries are billed by the amount of data scanned, so keep the time range tight.
Commands (replace <placeholders>) aws logs start-query --log-group-name /app/orders-api --start-time <epoch-seconds> --end-time <epoch-seconds> --query-string 'fields @timestamp, @message | filter @message like /ERROR/ | limit 20' --region eu-west-1 aws logs get-query-results --query-id <query-id> --region eu-west-1
Verify it without changing anything
These checks only read state, simulate, or use dry runs, so they are safe to run in any account. Exact error wording varies by service and over time.
Search the allowed group, then a different one
Run both as the on-call role. The second group can be any group the role should not read.
aws logs filter-log-events --log-group-name /app/orders-api --filter-pattern ERROR --start-time <epoch-milliseconds> --region eu-west-1
aws logs filter-log-events --log-group-name /app/payments --start-time <epoch-milliseconds> --region eu-west-1Expected: The first call returns matching events, or an empty events list if nothing matched; both mean access works. The second should fail with AccessDeniedException naming logs:FilterLogEvents and the /app/payments resource. If it succeeds, another policy on the role grants logs access.
Check what the policy deliberately leaves out
The first call confirms that Logs Insights results and live tail stay scoped to orders-api. The second covers the account-wide query history and unmasking, which are intentionally not granted. Without --resource-arns the simulator uses "*".
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names logs:GetQueryResults logs:StartLiveTail --resource-arns arn:aws:logs:eu-west-1:111122223333:log-group:/app/payments
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names logs:DescribeQueries logs:UnmaskExpected: implicitDeny for all four combinations. allowed for DescribeQueries means someone added it back; weigh the query-history exposure described in the steps. Test queries that name several groups separately rather than assuming a query skips groups the role cannot read.
Simulate both resource types
The second simulation is the one that fails if you remove the :* entry, which makes it a useful regression test when someone tidies the policy.
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names logs:StartQuery logs:FilterLogEvents --resource-arns arn:aws:logs:eu-west-1:111122223333:log-group:/app/orders-api arn:aws:logs:eu-west-1:111122223333:log-group:/app/payments
aws iam simulate-custom-policy --policy-input-list file://policy.json --action-names logs:GetLogEvents --resource-arns arn:aws:logs:eu-west-1:111122223333:log-group:/app/orders-api:log-stream:<stream-name>Expected: allowed for both actions on /app/orders-api and implicitDeny on /app/payments; allowed for GetLogEvents on the orders-api stream ARN. The simulator only evaluates the policy you pass in, so confirm with the real calls above.
Common mistakes and troubleshooting
Only the plain group ARN
With Resource set to log-group:/app/orders-api alone, the group-level calls work but GetLogEvents is denied, because it authorizes against stream ARNs. Users see filter-log-events working and get-log-events failing, which looks inconsistent until you check resource types in the Service Authorization Reference.
Prefix wildcards that match too much
log-group:/app/orders* also matches /app/orders-api-staging and /app/orders-export. Name each group, or tag the groups and use aws:ResourceTag, which CloudWatch Logs supports on most actions.
Masked data stays masked
If the group has a data protection policy, matching values are masked in results. Seeing them requires logs:Unmask, which is scoped to log groups and intentionally not granted. Grant it separately, and rarely.
Limitations and production considerations
- DescribeLogGroups reveals the names, retention, KMS key ARNs and sizes of every group in the Region. If names are sensitive, drop it and have engineers name the group in CLI calls; parts of the console will stop working.
- StopQuery and StopLiveTail on "*" let the role stop any query or session whose ID it knows. The risk is minor (an interrupted query), but it is not scoped.
- This policy scales poorly to many groups. For dozens of applications, tag each group with an owner and grant read access with an aws:ResourceTag condition instead of listing ARNs, keeping in mind that DescribeLogGroups cannot use that condition.
Official AWS documentation
Account IDs, bucket and organization names are AWS documentation placeholders. Test in a non-production account before use (see the methodology).