Repository: aws-actions/configure-aws-credentials
Pull request #17 (2020-01-07 23:15:23)

feat: add support for assuming a role
*Description of changes:*

As a security-conscious developer, long-lived credentials make me nervous, especially when I do not have a mechanism for an automated actor to change or delete them. Ideally, I would like to constantly refresh the GitHub Secrets that hold my AWS credentials with constantly rotating, short-lived, IAM Role credentials. Unfortunately, until GitHub adds an API for Secrets I cannot do that.

As a halfway measure, I would like to be able to set long-lived credentials in my GitHub Secrets that only have the permissions to assume short-lived credentials with wider permissions. The purpose of this PR is to enable this use-case.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.


mattsb42-aws: Something's wrong, I assume with the mock, but I'm not sure what.. digging more, but putting this up in case this is obvious to others.
seebees: This LGTM.
I prefer smaller files, but that is just personal preference.

Patch:
diff --git a/.gitignore b/.gitignore
index 28ed554..77aaf75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ node_modules/
 
 # Editors
 .vscode
+.idea
 
 # Logs
 logs
diff --git a/action.yml b/action.yml
index 66df941..a3bf25b 100644
--- a/action.yml
+++ b/action.yml
@@ -19,6 +19,12 @@ inputs:
   mask-aws-account-id:
     description: "Whether to set the AWS account ID for these credentials as a secret value, so that it is masked in logs. Valid values are 'true' and 'false'. Defaults to true"
     required: false
+  role-to-assume:
+    description: "Use the provided credentials to assume a Role and output the assumed credentials for that Role rather than the provided credentials"
+    required: false
+  role-duration-seconds:
+    description: "Role duration in seconds (default: 6 hours)"
+    required: false
 outputs:
   aws-account-id:
     description: 'The AWS account ID for the provided credentials'
diff --git a/index.js b/index.js
index fb3a1a4..ef4072f 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,94 @@
 const core = require('@actions/core');
 const aws = require('aws-sdk');
