feat: add support for assuming a role (#17)

* first draft attempt at adding role assumption option

* refinements

* const not var

* clean up asserts

* set explicit sts endpoint and clarify required inputs error message

* streamline mocks

* add new inputs to Action definition

* ignore .idea directory

* add initial assume role test

* make tests fail usefully when not in GitHub Actions

* add logic to handle suppression of stack trace

* pull credentials exports out into function

* convert environment variable patching to use object for source and add needed members

* add test for STS call

* compartmentalization and use custom user agent in role assumption STS client

* change DO_NOT_SUPRESS_STACK_TRACE to SHOW_STACK_TRACE

* update role-to-assume input description
This commit is contained in:
Matt Bullock
2020-01-22 11:04:03 -08:00
committed by Clare Liguori
parent e3c83cfe60
commit 25960ab095
4 changed files with 277 additions and 58 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ node_modules/
# Editors
.vscode
.idea
# Logs
logs

View File

@@ -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'

136
index.js
View File

@@ -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);
exportRegion(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);
}
await exportAccountId(maskAccountId);
}
catch (error) {
core.setFailed(error.message);
const showStackTrace = process.env.SHOW_STACK_TRACE;
if (showStackTrace === 'true') {
throw(error)
}
}
}

View File

@@ -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},
]
})
});
});