diff --git a/.projen/deps.json b/.projen/deps.json index 93f7293..bd6b033 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -16,6 +16,10 @@ "name": "@types/mime", "type": "build" }, + { + "name": "@types/mock-fs", + "type": "build" + }, { "name": "@types/node", "version": "^18", diff --git a/.projen/tasks.json b/.projen/tasks.json index c2d9d04..04b045b 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -245,13 +245,13 @@ }, "steps": [ { - "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --dep=dev,peer,prod,optional --filter=@types/archiver,@types/glob,@types/jest,@types/mime,@types/yargs,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-prettier,fs-extra,graceful-fs,jest,jszip,mock-fs,prettier,projen,ts-jest,ts-node,typescript,@aws-cdk/cloud-assembly-schema,@aws-cdk/cx-api,archiver,aws-sdk,glob,mime,yargs" + "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --dep=dev,peer,prod,optional --filter=@types/archiver,@types/glob,@types/jest,@types/mime,@types/mock-fs,@types/yargs,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-prettier,fs-extra,graceful-fs,jest,jszip,mock-fs,prettier,projen,ts-jest,ts-node,typescript,@aws-cdk/cloud-assembly-schema,@aws-cdk/cx-api,archiver,aws-sdk,glob,mime,yargs" }, { "exec": "yarn install --check-files" }, { - "exec": "yarn upgrade @types/archiver @types/glob @types/jest @types/mime @types/node @types/yargs @typescript-eslint/eslint-plugin @typescript-eslint/parser commit-and-tag-version constructs eslint-config-prettier eslint-import-resolver-typescript eslint-plugin-import eslint-plugin-prettier eslint fs-extra graceful-fs jest jest-junit jszip mock-fs prettier projen ts-jest ts-node typescript @aws-cdk/cloud-assembly-schema @aws-cdk/cx-api archiver aws-sdk glob mime yargs" + "exec": "yarn upgrade @types/archiver @types/glob @types/jest @types/mime @types/mock-fs @types/node @types/yargs @typescript-eslint/eslint-plugin @typescript-eslint/parser commit-and-tag-version constructs eslint-config-prettier eslint-import-resolver-typescript eslint-plugin-import eslint-plugin-prettier eslint fs-extra graceful-fs jest jest-junit jszip mock-fs prettier projen ts-jest ts-node typescript @aws-cdk/cloud-assembly-schema @aws-cdk/cx-api archiver aws-sdk glob mime yargs" }, { "exec": "npx projen" diff --git a/.projenrc.ts b/.projenrc.ts index 81f208d..0e97088 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -32,6 +32,7 @@ const project = new typescript.TypeScriptProject({ '@types/glob', '@types/mime', '@types/yargs', + '@types/mock-fs', 'fs-extra', 'graceful-fs', 'jszip', diff --git a/lib/aws.ts b/lib/aws.ts index 567d018..5238ca0 100644 --- a/lib/aws.ts +++ b/lib/aws.ts @@ -14,10 +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; + assumeRoleAdditionalOptions?: AssumeRoleAdditionalOptions; quiet?: boolean; } @@ -118,7 +125,8 @@ export class DefaultAwsClient implements IAws { credentials = await this.assumeRole( options.region, options.assumeRoleArn, - options.assumeRoleExternalId + options.assumeRoleExternalId, + options.assumeRoleAdditionalOptions ); } @@ -140,13 +148,18 @@ export class DefaultAwsClient implements IAws { private async assumeRole( region: string | undefined, roleArn: string, - externalId?: string + externalId?: string, + additionalOptions?: AssumeRoleAdditionalOptions ): Promise { return new this.AWS.ChainableTemporaryCredentials({ params: { RoleArn: roleArn, ExternalId: externalId, RoleSessionName: `cdk-assets-${safeUsername()}`, + TransitiveTagKeys: additionalOptions?.Tags + ? additionalOptions.Tags.map((t) => t.Key) + : undefined, + ...(additionalOptions ?? {}), }, stsConfig: { region, diff --git a/lib/private/handlers/container-images.ts b/lib/private/handlers/container-images.ts index e0089f1..b0ff312 100644 --- a/lib/private/handlers/container-images.ts +++ b/lib/private/handlers/container-images.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import { DockerImageDestination } from '@aws-cdk/cloud-assembly-schema'; import type * as AWS from 'aws-sdk'; +import { destinationToClientOptions } from '.'; import { DockerImageManifestEntry } from '../../asset-manifest'; import { EventType } from '../../progress'; import { IAssetHandler, IHandlerHost, IHandlerOptions } from '../asset-handler'; @@ -105,7 +106,7 @@ export class ContainerImageAssetHandler implements IAssetHandler { const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws); const ecr = await this.host.aws.ecrClient({ - ...destination, + ...destinationToClientOptions(destination), quiet: options.quiet, }); const account = async () => (await this.host.aws.discoverCurrentAccount())?.accountId; diff --git a/lib/private/handlers/files.ts b/lib/private/handlers/files.ts index d08d178..1716217 100644 --- a/lib/private/handlers/files.ts +++ b/lib/private/handlers/files.ts @@ -2,6 +2,7 @@ import { createReadStream, promises as fs } from 'fs'; import * as path from 'path'; import { FileAssetPackaging, FileSource } from '@aws-cdk/cloud-assembly-schema'; import * as mime from 'mime'; +import { destinationToClientOptions } from '.'; import { FileManifestEntry } from '../../asset-manifest'; import { EventType } from '../../progress'; import { zipDirectory } from '../archive'; @@ -35,7 +36,7 @@ export class FileAssetHandler implements IAssetHandler { const s3Url = `s3://${destination.bucketName}/${destination.objectKey}`; try { const s3 = await this.host.aws.s3Client({ - ...destination, + ...destinationToClientOptions(destination), quiet: true, }); this.host.emitMessage(EventType.CHECK, `Check ${s3Url}`); @@ -53,14 +54,17 @@ export class FileAssetHandler implements IAssetHandler { public async publish(): Promise { const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws); const s3Url = `s3://${destination.bucketName}/${destination.objectKey}`; - const s3 = await this.host.aws.s3Client(destination); + + const clientOptions = destinationToClientOptions(destination); + const s3 = await this.host.aws.s3Client(clientOptions); this.host.emitMessage(EventType.CHECK, `Check ${s3Url}`); const bucketInfo = BucketInformation.for(this.host); // A thunk for describing the current account. Used when we need to format an error // message, not in the success case. - const account = async () => (await this.host.aws.discoverTargetAccount(destination))?.accountId; + const account = async () => + (await this.host.aws.discoverTargetAccount(clientOptions))?.accountId; switch (await bucketInfo.bucketOwnership(s3, destination.bucketName)) { case BucketOwnership.MINE: break; diff --git a/lib/private/handlers/index.ts b/lib/private/handlers/index.ts index 0eccd0c..d45e660 100644 --- a/lib/private/handlers/index.ts +++ b/lib/private/handlers/index.ts @@ -1,3 +1,4 @@ +import { AwsDestination } from '@aws-cdk/cloud-assembly-schema'; import { ContainerImageAssetHandler } from './container-images'; import { FileAssetHandler } from './files'; import { @@ -6,6 +7,7 @@ import { FileManifestEntry, IManifestEntry, } from '../../asset-manifest'; +import type { ClientOptions } from '../../aws'; import { IAssetHandler, IHandlerHost, IHandlerOptions } from '../asset-handler'; export function makeAssetHandler( @@ -23,3 +25,15 @@ export function makeAssetHandler( throw new Error(`Unrecognized asset type: '${asset}'`); } + +export function destinationToClientOptions(destination: AwsDestination): ClientOptions { + // Explicitly build ClientOptions from AwsDestination. The fact they are structurally compatible is coincidental. + // This also enforces better type checking that cdk-assets depends on the appropriate version of + // @aws-cdk/cloud-assembly-schema. + return { + assumeRoleArn: destination.assumeRoleArn, + assumeRoleExternalId: destination.assumeRoleExternalId, + assumeRoleAdditionalOptions: destination.assumeRoleAdditionalOptions, + region: destination.region, + }; +} diff --git a/package.json b/package.json index 510cb0a..8300622 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/glob": "^7.2.0", "@types/jest": "^29.5.13", "@types/mime": "^2.0.3", + "@types/mock-fs": "^4.13.4", "@types/node": "^18", "@types/yargs": "^15.0.19", "@typescript-eslint/eslint-plugin": "^7", @@ -59,7 +60,7 @@ "typescript": "^5.6.2" }, "dependencies": { - "@aws-cdk/cloud-assembly-schema": "^36.3.0", + "@aws-cdk/cloud-assembly-schema": "^38.0.0", "@aws-cdk/cx-api": "^2.158.0", "archiver": "^5.3.2", "aws-sdk": "^2.1691.0", diff --git a/test/aws.test.ts b/test/aws.test.ts new file mode 100644 index 0000000..3282cbe --- /dev/null +++ b/test/aws.test.ts @@ -0,0 +1,136 @@ +import * as os from 'os'; +import { DefaultAwsClient } from '../lib'; + +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 + 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: { + Tags: [{ Key: 'Departement', Value: 'Engineering' }], + }, + }); + expect(AWS.ChainableTemporaryCredentials).toHaveBeenCalledWith({ + params: { + ExternalId: 'external-id', + RoleArn: 'arn:aws:iam::123456789012:role/my-role', + Tags: [{ Key: 'Departement', Value: 'Engineering' }], + TransitiveTagKeys: ['Departement'], + RoleSessionName: `cdk-assets-foo`, + }, + stsConfig: { + customUserAgent: 'cdk-assets', + region: 'us-east-1', + }, + }); + }); +}); + +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' }], + }, }) ); }); diff --git a/yarn.lock b/yarn.lock index cea9f8f..03d2b46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,10 +10,10 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@aws-cdk/cloud-assembly-schema@^36.3.0": - version "36.3.0" - resolved "https://registry.yarnpkg.com/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-36.3.0.tgz#17aeb389cbbff72f2b8d5b3b25d8d21d6ec3f0ef" - integrity sha512-mLSYgcMFTNCXrGAD7xob95p9s47/7WwEWUJiexxM46H2GxiijhlhLQJs31AS5uRRP6Cx1DLEu4qayKAUOOVGrw== +"@aws-cdk/cloud-assembly-schema@^38.0.0": + version "38.0.1" + resolved "https://registry.yarnpkg.com/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-38.0.1.tgz#cdf4684ae8778459e039cd44082ea644a3504ca9" + integrity sha512-KvPe+NMWAulfNVwY7jenFhzhuLhLqJ/OPy5jx7wUstbjnYnjRVLpUHPU3yCjXFE0J8cuJVdx95BJ4rOs66Pi9w== dependencies: jsonschema "^1.4.1" semver "^7.6.3" @@ -828,6 +828,13 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== +"@types/mock-fs@^4.13.4": + version "4.13.4" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.4.tgz#e73edb4b4889d44d23f1ea02d6eebe50aa30b09a" + integrity sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg== + dependencies: + "@types/node" "*" + "@types/node@*": version "22.5.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.5.5.tgz#52f939dd0f65fc552a4ad0b392f3c466cc5d7a44"