diff --git a/.gitignore b/.gitignore index bc18168c2..6841d2159 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.history node_modules coverage .DS_Store diff --git a/action.yml b/action.yml index 91ba47548..0249e9d36 100644 --- a/action.yml +++ b/action.yml @@ -1,4 +1,3 @@ - name: '"Configure AWS Credentials" Action for GitHub Actions' description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow runs: @@ -75,6 +74,9 @@ inputs: required: false use-existing-credentials: description: When enabled, this option will check if there are already valid credentials in the environment. If there are, new credentials will not be fetched. If there are not, the action will run as normal. + custom-tags: + description: Additional tags to apply to the assumed role session. Must be a JSON object provided as a string. + required: false outputs: aws-account-id: description: The AWS account ID for the provided credentials diff --git a/dist/cleanup/assumeRole.d.ts b/dist/cleanup/assumeRole.d.ts index 681a211f0..6f09b197d 100644 --- a/dist/cleanup/assumeRole.d.ts +++ b/dist/cleanup/assumeRole.d.ts @@ -13,5 +13,6 @@ export interface assumeRoleParams { managedSessionPolicies?: { arn: string; }[]; + customTags?: string; } export declare function assumeRole(params: assumeRoleParams): Promise; diff --git a/dist/index.js b/dist/index.js index 0b7e0e108..d8c55ebfc 100644 --- a/dist/index.js +++ b/dist/index.js @@ -162,7 +162,7 @@ async function assumeRoleWithCredentials(params, client) { } } async function assumeRole(params) { - const { credentialsClient, sourceAccountId, roleToAssume, roleExternalId, roleDuration, roleSessionName, roleSkipSessionTagging, webIdentityTokenFile, webIdentityToken, inlineSessionPolicy, managedSessionPolicies, } = { ...params }; + const { credentialsClient, sourceAccountId, roleToAssume, roleExternalId, roleDuration, roleSessionName, roleSkipSessionTagging, webIdentityTokenFile, webIdentityToken, inlineSessionPolicy, managedSessionPolicies, customTags, } = { ...params }; // Load GitHub environment variables const { GITHUB_REPOSITORY, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_ACTOR, GITHUB_SHA, GITHUB_WORKSPACE } = process.env; if (!GITHUB_REPOSITORY || !GITHUB_WORKFLOW || !GITHUB_ACTION || !GITHUB_ACTOR || !GITHUB_SHA || !GITHUB_WORKSPACE) { @@ -183,12 +183,26 @@ async function assumeRole(params) { Value: (0, helpers_1.sanitizeGitHubVariables)(process.env.GITHUB_REF), }); } + if (customTags) { + try { + const parsed = JSON.parse(customTags); + // Then do the mapping + const newTags = Object.entries(parsed).map(([Key, Value]) => ({ + Key, + Value: String(Value), + })); + tagArray.push(...newTags); + } + catch { + throw new Error('Invalid custom-tags, json is not valid'); + } + } const tags = roleSkipSessionTagging ? undefined : tagArray; if (!tags) { core.debug('Role session tagging has been skipped.'); } else { - core.debug(`${tags.length} role session tags are being used.`); + core.debug(`${tags.length} role session tags are being used:`); } // Calculate role ARN from name and account ID (currently only supports `aws` partition) let roleArn = roleToAssume; @@ -491,6 +505,7 @@ async function run() { const roleSkipSessionTaggingInput = core.getInput('role-skip-session-tagging', { required: false }) || 'false'; const roleSkipSessionTagging = roleSkipSessionTaggingInput.toLowerCase() === 'true'; const proxyServer = core.getInput('http-proxy', { required: false }); + const customTags = core.getInput('custom-tags', { required: false }); const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false, }); @@ -613,6 +628,7 @@ async function run() { webIdentityToken, inlineSessionPolicy, managedSessionPolicies, + customTags, }); }, !disableRetry, maxRetries); } while (specialCharacterWorkaround && !(0, helpers_1.verifyKeys)(roleCredentials.Credentials)); diff --git a/src/assumeRole.ts b/src/assumeRole.ts index 668c2a0cf..e5ccb8a79 100644 --- a/src/assumeRole.ts +++ b/src/assumeRole.ts @@ -76,6 +76,7 @@ export interface assumeRoleParams { webIdentityToken?: string; inlineSessionPolicy?: string; managedSessionPolicies?: { arn: string }[]; + customTags?: string; } export async function assumeRole(params: assumeRoleParams) { @@ -91,6 +92,7 @@ export async function assumeRole(params: assumeRoleParams) { webIdentityToken, inlineSessionPolicy, managedSessionPolicies, + customTags, } = { ...params }; // Load GitHub environment variables @@ -108,17 +110,35 @@ export async function assumeRole(params: assumeRoleParams) { { Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) }, { Key: 'Commit', Value: GITHUB_SHA }, ]; + if (process.env.GITHUB_REF) { tagArray.push({ Key: 'Branch', Value: sanitizeGitHubVariables(process.env.GITHUB_REF), }); } + + if (customTags) { + try { + const parsed = JSON.parse(customTags); + + // Then do the mapping + const newTags = Object.entries(parsed).map(([Key, Value]) => ({ + Key, + Value: String(Value), + })); + + tagArray.push(...newTags); + } catch { + throw new Error('Invalid custom-tags, json is not valid'); + } + } + const tags = roleSkipSessionTagging ? undefined : tagArray; if (!tags) { core.debug('Role session tagging has been skipped.'); } else { - core.debug(`${tags.length} role session tags are being used.`); + core.debug(`${tags.length} role session tags are being used:`); } // Calculate role ARN from name and account ID (currently only supports `aws` partition) diff --git a/src/index.ts b/src/index.ts index a35452bf4..f79634649 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,8 @@ export async function run() { const roleSkipSessionTaggingInput = core.getInput('role-skip-session-tagging', { required: false }) || 'false'; const roleSkipSessionTagging = roleSkipSessionTaggingInput.toLowerCase() === 'true'; const proxyServer = core.getInput('http-proxy', { required: false }); + const customTags = core.getInput('custom-tags', { required: false }); + const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false, }); @@ -184,6 +186,7 @@ export async function run() { webIdentityToken, inlineSessionPolicy, managedSessionPolicies, + customTags, }); }, !disableRetry, diff --git a/test/index.test.ts b/test/index.test.ts index a95443da6..dd009b1a8 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -243,6 +243,41 @@ describe('Configure AWS Credentials', {}, () => { }); }); + describe('Custom Tags', {}, () => { + beforeEach(() => { + mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method + vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials') + .mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' }) + .mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' }); + }); + it('rejects invalid JSON in custom tags', {}, async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_JSON_INPUTS)); + await run(); + expect(core.setFailed).toHaveBeenCalledWith('Invalid custom-tags, json is not valid'); + //expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0); + }); + it('handles object custom tags', {}, async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_OBJECT_INPUTS)); + await run(); + expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials'); + expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID'); + expect(mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input).toMatchObject({ + Tags: expect.arrayContaining([ + { Key: 'GitHub', Value: 'Actions' }, + { Key: 'Repository', Value: 'MY-REPOSITORY-NAME' }, + { Key: 'Workflow', Value: 'MY-WORKFLOW-ID' }, + { Key: 'Action', Value: 'MY-ACTION-NAME' }, + { Key: 'Actor', Value: 'MY-USERNAME_bot_' }, + { Key: 'Commit', Value: 'MY-COMMIT-ID' }, + { Key: 'Environment', Value: 'Production' }, + { Key: 'Team', Value: 'DevOps' }, + ]) + }); + }); + }); + describe('Odd inputs', {}, () => { it('fails when github env vars are missing', {}, async () => { vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS)); @@ -251,7 +286,7 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).toHaveBeenCalled(); }); - it('does not fail if GITHUB_REF is missing', {}, async () => { + it('does not fail if GITHUB_REF is missing', {},async () => { vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS)); mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY }); // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method @@ -311,6 +346,7 @@ describe('Configure AWS Credentials', {}, () => { mockedSTSClient.on(GetCallerIdentityCommand).resolves(mocks.outputs.GET_CALLER_IDENTITY); await run(); expect(core.setFailed).not.toHaveBeenCalled(); - }) + }); }); }); + diff --git a/test/mockinputs.test.ts b/test/mockinputs.test.ts index c5908a818..e15b233d0 100644 --- a/test/mockinputs.test.ts +++ b/test/mockinputs.test.ts @@ -6,6 +6,29 @@ const inputs = { 'aws-region': 'fake-region-1', 'special-characters-workaround': 'true', }, + CUSTOM_TAGS_JSON_INPUTS: { + 'aws-access-key-id': 'MYAWSACCESSKEYID', + 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY', + 'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE', + 'aws-region': 'fake-region-1', + 'custom-tags': '{"Environment": "Production", "Team": "DevOps"}', + }, + CUSTOM_TAGS_INVALID_JSON_INPUTS: { + 'aws-access-key-id': 'MYAWSACCESSKEYID', + 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY', + 'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE', + 'aws-region': 'fake-region-1', + 'retry-max-attempts': '1', + 'custom-tags': 'not a json', + }, + CUSTOM_TAGS_OBJECT_INPUTS: { + 'aws-access-key-id': 'MYAWSACCESSKEYID', + 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY', + 'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE', + 'aws-region': 'fake-region-1', + 'retry-max-attempts': '1', + 'custom-tags': JSON.stringify({ Environment: 'Production', Team: 'DevOps' }), + }, IAM_USER_INPUTS: { 'aws-access-key-id': 'MYAWSACCESSKEYID', 'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',