Skip to content

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

Open
@garysassano

Description

@garysassano

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):

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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    @aws-cdk/aws-iamRelated to AWS Identity and Access ManagementbugThis issue is a bug.p2

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions