diff --git a/lib/aws.ts b/lib/aws.ts index ff48951..4e5a523 100644 --- a/lib/aws.ts +++ b/lib/aws.ts @@ -14,11 +14,17 @@ export interface IAws { secretsManagerClient(options: ClientOptions): Promise; } +// Partial because `RoleSessionName` is required in STS, but we have a default value for it. +export type AssumeRoleAdditionalOptions = Partial< + // cloud-assembly-schema validates that `ExternalId` and `RoleArn` are not configured + Omit +>; + export interface ClientOptions { region?: string; assumeRoleArn?: string; assumeRoleExternalId?: string; - assumeRoleSessionTags?: { [key: string]: string }; + assumeRoleAdditionalOptions?: AssumeRoleAdditionalOptions; quiet?: boolean; } @@ -120,7 +126,7 @@ export class DefaultAwsClient implements IAws { options.region, options.assumeRoleArn, options.assumeRoleExternalId, - options.assumeRoleSessionTags + options.assumeRoleAdditionalOptions ); } @@ -143,22 +149,21 @@ export class DefaultAwsClient implements IAws { region: string | undefined, roleArn: string, externalId?: string, - sessionTags?: { [key: string]: string } + additionalOptions?: AssumeRoleAdditionalOptions ): Promise { - const parsedTags = sessionTags - ? Object.entries(sessionTags).map(([key, value]) => ({ - Key: key, - Value: value, - })) - : []; - + if ( + additionalOptions?.Tags && + additionalOptions.Tags.length > 0 && + !additionalOptions.TransitiveTagKeys + ) { + additionalOptions.TransitiveTagKeys = additionalOptions.Tags?.map((t) => t.Key); + } return new this.AWS.ChainableTemporaryCredentials({ params: { RoleArn: roleArn, ExternalId: externalId, RoleSessionName: `cdk-assets-${safeUsername()}`, - Tags: parsedTags, - TransitiveTagKeys: sessionTags ? Object.keys(sessionTags) : [], + // ...(additionalOptions ?? {}), }, stsConfig: { region, diff --git a/lib/private/handlers/container-images.ts b/lib/private/handlers/container-images.ts index 2e6cce4..6cae509 100644 --- a/lib/private/handlers/container-images.ts +++ b/lib/private/handlers/container-images.ts @@ -107,7 +107,8 @@ export class ContainerImageAssetHandler implements IAssetHandler { const ecr = await this.host.aws.ecrClient({ assumeRoleArn: destination.assumeRoleArn, assumeRoleExternalId: destination.assumeRoleExternalId, - assumeRoleSessionTags: destination.assumeRoleSessionTags, + assumeRoleAdditionalOptions: + destination.assumeRoleAdditionalOptions as AWS.STS.Types.AssumeRoleRequest, region: destination.region, quiet: options.quiet, }); @@ -129,7 +130,7 @@ export class ContainerImageAssetHandler implements IAssetHandler { destinationAlreadyExists: await this.destinationAlreadyExists(ecr, destination, imageUri), }; - return this.init; + return this.init!; } /** diff --git a/lib/private/handlers/files.ts b/lib/private/handlers/files.ts index db26f82..d3152ce 100644 --- a/lib/private/handlers/files.ts +++ b/lib/private/handlers/files.ts @@ -37,7 +37,7 @@ export class FileAssetHandler implements IAssetHandler { const s3 = await this.host.aws.s3Client({ assumeRoleArn: destination.assumeRoleArn, assumeRoleExternalId: destination.assumeRoleExternalId, - assumeRoleSessionTags: destination.assumeRoleSessionTags, + assumeRoleAdditionalOptions: destination.assumeRoleAdditionalOptions, region: destination.region, quiet: true, }); @@ -59,7 +59,7 @@ export class FileAssetHandler implements IAssetHandler { const s3 = await this.host.aws.s3Client({ assumeRoleArn: destination.assumeRoleArn, assumeRoleExternalId: destination.assumeRoleExternalId, - assumeRoleSessionTags: destination.assumeRoleSessionTags, + assumeRoleAdditionalOptions: destination.assumeRoleAdditionalOptions, region: destination.region, }); this.host.emitMessage(EventType.CHECK, `Check ${s3Url}`); @@ -73,7 +73,7 @@ export class FileAssetHandler implements IAssetHandler { await this.host.aws.discoverTargetAccount({ assumeRoleArn: destination.assumeRoleArn, assumeRoleExternalId: destination.assumeRoleExternalId, - assumeRoleSessionTags: destination.assumeRoleSessionTags, + assumeRoleAdditionalOptions: destination.assumeRoleAdditionalOptions, region: destination.region, }) )?.accountId; diff --git a/test/aws.test.ts b/test/aws.test.ts new file mode 100644 index 0000000..8d6e498 --- /dev/null +++ b/test/aws.test.ts @@ -0,0 +1,117 @@ +import * as os from 'os'; +import { DefaultAwsClient } from '../lib'; + +afterEach(() => { + jest.requireActual('aws-sdk'); +}); + +beforeEach(() => { + jest.requireActual('aws-sdk'); +}); + +test('assumeRole passes the right parameters to STS', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const AWS = require('aws-sdk'); + + jest.mock('aws-sdk', () => { + return { + STS: jest.fn().mockReturnValue({ + getCallerIdentity: jest.fn().mockReturnValue({ + promise: jest.fn().mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:role/my-role', + }), + }), + }), + ChainableTemporaryCredentials: jest.fn(), + }; + }); + const aws = new DefaultAwsClient(); + await withMocked(os, 'userInfo', async (userInfo) => { + userInfo.mockReturnValue({ + username: 'foo', + uid: 1, + gid: 1, + homedir: '/here', + shell: '/bin/sh', + }); + await aws.discoverTargetAccount({ + region: 'us-east-1', + assumeRoleArn: 'arn:aws:iam::123456789012:role/my-role', + assumeRoleExternalId: 'external-id', + assumeRoleAdditionalOptions: { + DurationSeconds: 3600, + }, + }); + expect(AWS.ChainableTemporaryCredentials).toHaveBeenCalledWith({ + params: { + ExternalId: 'external-id', + RoleArn: 'arn:aws:iam::123456789012:role/my-role', + DurationSeconds: 3600, + RoleSessionName: `cdk-assets-foo`, + }, + stsConfig: { + customUserAgent: 'cdk-assets', + region: 'us-east-1', + }, + }); + }); +}); + +test('assumeRole defaults session tags to all', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('aws-sdk'); + + jest.mock('aws-sdk', () => { + return { + STS: jest.fn().mockReturnValue({ + getCallerIdentity: jest.fn().mockReturnValue({ + promise: jest.fn().mockResolvedValue({ + Account: '123456789012', + Arn: 'arn:aws:iam::123456789012:role/my-role', + }), + }), + }), + ChainableTemporaryCredentials: jest.fn(), + }; + }); + + const aws = new DefaultAwsClient(); + + const account = await aws.discoverTargetAccount({}); + expect(account).toEqual({ + accountId: '123456789012', + partition: 'aws', + }); +}); + +export function withMocked( + obj: A, + key: K, + block: (fn: jest.Mocked[K]) => B +): B { + const original = obj[key]; + const mockFn = jest.fn(); + (obj as any)[key] = mockFn; + + let asyncFinally: boolean = false; + try { + const ret = block(mockFn as any); + if (!isPromise(ret)) { + return ret; + } + + asyncFinally = true; + return ret.finally(() => { + obj[key] = original; + }) as any; + } finally { + if (!asyncFinally) { + obj[key] = original; + } + } +} + +function isPromise(object: any): object is Promise { + return Promise.resolve(object) === object; +} diff --git a/test/docker-images.test.ts b/test/docker-images.test.ts index 4feedfe..91a7f6d 100644 --- a/test/docker-images.test.ts +++ b/test/docker-images.test.ts @@ -29,6 +29,10 @@ beforeEach(() => { theDestination: { region: 'us-north-50', assumeRoleArn: 'arn:aws:role', + assumeRoleExternalId: 'external-id', + assumeRoleAdditionalOptions: { + Tags: [{ Key: 'Departement', Value: 'Engineering' }], + }, repositoryName: 'repo', imageTag: 'abcdef', }, @@ -249,6 +253,10 @@ test('pass destination properties to AWS client', async () => { expect.objectContaining({ region: 'us-north-50', assumeRoleArn: 'arn:aws:role', + assumeRoleExternalId: 'external-id', + assumeRoleAdditionalOptions: { + Tags: [{ Key: 'Departement', Value: 'Engineering' }], + }, }) ); }); diff --git a/test/files.test.ts b/test/files.test.ts index e36e88a..35bbc57 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -1,6 +1,6 @@ jest.mock('child_process'); -import { Manifest } from '@aws-cdk/cloud-assembly-schema'; +import { FileDestination, Manifest } from '@aws-cdk/cloud-assembly-schema'; import * as mockfs from 'mock-fs'; import { FakeListener } from './fake-listener'; import { mockAws, mockedApiFailure, mockedApiResult, mockUpload } from './mock-aws'; @@ -9,9 +9,13 @@ import { AssetPublishing, AssetManifest } from '../lib'; const ABS_PATH = '/simple/cdk.out/some_external_file'; -const DEFAULT_DESTINATION = { +const DEFAULT_DESTINATION: FileDestination = { region: 'us-north-50', assumeRoleArn: 'arn:aws:role', + assumeRoleExternalId: 'external-id', + assumeRoleAdditionalOptions: { + Tags: [{ Key: 'Departement', Value: 'Engineering' }], + }, bucketName: 'some_bucket', objectKey: 'some_key', }; @@ -114,6 +118,10 @@ test('pass destination properties to AWS client', async () => { expect.objectContaining({ region: 'us-north-50', assumeRoleArn: 'arn:aws:role', + assumeRoleExternalId: 'external-id', + assumeRoleAdditionalOptions: { + Tags: [{ Key: 'Departement', Value: 'Engineering' }], + }, }) ); });