+const assert = require('assert');
+const util = require('util');
+
+// 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.
+const MAX_ACTION_RUNTIME = 6 * 3600;
+const USER_AGENT = 'configure-aws-credentials-for-github-actions';
+
+async function assumeRole(params) {
+  // Assume a role to get short-lived credentials using longer-lived credentials.
+  const isDefined = i => !!i;
+
+  const {roleToAssume, roleDurationSeconds, accessKeyId, secretAccessKey, sessionToken, region} = params;
+  assert(
+      [roleToAssume, roleDurationSeconds, accessKeyId, secretAccessKey, region].every(isDefined),
+      "Missing required input when assuming a Role."
+  );
+
+  const {GITHUB_REPOSITORY, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_ACTOR, GITHUB_REF, GITHUB_SHA} = process.env;
+  assert(
+      [GITHUB_REPOSITORY, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_ACTOR, GITHUB_REF, GITHUB_SHA].every(isDefined),
+      'Missing required environment value. Are you running in GitHub Actions?'
+  );
+
+  const endpoint = util.format('https://sts.%s.amazonaws.com', region);
+
+  const sts = new aws.STS({
+    accessKeyId, secretAccessKey, sessionToken, region, endpoint, customUserAgent: USER_AGENT
+  });
+  return sts.assumeRole({
+    RoleArn: roleToAssume,
+    RoleSessionName: 'GitHubActions',
+    DurationSeconds: roleDurationSeconds,
+    Tags: [
+      {Key: 'GitHub', Value: 'Actions'},
+      {Key: 'Repository', Value: GITHUB_REPOSITORY},
+      {Key: 'Workflow', Value: GITHUB_WORKFLOW},
+      {Key: 'Action', Value: GITHUB_ACTION},
+      {Key: 'Actor', Value: GITHUB_ACTOR},
+      {Key: 'Branch', Value: GITHUB_REF},
+      {Key: 'Commit', Value: GITHUB_SHA},
+    ]
+  })
+  .promise()
+  .then(function (data) {
+    return {
+      accessKeyId: data.Credentials.AccessKeyId,
+      secretAccessKey: data.Credentials.SecretAccessKey,
+      sessionToken: data.Credentials.SessionToken,
+    };
+  });
+}
+
+function exportCredentials(params){
+  // Configure the AWS CLI and AWS SDKs using environment variables
+  const {accessKeyId, secretAccessKey, sessionToken} = params;
+
+  // AWS_ACCESS_KEY_ID:
+  // Specifies an AWS access key associated with an IAM user or role
+  core.exportVariable('AWS_ACCESS_KEY_ID', accessKeyId);
+
+  // AWS_SECRET_ACCESS_KEY:
+  // Specifies the secret key associated with the access key. This is essentially the "password" for the access key.
+  core.exportVariable('AWS_SECRET_ACCESS_KEY', secretAccessKey);
+
+  // AWS_SESSION_TOKEN:
+  // Specifies the session token value that is required if you are using temporary security credentials.
+  if (sessionToken) {
+    core.exportVariable('AWS_SESSION_TOKEN', sessionToken);
+  }
+}
+
+function exportRegion(region) {
+  // AWS_DEFAULT_REGION and AWS_REGION:
+  // Specifies the AWS Region to send requests to
+  core.exportVariable('AWS_DEFAULT_REGION', region);
+  core.exportVariable('AWS_REGION', region);
+}
+
+async function exportAccountId(maskAccountId) {
+  // Get the AWS account ID
+  const sts = new aws.STS({customUserAgent: USER_AGENT});
+  const identity = await sts.getCallerIdentity().promise();
+  const accountId = identity.Account;
+  core.setOutput('aws-account-id', accountId);
+  if (!maskAccountId || maskAccountId.toLowerCase() == 'true') {
+    core.setSecret(accountId);
+  }
+}
 
 async function run() {
   try {
@@ -9,41 +98,32 @@ async function run() {
     const region = core.getInput('aws-region', { required: true });
     const sessionToken = core.getInput('aws-session-token', { required: false });
     const maskAccountId = core.getInput('mask-aws-account-id', { required: false });
+    const roleToAssume = core.getInput('role-to-assume', {required: false});
+    const roleDurationSeconds = core.getInput('role-duration-seconds', {required: false}) || MAX_ACTION_RUNTIME;
 
-    // Configure the AWS CLI and AWS SDKs using environment variables
-
-    // AWS_ACCESS_KEY_ID:
-    // Specifies an AWS access key associated with an IAM user or role
-    core.exportVariable('AWS_ACCESS_KEY_ID', accessKeyId);
-
-    // AWS_SECRET_ACCESS_KEY:
-    // Specifies the secret key associated with the access key. This is essentially the "password" for the access key.
-    core.exportVariable('AWS_SECRET_ACCESS_KEY', secretAccessKey);
-
-    // AWS_SESSION_TOKEN:
-    // Specifies the session token value that is required if you are using temporary security credentials.
-    if (sessionToken) {
-      core.exportVariable('AWS_SESSION_TOKEN', sessionToken);
+    // Get role credentials if configured to do so
+    if (roleToAssume) {
+      const roleCredentials = await assumeRole(
+          {accessKeyId, secretAccessKey, sessionToken, region, roleToAssume, roleDurationSeconds}
+      );
+      exportCredentials(roleCredentials);
+    } else {
+      exportCredentials({accessKeyId, secretAccessKey, sessionToken});
     }
 
-    // AWS_DEFAULT_REGION and AWS_REGION:
-    // Specifies the AWS Region to send requests to
-    core.exportVariable('AWS_DEFAULT_REGION', region);
-    core.exportVariable('AWS_REGION', region);
-
-    // Get the AWS account ID
-    const sts = new aws.STS({
-      customUserAgent: 'configure-aws-credentials-for-github-actions'
-    });
-    const identity = await sts.getCallerIdentity().promise();
-    const accountId = identity.Account;
-    core.setOutput('aws-account-id', accountId);
-    if (!maskAccountId || maskAccountId.toLowerCase() == 'true') {
-      core.setSecret(accountId);
-    }
+    exportRegion(region);
+
+    await exportAccountId(maskAccountId);
   }
   catch (error) {
     core.setFailed(error.message);
+
+    const showStackTrace = process.env.SHOW_STACK_TRACE;
+
+    if (showStackTrace === 'true') {
+      throw(error)
+    }
+
   }
 }
 


Test patch:
diff --git a/index.test.js b/index.test.js
index 1a12c65..cf5ca3a 100644
--- a/index.test.js
+++ b/index.test.js
@@ -1,89 +1,148 @@
 const core = require('@actions/core');
+const assert = require('assert');
 
 const run = require('.');
 
 jest.mock('@actions/core');
 
+const FAKE_ACCESS_KEY_ID = 'MY-AWS-ACCESS-KEY-ID';
+const FAKE_SECRET_ACCESS_KEY = 'MY-AWS-SECRET-ACCESS-KEY';
+const FAKE_SESSION_TOKEN = 'MY-AWS-SESSION-TOKEN';
+const FAKE_STS_ACCESS_KEY_ID = 'STS-AWS-ACCESS-KEY-ID';
+const FAKE_STS_SECRET_ACCESS_KEY = 'STS-AWS-SECRET-ACCESS-KEY';
+const FAKE_STS_SESSION_TOKEN = 'STS-AWS-SESSION-TOKEN';
+const FAKE_REGION = 'fake-region-1';
+const FAKE_ACCOUNT_ID = '123456789012';
+const ROLE_NAME = 'MY-ROLE';
+const ENVIRONMENT_VARIABLE_OVERRIDES = {
+    SHOW_STACK_TRACE: 'true',
+    GITHUB_REPOSITORY: 'MY-REPOSITORY-NAME',
+    GITHUB_WORKFLOW: 'MY-WORKFLOW-ID',
+    GITHUB_ACTION: 'MY-ACTION-NAME',
+    GITHUB_ACTOR: 'MY-USERNAME',
+    GITHUB_REF: 'MY-BRANCH',
+    GITHUB_SHA: 'MY-COMMIT-ID',
+};
+
+function mockGetInput(requestResponse) {
+    return function (name, options) { // eslint-disable-line no-unused-vars
+        return requestResponse[name]
+    }
+}
+const REQUIRED_INPUTS = {
+    'aws-access-key-id': FAKE_ACCESS_KEY_ID,
+    'aws-secret-access-key': FAKE_SECRET_ACCESS_KEY
+};
+const DEFAULT_INPUTS = {
+    ...REQUIRED_INPUTS,
+    'aws-session-token': FAKE_SESSION_TOKEN,
+    'aws-region': FAKE_REGION,
+    'mask-aws-account-id': 'TRUE'
+};
+const ASSUME_ROLE_INPUTS = {...REQUIRED_INPUTS, 'role-to-assume': ROLE_NAME, 'aws-region': FAKE_REGION};
+
 const mockStsCallerIdentity = jest.fn();
+const mockStsAssumeRole = jest.fn();
+
 jest.mock('aws-sdk', () => {
     return {
         STS: jest.fn(() => ({
-            getCallerIdentity: mockStsCallerIdentity
+            getCallerIdentity: mockStsCallerIdentity,
+            assumeRole: mockStsAssumeRole,
         }))
     };
 });
 
 describe('Configure AWS Credentials', () => {
+    const OLD_ENV = process.env;
 
     beforeEach(() => {
+        jest.resetModules();
+        process.env = {...OLD_ENV, ...ENVIRONMENT_VARIABLE_OVERRIDES};
+
         jest.clearAllMocks();
 
         core.getInput = jest
             .fn()
-            .mockReturnValueOnce('MY-AWS-ACCESS-KEY-ID')     // aws-access-key-id
-            .mockReturnValueOnce('MY-AWS-SECRET-ACCESS-KEY') // aws-secret-access-key
-            .mockReturnValueOnce('us-east-2')                // aws-default-region
-            .mockReturnValueOnce('MY-AWS-SESSION-TOKEN')     // aws-session-token
-            .mockReturnValueOnce('TRUE');                    // mask-aws-account-id
+            .mockImplementation(mockGetInput(DEFAULT_INPUTS));
 
         mockStsCallerIdentity.mockImplementation(() => {
             return {
                 promise() {
-                   return Promise.resolve({ Account: '123456789012' });
+                   return Promise.resolve({ Account: FAKE_ACCOUNT_ID });
                 }
             };
         });
+
+        mockStsAssumeRole.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(() => {
+        process.env = OLD_ENV;
     });
 
     test('exports env vars', async () => {
         await run();
+        expect(mockStsAssumeRole).toHaveBeenCalledTimes(0);
         expect(core.exportVariable).toHaveBeenCalledTimes(5);
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MY-AWS-ACCESS-KEY-ID');
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MY-AWS-SECRET-ACCESS-KEY');
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'MY-AWS-SESSION-TOKEN');
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'us-east-2');
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'us-east-2');
-        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '123456789012');
-        expect(core.setSecret).toHaveBeenCalledWith('123456789012');
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_ACCESS_KEY_ID);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_SECRET_ACCESS_KEY);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', FAKE_SESSION_TOKEN);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', FAKE_REGION);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', FAKE_REGION);
+        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID);
+        expect(core.setSecret).toHaveBeenCalledWith(FAKE_ACCOUNT_ID);
     });
 
     test('session token is optional', async () => {
+        const mockInputs = {...REQUIRED_INPUTS, 'aws-region': 'eu-west-1'};
         core.getInput = jest
             .fn()
-            .mockReturnValueOnce('MY-AWS-ACCESS-KEY-ID')     // aws-access-key-id
-            .mockReturnValueOnce('MY-AWS-SECRET-ACCESS-KEY') // aws-secret-access-key
-            .mockReturnValueOnce('eu-west-1');               // aws-default-region
+            .mockImplementation(mockGetInput(mockInputs));
 
         await run();
+        expect(mockStsAssumeRole).toHaveBeenCalledTimes(0);
         expect(core.exportVariable).toHaveBeenCalledTimes(4);
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MY-AWS-ACCESS-KEY-ID');
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MY-AWS-SECRET-ACCESS-KEY');
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_ACCESS_KEY_ID);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_SECRET_ACCESS_KEY);
         expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'eu-west-1');
         expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'eu-west-1');
