Repository: cdklabs/cdk-enterprise-iac (language: python)

Issue:
feat: make role an optional parameter for EcsIsoServiceAutoscalerProps
### Description

Currently you must provide an IAM role to assign to the lambda function that handles autoscaling in the `EcsIsoServiceAutoscaler` construct.

I'd like to make the `role` property optional, allowing the `Function` construct to create it's own IAM role, and for the `Function` to be exposed as a property of `EcsIsoServiceAutoscaler`

### Use Case

Within the enterprise, I'm able to create my own IAM roles, so want to allow the `Function` construct to create the role on my behalf

### Proposed Solution

Turn the following property of `EcsIsoServiceAutoscalerProps` from 

```ts
readonly role: IRole;
```

to 

```ts
readonly role?: IRole;
```

Ensure that the `ecsScalingManager` function is a public readonly property of the construct so it's accessible from outside the construct

### Other information

_No response_

### Acknowledge

- [X] I may be able to implement this feature request
- [ ] This feature might incur a breaking change


Discussion / hints:

Fix patch:
diff --git a/API.md b/API.md
index 8b5df23..d2dec92 100644
--- a/API.md
+++ b/API.md
@@ -90,6 +90,7 @@ Any object.
 | **Name** | **Type** | **Description** |
 | --- | --- | --- |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.property.node">node</a></code> | <code>constructs.Node</code> | The tree node. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.property.ecsScalingManagerFunction">ecsScalingManagerFunction</a></code> | <code>aws-cdk-lib.aws_lambda.Function</code> | *No description.* |
 
 ---
 
@@ -105,6 +106,16 @@ The tree node.
 
 ---
 
+##### `ecsScalingManagerFunction`<sup>Required</sup> <a name="ecsScalingManagerFunction" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscaler.property.ecsScalingManagerFunction"></a>
+
+```typescript
+public readonly ecsScalingManagerFunction: Function;
+```
+
+- *Type:* aws-cdk-lib.aws_lambda.Function
+
+---
+
 
 ### PopulateWithConfig <a name="PopulateWithConfig" id="@cdklabs/cdk-enterprise-iac.PopulateWithConfig"></a>
 
@@ -462,10 +473,10 @@ const ecsIsoServiceAutoscalerProps: EcsIsoServiceAutoscalerProps = { ... }
 | --- | --- | --- |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.ecsCluster">ecsCluster</a></code> | <code>aws-cdk-lib.aws_ecs.Cluster</code> | The cluster the service you wish to scale resides in. |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.ecsService">ecsService</a></code> | <code>aws-cdk-lib.aws_ecs.IService</code> | The ECS service you wish to scale. |
-| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.role">role</a></code> | <code>aws-cdk-lib.aws_iam.IRole</code> | The IAM role that allows the created lambda to adjust the desired count on the ECS Service . |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleAlarm">scaleAlarm</a></code> | <code>aws-cdk-lib.aws_cloudwatch.Alarm</code> | The Cloudwatch Alarm that will cause scaling actions to be invoked, whether it's in or not in alarm will determine scale up and down actions. |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.maximumTaskCount">maximumTaskCount</a></code> | <code>number</code> | The maximum number of tasks that the service will scale out to. |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.minimumTaskCount">minimumTaskCount</a></code> | <code>number</code> | The minimum number of tasks the service will have. |
+| <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.role">role</a></code> | <code>aws-cdk-lib.aws_iam.IRole</code> | Optional IAM role to attach to the created lambda to adjust the desired count on the ECS Service. |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInCooldown">scaleInCooldown</a></code> | <code>aws-cdk-lib.Duration</code> | How long will the application wait before performing another scale in action. |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInIncrement">scaleInIncrement</a></code> | <code>number</code> | The number of tasks that will scale in on scale in alarm status. |
 | <code><a href="#@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleOutCooldown">scaleOutCooldown</a></code> | <code>aws-cdk-lib.Duration</code> | How long will a the application wait before performing another scale out action. |
@@ -497,20 +508,6 @@ The ECS service you wish to scale.
 
 ---
 
