How to safely modify resource policy in other CDK Stack

Issue #1060

Imagine a single IAM role that lives outside your stack, created once and reused by many deployments. Maybe it belongs to a partner service, a shared gateway, or a piece of platform infrastructure that several teams call into. Your CDK stack imports that role and grants it permission to invoke a Lambda function. It works the first time. Then a second developer deploys the same stack, under a different name, and the deployment fails with an error that makes no sense at first glance:

Policy resource was already managed by another stack or another resource
in the current stack. Found stacks: [dev-team-a-worker|SharedRoleDefaultPolicyABC123]

Both stacks are deploying the same source code. Both are granting a permission to the same shared role. Nothing about that should conflict, since IAM roles can hold any number of inline policies. Yet CloudFormation refuses to let the second stack proceed. The reason has nothing to do with IAM and everything to do with how CDK names resources.

A minimal reproduction

Here is a stack that imports a shared role by ARN and grants it invoke access on a Lambda function.

import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

interface WorkerStackProps extends cdk.StackProps {
  sharedRoleArn: string;
}

class WorkerStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: WorkerStackProps) {
    super(scope, id, props);

    const worker = new lambda.Function(this, 'Worker', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline('exports.handler = async () => {};'),
    });

    const sharedRole = iam.Role.fromRoleArn(this, 'SharedRole', props.sharedRoleArn);

    worker.grantInvoke(sharedRole);
  }
}

The app entry point takes a label from the environment, so the same code can be deployed many times side by side under different CloudFormation stack names.

const app = new cdk.App();
const label = app.node.tryGetContext('label') ?? 'default';

new WorkerStack(app, 'WorkerStack', {
  stackName: `dev-${label}-worker`,
  sharedRoleArn: 'arn:aws:iam::111111111111:role/shared-gateway-execution',
});

Deploy this with -c label=team-a, then again with -c label=team-b. The first deployment succeeds. The second fails with the exact error above.

Why the construct id matters more than the stack name

stackName only changes the physical name CloudFormation uses. It has no effect on how CDK computes logical ids for the resources inside the stack. Those ids come from the construct tree path, and that path starts with the id you pass to the Stack constructor itself, which in this example is the hardcoded string 'WorkerStack', not the label-specific value passed to stackName.

Running cdk synth for both labels confirms it. Search the output template for the generated policy resource and the logical id is identical in both cases:

WorkerStack/SharedRole/DefaultPolicy -> SharedRoleDefaultPolicyABC123

CDK hashes the construct path to produce that suffix, and since the path WorkerStack/SharedRole/DefaultPolicy never changes between labels, every deployment computes the same logical id and therefore the same underlying PolicyName. CloudFormation tracks ownership of an AWS::IAM::Policy attached to an external role by that name. The first stack to deploy claims it. Every subsequent stack, even one with a completely different stackName, is rejected because it is trying to manage a policy that another stack already owns, regardless of the fact that the policy content (a different Lambda ARN in each case) is different.

This is easy to miss because everything else about the deployment looks correctly parameterized. The Lambda function gets a unique name, the stack gets a unique name, but the construct id used to import the shared role was left as a static string, and that single detail decides the fate of every resource nested under it.

Make the construct id unique

The simplest fix is to stop hardcoding the construct id and include the label in it instead.

const sharedRole = iam.Role.fromRoleArn(this, `SharedRole-${label}`, props.sharedRoleArn);

Now the construct path differs per label, the logical id hash differs, and the generated policy name is unique to each deployment. CloudFormation no longer sees a conflict, and the shared role ends up with one inline policy per deployment. That works, but it means every label leaves a permanent policy behind on the shared role unless the stack is destroyed, and over time the role accumulates one inline policy per team or environment that ever deployed against it.

Grant the permission on your own resource instead

A cleaner option is to avoid mutating the shared role altogether. Import it as immutable, and grant the permission the other way around, using a resource-based policy on the Lambda function itself.

const sharedRole = iam.Role.fromRoleArn(this, 'SharedRole', props.sharedRoleArn, {
  mutable: false,
});

worker.addPermission('AllowSharedRoleInvoke', {
  principal: new iam.ArnPrincipal(props.sharedRoleArn),
  action: 'lambda:InvokeFunction',
});

Setting mutable: false tells CDK not to attempt any grant against the imported role at all, so calling grantInvoke on it would silently do nothing. The permission instead becomes an AWS::Lambda::Permission attached to the function, which every stack owns exclusively because every stack’s Lambda function has its own unique name and ARN. There is no shared construct path to collide on, since the resource being modified is no longer shared.

This is worth checking with cdk synth before relying on it. Diff the resources produced with mutable: true against mutable: false and confirm the AWS::IAM::Policy on the shared role disappears and nothing silently takes its place other than the resource-based permission you added explicitly. It is a small assumption to leave unverified, since a role that grants no invoke permission at all fails just as loudly at runtime, only much later, when the caller tries to actually use it.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation