aws / aws/aws-cdk

(aws-iam): `Role.grantX()` doesn't create implicit dependency for attached policy resources

Open
#34,325 1 comment 0 reactions 0 assignees View on GitHub
@aws-cdk/aws-iam bug p2
Dominant language
TypeScript
Stars
12.9k
Forks
4.6k
Avg merge
2d 3h
Merged PRs (30d)
83

Description

### Describe the bug

Using `stream.grantWrite(role)` to grant permissions does not seem to establish a CloudFormation dependency for the generated IAM Policy, causing resources like `CfnDestination` that use the `role.roleArn` to fail deployment due to missing permissions during creation/validation.

### Regression Issue

- [ ] Select this option if this issue appears to be a regression.

### Last Known Working CDK Version

_No response_

### Reproduction Steps

Consider the following CDK TypeScript code which sets up a CloudWatch Logs Destination (`CfnDestination`) pointing to a Kinesis Stream (`Stream`). A role (`Role`) is created for CloudWatch Logs to assume, and permissions to write to the Kinesis stream are granted using `stream.grantWrite(role)`:

```typescript
import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Stream } from "aws-cdk-lib/aws-kinesis";
import { CfnDestination } from "aws-cdk-lib/aws-logs";
import { Construct } from "constructs";

export class MonitoringStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps = {}) {
super(scope, id, props);

const DESTINATION_NAME = "test-destination";

// Create a Kinesis stream
const testStream = new Stream(this, "TestStream", {
shardCount: 1,
removalPolicy: RemovalPolicy.DESTROY,
});

// Role for CloudWatch Logs to assume
const cwlToKinesisRole = new Role(this, "CwlToKinesisRole", {
assumedBy: new ServicePrincipal("logs.amazonaws.com"),
});

// Grant write permissions to the role
testStream.grantWrite(cwlToKinesisRole);

// Create the CloudWatch Logs Destination
new CfnDestination(this, "TestDestination", {
destinationName: DESTINATION_NAME,
targetArn: testStream.streamArn,
roleArn: cwlToKinesisRole.roleArn,
destinationPolicy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Sid: "AllowSubscriptionFilter",
Effect: "Allow",
Principal: "*",
Action: ["logs:PutSubscriptionFilter", "logs:PutAccountPolicy"],
Resource: `arn:aws:logs:${this.region}:${this.account}:destination:${DESTINATION_NAME}`,
Condition: {
StringEquals: {
"aws:PrincipalOrgID": ["o-xxxxxxxxxx"],
},
},
},
],
}),
});
}
}
```

**Observed Behavior**

When deploying this stack, the creation of the `AWS::Logs::Destination` resource (`TestDestination`) fails with the following CloudFormation error:

```
CREATE_FAILED | AWS::Logs::Destination | TestDestination
Resource handler returned message: "Invalid request provided: AWS::Logs::Destination. Could not deliver test message to specified destination. Check if the destination is valid. (Service: CloudWatchLogs, Status Code: 400, ...)"
```

It appears there's a race condition. The `CfnDestination` resource attempts creation before the IAM Policy granting `kinesis:PutRecord` permissions (generated by `testStream.grantWrite()`) is fully attached to the `cwlToKinesisRole`. Since the role lacks the necessary permissions at the moment CloudWatch Logs tries to validate the destination by sending a test message, the creation fails.

**Expected Behavior**

Calling `testStream.grantWrite(role)` should ideally create an implicit dependency. The `CfnDestination` resource, which uses `role.roleArn`, should depend not only on the `AWS::IAM::Role` resource itself but also on the `AWS::IAM::Policy` resource generated by the `grantWrite()` call that attaches permissions *to* that role. This would ensure the permissions are in place before the destination is created and validated.

### Possible Solution

Defining the policy inline within the `Role` construct resolves the issue, because it forces CloudFormation to create the role and its inline policy together, before the `CfnDestination` (which depends from the `roleArn`) is created:

```typescript
import { Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Stream } from "aws-cdk-lib/aws-kinesis";
import { CfnDestination } from "aws-cdk-lib/aws-logs";
import { Construct } from "constructs";
import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";

export class MonitoringStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps = {}) {
super(scope, id, props);

const DESTINATION_NAME = "test-destination";

// Create a Kinesis stream
const testStream = new Stream(this, "TestStream", {
shardCount: 1,
removalPolicy: RemovalPolicy.DESTROY,
});

// Role for CloudWatch Logs to assume
const cwlToKinesisRole = new Role(this, "CwlToKinesisRole", {
assumedBy: new ServicePrincipal("logs.amazonaws.com"),
inlinePolicies: {
KinesisWritePolicy: new PolicyDocument({
statements: [
new PolicyStatement({
actions: ["kinesis:PutRecord", "kinesis:PutRecords"],
resources: [testStream.streamArn],
effect: Effect.ALLOW,
}),
],
}),
},
});

// Create the CloudWatch Logs Destination
new CfnDestination(this, "TestDestination", {
destinationName: DESTINATION_NAME,
targetArn: testStream.streamArn,
roleArn: cwlToKinesisRole.roleArn,
destinationPolicy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Sid: "AllowSubscriptionFilter",
Effect: "Allow",
Principal: "*",
Action: ["logs:PutSubscriptionFilter", "logs:PutAccountPolicy"],
Resource: `arn:aws:logs:${this.region}:${this.account}:destination:${DESTINATION_NAME}`,
Condition: {
StringEquals: {
"aws:PrincipalOrgID": ["o-xxxxxxxxxx"],
},
},
},
],
}),
});
}
}
```

### Additional Information/Context

_No response_

### CDK CLI Version

2.192.0

### Framework Version

_No response_

### Node.js Version

22.15.0

### OS

Ubuntu 24.04

### Language

TypeScript

### Language Version

_No response_

### Other information

_No response_

Contributor guide

Open the contributing guide

Research direction

Start with the TypeScript reproduction using Stream.grantWrite(), Role.roleArn, and CfnDestination, then synthesize the stack and inspect the generated AWS::IAM::Policy and resource dependencies. Done means the destination waits for the grant-generated policy attachment, with a regression test covering the synthesized dependency and deployment ordering.

Written by the indexing model from the issue text.

Assessment

Tech stack
aws, typescript
Domain
infrastructure, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.