-##### `role`<sup>Required</sup> <a name="role" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.role"></a>
-
-```typescript
-public readonly role: IRole;
-```
-
-- *Type:* aws-cdk-lib.aws_iam.IRole
-
-The IAM role that allows the created lambda to adjust the desired count on the ECS Service .
-
-TODO: Make optional and add function for auto generation
-
----
-
 ##### `scaleAlarm`<sup>Required</sup> <a name="scaleAlarm" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleAlarm"></a>
 
 ```typescript
@@ -551,6 +548,48 @@ The minimum number of tasks the service will have.
 
 ---
 
+##### `role`<sup>Optional</sup> <a name="role" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.role"></a>
+
+```typescript
+public readonly role: IRole;
+```
+
+- *Type:* aws-cdk-lib.aws_iam.IRole
+- *Default:* A role is created for you with least privilege IAM policy
+
+Optional IAM role to attach to the created lambda to adjust the desired count on the ECS Service.
+
+Ensure this role has appropriate privileges. Example IAM policy statements:
+```json
+{
+  "PolicyDocument": {
+    "Statement": [
+      {
+        "Action": "cloudwatch:DescribeAlarms",
+        "Effect": "Allow",
+        "Resource": "arn:${Partition}:cloudwatch:${Region}:${Account}:alarm:${AlarmName}"
+      },
+      {
+        "Action": [
+          "ecs:DescribeServices",
+          "ecs:UpdateService"
+        ],
+        "Condition": {
+          "StringEquals": {
+            "ecs:cluster": "arn:${Partition}:ecs:${Region}:${Account}:cluster/${ClusterName}"
+          }
+        },
+        "Effect": "Allow",
+        "Resource": "arn:${Partition}:ecs:${Region}:${Account}:service/${ClusterName}/${ServiceName}"
+      }
+    ],
+    "Version": "2012-10-17"
+  }
+}
+```
+
+---
+
 ##### `scaleInCooldown`<sup>Optional</sup> <a name="scaleInCooldown" id="@cdklabs/cdk-enterprise-iac.EcsIsoServiceAutoscalerProps.property.scaleInCooldown"></a>
 
 ```typescript
diff --git a/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts b/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
index ad335c8..f5247a9 100644
--- a/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
+++ b/src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.ts
@@ -9,16 +9,45 @@ import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';
 import { Cluster, IService } from 'aws-cdk-lib/aws-ecs';
 import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
 import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