-        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '123456789012');
-        expect(core.setSecret).toHaveBeenCalledWith('123456789012');
+        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID);
+        expect(core.setSecret).toHaveBeenCalledWith(FAKE_ACCOUNT_ID);
     });
 
     test('can opt out of masking account ID', async () => {
+        const mockInputs = {...REQUIRED_INPUTS, 'aws-region': 'us-east-1', 'mask-aws-account-id': 'false'};
         core.getInput = jest
             .fn()
-            .mockReturnValueOnce('MY-AWS-ACCESS-KEY-ID')     // aws-access-key-id
-            .mockReturnValueOnce('MY-AWS-SECRET-ACCESS-KEY') // aws-secret-access-key
-            .mockReturnValueOnce('us-east-1')                // aws-default-region
-            .mockReturnValueOnce('')                         // aws-session-token
-            .mockReturnValueOnce('false');                   // mask-aws-account-id
+            .mockImplementation(mockGetInput(mockInputs));
 
         await run();
+        expect(mockStsAssumeRole).toHaveBeenCalledTimes(0);
         expect(core.exportVariable).toHaveBeenCalledTimes(4);
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MY-AWS-ACCESS-KEY-ID');
-        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MY-AWS-SECRET-ACCESS-KEY');
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_ACCESS_KEY_ID);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_SECRET_ACCESS_KEY);
         expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'us-east-1');
         expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'us-east-1');
