Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add the ability to use a web identity token file #240

Merged
merged 9 commits into from
Aug 3, 2021
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 40 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -22,7 +24,8 @@ async function assumeRole(params) {
roleDurationSeconds,
roleSessionName,
region,
roleSkipSessionTagging
roleSkipSessionTagging,
webIdentityTokenFile
} = params;
assert(
[sourceAccountId, roleToAssume, roleDurationSeconds, roleSessionName, region].every(isDefined),
Expand All @@ -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},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -249,7 +277,8 @@ async function run() {
roleExternalId,
roleDurationSeconds,
roleSessionName,
roleSkipSessionTagging
roleSkipSessionTagging,
webIdentityTokenFile
});
exportCredentials(roleCredentials);
await validateCredentials(roleCredentials.accessKeyId);
Expand Down
54 changes: 54 additions & 0 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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_'

Expand All @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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()
Expand Down