-import { IRole } from 'aws-cdk-lib/aws-iam';
+import { Effect, IRole, PolicyStatement } from 'aws-cdk-lib/aws-iam';
 import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
 import { Construct } from 'constructs';
 
 export interface EcsIsoServiceAutoscalerProps {
   /**
-   * The IAM role that allows the created lambda to adjust the desired count on the ECS Service .
-   * TODO: Make optional and add function for auto generation
+   * Optional IAM role to attach to the created lambda to adjust the desired count on the ECS Service
+   *
+   * Ensure this role has appropriate privileges. Example IAM policy statements:
+   * ```json
+   * {
+   *  "PolicyDocument": {
+   *    "Statement": [
+   *      {
+   *        "Action": "cloudwatch:DescribeAlarms",
+   *        "Effect": "Allow",
+   *        "Resource": "arn:${Partition}:cloudwatch:${Region}:${Account}:alarm:${AlarmName}"
+   *      },
+   *      {
+   *        "Action": [
+   *          "ecs:DescribeServices",
+   *          "ecs:UpdateService"
+   *        ],
+   *        "Condition": {
+   *          "StringEquals": {
+   *            "ecs:cluster": "arn:${Partition}:ecs:${Region}:${Account}:cluster/${ClusterName}"
+   *          }
+   *        },
+   *        "Effect": "Allow",
+   *        "Resource": "arn:${Partition}:ecs:${Region}:${Account}:service/${ClusterName}/${ServiceName}"
+   *      }
+   *    ],
+   *    "Version": "2012-10-17"
+   *  }
+   *}
+   * ```
+   * @default A role is created for you with least privilege IAM policy
    */
-  readonly role: IRole;
+  readonly role?: IRole;
   /**
    * The cluster the service you wish to scale resides in.
    */
@@ -80,6 +109,8 @@ export interface EcsIsoServiceAutoscalerProps {
  */
 
 export class EcsIsoServiceAutoscaler extends Construct {
+  public readonly ecsScalingManagerFunction: Function;
+
   constructor(
     scope: Construct,
     id: string,
@@ -96,7 +127,7 @@ export class EcsIsoServiceAutoscaler extends Construct {
       scaleInCooldown = Duration.seconds(60),
     } = props;
 
-    const ecsScalingManager = new Function(
+    this.ecsScalingManagerFunction = new Function(
       this,
       `${id}-EcsServiceScalingManager`,
       {
@@ -108,7 +139,7 @@ export class EcsIsoServiceAutoscaler extends Construct {
         ),
         handler: 'ecs_scaling_manager.handler',
         runtime: Runtime.PYTHON_3_7,
-        role: props.role,
+        role: props.role || undefined,
         environment: {
           ECS_CLUSTER_NAME: props.ecsCluster.clusterName,
           ECS_SERVICE_NAME: props.ecsService.serviceName,
@@ -127,7 +158,31 @@ export class EcsIsoServiceAutoscaler extends Construct {
       description: `Kicks off Lambda to adjust ECS scaling for service: ${props.ecsService.serviceName}`,
       enabled: true,
       schedule: Schedule.rate(Duration.minutes(1)),
-      targets: [new LambdaFunction(ecsScalingManager)],
+      targets: [new LambdaFunction(this.ecsScalingManagerFunction)],
     });
+
+    if (!props.role) {
+      // Set permissions for ecsScalingManagerFunction role
+      this.ecsScalingManagerFunction.addToRolePolicy(
+        new PolicyStatement({
+          actions: ['cloudwatch:DescribeAlarms'],
+          effect: Effect.ALLOW,
+          resources: [props.scaleAlarm.alarmArn],
+        })
+      );
+
+      this.ecsScalingManagerFunction.addToRolePolicy(
+        new PolicyStatement({
+          actions: ['ecs:DescribeServices', 'ecs:UpdateService'],
+          effect: Effect.ALLOW,
+          resources: [props.ecsService.serviceArn],
+          conditions: {
+            StringEquals: {
+              'ecs:cluster': props.ecsCluster.clusterArn,
+            },
+          },
+        })
+      );
+    }
   }
 }


Test patch:
diff --git a/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts b/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
index 04c3191..4e44ec4 100644
--- a/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
+++ b/test/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler.test.ts
@@ -4,16 +4,19 @@ SPDX-License-Identifier: Apache-2.0
 */
 import { spawnSync } from 'child_process';
 import * as path from 'path';
-import { Duration, Stack } from 'aws-cdk-lib';
+import { CfnElement, Duration, Stack } from 'aws-cdk-lib';
 import { Match, Template } from 'aws-cdk-lib/assertions';
 import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';
 import {
+  CfnCluster,
+  CfnService,
   Cluster,
   ContainerImage,
   FargateService,
   FargateTaskDefinition,
 } from 'aws-cdk-lib/aws-ecs';
-import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
+import { CfnRole, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
+import { IConstruct } from 'constructs';
 import { EcsIsoServiceAutoscaler } from '../../../src/constructs/ecsIsoServiceAutoscaler/ecsIsoServiceAutoscaler';
 
 let stack: Stack;
@@ -121,4 +124,86 @@ describe('EcsIsoServiceAutoscaler construct', () => {
       1
     );
   });
+  test('Lambda creates own role if optional role not passed, and role has least privilege', () => {
+    const autoScaler = new EcsIsoServiceAutoscaler(
+      stack,
+      'TestEcsIsoServiceAutoscaler',
+      {
+        ecsCluster: cluster,
+        ecsService: service,
+        scaleAlarm: alarm,
+      }
+    );
+
+    const autoScalerRole = autoScaler.node.findAll().find((x: IConstruct) => {
+      return x instanceof CfnRole;
+    });
+    const serviceId = service.node.findAll().find((x: IConstruct) => {
+      return x instanceof CfnService;
+    });
+
+    const clusterId = cluster.node.findAll().find((x: IConstruct) => {
+      return x instanceof CfnCluster;
+    });
+
+    const template = Template.fromStack(stack);
+
+    template.resourcePropertiesCountIs(
+      'AWS::IAM::Policy',
+      {
+        PolicyDocument: {
+          Statement: Match.arrayWith([
+            Match.objectLike({
+              Action: 'cloudwatch:DescribeAlarms',
+              Resource: {
+                'Fn::GetAtt': [
+                  stack
+                    .getLogicalId(
+                      alarm.node.findChild('Resource') as CfnElement
+                    )
+                    .toString(),
+                  'Arn',
+                ],
+              },
+            }),
+            Match.objectLike({
+              Action: ['ecs:DescribeServices', 'ecs:UpdateService'],
+              Resource: {
+                Ref: stack.getLogicalId(serviceId as CfnElement).toString(),
+              },
+              Condition: {
+                StringEquals: {
+                  'ecs:cluster': {
+                    'Fn::GetAtt': [
+                      stack.getLogicalId(clusterId as CfnElement).toString(),
+                      'Arn',
+                    ],
+                  },
+                },
+              },
+            }),
+          ]),
+        },
+        Roles: [
+          {
+            Ref: stack.getLogicalId(autoScalerRole as CfnElement).toString(),
+          },
+        ],
+      },
+      1
+    );
+  });
+  test('Autoscaler Lambda is accessible as a property', () => {
+    const autoScaler = new EcsIsoServiceAutoscaler(
+      stack,
+      'TestEcsIsoServiceAutoscaler',
+      {
+        ecsCluster: cluster,
+        ecsService: service,
+        scaleAlarm: alarm,
+      }
+    );
+
+    expect(autoScaler).toHaveProperty('ecsScalingManagerFunction');
+  });
 });