-        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '123456789012');
+        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID);
         expect(core.setSecret).toHaveBeenCalledTimes(0);
     });
 
-    test('error is caught by core.setFailed', async () => {
+    test('error is caught by core.setFailed and caught', async () => {
+        process.env.SHOW_STACK_TRACE = 'false';
+
         mockStsCallerIdentity.mockImplementation(() => {
             throw new Error();
         });
@@ -92,4 +151,77 @@ describe('Configure AWS Credentials', () => {
 
         expect(core.setFailed).toBeCalled();
     });
+
+    test('error is caught by core.setFailed and passed', async () => {
+
+        mockStsCallerIdentity.mockImplementation(() => {
+            throw new Error();
+        });
+
+        await assert.rejects(() => run());
+
+        expect(core.setFailed).toBeCalled();
+    });
+
+    test('basic role assumption exports', async () => {
+        core.getInput = jest
+            .fn()
+            .mockImplementation(mockGetInput(ASSUME_ROLE_INPUTS));
+
+        await run();
+        expect(mockStsAssumeRole).toHaveBeenCalledTimes(1);
+        expect(core.exportVariable).toHaveBeenCalledTimes(5);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', FAKE_STS_ACCESS_KEY_ID);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', FAKE_STS_SECRET_ACCESS_KEY);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', FAKE_STS_SESSION_TOKEN);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', FAKE_REGION);
+        expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', FAKE_REGION);
+        expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', FAKE_ACCOUNT_ID);
+        expect(core.setSecret).toHaveBeenCalledWith(FAKE_ACCOUNT_ID);
+    });
+
+    test('role assumption tags', async () => {
+        core.getInput = jest
+            .fn()
+            .mockImplementation(mockGetInput(ASSUME_ROLE_INPUTS));
+
+        await run();
+        expect(mockStsAssumeRole).toHaveBeenCalledWith({
+            RoleArn: ROLE_NAME,
+            RoleSessionName: 'GitHubActions',
+            DurationSeconds: 6 * 3600,
+            Tags: [
+                {Key: 'GitHub', Value: 'Actions'},
+                {Key: 'Repository', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REPOSITORY},
+                {Key: 'Workflow', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_WORKFLOW},
+                {Key: 'Action', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTION},
+                {Key: 'Actor', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTOR},
+                {Key: 'Branch', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REF},
+                {Key: 'Commit', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_SHA},
+            ]
+        })
+    });
+
+    test('role assumption duration provided', async () => {
+        core.getInput = jest
+            .fn()
+            .mockImplementation(mockGetInput({...ASSUME_ROLE_INPUTS, 'role-duration-seconds': 5}));
+
+        await run();
+        expect(mockStsAssumeRole).toHaveBeenCalledWith({
+            RoleArn: ROLE_NAME,
+            RoleSessionName: 'GitHubActions',
+            DurationSeconds: 5,
+            Tags: [
+                {Key: 'GitHub', Value: 'Actions'},
+                {Key: 'Repository', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REPOSITORY},
+                {Key: 'Workflow', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_WORKFLOW},
+                {Key: 'Action', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTION},
+                {Key: 'Actor', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_ACTOR},
+                {Key: 'Branch', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_REF},
+                {Key: 'Commit', Value: ENVIRONMENT_VARIABLE_OVERRIDES.GITHUB_SHA},
+            ]
+        })
+    });
+
 });