Don't let CDK overwrite your permissions

Don't let CDK overwrite your permissions

Working with AWS CDK , on a daily basis, grants you a lot of possibilities to automate your cloud infrastructure. However, as in every tool, there are sometimes pitfalls which are, at least for me, worth to document.

In CDK, it is possible to import IAM roles by their specific ARN (Amazon Resource Names, starting with arn:partition...).

With that you can easily, lets say during the creation of an eks cluster, set up IAM roles without any permissions and import them later on to grant rights on specific resources, for examples Secrets Manager Secrets.

Lets assume the following setup. We have an app with 2 different stacks. In another app, We've set up an External Secrets Operator helm chart with IAM Roles for ServiceAccounts and want to grant this IAM role now permissions for reading database secrets.

bin
 └───app.ts 
src
 ├───mariadb-stack.ts
 └───postgresql-stack.ts
packagage.json
.stuff-config.json
.
..
...

In our mariadb-stack.ts we define the following.

import { App, Stack, StackProps } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Role } from 'aws-cdk-lib/aws-iam';
import { Key } from 'aws-cdk-lib/aws-kms';
import {
  Credentials,
  DatabaseInstance,
  DatabaseInstanceEngine,
  MariaDbEngineVersion,
} from 'aws-cdk-lib/aws-rds';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';

export interface MariaDbProps extends StackProps {}

export class MariaDbStack extends Stack {
  constructor(app: App, id: string, props: MariaDbProps) {
    super(app, id, props);

    const encryptionKey = new Key(this, 'MariaDbKey', {});
    const vpc = Vpc.fromLookup(this, 'MyVpc', {
      vpcId: 'vpc-01234567890abcdef',
    });

    const databaseEngine = DatabaseInstanceEngine.mariaDb({
      version: MariaDbEngineVersion.VER_10_5_9,
    });

    const databaseSecret = new Secret(this, 'DatabaseSecret', {
      description: 'my database secret',
      encryptionKey: encryptionKey,
      secretName: `myDatabaseSecret`,
    });

    new DatabaseInstance(this, 'MariaDbDatabase', {
      engine: databaseEngine,
      vpc,
      credentials: Credentials.fromSecret(databaseSecret),
    });

    const serviceAccountRoleExternalSecrets = Role.fromRoleArn(
      this,
      'ExternalSecretsRole',
      'arn:eu-west-1:iam::123456789:role/MyExternalSecretsOperator',
    );
    databaseSecret.grantRead(serviceAccountRoleExternalSecrets);
  }
}

Our postgresql-stack.ts looks like this

import { App, Stack, StackProps } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Role } from 'aws-cdk-lib/aws-iam';
import { Key } from 'aws-cdk-lib/aws-kms';
import {
  Credentials,
  DatabaseInstance,
  DatabaseInstanceEngine,
  PostgresEngineVersion,
} from 'aws-cdk-lib/aws-rds';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';

export interface PostgresqlProps extends StackProps {}

export class PostgresqlStack extends Stack {
  constructor(app: App, id: string, props: PostgresqlProps) {
    super(app, id, props);

    const encryptionKey = new Key(this, 'PostgresqlKey', {});
    const vpc = Vpc.fromLookup(this, 'MyVpc', {
      vpcId: 'vpc-01234567890abcdef',
    });

    const databaseEngine = DatabaseInstanceEngine.postgres({
      version: PostgresEngineVersion.VER_14,
    });

    const databaseSecret = new Secret(this, 'DatabaseSecret', {
      description: 'my database secret',
      encryptionKey: encryptionKey,
      secretName: `myDatabaseSecret`,
    });

    new DatabaseInstance(this, 'PostgresqlDatabase', {
      engine: databaseEngine,
      vpc,
      credentials: Credentials.fromSecret(databaseSecret),
    });

    const serviceAccountRoleExternalSecrets = Role.fromRoleArn(
      this,
      'ExternalSecretsRole',
      'arn:eu-west-1:iam::123456789012:role/MyExternalSecretsOperator',
    );
    databaseSecret.grantRead(serviceAccountRoleExternalSecrets);
  }
}

nearly the same, I know, but it is important that his code comes from 2 different stacks. :)

The interesting part is where we import the IAM role and grant read permissions for the database secret.

    const serviceAccountRoleExternalSecrets = Role.fromRoleArn(
      this,
      'ExternalSecretsRole',
      'arn:eu-west-1:iam::123456789012:role/MyExternalSecretsOperator',
    );
    databaseSecret.grantRead(serviceAccountRoleExternalSecrets);
  }

While this code works it will create one policy and every time we import that role again in another stack and granting permissions to it, this previous policy will be overwritten. When first humbling around this I really wondered why the f***, the service was able to fetch that secret in a previous step but not afterward, until examining the resulting policy and related cdk diff.

Obviously I was not the only one with that problem and so AWS already fixed that in version 2.29. So the way to solve this would look like this. You add a new property to define a default policy name so that different policies per stack will be created.

The resulting code should look like this.

postgresql-stack.ts

    const serviceAccountRoleExternalSecrets = Role.fromRoleArn(
      this,
      'ExternalSecretsRole',
      'arn:eu-west-1:iam::123456789012:role/MyExternalSecretsOperator',
      {
        defaultPolicyName: 'PostgreSqlStackPolicy',
      },
    );
    databaseSecret.grantRead(serviceAccountRoleExternalSecrets);
    

mariadb-stack.ts

    const serviceAccountRoleExternalSecrets = Role.fromRoleArn(
      this,
      'ExternalSecretsRole',
      'arn:eu-west-1:iam::123456789012:role/MyExternalSecretsOperator',
      {
        defaultPolicyName: 'MariaDbStackPolicy',
      },
    );
    databaseSecret.grantRead(serviceAccountRoleExternalSecrets);