Repository: cdklabs/cdk-pipelines-github (ts)

Issue:
Allow access to `permissions` property of GitHubActionStep's in pre/post
#304 is a great feature, however, it doesn't not provide a surface to control the `permissions` property emitted for the job. This means that the job emitted cannot be provided with the `id-token: write` permission and cannot authenticate with AWS using the GitHub identity provider.

Either access to the `permissions` property of the `GitHubActionStep` or the ability to pass `id-token: write` as a boolean would make `GitHubActionStep`'s much more useful in pre/post deploy steps.

Taking a look over #304, I think the change should be fairly trivial? Provided I have time, I may be able to open a PR.

Pull request description:
feat: customizable `permissions` in `GitHubActionStep`
This PR adds configuration passthrough for the `permissions` object that `GitHubActionStep` uses under the hood. The default remains `contents: write`.

I have also added a snapshot test showing that the change works, and made a small modification to the README. 

Fixes #731 

Patch:
diff --git a/API.md b/API.md
index f110fdb..62abf89 100644
--- a/API.md
+++ b/API.md
@@ -441,6 +441,9 @@ If you want to call a GitHub Action in a step, you can utilize the `GitHubAction
 
 The `jobSteps` array is placed into the pipeline job at the relevant `jobs.<job_id>.steps` as [documented here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsteps).
 
+GitHub Actions Job permissions can be modified by passing the `permissions` object to `GitHubActionStep`.
+The default set of permissions is simply `contents: write`.
+
 In this example,
 
 ```ts
