From 8053174404968575ac1dd102dcb1109d2fe6d9ea Mon Sep 17 00:00:00 2001 From: Michael Nesta Date: Tue, 3 Aug 2021 16:35:25 -0400 Subject: [PATCH] feat: Add the ability to use a web identity token file (#240) * feat: Add the ability to use a web identity token file * mark web identity token file as not required * fix indentation * better docs and added support for relative vs absolute paths * bind sts context and adjust fs calls * exclude tags if using web identity token file * fix readme aand adjust tag removal logic * undo re-ordering of lines Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- README.md | 11 +++++++++++ action.yml | 5 +++++ index.js | 51 +++++++++++++++++++++++++++++++++++++----------- index.test.js | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 864f9c9c4..1b5b108b0 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,17 @@ with: ``` In this case, your runner's credentials must have permissions to assume the role. +You can also assume a role using a web identity token file, such as if using [Amazon EKS IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts-technical-overview.html). Pods running in EKS worker nodes that do not run as root can use this file to assume a role with a web identity. + +You can configure your workflow as follows in order to use this file: +```yaml +uses: aws-actions/configure-aws-credentials@v1 +with: + aws-region: us-east-2 + role-to-assume: my-github-actions-role + web-identity-token-file: /var/run/secrets/eks.amazonaws.com/serviceaccount/token +``` + ### Use with the AWS CLI This workflow does _not_ install the [AWS CLI](https://aws.amazon.com/cli/) into your environment. Self-hosted runners that intend to run this action prior to executing `aws` commands need to have the AWS CLI [installed](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) if it's not already present. diff --git a/action.yml b/action.yml index fafa25b8a..4e41aeb5e 100644 --- a/action.yml +++ b/action.yml @@ -34,6 +34,11 @@ inputs: environment with the assumed role credentials rather than with the provided credentials required: false + web-identity-token-file: + description: >- + Use the web identity token file from the provided file system path in order to + assume an IAM role using a web identity. E.g., from within an Amazon EKS worker node + required: false role-duration-seconds: description: "Role duration in seconds (default: 6 hours)" required: false diff --git a/index.js b/index.js index 018aac3e9..5b6262efa 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,8 @@ const core = require('@actions/core'); const aws = require('aws-sdk'); const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); // The max time that a GitHub action is allowed to run is 6 hours. // That seems like a reasonable default to use if no role duration is defined. @@ -22,7 +24,8 @@ async function assumeRole(params) { roleDurationSeconds, roleSessionName, region, - roleSkipSessionTagging + roleSkipSessionTagging, + webIdentityTokenFile } = params; assert( [sourceAccountId, roleToAssume, roleDurationSeconds, roleSessionName, region].every(isDefined), @@ -42,6 +45,7 @@ async function assumeRole(params) { // Supports only 'aws' partition. Customers in other partitions ('aws-cn') will need to provide full ARN roleArn = `arn:aws:iam::${sourceAccountId}:role/${roleArn}`; } + const tagArray = [ {Key: 'GitHub', Value: 'Actions'}, {Key: 'Repository', Value: GITHUB_REPOSITORY}, @@ -74,15 +78,38 @@ async function assumeRole(params) { assumeRoleRequest.ExternalId = roleExternalId; } - return sts.assumeRole(assumeRoleRequest) - .promise() - .then(function (data) { - return { - accessKeyId: data.Credentials.AccessKeyId, - secretAccessKey: data.Credentials.SecretAccessKey, - sessionToken: data.Credentials.SessionToken, - }; - }); + let assumeFunction = sts.assumeRole.bind(sts); + + if(isDefined(webIdentityTokenFile)) { + core.debug("webIdentityTokenFile provided. Will call sts:AssumeRoleWithWebIdentity and take session tags from token contents.") + delete assumeRoleRequest.Tags; + + const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile) ? + webIdentityTokenFile : + path.join(process.env.GITHUB_WORKSPACE, webIdentityTokenFile); + + if (!fs.existsSync(webIdentityTokenFilePath)) { + throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`); + } + + try { + assumeRoleRequest.WebIdentityToken = await fs.promises.readFile(webIdentityTokenFilePath, 'utf8'); + assumeFunction = sts.assumeRoleWithWebIdentity.bind(sts); + } catch(error) { + throw new Error(`Web identity token file could not be read: ${error.message}`); + } + + } + + return assumeFunction(assumeRoleRequest) + .promise() + .then(function (data) { + return { + accessKeyId: data.Credentials.AccessKeyId, + secretAccessKey: data.Credentials.SecretAccessKey, + sessionToken: data.Credentials.SessionToken, + }; + }); } function sanitizeGithubActor(actor) { @@ -211,6 +238,7 @@ async function run() { const roleSessionName = core.getInput('role-session-name', { required: false }) || ROLE_SESSION_NAME; const roleSkipSessionTaggingInput = core.getInput('role-skip-session-tagging', { required: false })|| 'false'; const roleSkipSessionTagging = roleSkipSessionTaggingInput.toLowerCase() === 'true'; + const webIdentityTokenFile = core.getInput('web-identity-token-file', { required: false }) if (!region.match(REGION_REGEX)) { throw new Error(`Region is not valid: ${region}`); @@ -249,7 +277,8 @@ async function run() { roleExternalId, roleDurationSeconds, roleSessionName, - roleSkipSessionTagging + roleSkipSessionTagging, + webIdentityTokenFile }); exportCredentials(roleCredentials); await validateCredentials(roleCredentials.accessKeyId); diff --git a/index.test.js b/index.test.js index f2295c991..af9d4a61b 100644 --- a/index.test.js +++ b/index.test.js @@ -24,6 +24,7 @@ const ENVIRONMENT_VARIABLE_OVERRIDES = { GITHUB_ACTOR: 'MY-USERNAME[bot]', GITHUB_SHA: 'MY-COMMIT-ID', GITHUB_REF: 'MY-BRANCH', + GITHUB_WORKSPACE: '/home/github' }; const GITHUB_ACTOR_SANITIZED = 'MY-USERNAME_bot_' @@ -46,6 +47,7 @@ const ASSUME_ROLE_INPUTS = {...CREDS_INPUTS, 'role-to-assume': ROLE_ARN, 'aws-re const mockStsCallerIdentity = jest.fn(); const mockStsAssumeRole = jest.fn(); +const mockStsAssumeRoleWithWebIdentity = jest.fn(); jest.mock('aws-sdk', () => { return { @@ -55,10 +57,20 @@ jest.mock('aws-sdk', () => { STS: jest.fn(() => ({ getCallerIdentity: mockStsCallerIdentity, assumeRole: mockStsAssumeRole, + assumeRoleWithWebIdentity: mockStsAssumeRoleWithWebIdentity })) }; }); +jest.mock('fs', () => { + return { + promises: { + readFile: jest.fn(() => Promise.resolve('testpayload')), + }, + existsSync: jest.fn(() => true) + }; +}); + describe('Configure AWS Credentials', () => { const OLD_ENV = process.env; @@ -119,6 +131,20 @@ describe('Configure AWS Credentials', () => { } } }); + + mockStsAssumeRoleWithWebIdentity.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + Credentials: { + AccessKeyId: FAKE_STS_ACCESS_KEY_ID, + SecretAccessKey: FAKE_STS_SECRET_ACCESS_KEY, + SessionToken: FAKE_STS_SESSION_TOKEN + } + }); + } + } + }); }); afterEach(() => { @@ -507,6 +533,34 @@ describe('Configure AWS Credentials', () => { }) }); + test('web identity token file provided with absolute path', async () => { + core.getInput = jest + .fn() + .mockImplementation(mockGetInput({'role-to-assume': ROLE_ARN, 'aws-region': FAKE_REGION, 'web-identity-token-file': '/fake/token/file'})); + + await run(); + expect(mockStsAssumeRoleWithWebIdentity).toHaveBeenCalledWith({ + RoleArn: 'arn:aws:iam::111111111111:role/MY-ROLE', + RoleSessionName: 'GitHubActions', + DurationSeconds: 6 * 3600, + WebIdentityToken: 'testpayload' + }) + }); + + test('web identity token file provided with relative path', async () => { + core.getInput = jest + .fn() + .mockImplementation(mockGetInput({'role-to-assume': ROLE_ARN, 'aws-region': FAKE_REGION, 'web-identity-token-file': 'fake/token/file'})); + + await run(); + expect(mockStsAssumeRoleWithWebIdentity).toHaveBeenCalledWith({ + RoleArn: 'arn:aws:iam::111111111111:role/MY-ROLE', + RoleSessionName: 'GitHubActions', + DurationSeconds: 6 * 3600, + WebIdentityToken: 'testpayload' + }) + }); + test('role external ID provided', async () => { core.getInput = jest .fn()