(apigateway): LogGroupLogDestination emits log group ARN with trailing `:*`, causing permanent Stage CFN drift
- Dominant language
- TypeScript
- Stars
- 12.9k
- Forks
- 4.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 83
Description
### Describe the bug
`LogGroupLogDestination` sets the REST API Stage property `AccessLogSetting.DestinationArn` to the log group `logGroupArn`, which carries a trailing `:*`. API Gateway stores this ARN without the `:*`. As a result, CloudFormation drift detection reports the Stage as `MODIFIED` on every drift run, even though nothing was changed and access logging works correctly.
CloudFormation Drift detection is only useful if `IN_SYNC` is the normal, trusted state. A resource that is permanently `MODIFIED` trains operators to ignore drift, which then hides real drift. Many teams also gate deploys, audits, or compliance checks on a clean drift status, so a permanent false positive weakens those gates. "Just ignore it" or "add it to an allow list" does not scale across stacks and environments, and it suppresses real drift on the same property. The goal is a clean `IN_SYNC` state without manual suppression.
### Regression Issue
- [ ] Select this option if this issue appears to be a regression.
### Last Known Working CDK Library Version
_No response_
### Expected Behavior
`LogGroupLogDestination` sets `AccessLogSetting.DestinationArn` to the log group ARN without the trailing `:*`, so the synthesized template matches what API Gateway stores and drift detection reports the Stage as `IN_SYNC`.
### Current Behavior
The synthesized `DestinationArn` points at the log group `Arn` attribute, which includes the `:*`:
```json
{
"AccessLogSetting": {
"DestinationArn": {
"Fn::GetAtt": ["AccessLogs8B620ECA", "Arn"]
}
}
}
```
At deploy time this resolves to an ARN with a trailing `:*`:
```
arn:aws:logs:eu-central-1:111122223333:log-group:/aws/apigateway/demo/accessLogs:*
```
API Gateway stores the ARN without the `:*`. Reading it back from the service:
```bash
aws apigateway get-stage \
--rest-api-id \
--stage-name prod \
--query 'accessLogSettings.destinationArn' \
--output text
```
```
arn:aws:logs:eu-central-1:111122223333:log-group:/aws/apigateway/demo/accessLogs
```
CloudFormation drift detection then reports the Stage as `MODIFIED` with a single property difference:
```bash
aws cloudformation describe-stack-resource-drifts \
--stack-name \
--stack-resource-drift-status-filters MODIFIED
```
```json
{
"LogicalResourceId": "ApiDeploymentStageprod...",
"ResourceType": "AWS::ApiGateway::Stage",
"PropertyDifferences": [
{
"PropertyPath": "/AccessLogSetting/DestinationArn",
"ExpectedValue": "arn:aws:logs:eu-central-1:111122223333:log-group:/aws/apigateway/demo/accessLogs:*",
"ActualValue": "arn:aws:logs:eu-central-1:111122223333:log-group:/aws/apigateway/demo/accessLogs",
"DifferenceType": "NOT_EQUAL"
}
],
"StackResourceDriftStatus": "MODIFIED"
}
```
The only difference is the trailing `:*`. This repeats on every drift run and affects every stack that enables access logging this way.
### Reproduction Steps
Below you can find a sample CDK code in TypeScript. After synth, inspect the Stage in the generated template.
```ts
import { App, Stack } from 'aws-cdk-lib';
import { RestApi, LogGroupLogDestination, AccessLogFormat, MethodLoggingLevel } from 'aws-cdk-lib/aws-apigateway';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
const app = new App();
const stack = new Stack(app, 'DefaultStack');
const logGroup = new LogGroup(stack, 'AccessLogs', {
logGroupName: '/aws/apigateway/demo/accessLogs',
});
new RestApi(stack, 'Api', {
deployOptions: {
accessLogDestination: new LogGroupLogDestination(logGroup),
accessLogFormat: AccessLogFormat.jsonWithStandardFields(),
loggingLevel: MethodLoggingLevel.ERROR,
},
}).root.addMethod('GET');
app.synth();
```
In the synthesized `DefaultStack` template, the `AWS::ApiGateway::Stage` resource has `AccessLogSetting.DestinationArn` set to `{ "Fn::GetAtt": ["AccessLogs...", "Arn"] }`, which resolves with the trailing `:*`. Deploy the stack and run drift detection to see the Stage reported as `MODIFIED`.
### Possible Solution
The `:*` on `LogGroup.logGroupArn` is intended and should not be changed globally, because IAM policies rely on it. The relevant source confirms this.
`LogGroupLogDestination.bind()` (`packages/aws-cdk-lib/aws-apigateway/lib/access-log.ts`) returns the ARN unchanged:
```ts
public bind(_stage: IStageRef): AccessLogDestinationConfig {
return {
destinationArn: this.logGroup.logGroupRef.logGroupArn,
};
}
```
`logGroupArn` is documented to include the `:*` (`packages/aws-cdk-lib/aws-logs/lib/log-group.ts`):
```ts
/**
* The ARN of this log group, with ':*' appended
*
* @attribute
*/
readonly logGroupArn: string;
```
The same file documents the IAM reason for the `:*` in `grant()`:
```ts
// A LogGroup ARN out of CloudFormation already includes a ':*' at the end to
// include the log streams under the group.
```
So the fix belongs in `LogGroupLogDestination`, which should use the log group ARN without the `:*` for this destination. CDK already knows how to build the `:*`-free form: in `log-group.ts`, `fromLogGroupName` builds the ARN with `formatArn(... ArnFormat.COLON_RESOURCE_NAME)` from the name, and `fromLogGroupArn` starts by stripping the suffix with `logGroupArn.replace(/:\*$/, '')`. The destination could reuse the same approach.
An alternative or additional fix is on the CloudFormation side: give the `AWS::ApiGateway::Stage` resource type a `propertyTransform` for `AccessLogSetting.DestinationArn` so the server side normalization is not reported as drift. See [Preventing false drift detection results for resource types](https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-model-false-drift.html). That page documents an equivalent case already solved this way: `AWS::Route53::HostedZone` uses a `propertyTransform` for a trailing `.` on the `Name` property, which is structurally the same problem as the trailing `:*` here. Note that this would be a change to the AWS owned resource type schema, so it can only be done by AWS, not by CDK or the user. Its advantage is that it would fix existing stacks without any template change or redeploy.
Before, I would create a PR, **I'd like to check the following question with you**:
- (1) Preferred fix location: normalize the ARN inside `LogGroupLogDestination`, or add a `propertyTransform` for `AWS::ApiGateway::Stage`, or both. The `propertyTransform` route fixes drift for existing stacks without any template change, which is the cleanest path to `IN_SYNC`.
- (2) If the fix is in `LogGroupLogDestination`, the synthesized template changes from `Fn::GetAtt ... Arn` to a name based ARN, so existing stacks will see a diff on the next `cdk diff`. The diff is an in place property update, not a resource replacement, and the value stored in API Gateway does not change. Should this go behind a feature flag so existing users do not get an unexpected diff on a plain version upgrade? This question is independent of how small the code change is: the need for a feature flag depends only on whether the synthesized template changes for existing stacks, not on the size of the fix.
I am happy to open a PR once there is agreement on the fix location and on whether a feature flag is wanted.
### Additional Information/Context
Workaround using a custom `IAccessLogDestination` that rebuilds the ARN from the log group name. The `:*` is only added when the `Arn` attribute is resolved at deploy time, so trimming the string at synth time does not work; rebuilding from the name avoids the `:*` entirely.
```ts
import { Stack, ArnFormat } from 'aws-cdk-lib';
import { IAccessLogDestination, AccessLogDestinationConfig } from 'aws-cdk-lib/aws-apigateway';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
class LogGroupLogDestinationWithoutWildcard implements IAccessLogDestination {
private readonly destinationArn: string;
constructor(scope: Construct, logGroup: LogGroup) {
this.destinationArn = Stack.of(scope).formatArn({
service: 'logs',
resource: 'log-group',
resourceName: logGroup.logGroupName,
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
});
}
bind(): AccessLogDestinationConfig {
return { destinationArn: this.destinationArn };
}
}
```
With the workaround the synthesized `DestinationArn` no longer contains the `:*`, and drift detection reports the Stage as `IN_SYNC`:
```json
{
"AccessLogSetting": {
"DestinationArn": {
"Fn::Join": [
"",
[
"arn:", { "Ref": "AWS::Partition" },
":logs:", { "Ref": "AWS::Region" },
":", { "Ref": "AWS::AccountId" },
":log-group:", { "Ref": "AccessLogs8B620ECA" }
]
]
}
}
}
```
Related: [aws-cdk #18253](https://github.com/aws/aws-cdk/issues/18253) "(logs): Log Group ARN has extra `:*`" describes the same underlying behavior with a WAF example, and suggests the same `formatArn(... COLON_RESOURCE_NAME)` approach. It notes that it may be better to normalize the ARN in the L2 constructs that consume the log group ARN rather than changing `logGroupArn` globally. This issue applies that idea to `LogGroupLogDestination` and the API Gateway Stage drift specifically. The same fix pattern could later be reused for other consumers of the log group ARN, such as the WAF logging configuration in #18253.
### AWS CDK Library version (aws-cdk-lib)
2.268.0
### AWS CDK CLI version
2.1140.0
### Node.js Version
v24.20.0
### OS
Linux
### Language
TypeScript
### Language Version
TypeScript (7.0.2)
### Other information
_No response_
Contributor guide
Research direction
Start by reading LogGroupLogDestination.bind() in packages/aws-cdk-lib/aws-apigateway/lib/access-log.ts and the ARN handling in packages/aws-cdk-lib/aws-logs/lib/log-group.ts. Reproduce the issue with the TypeScript sample and inspect the synthesized AWS::ApiGateway::Stage resource. Done means the destination ARN matches the value API Gateway stores and the change includes appropriate regression coverage, after the fix location and compatibility decision are resolved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, typescript
- Domain
- api, cloud, infrastructure
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100