@@ -461,6 +464,10 @@ const pipeline = new GitHubWorkflow(app, 'Pipeline', {
 const stage = new MyStage(app, 'Beta', { env: BETA_ENV });
 pipeline.addStage(stage, {
   pre: [new GitHubActionStep('PreBetaDeployAction', {
+    permissions: {
+      idToken: JobPermission.WRITE,
+      contents: JobPermission.WRITE,
+    },
     jobSteps: [
       {
         name: 'Checkout',
@@ -2314,6 +2321,7 @@ const gitHubActionStepProps: GitHubActionStepProps = { ... }
 | --- | --- | --- |
 | <code><a href="#cdk-pipelines-github.GitHubActionStepProps.property.jobSteps">jobSteps</a></code> | <code><a href="#cdk-pipelines-github.JobStep">JobStep</a>[]</code> | The Job steps. |
 | <code><a href="#cdk-pipelines-github.GitHubActionStepProps.property.env">env</a></code> | <code>{[ key: string ]: string}</code> | Environment variables to set. |
+| <code><a href="#cdk-pipelines-github.GitHubActionStepProps.property.permissions">permissions</a></code> | <code><a href="#cdk-pipelines-github.JobPermissions">JobPermissions</a></code> | Permissions for the GitHub Action step. |
 
 ---
 
@@ -2341,6 +2349,19 @@ Environment variables to set.
 
 ---
 
+##### `permissions`<sup>Optional</sup> <a name="permissions" id="cdk-pipelines-github.GitHubActionStepProps.property.permissions"></a>
+
+```typescript
+public readonly permissions: JobPermissions;
+```
+
+- *Type:* <a href="#cdk-pipelines-github.JobPermissions">JobPermissions</a>
+- *Default:* The job receives 'contents: write' permissions. If you set additional permissions and require 'contents: write', it must be provided in your configuration.
+
+Permissions for the GitHub Action step.
+
+---
+
 ### GitHubCommonProps <a name="GitHubCommonProps" id="cdk-pipelines-github.GitHubCommonProps"></a>
 
 Common properties to extend both StageProps and AddStageOpts.
@@ -5568,6 +5589,7 @@ API. For example, if you want `secondStep` to occur after `firstStep`, call
 | <code><a href="#cdk-pipelines-github.GitHubActionStep.property.primaryOutput">primaryOutput</a></code> | <code>aws-cdk-lib.pipelines.FileSet</code> | The primary FileSet produced by this Step. |
 | <code><a href="#cdk-pipelines-github.GitHubActionStep.property.env">env</a></code> | <code>{[ key: string ]: string}</code> | *No description.* |
 | <code><a href="#cdk-pipelines-github.GitHubActionStep.property.jobSteps">jobSteps</a></code> | <code><a href="#cdk-pipelines-github.JobStep">JobStep</a>[]</code> | *No description.* |
+| <code><a href="#cdk-pipelines-github.GitHubActionStep.property.permissions">permissions</a></code> | <code><a href="#cdk-pipelines-github.JobPermissions">JobPermissions</a></code> | *No description.* |
 
 ---
 
@@ -5668,6 +5690,16 @@ public readonly jobSteps: JobStep[];
 
 ---
 
+##### `permissions`<sup>Optional</sup> <a name="permissions" id="cdk-pipelines-github.GitHubActionStep.property.permissions"></a>
+
+```typescript
+public readonly permissions: JobPermissions;
+```
+
+- *Type:* <a href="#cdk-pipelines-github.JobPermissions">JobPermissions</a>
+
+---
+
 
 ### GitHubWave <a name="GitHubWave" id="cdk-pipelines-github.GitHubWave"></a>
 
diff --git a/README.md b/README.md
index 5031774..69d965c 100644
--- a/README.md
+++ b/README.md
@@ -441,6 +441,9 @@ If you want to call a GitHub Action in a step, you can utilize the `GitHubAction
 
 The `jobSteps` array is placed into the pipeline job at the relevant `jobs.<job_id>.steps` as [documented here](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idsteps).
 
+GitHub Actions Job permissions can be modified by passing the `permissions` object to `GitHubActionStep`.
+The default set of permissions is simply `contents: write`.
+
 In this example,
 
 ```ts
@@ -461,6 +464,10 @@ const pipeline = new GitHubWorkflow(app, 'Pipeline', {
 const stage = new MyStage(app, 'Beta', { env: BETA_ENV });
 pipeline.addStage(stage, {
   pre: [new GitHubActionStep('PreBetaDeployAction', {
+    permissions: {
+      idToken: JobPermission.WRITE,
+      contents: JobPermission.WRITE,
+    },
     jobSteps: [
       {
         name: 'Checkout',
diff --git a/src/pipeline.ts b/src/pipeline.ts
index 0a63e7e..fc32fb5 100644
--- a/src/pipeline.ts
+++ b/src/pipeline.ts
@@ -832,7 +832,7 @@ export class GitHubWorkflow extends PipelineBase {
       definition: {
         name: step.id,
         ...this.renderJobSettingParameters(),
-        permissions: {
+        permissions: step.permissions ?? {
           contents: github.JobPermission.WRITE,
         },
         runsOn: this.runner.runsOn,
diff --git a/src/steps/github-action-step.ts b/src/steps/github-action-step.ts
index e5bfa41..7033ec4 100644
--- a/src/steps/github-action-step.ts
+++ b/src/steps/github-action-step.ts
@@ -1,5 +1,5 @@
 import { Step } from 'aws-cdk-lib/pipelines';
-import { JobStep } from '../workflows-model';
+import { JobStep, JobPermissions } from '../workflows-model';
 
 export interface GitHubActionStepProps {
   /**
@@ -11,6 +11,12 @@ export interface GitHubActionStepProps {
    * Environment variables to set.
    */
   readonly env?: Record<string, string>;
+
+  /**
+   * Permissions for the GitHub Action step.
+   * @default The job receives 'contents: write' permissions. If you set additional permissions and require 'contents: write', it must be provided in your configuration.
+   */
+  readonly permissions?: JobPermissions;
 }
 
 /**
@@ -19,10 +25,12 @@ export interface GitHubActionStepProps {
 export class GitHubActionStep extends Step {
   public readonly env: Record<string, string>;
   public readonly jobSteps: JobStep[];
+  public readonly permissions?: JobPermissions;
 
   constructor(id: string, props: GitHubActionStepProps) {
     super(id);
     this.jobSteps = props.jobSteps;
     this.env = props.env ?? {};
+    this.permissions = props.permissions;
   }
 }


Test patch:
diff --git a/test/__snapshots__/github.test.ts.snap b/test/__snapshots__/github.test.ts.snap
index 78ef0c2..d4589a7 100644
--- a/test/__snapshots__/github.test.ts.snap
+++ b/test/__snapshots__/github.test.ts.snap
@@ -527,6 +527,269 @@ jobs:
 "
 `;
 
+exports[`pipeline with GitHubSteps customizing permissions 1`] = `
+"# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY.
+# Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github)
+
+name: deploy
+on:
+  push:
+    branches:
+      - main
+  workflow_dispatch: {}
+jobs:
+  Build-Build:
+    name: Synthesize
+    permissions:
+      contents: read
+      id-token: none
+    runs-on: ubuntu-latest
+    needs: []
+    env: {}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Build
+        run: \\"\\"
+      - name: Upload cdk.out
+        uses: actions/upload-artifact@v4
+        with:
+          name: cdk.out
+          path: cdk.out
+  Assets-FileAsset1:
+    name: Publish Assets Assets-FileAsset1
+    needs:
+      - Build-Build
+    permissions:
+      contents: read
+      id-token: none
+    runs-on: ubuntu-latest
+    outputs:
+      asset-hash: \${{ steps.Publish.outputs.asset-hash }}
+    steps:
+      - name: Download cdk.out
+        uses: actions/download-artifact@v4
+        with:
+          name: cdk.out
+          path: github.out
+      - name: Install
+        run: npm install --no-save cdk-assets
+      - name: Authenticate Via GitHub Secrets
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          aws-region: us-west-2
+          role-duration-seconds: 1800
+          role-skip-session-tagging: true
+          aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      - id: Publish
+        name: Publish Assets-FileAsset1
+        run: /bin/bash ./cdk.out/assembly-MyStage/publish-Assets-FileAsset1-step.sh
+  Assets-FileAsset2:
+    name: Publish Assets Assets-FileAsset2
+    needs:
+      - Build-Build
+    permissions:
+      contents: read
+      id-token: none
+    runs-on: ubuntu-latest
+    outputs:
+      asset-hash: \${{ steps.Publish.outputs.asset-hash }}
+    steps:
+      - name: Download cdk.out
+        uses: actions/download-artifact@v4
+        with:
+          name: cdk.out
+          path: github.out
+      - name: Install
+        run: npm install --no-save cdk-assets
+      - name: Authenticate Via GitHub Secrets
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          aws-region: us-west-2
+          role-duration-seconds: 1800
+          role-skip-session-tagging: true
+          aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      - id: Publish
+        name: Publish Assets-FileAsset2
+        run: /bin/bash ./cdk.out/assembly-MyStage/publish-Assets-FileAsset2-step.sh
+  MyStage-Test-Step:
+    name: Test-Step
+    permissions:
+      id-token: write
+      contents: write
+    runs-on: ubuntu-latest
+    needs:
+      - Build-Build
+    env: {}
+    steps:
+      - name: Hello World
+        run: echo hello
+  MyStage-MyStack-Deploy:
+    name: Deploy MyStageMyStackD5720EA1
+    permissions:
+      contents: read
+      id-token: none
+    needs:
+      - Build-Build
+      - Assets-FileAsset1
+      - Assets-FileAsset2
+      - MyStage-Test-Step
+    runs-on: ubuntu-latest
+    steps:
+      - name: Authenticate Via GitHub Secrets
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          aws-region: us-east-1
+          role-duration-seconds: 1800
+          role-skip-session-tagging: true
+          aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1
+          role-external-id: Pipeline
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
+        with:
+          name: MyStage-MyStack
+          template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/\${{
+            needs.Assets-FileAsset1.outputs.asset-hash }}.json
+          no-fail-on-empty-changeset: \\"1\\"
+          role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1
+"
+`;
+
+exports[`pipeline with GitHubSteps default permissions 1`] = `
+"# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY.
+# Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github)
+
+name: deploy
+on:
+  push:
+    branches:
+      - main
+  workflow_dispatch: {}
+jobs:
+  Build-Build:
+    name: Synthesize
+    permissions:
+      contents: read
+      id-token: none
+    runs-on: ubuntu-latest
+    needs: []
+    env: {}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Build
+        run: \\"\\"
+      - name: Upload cdk.out
+        uses: actions/upload-artifact@v4
+        with:
+          name: cdk.out
+          path: cdk.out
+  Assets-FileAsset1:
+    name: Publish Assets Assets-FileAsset1
+    needs:
+      - Build-Build
+    permissions:
+      contents: read
+      id-token: none
+    runs-on: ubuntu-latest
+    outputs:
+      asset-hash: \${{ steps.Publish.outputs.asset-hash }}
+    steps:
+      - name: Download cdk.out
+        uses: actions/download-artifact@v4
+        with:
+          name: cdk.out
+          path: github.out
+      - name: Install
+        run: npm install --no-save cdk-assets
+      - name: Authenticate Via GitHub Secrets
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          aws-region: us-west-2
+          role-duration-seconds: 1800
+          role-skip-session-tagging: true
+          aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      - id: Publish
+        name: Publish Assets-FileAsset1
+        run: /bin/bash ./cdk.out/assembly-MyStage/publish-Assets-FileAsset1-step.sh
+  Assets-FileAsset2:
+    name: Publish Assets Assets-FileAsset2
+    needs:
+      - Build-Build
+    permissions:
+      contents: read
+      id-token: none
+    runs-on: ubuntu-latest
+    outputs:
+      asset-hash: \${{ steps.Publish.outputs.asset-hash }}
+    steps:
+      - name: Download cdk.out
+        uses: actions/download-artifact@v4
+        with:
+          name: cdk.out
+          path: github.out
+      - name: Install
+        run: npm install --no-save cdk-assets
+      - name: Authenticate Via GitHub Secrets
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          aws-region: us-west-2
+          role-duration-seconds: 1800
+          role-skip-session-tagging: true
+          aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
+      - id: Publish
+        name: Publish Assets-FileAsset2
+        run: /bin/bash ./cdk.out/assembly-MyStage/publish-Assets-FileAsset2-step.sh
+  MyStage-Test-Step:
+    name: Test-Step
+    permissions:
+      contents: write
+    runs-on: ubuntu-latest
+    needs:
+      - Build-Build
+    env: {}
+    steps:
+      - name: Hello World
+        run: echo hello
+  MyStage-MyStack-Deploy:
+    name: Deploy MyStageMyStackD5720EA1
+    permissions:
+      contents: read
+      id-token: none
+    needs:
+      - Build-Build
+      - Assets-FileAsset1
+      - Assets-FileAsset2
+      - MyStage-Test-Step
+    runs-on: ubuntu-latest
+    steps:
+      - name: Authenticate Via GitHub Secrets
+        uses: aws-actions/configure-aws-credentials@v4
+        with:
+          aws-region: us-east-1
+          role-duration-seconds: 1800
+          role-skip-session-tagging: true
+          aws-access-key-id: \${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          role-to-assume: arn:aws:iam::111111111111:role/cdk-hnb659fds-deploy-role-111111111111-us-east-1
+          role-external-id: Pipeline
+      - id: Deploy
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
+        with:
+          name: MyStage-MyStack
+          template: https://cdk-hnb659fds-assets-111111111111-us-east-1.s3.us-east-1.amazonaws.com/\${{
+            needs.Assets-FileAsset1.outputs.asset-hash }}.json
+          no-fail-on-empty-changeset: \\"1\\"
+          role-arn: arn:aws:iam::111111111111:role/cdk-hnb659fds-cfn-exec-role-111111111111-us-east-1
+"
+`;
+
 exports[`pipeline with concurrency 1`] = `
 "# AUTOMATICALLY GENERATED FILE, DO NOT EDIT MANUALLY.
 # Generated by AWS CDK and [cdk-pipelines-github](https://github.com/cdklabs/cdk-pipelines-github)
diff --git a/test/github.test.ts b/test/github.test.ts
index 317302d..65c6256 100644
--- a/test/github.test.ts
+++ b/test/github.test.ts
@@ -4,8 +4,8 @@ import { Stack, Stage } from 'aws-cdk-lib';
 import * as lambda from 'aws-cdk-lib/aws-lambda';
 import { ShellStep } from 'aws-cdk-lib/pipelines';
 import { GitHubExampleApp } from './example-app';
-import { withTemporaryDirectory, TestApp } from './testutil';
-import { GitHubWorkflow, JsonPatch, Runner, AwsCredentials } from '../src';
+import { TestApp, withTemporaryDirectory } from './testutil';
+import { AwsCredentials, GitHubActionStep, GitHubStage, GitHubWorkflow, JobPermission, JsonPatch, Runner } from '../src';
 
 const fixtures = join(__dirname, 'fixtures');
 
@@ -287,6 +287,103 @@ test('pipeline with job settings', () => {
   });
 });
 
+test('pipeline with GitHubSteps customizing permissions', () => {
+  withTemporaryDirectory((dir) => {
+    const pipeline = new GitHubWorkflow(app, 'Pipeline', {
+      workflowPath: `${dir}/.github/workflows/deploy.yml`,
+      synth: new ShellStep('Build', {
+        commands: [],
+      }),
+    });
+
+    const wave = pipeline.addGitHubWave('Test-Wave');
+    const stage = new GitHubStage(app, 'MyStage', {
+      env: { account: '111111111111', region: 'us-east-1' },
+    });
+
+    const stack = new Stack(stage, 'MyStack');
+
+    new lambda.Function(stack, 'Function', {
+      code: lambda.Code.fromAsset(fixtures),
+      handler: 'index.handler',
+      runtime: lambda.Runtime.NODEJS_14_X,
+    });
+
+    wave.addStageWithGitHubOptions(stage, {
+      pre: [new GitHubActionStep('Test-Step', {
+        permissions: {
+          idToken: JobPermission.WRITE,
+          contents: JobPermission.WRITE,
+        },
+        jobSteps: [{
+          name: 'Hello World',
+          run: 'echo hello',
+        }],
+      })],
+    });
+
+    app.synth();
+
+    const file = readFileSync(pipeline.workflowPath, 'utf-8');
+
+    expect(file).toMatchSnapshot();
+    expect(file).toContain(`MyStage-Test-Step:
+    name: Test-Step
+    permissions:
+      id-token: write
+      contents: write`);
+  });
+});
+
+test('pipeline with GitHubSteps default permissions', () => {
+  withTemporaryDirectory((dir) => {
+    const pipeline = new GitHubWorkflow(app, 'Pipeline', {
+      workflowPath: `${dir}/.github/workflows/deploy.yml`,
+      synth: new ShellStep('Build', {
+        commands: [],
+      }),
+    });
+
+    const wave = pipeline.addGitHubWave('Test-Wave');
+    const stage = new GitHubStage(app, 'MyStage', {
+      env: { account: '111111111111', region: 'us-east-1' },
+    });
+
+    const stack = new Stack(stage, 'MyStack');
+
+    new lambda.Function(stack, 'Function', {
+      code: lambda.Code.fromAsset(fixtures),
+      handler: 'index.handler',
+      runtime: lambda.Runtime.NODEJS_14_X,
+    });
+
+    wave.addStageWithGitHubOptions(stage, {
+      pre: [new GitHubActionStep('Test-Step', {
+        jobSteps: [{
+          name: 'Hello World',
+          run: 'echo hello',
+        }],
+      })],
+    });
+
+    app.synth();
+
+    const file = readFileSync(pipeline.workflowPath, 'utf-8');
+
+    expect(file).toMatchSnapshot();
+    expect(file).toContain(`MyStage-Test-Step:
+    name: Test-Step
+    permissions:
+      contents: write`);
+
+    expect(file).not.toContain(`MyStage-Test-Step:
+      name: Test-Step
+      permissions:
+        id-token: write
+        contents: write`);
+  });
+});
+
 test('single wave/stage/stack', () => {
   withTemporaryDirectory((dir) => {
     const pipeline = new GitHubWorkflow(app, 'Pipeline', {