diff --git a/packages/aws-cdk/lib/api/aws-auth/account-cache.ts b/packages/aws-cdk/lib/api/aws-auth/account-cache.ts index 30ac1704ccc36..d7c753781c11f 100644 --- a/packages/aws-cdk/lib/api/aws-auth/account-cache.ts +++ b/packages/aws-cdk/lib/api/aws-auth/account-cache.ts @@ -43,7 +43,6 @@ export class AccountAccessKeyCache { // try to get account ID based on this access key ID from disk. const cached = await this.get(accessKeyId); if (cached) { - debug(`Retrieved account ID ${cached.accountId} from disk cache`); return cached; } diff --git a/packages/aws-cdk/lib/api/aws-auth/aws-sdk-inifile.ts b/packages/aws-cdk/lib/api/aws-auth/aws-sdk-inifile.ts index 012bb80470039..96c155616e1f9 100644 --- a/packages/aws-cdk/lib/api/aws-auth/aws-sdk-inifile.ts +++ b/packages/aws-cdk/lib/api/aws-auth/aws-sdk-inifile.ts @@ -118,7 +118,6 @@ export class PatchedSharedIniFileCredentials extends AWS.SharedIniFileCredential return; } sts.assumeRole(roleParams, callback); - } private sourceProfileCredentials(sourceProfile: string, profiles: Record>) { diff --git a/packages/aws-cdk/lib/api/aws-auth/credential-plugins.ts b/packages/aws-cdk/lib/api/aws-auth/credential-plugins.ts index 94cdfa052feb4..d8ebb50d889af 100644 --- a/packages/aws-cdk/lib/api/aws-auth/credential-plugins.ts +++ b/packages/aws-cdk/lib/api/aws-auth/credential-plugins.ts @@ -15,9 +15,9 @@ import { CredentialProviderSource, Mode } from './credentials'; * for the given account. */ export class CredentialPlugins { - private readonly cache: {[key: string]: AWS.Credentials | undefined} = {}; + private readonly cache: {[key: string]: PluginCredentials | undefined} = {}; - public async fetchCredentialsFor(awsAccountId: string, mode: Mode): Promise { + public async fetchCredentialsFor(awsAccountId: string, mode: Mode): Promise { const key = `${awsAccountId}-${mode}`; if (!(key in this.cache)) { this.cache[key] = await this.lookupCredentials(awsAccountId, mode); @@ -29,7 +29,7 @@ export class CredentialPlugins { return PluginHost.instance.credentialProviderSources.map(s => s.name); } - private async lookupCredentials(awsAccountId: string, mode: Mode): Promise { + private async lookupCredentials(awsAccountId: string, mode: Mode): Promise { const triedSources: CredentialProviderSource[] = []; // Otherwise, inspect the various credential sources we have for (const source of PluginHost.instance.credentialProviderSources) { @@ -44,11 +44,15 @@ export class CredentialPlugins { // Backwards compatibility: if the plugin returns a ProviderChain, resolve that chain. // Otherwise it must have returned credentials. - if ((providerOrCreds as any).resolvePromise) { - return (providerOrCreds as any).resolvePromise(); - } - return providerOrCreds; + const credentials = (providerOrCreds as any).resolvePromise ? await (providerOrCreds as any).resolvePromise() : providerOrCreds; + + return { credentials, pluginName: source.name }; } return undefined; } } + +export interface PluginCredentials { + readonly credentials: AWS.Credentials; + readonly pluginName: string; +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts b/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts index 8477a8c855e09..62484006583ab 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk-provider.ts @@ -5,7 +5,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as AWS from 'aws-sdk'; import type { ConfigurationOptions } from 'aws-sdk/lib/config-base'; import * as fs from 'fs-extra'; -import { debug } from '../../logging'; +import { debug, warning } from '../../logging'; import { cached } from '../../util/functions'; import { CredentialPlugins } from '../aws-auth/credential-plugins'; import { Mode } from '../aws-auth/credentials'; @@ -79,12 +79,27 @@ const CACHED_ACCOUNT = Symbol('cached_account'); const CACHED_DEFAULT_CREDENTIALS = Symbol('cached_default_credentials'); /** - * Creates instances of the AWS SDK appropriate for a given account/region + * Creates instances of the AWS SDK appropriate for a given account/region. * - * If an environment is given and the current credentials are NOT for the indicated - * account, will also search the set of credential plugin providers. + * Behavior is as follows: * - * If no environment is given, the default credentials will always be used. + * - First, a set of "base" credentials are established + * - If a target environment is given and the default ("current") SDK credentials are for + * that account, return those; otherwise + * - If a target environment is given, scan all credential provider plugins + * for credentials, and return those if found; otherwise + * - Return default ("current") SDK credentials, noting that they might be wrong. + * + * - Second, a role may optionally need to be assumed. Use the base credentials + * established in the previous process to assume that role. + * - If assuming the role fails and the base credentials are for the correct + * account, return those. This is a fallback for people who are trying to interact + * with a Default Synthesized stack and already have right credentials setup. + * + * Typical cases we see in the wild: + * - Credential plugin setup that, although not recommended, works for them + * - Seeded terminal with `ReadOnly` credentials in order to do `cdk diff`--the `ReadOnly` + * role doesn't have `sts:AssumeRole` and will fail for no real good reason. */ export class SdkProvider { /** @@ -126,49 +141,53 @@ export class SdkProvider { * * The `environment` parameter is resolved first (see `resolveEnvironment()`). */ - public async forEnvironment(environment: cxapi.Environment, mode: Mode): Promise { + public async forEnvironment(environment: cxapi.Environment, mode: Mode, options?: CredentialsOptions): Promise { const env = await this.resolveEnvironment(environment); - const creds = await this.obtainCredentials(env.account, mode); - return new SDK(creds, env.region, this.sdkOptions); - } - - /** - * Return an SDK which uses assumed role credentials - * - * The base credentials used to retrieve the assumed role credentials will be the - * same credentials returned by obtainCredentials if an environment and mode is passed, - * otherwise it will be the current credentials. - * - */ - public async withAssumedRole(roleArn: string, externalId: string | undefined, environment: cxapi.Environment | undefined, mode: Mode | undefined) { - debug(`Assuming role '${roleArn}'.`); + const baseCreds = await this.obtainBaseCredentials(env.account, mode); - let region: string; - let masterCredentials: AWS.Credentials; + // At this point, we need at least SOME credentials + if (baseCreds.source === 'none') { throw new Error(fmtObtainCredentialsError(env.account, baseCreds)); } - if (environment !== undefined && mode !== undefined) { - const env = await this.resolveEnvironment(environment); - masterCredentials = await this.obtainCredentials(env.account, mode); - region = env.region; - } else { - region = this.defaultRegion; - masterCredentials = await this.defaultCredentials(); + // Simple case is if we don't need to "assumeRole" here. If so, we must now have credentials for the right + // account. + if (options?.assumeRoleArn === undefined) { + if (baseCreds.source === 'incorrectDefault') { throw new Error(fmtObtainCredentialsError(env.account, baseCreds)); } + return new SDK(baseCreds.credentials, env.region, this.sdkOptions); } - const creds = new AWS.ChainableTemporaryCredentials({ - params: { - RoleArn: roleArn, - ...externalId ? { ExternalId: externalId } : {}, - RoleSessionName: `aws-cdk-${safeUsername()}`, - }, - stsConfig: { - region, - ...this.sdkOptions, - }, - masterCredentials: masterCredentials, - }); + // We will proceed to AssumeRole using whatever we've been given. + const sdk = await this.withAssumedRole(baseCreds, options.assumeRoleArn, options.assumeRoleExternalId, env.region); + + // Exercise the AssumeRoleCredentialsProvider we've gotten at least once so + // we can determine whether the AssumeRole call succeeds or not. + try { + await sdk.forceCredentialRetrieval(); + return sdk; + } catch (e) { + // AssumeRole failed. Proceed and warn *if and only if* the baseCredentials were already for the right account + // or returned from a plugin. This is to cover some current setups for people using plugins or preferring to + // feed the CLI credentials which are sufficient by themselves. Prefer to assume the correct role if we can, + // but if we can't then let's just try with available credentials anyway. + if (baseCreds.source === 'correctDefault' || baseCreds.source === 'plugin') { + debug(e.message); + warning(`${fmtObtainedCredentials(baseCreds)} could not be used to assume '${options.assumeRoleArn}', but are for the right account. Proceeding anyway.`); + return new SDK(baseCreds.credentials, env.region, this.sdkOptions); + } - return new SDK(creds, region, this.sdkOptions); + throw e; + } + } + + /** + * Return the partition that base credentials are for + * + * Returns `undefined` if there are no base credentials. + */ + public async baseCredentialsPartition(environment: cxapi.Environment, mode: Mode): Promise { + const env = await this.resolveEnvironment(environment); + const baseCreds = await this.obtainBaseCredentials(env.account, mode); + if (baseCreds.source === 'none') { return undefined; } + return (await new SDK(baseCreds.credentials, env.region, this.sdkOptions).currentAccount()).partition; } /** @@ -230,30 +249,40 @@ export class SdkProvider { /** * Get credentials for the given account ID in the given mode * - * Use the current credentials if the destination account matches the current credentials' account. - * Otherwise try all credential plugins. + * 1. Use the default credentials if the destination account matches the + * current credentials' account. + * 2. Otherwise try all credential plugins. + * 3. Fail if neither of these yield any credentials. + * 4. Return a failure if any of them returned credentials */ - protected async obtainCredentials(accountId: string, mode: Mode): Promise { + private async obtainBaseCredentials(accountId: string, mode: Mode): Promise { // First try 'current' credentials const defaultAccountId = (await this.defaultAccount())?.accountId; if (defaultAccountId === accountId) { - return this.defaultCredentials(); + return { source: 'correctDefault', credentials: await this.defaultCredentials() }; } // Then try the plugins const pluginCreds = await this.plugins.fetchCredentialsFor(accountId, mode); if (pluginCreds) { - return pluginCreds; + return { source: 'plugin', ...pluginCreds }; } - // No luck, format a useful error message - const error = [`Need to perform AWS calls for account ${accountId}`]; - error.push(defaultAccountId ? `but the current credentials are for ${defaultAccountId}` : 'but no credentials have been configured'); - if (this.plugins.availablePluginNames.length > 0) { - error.push(`and none of these plugins found any: ${this.plugins.availablePluginNames.join(', ')}`); + // Fall back to default credentials with a note that they're not the right ones yet + if (defaultAccountId !== undefined) { + return { + source: 'incorrectDefault', + accountId: defaultAccountId, + credentials: await this.defaultCredentials(), + unusedPlugins: this.plugins.availablePluginNames, + }; } - throw new Error(`${error.join(', ')}.`); + // Apparently we didn't find any at all + return { + source: 'none', + unusedPlugins: this.plugins.availablePluginNames, + }; } /** @@ -265,6 +294,40 @@ export class SdkProvider { return this.defaultChain.resolvePromise(); }); } + + /** + * Return an SDK which uses assumed role credentials + * + * The base credentials used to retrieve the assumed role credentials will be the + * same credentials returned by obtainCredentials if an environment and mode is passed, + * otherwise it will be the current credentials. + */ + private async withAssumedRole( + masterCredentials: Exclude, + roleArn: string, + externalId: string | undefined, + region: string | undefined) { + debug(`Assuming role '${roleArn}'.`); + + region = region ?? this.defaultRegion; + + const creds = new AWS.ChainableTemporaryCredentials({ + params: { + RoleArn: roleArn, + ...externalId ? { ExternalId: externalId } : {}, + RoleSessionName: `aws-cdk-${safeUsername()}`, + }, + stsConfig: { + region, + ...this.sdkOptions, + }, + masterCredentials: masterCredentials.credentials, + }); + + return new SDK(creds, region, this.sdkOptions, { + assumeRoleCredentialsSourceDescription: fmtObtainedCredentials(masterCredentials), + }); + } } /** @@ -383,3 +446,79 @@ function readIfPossible(filename: string): string | undefined { function safeUsername() { return os.userInfo().username.replace(/[^\w+=,.@-]/g, '@'); } + +/** + * Options for obtaining credentials for an environment + */ +export interface CredentialsOptions { + /** + * The ARN of the role that needs to be assumed, if any + */ + readonly assumeRoleArn?: string; + + /** + * External ID required to assume the given role. + */ + readonly assumeRoleExternalId?: string; +} + +/** + * Result of obtaining base credentials + */ +type ObtainBaseCredentialsResult = + { source: 'correctDefault'; credentials: AWS.Credentials } + | { source: 'plugin'; pluginName: string, credentials: AWS.Credentials } + | { source: 'incorrectDefault'; credentials: AWS.Credentials; accountId: string; unusedPlugins: string[] } + | { source: 'none'; unusedPlugins: string[] }; + +/** + * Isolating the code that translates calculation errors into human error messages + * + * We cover the following cases: + * + * - No credentials are available at all + * - Default credentials are for the wrong account + */ +function fmtObtainCredentialsError(targetAccountId: string, obtainResult: ObtainBaseCredentialsResult & { source: 'none' | 'incorrectDefault' }): string { + const msg = [`Need to perform AWS calls for account ${targetAccountId}`]; + switch (obtainResult.source) { + case 'incorrectDefault': + msg.push(`but the current credentials are for ${obtainResult.accountId}`); + break; + case 'none': + msg.push('but no credentials have been configured'); + } + if (obtainResult.unusedPlugins.length > 0) { + msg.push(`and none of these plugins found any: ${obtainResult.unusedPlugins.join(', ')}`); + } + return msg.join(', '); +} + +/** + * Format a message indicating where we got base credentials for the assume role + * + * We cover the following cases: + * + * - Default credentials for the right account + * - Default credentials for the wrong account + * - Credentials returned from a plugin + */ +function fmtObtainedCredentials( + obtainResult: Exclude): string { + switch (obtainResult.source) { + case 'correctDefault': + return 'current credentials'; + case 'plugin': + return `credentials returned by plugin '${obtainResult.pluginName}'`; + case 'incorrectDefault': + const msg = []; + msg.push(`current credentials (which are for account ${obtainResult.accountId}`); + + if (obtainResult.unusedPlugins.length > 0) { + msg.push(`, and none of the following plugins provided credentials: ${obtainResult.unusedPlugins.join(', ')}`); + } + msg.push(')'); + + return msg.join(''); + } +} diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index 5825d0f627d39..3644e61189dc6 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -32,6 +32,18 @@ export interface ISDK { elbv2(): AWS.ELBv2; } +/** + * Additional SDK configuration options + */ +export interface SdkOptions { + /** + * Additional descriptive strings that indicate where the "AssumeRole" credentials are coming from + * + * Will be printed in an error message to help users diagnose auth problems. + */ + readonly assumeRoleCredentialsSourceDescription?: string; +} + /** * Base functionality of SDK without credential fetching */ @@ -55,11 +67,16 @@ export class SDK implements ISDK { */ private readonly cloudFormationRetryOptions = { maxRetries: 10, retryDelayOptions: { base: 1_000 } }; - constructor(private readonly credentials: AWS.Credentials, region: string, httpOptions: ConfigurationOptions = {}) { + constructor( + private readonly _credentials: AWS.Credentials, + region: string, + httpOptions: ConfigurationOptions = {}, + private readonly sdkOptions: SdkOptions = {}) { + this.config = { ...httpOptions, ...this.retryOptions, - credentials, + credentials: _credentials, region, logger: { log: (...messages) => messages.forEach(m => trace('%s', m)) }, }; @@ -67,38 +84,41 @@ export class SDK implements ISDK { } public cloudFormation(): AWS.CloudFormation { - return wrapServiceErrorHandling(new AWS.CloudFormation({ + return this.wrapServiceErrorHandling(new AWS.CloudFormation({ ...this.config, ...this.cloudFormationRetryOptions, })); } public ec2(): AWS.EC2 { - return wrapServiceErrorHandling(new AWS.EC2(this.config)); + return this.wrapServiceErrorHandling(new AWS.EC2(this.config)); } public ssm(): AWS.SSM { - return wrapServiceErrorHandling(new AWS.SSM(this.config)); + return this.wrapServiceErrorHandling(new AWS.SSM(this.config)); } public s3(): AWS.S3 { - return wrapServiceErrorHandling(new AWS.S3(this.config)); + return this.wrapServiceErrorHandling(new AWS.S3(this.config)); } public route53(): AWS.Route53 { - return wrapServiceErrorHandling(new AWS.Route53(this.config)); + return this.wrapServiceErrorHandling(new AWS.Route53(this.config)); } public ecr(): AWS.ECR { - return wrapServiceErrorHandling(new AWS.ECR(this.config)); + return this.wrapServiceErrorHandling(new AWS.ECR(this.config)); } public elbv2(): AWS.ELBv2 { - return wrapServiceErrorHandling(new AWS.ELBv2(this.config)); + return this.wrapServiceErrorHandling(new AWS.ELBv2(this.config)); } public async currentAccount(): Promise { - return cached(this, CURRENT_ACCOUNT_KEY, () => SDK.accountCache.fetch(this.credentials.accessKeyId, async () => { + // Get/refresh if necessary before we can access `accessKeyId` + await this.forceCredentialRetrieval(); + + return cached(this, CURRENT_ACCOUNT_KEY, () => SDK.accountCache.fetch(this._credentials.accessKeyId, async () => { // if we don't have one, resolve from STS and store in cache. debug('Looking up default account ID from STS'); const result = await new AWS.STS(this.config).getCallerIdentity().promise(); @@ -118,108 +138,141 @@ export class SDK implements ISDK { * Don't use -- only used to write tests around assuming roles. */ public async currentCredentials(): Promise { - await this.credentials.getPromise(); - return this.credentials; + await this.forceCredentialRetrieval(); + return this._credentials; } -} -/** - * Return a wrapping object for the underlying service object - * - * Responds to failures in the underlying service calls, in two different - * ways: - * - * - When errors are encountered, log the failing call and the error that - * it triggered (at debug level). This is necessary because the lack of - * stack traces in NodeJS otherwise makes it very hard to suss out where - * a certain AWS error occurred. - * - The JS SDK has a funny business of wrapping any credential-based error - * in a super-generic (and in our case wrong) exception. If we then use a - * 'ChainableTemporaryCredentials' and the target role doesn't exist, - * the error message that shows up by default is super misleading - * (https://github.com/aws/aws-sdk-js/issues/3272). We can fix this because - * the exception contains the "inner exception", so we unwrap and throw - * the correct error ("cannot assume role"). - * - * The wrapping business below is slightly more complicated than you'd think - * because we must hook into the `promise()` method of the object that's being - * returned from the methods of the object that we wrap, so there's two - * levels of wrapping going on, and also some exceptions to the wrapping magic. - */ -function wrapServiceErrorHandling(serviceObject: A): A { - const classObject = serviceObject.constructor.prototype; - - return new Proxy(serviceObject, { - get(obj: A, prop: string) { - const real = (obj as any)[prop]; - // Things we don't want to intercept: - // - Anything that's not a function. - // - 'constructor', s3.upload() will use this to do some magic and we need the underlying constructor. - // - Any method that's not on the service class (do not intercept 'makeRequest' and other helpers). - if (prop === 'constructor' || !classObject.hasOwnProperty(prop) || !isFunction(real)) { return real; } - - // NOTE: This must be a function() and not an () => { - // because I need 'this' to be dynamically bound and not statically bound. - // If your linter complains don't listen to it! - return function(this: any) { - // Call the underlying function. If it returns an object with a promise() - // method on it, wrap that 'promise' method. - const args = [].slice.call(arguments, 0); - const response = real.apply(this, args); - - // Don't intercept unless the return value is an object with a '.promise()' method. - if (typeof response !== 'object' || !response) { return response; } - if (!('promise' in response)) { return response; } - - // Return an object with the promise method replaced with a wrapper which will - // do additional things to errors. - return Object.assign(Object.create(response), { - promise() { - return response.promise().catch((e: Error) => { - e = makeDetailedException(e); - debug(`Call failed: ${prop}(${JSON.stringify(args[0])}) => ${e.message}`); - return Promise.reject(e); // Re-'throw' the new error - }); - }, - }); - }; - }, - }); -} + /** + * Force retrieval of the current credentials + * + * Relevant if the current credentials are AssumeRole credentials -- do the actual + * lookup, and translate any error into a useful error message (taking into + * account credential provenance). + */ + public async forceCredentialRetrieval() { + try { + await this._credentials.getPromise(); + } catch (e) { + debug(`Assuming role failed: ${e.message}`); + throw new Error([ + 'Could not assume role in target account', + ...this.sdkOptions.assumeRoleCredentialsSourceDescription + ? [`using ${this.sdkOptions.assumeRoleCredentialsSourceDescription}`] + : [], + '(did you bootstrap the environment with the right \'--trust\'s?):', + e.message, + ].join(' ')); + } + } -const CURRENT_ACCOUNT_KEY = Symbol('current_account_key'); + /** + * Return a wrapping object for the underlying service object + * + * Responds to failures in the underlying service calls, in two different + * ways: + * + * - When errors are encountered, log the failing call and the error that + * it triggered (at debug level). This is necessary because the lack of + * stack traces in NodeJS otherwise makes it very hard to suss out where + * a certain AWS error occurred. + * - The JS SDK has a funny business of wrapping any credential-based error + * in a super-generic (and in our case wrong) exception. If we then use a + * 'ChainableTemporaryCredentials' and the target role doesn't exist, + * the error message that shows up by default is super misleading + * (https://github.com/aws/aws-sdk-js/issues/3272). We can fix this because + * the exception contains the "inner exception", so we unwrap and throw + * the correct error ("cannot assume role"). + * + * The wrapping business below is slightly more complicated than you'd think + * because we must hook into the `promise()` method of the object that's being + * returned from the methods of the object that we wrap, so there's two + * levels of wrapping going on, and also some exceptions to the wrapping magic. + */ + private wrapServiceErrorHandling(serviceObject: A): A { + const classObject = serviceObject.constructor.prototype; + const self = this; -function isFunction(x: any): x is (...args: any[]) => any { - return x && {}.toString.call(x) === '[object Function]'; -} + return new Proxy(serviceObject, { + get(obj: A, prop: string) { + const real = (obj as any)[prop]; + // Things we don't want to intercept: + // - Anything that's not a function. + // - 'constructor', s3.upload() will use this to do some magic and we need the underlying constructor. + // - Any method that's not on the service class (do not intercept 'makeRequest' and other helpers). + if (prop === 'constructor' || !classObject.hasOwnProperty(prop) || !isFunction(real)) { return real; } -/** - * Extract a more detailed error out of a generic error if we can - */ -function makeDetailedException(e: Error): Error { - // This is the super-generic "something's wrong" error that the JS SDK wraps other errors in. - // https://github.com/aws/aws-sdk-js/blob/f0ac2e53457c7512883d0677013eacaad6cd8a19/lib/event_listeners.js#L84 - if (typeof e.message === 'string' && e.message.startsWith('Missing credentials in config')) { - const original = (e as any).originalError; - if (original) { - // When the SDK does a 'util.copy', they lose the Error-ness of the inner error - // (they copy the Error's properties into a plain object) so make it an Error object again. - e = Object.assign(new Error(), original); - } + // NOTE: This must be a function() and not an () => { + // because I need 'this' to be dynamically bound and not statically bound. + // If your linter complains don't listen to it! + return function(this: any) { + // Call the underlying function. If it returns an object with a promise() + // method on it, wrap that 'promise' method. + const args = [].slice.call(arguments, 0); + const response = real.apply(this, args); + + // Don't intercept unless the return value is an object with a '.promise()' method. + if (typeof response !== 'object' || !response) { return response; } + if (!('promise' in response)) { return response; } + + // Return an object with the promise method replaced with a wrapper which will + // do additional things to errors. + return Object.assign(Object.create(response), { + promise() { + return response.promise().catch((e: Error) => { + e = self.makeDetailedException(e); + debug(`Call failed: ${prop}(${JSON.stringify(args[0])}) => ${e.message}`); + return Promise.reject(e); // Re-'throw' the new error + }); + }, + }); + }; + }, + }); } - // At this point, the error might still be a generic "ChainableTemporaryCredentials failed" - // error which wraps the REAL error (AssumeRole failed). We're going to replace the error - // message with one that's more likely to help users, and tell them the most probable - // fix (bootstrapping). The underlying service call failure will be appended below. - if (e.message === 'Could not load credentials from ChainableTemporaryCredentials') { - e.message = 'Could not assume role in target account (did you bootstrap the environment with the right \'--trust\'s?)'; + /** + * Extract a more detailed error out of a generic error if we can + * + * If this is an error about Assuming Roles, add in the context showing the + * chain of credentials we used to try to assume the role. + */ + private makeDetailedException(e: Error): Error { + // This is the super-generic "something's wrong" error that the JS SDK wraps other errors in. + // https://github.com/aws/aws-sdk-js/blob/f0ac2e53457c7512883d0677013eacaad6cd8a19/lib/event_listeners.js#L84 + if (typeof e.message === 'string' && e.message.startsWith('Missing credentials in config')) { + const original = (e as any).originalError; + if (original) { + // When the SDK does a 'util.copy', they lose the Error-ness of the inner error + // (they copy the Error's properties into a plain object) so make it an Error object again. + e = Object.assign(new Error(), original); + } + } + + // At this point, the error might still be a generic "ChainableTemporaryCredentials failed" + // error which wraps the REAL error (AssumeRole failed). We're going to replace the error + // message with one that's more likely to help users, and tell them the most probable + // fix (bootstrapping). The underlying service call failure will be appended below. + if (e.message === 'Could not load credentials from ChainableTemporaryCredentials') { + e.message = [ + 'Could not assume role in target account', + ...this.sdkOptions.assumeRoleCredentialsSourceDescription + ? [`using ${this.sdkOptions.assumeRoleCredentialsSourceDescription}`] + : [], + '(did you bootstrap the environment with the right \'--trust\'s?)', + ].join(' '); + } + + // Replace the message on this error with a concatenation of all inner error messages. + // Must more clear what's going on that way. + e.message = allChainedExceptionMessages(e); + return e; } +} + +const CURRENT_ACCOUNT_KEY = Symbol('current_account_key'); - // Replace the message on this error with a concatenation of all inner error messages. - // Must more clear what's going on that way. - e.message = allChainedExceptionMessages(e); - return e; +function isFunction(x: any): x is (...args: any[]) => any { + return x && {}.toString.call(x) === '[object Function]'; } /** diff --git a/packages/aws-cdk/lib/api/cloudformation-deployments.ts b/packages/aws-cdk/lib/api/cloudformation-deployments.ts index 660f78780213c..35f7fdcf4e7a4 100644 --- a/packages/aws-cdk/lib/api/cloudformation-deployments.ts +++ b/packages/aws-cdk/lib/api/cloudformation-deployments.ts @@ -219,9 +219,9 @@ export class CloudFormationDeployments { cloudFormationRoleArn: roleArn ?? stack.cloudFormationExecutionRoleArn, }, resolvedEnvironment); - const stackSdk = arns.assumeRoleArn - ? await this.sdkProvider.withAssumedRole(arns.assumeRoleArn, undefined, resolvedEnvironment, mode) - : await this.sdkProvider.forEnvironment(resolvedEnvironment, mode); + const stackSdk = await this.sdkProvider.forEnvironment(resolvedEnvironment, mode, { + assumeRoleArn: arns.assumeRoleArn, + }); return { stackSdk, @@ -238,12 +238,12 @@ export class CloudFormationDeployments { accountId: () => Promise.resolve(env.account), region: () => Promise.resolve(env.region), partition: async () => { - // We need to do a rather complicated dance here to get the right - // partition value to substitute into placeholders :( - const defaultAccount = await this.sdkProvider.defaultAccount(); - return env.account === defaultAccount?.accountId - ? defaultAccount.partition - : (await (await this.sdkProvider.forEnvironment(env, Mode.ForReading)).currentAccount()).partition; + // There's no good way to get the partition! + // We should have had it already, except we don't. + // + // Best we can do is ask the "base credentials" for this environment for their partition. Cross-partition + // AssumeRole'ing will never work anyway, so this answer won't be wrong (it will just be slow!) + return (await this.sdkProvider.baseCredentialsPartition(env, Mode.ForReading)) ?? 'aws'; }, }); } diff --git a/packages/aws-cdk/lib/util/asset-publishing.ts b/packages/aws-cdk/lib/util/asset-publishing.ts index 17780cb49f3c3..4d5ef96362ddc 100644 --- a/packages/aws-cdk/lib/util/asset-publishing.ts +++ b/packages/aws-cdk/lib/util/asset-publishing.ts @@ -64,10 +64,10 @@ class PublishingAws implements cdk_assets.IAws { region: options.region ?? this.targetEnv.region, // Default: same region as the stack }; - return options.assumeRoleArn - ? this.aws.withAssumedRole(options.assumeRoleArn, options.assumeRoleExternalId, env, Mode.ForWriting) - : this.aws.forEnvironment(env, Mode.ForWriting); - + return this.aws.forEnvironment(env, Mode.ForWriting, { + assumeRoleArn: options.assumeRoleArn, + assumeRoleExternalId: options.assumeRoleExternalId, + }); } } diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index b64d83e4ec133..c145513004767 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -39,6 +39,7 @@ "license": "Apache-2.0", "devDependencies": { "@aws-cdk/core": "0.0.0", + "@octokit/rest": "^18.0.9", "@types/archiver": "^5.1.0", "@types/fs-extra": "^8.1.1", "@types/glob": "^7.1.3", @@ -56,13 +57,14 @@ "aws-sdk-mock": "^5.1.0", "cdk-build-tools": "0.0.0", "jest": "^26.6.3", + "make-runnable": "^1.3.8", "mockery": "^2.1.0", + "nock": "^13.0.5", "pkglint": "0.0.0", "sinon": "^9.2.1", "ts-jest": "^26.4.4", "ts-mock-imports": "^1.3.1", - "@octokit/rest": "^18.0.9", - "make-runnable": "^1.3.8" + "xml-js": "^1.6.11" }, "dependencies": { "@aws-cdk/cloud-assembly-schema": "0.0.0", diff --git a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts index 6f1276d9135f7..4ffbe14dfddb0 100644 --- a/packages/aws-cdk/test/api/cloudformation-deployments.test.ts +++ b/packages/aws-cdk/test/api/cloudformation-deployments.test.ts @@ -35,8 +35,8 @@ test('placeholders are substituted in CloudFormation execution role', async () = }); test('role with placeholders is assumed if assumerole is given', async () => { - const mockWithAssumedRole = jest.fn(); - sdkProvider.withAssumedRole = mockWithAssumedRole; + const mockForEnvironment = jest.fn(); + sdkProvider.forEnvironment = mockForEnvironment; await deployments.deployStack({ stack: testStack({ @@ -47,7 +47,9 @@ test('role with placeholders is assumed if assumerole is given', async () => { }), }); - expect(mockWithAssumedRole).toHaveBeenCalledWith('bloop:here:123456789012', undefined, expect.anything(), expect.anything()); + expect(mockForEnvironment).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ + assumeRoleArn: 'bloop:here:123456789012', + })); }); test('deployment fails if bootstrap stack is missing', async () => { diff --git a/packages/aws-cdk/test/api/fake-sts.ts b/packages/aws-cdk/test/api/fake-sts.ts new file mode 100644 index 0000000000000..e6f48225f5048 --- /dev/null +++ b/packages/aws-cdk/test/api/fake-sts.ts @@ -0,0 +1,255 @@ +import * as nock from 'nock'; +import * as uuid from 'uuid'; +import * as xmlJs from 'xml-js'; + +interface RegisteredIdentity { + readonly account: string; + readonly arn: string; + readonly userId: string; +} + +interface RegisteredRole { + readonly account: string; + readonly allowedAccounts: string[]; + readonly arn: string; + readonly roleName: string; +} + +interface AssumedRole { + readonly roleArn: string; + readonly serialNumber: string; + readonly tokenCode: string; + readonly roleSessionName: string; +} + +/** + * Class for mocking AWS HTTP Requests and pretending to be STS + * + * This is necessary for testing our authentication layer. Most other mocking + * libraries don't consider as they mock functional methods which happen BEFORE + * the SDK's HTTP/Authentication layer. + * + * Instead, we want to validate how we're setting up credentials for the + * SDK, so we pretend to be the STS server and have an in-memory database + * of users and roles. + */ +export class FakeSts { + public readonly assumedRoles = new Array(); + + private identities: Record = {}; + private roles: Record = {}; + + constructor() { + } + + /** + * Begin mocking + */ + public begin() { + const self = this; + + nock.disableNetConnect(); + if (!nock.isActive()) { + nock.activate(); + } + nock(/.*/).persist().post(/.*/).reply(function (this, uri, body, cb) { + const parsedBody = typeof body === 'string' ? urldecode(body) : body; + + try { + const response = self.handleRequest({ + uri, + host: this.req.headers.host, + parsedBody, + headers: this.req.headers, + }); + cb(null, [200, xmlJs.js2xml(response, { compact: true })]); + } catch (e) { + cb(null, [400, xmlJs.js2xml({ + ErrorResponse: { + _attributes: { xmlns: 'https://sts.amazonaws.com/doc/2011-06-15/' }, + Error: { + Type: 'Sender', + Code: 'Error', + Message: e.message, + }, + RequestId: '1', + }, + }, { compact: true })]); + } + }); + + // Scrub some environment variables that might be set if we're running on CodeBuild which will interfere with the tests. + delete process.env.AWS_PROFILE; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + } + + /** + * Restore everything to normal + */ + public restore() { + nock.restore(); // https://github.com/nock/nock/issues/1817 + nock.cleanAll(); + nock.enableNetConnect(); + } + + /** + * Register a user + */ + public registerUser(account: string, accessKey: string, options: RegisterUserOptions = {}) { + const userName = options.name ?? `User${Object.keys(this.identities).length + 1 }`; + + this.identities[accessKey] = { + account: account, + arn: `arn:${options.partition ?? 'aws'}:sts::${account}:user/${userName}`, + userId: `${accessKey}:${userName}`, + }; + } + + /** + * Register an assumable role + */ + public registerRole(account: string, roleArn: string, options: RegisterRoleOptions = {}) { + const roleName = options.name ?? `Role${Object.keys(this.roles).length + 1 }`; + + this.roles[roleArn] = { + allowedAccounts: options.allowedAccounts ?? [account], + arn: roleArn, + roleName, + account, + }; + } + + private handleRequest(mockRequest: MockRequest): Record { + const response = (() => { + switch (mockRequest.parsedBody.Action) { + case 'GetCallerIdentity': + return this.handleGetCallerIdentity(mockRequest); + + case 'AssumeRole': + return this.handleAssumeRole(mockRequest); + } + + throw new Error(`Unrecognized Action in MockAwsHttp: ${mockRequest.parsedBody.Action}`); + })(); + // console.log(mockRequest.parsedBody, '->', response); + return response; + } + + private handleGetCallerIdentity(mockRequest: MockRequest): Record { + const identity = this.identity(mockRequest); + return { + GetCallerIdentityResponse: { + _attributes: { xmlns: 'https://sts.amazonaws.com/doc/2011-06-15/' }, + GetCallerIdentityResult: { + Arn: identity.arn, + UserId: identity.userId, + Account: identity.account, + }, + ResponseMetadata: { + RequestId: '1', + }, + }, + }; + } + + private handleAssumeRole(mockRequest: MockRequest): Record { + const identity = this.identity(mockRequest); + + this.assumedRoles.push({ + roleArn: mockRequest.parsedBody.RoleArn, + roleSessionName: mockRequest.parsedBody.RoleSessionName, + serialNumber: mockRequest.parsedBody.SerialNumber, + tokenCode: mockRequest.parsedBody.TokenCode, + }); + + const roleArn = mockRequest.parsedBody.RoleArn; + const targetRole = this.roles[roleArn]; + if (!targetRole) { + throw new Error(`No such role: ${roleArn}`); + } + + if (!targetRole.allowedAccounts.includes(identity.account)) { + throw new Error(`Identity from account: ${identity.account} not allowed to assume ${roleArn}, must be one of: ${targetRole.allowedAccounts}`); + } + + const freshAccessKey = uuid.v4(); + + // Register a new "user" (identity) for this access key + this.registerUser(targetRole.account, freshAccessKey, { + name: `AssumedRole-${targetRole.roleName}-${identity.userId}`, + }); + + return { + AssumeRoleResponse: { + _attributes: { xmlns: 'https://sts.amazonaws.com/doc/2011-06-15/' }, + AssumeRoleResult: { + AssumedRoleUser: { + Arn: roleArn, + AssumedRoleId: `${freshAccessKey}:${targetRole.roleName}`, + }, + Credentials: { + AccessKeyId: freshAccessKey, + SecretAccessKey: 'Secret', + SessionToken: 'Token', + Expiration: new Date(Date.now() + 3600 * 1000).toISOString(), + }, + PackedPolicySize: 6, + }, + }, + ResponseMetadata: { + RequestId: '1', + }, + }; + } + + private identity(mockRequest: MockRequest) { + const keyId = this.accessKeyId(mockRequest); + const ret = this.identities[keyId]; + if (!ret) { throw new Error(`Unrecognized access key used: ${keyId}`); } + return ret; + } + + /** + * Return the access key from a signed request + */ + private accessKeyId(mockRequest: MockRequest): string { + // "AWS4-HMAC-SHA256 Credential=(ab1a5e4c-ff41-4811-ac5f-6d1230f7aa90)access/20201210/eu-bla-5/sts/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=9b31011173a7842fa372d4ef7c431c08f0b1514fdaf54145560a4db7ecd24529" + const auth = mockRequest.headers.authorization; + + const m = auth?.match(/Credential=([^\/]+)/); + if (!m) { throw new Error(`No correct authorization header: ${auth}`); } + return m[1]; + } +} + +export interface RegisterUserOptions { + readonly name?: string; + readonly partition?: string; +} + +export interface RegisterRoleOptions { + readonly allowedAccounts?: string[]; + readonly name?: string; + readonly partition?: string; +} + +interface MockRequest { + readonly host: string; + readonly uri: string; + readonly headers: Record; + readonly parsedBody: Record; +} + +function urldecode(body: string): Record { + const parts = body.split('&'); + const ret: Record = {}; + for (const part of parts) { + const [k, v] = part.split('='); + ret[decodeURIComponent(k)] = decodeURIComponent(v); + } + return ret; +} diff --git a/packages/aws-cdk/test/api/sdk-provider.test.ts b/packages/aws-cdk/test/api/sdk-provider.test.ts index 0b9057b5eab1b..c09577d740870 100644 --- a/packages/aws-cdk/test/api/sdk-provider.test.ts +++ b/packages/aws-cdk/test/api/sdk-provider.test.ts @@ -1,54 +1,42 @@ import * as os from 'os'; import * as cxapi from '@aws-cdk/cx-api'; import * as AWS from 'aws-sdk'; -import * as SDKMock from 'aws-sdk-mock'; import type { ConfigurationOptions } from 'aws-sdk/lib/config-base'; import * as promptly from 'promptly'; import * as uuid from 'uuid'; import { PluginHost } from '../../lib'; -import { ISDK, Mode, SdkProvider } from '../../lib/api/aws-auth'; +import { ISDK, Mode, SDK, SdkProvider } from '../../lib/api/aws-auth'; import * as logging from '../../lib/logging'; import * as bockfs from '../bockfs'; import { withMocked } from '../util'; +import { FakeSts, RegisterRoleOptions, RegisterUserOptions } from './fake-sts'; -// Mock promptly prompt to test MFA support jest.mock('promptly', () => ({ - prompt: jest.fn().mockRejectedValue(new Error('test')), + prompt: jest.fn().mockResolvedValue('1234'), })); -SDKMock.setSDKInstance(AWS); - -type AwsCallback = (err: Error | null, val: T) => void; - const defaultCredOptions = { ec2creds: false, containerCreds: false, }; -// Account cache buster let uid: string; let pluginQueried = false; -let defaultEnv: cxapi.Environment; -let pluginEnv: cxapi.Environment; -let getCallerIdentityError: Error | null = null; beforeEach(() => { + // Cache busters! + // We prefix everything with UUIDs because: + // + // - We have a cache from account# -> credentials + // - We have a cache from access key -> account uid = `(${uuid.v4()})`; logging.setLogLevel(logging.LogLevel.TRACE); - SDKMock.mock('STS', 'getCallerIdentity', (cb: AwsCallback) => { - return cb(getCallerIdentityError, { - Account: `${uid}the_account_#`, - UserId: 'you!', - Arn: 'arn:aws-here:iam::12345:role/test', - }); - }); - PluginHost.instance.credentialProviderSources.splice(0); PluginHost.instance.credentialProviderSources.push({ isAvailable() { return Promise.resolve(true); }, - canProvideCredentials(account) { return Promise.resolve(account === `${uid}plugin_account_#`); }, + canProvideCredentials(account) { return Promise.resolve(account === uniq('99999')); }, getProvider() { pluginQueried = true; return Promise.resolve(new AWS.Credentials({ @@ -60,105 +48,109 @@ beforeEach(() => { name: 'test plugin', }); - defaultEnv = cxapi.EnvironmentUtils.make(`${uid}the_account_#`, 'def'); - pluginEnv = cxapi.EnvironmentUtils.make(`${uid}plugin_account_#`, 'def'); - - // Scrub some environment variables that might be set if we're running on CodeBuild which will interfere with the tests. - delete process.env.AWS_PROFILE; - delete process.env.AWS_REGION; - delete process.env.AWS_DEFAULT_REGION; - delete process.env.AWS_ACCESS_KEY_ID; - delete process.env.AWS_SECRET_ACCESS_KEY; - delete process.env.AWS_SESSION_TOKEN; + // Make sure these point to nonexistant files to start, if we don't call + // prepare() then we don't accidentally want to fall back to system config. + process.env.AWS_CONFIG_FILE = '/dev/null'; + process.env.AWS_SHARED_CREDENTIALS_FILE = '/dev/null'; }); afterEach(() => { - logging.setLogLevel(logging.LogLevel.DEFAULT); - - SDKMock.restore(); bockfs.restore(); }); -describe('with default config files', () => { +function uniq(account: string) { + return `${uid}${account}`; +} + +function env(account: string) { + return cxapi.EnvironmentUtils.make(account, 'def'); +} + +describe('with intercepted network calls', () => { + // Most tests will use intercepted network calls, except one test that tests + // that the right HTTP `Agent` is used. + + let fakeSts: FakeSts; beforeEach(() => { - bockfs({ - '/home/me/.bxt/credentials': dedent(` - [default] - aws_access_key_id=${uid}access - aws_secret_access_key=secret - - [foo] - aws_access_key_id=${uid}fooccess - aws_secret_access_key=secret - - [assumer] - aws_access_key_id=${uid}assumer - aws_secret_access_key=secret - - [mfa] - aws_access_key_id=${uid}mfaccess - aws_secret_access_key=secret - `), - '/home/me/.bxt/config': dedent(` - [default] - region=eu-bla-5 - - [profile foo] - region=eu-west-1 - - [profile boo] - aws_access_key_id=${uid}booccess - aws_secret_access_key=boocret - # No region here - - [profile assumable] - role_arn=arn:aws:iam::12356789012:role/Assumable - source_profile=assumer - - [profile assumer] - region=us-east-2 - - [profile mfa] - region=eu-west-1 - - [profile mfa-role] - source_profile=mfa - role_arn=arn:aws:iam::account:role/role - mfa_serial=arn:aws:iam::account:mfa/user - `), - }); + fakeSts = new FakeSts(); + fakeSts.begin(); - // Set environment variables that we want - process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); - process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); + // Make sure the KeyID returned by the plugin is recognized + fakeSts.registerUser(uniq('99999'), uniq('plugin_key')); }); - describe('CLI compatible credentials loading', () => { - test('default config credentials', async () => { + afterEach(() => { + fakeSts.restore(); + }); + + // Set of tests where the CDK will not trigger assume-role + // (the INI file might still do assume-role) + describe('when CDK does not AssumeRole', () => { + test('uses default credentials by default', async () => { // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions }); + prepareCreds({ + fakeSts, + credentials: { + default: { aws_access_key_id: 'access', $account: '11111', $fakeStsOptions: { partition: 'aws-here' } }, + }, + config: { + default: { region: 'eu-bla-5' }, + }, + }); + const provider = await providerFromProfile(undefined); // THEN expect(provider.defaultRegion).toEqual('eu-bla-5'); - await expect(provider.defaultAccount()).resolves.toEqual({ accountId: `${uid}the_account_#`, partition: 'aws-here' }); - const sdk = await provider.forEnvironment({ ...defaultEnv, region: 'rgn' }, Mode.ForReading); - expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(`${uid}access`); - expect(sdkConfig(sdk).region).toEqual('rgn'); + await expect(provider.defaultAccount()).resolves.toEqual({ accountId: uniq('11111'), partition: 'aws-here' }); + + // Ask for a different region + const sdk = await provider.forEnvironment({ ...env(uniq('11111')), region: 'rgn' }, Mode.ForReading); + expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(uniq('access')); + expect(sdk.currentRegion).toEqual('rgn'); }); - test('unknown account and region uses current', async () => { + test('throws if profile credentials are not for the right account', async () => { // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions }); + prepareCreds({ + fakeSts, + config: { + 'profile boo': { aws_access_key_id: 'access', $account: '11111' }, + }, + }); + const provider = await providerFromProfile('boo'); + + await expect(provider.forEnvironment(env(uniq('some_account_#')), Mode.ForReading)).rejects.toThrow('Need to perform AWS calls'); + }); + + test('use profile acct/region if agnostic env requested', async () => { + // WHEN + prepareCreds({ + fakeSts, + credentials: { + default: { aws_access_key_id: 'access', $account: '11111' }, + }, + config: { + default: { region: 'eu-bla-5' }, + }, + }); + const provider = await providerFromProfile(undefined); // THEN const sdk = await provider.forEnvironment(cxapi.EnvironmentUtils.make(cxapi.UNKNOWN_ACCOUNT, cxapi.UNKNOWN_REGION), Mode.ForReading); - expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(`${uid}access`); - expect(sdkConfig(sdk).region).toEqual('eu-bla-5'); + expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(uniq('access')); + expect((await sdk.currentAccount()).accountId).toEqual(uniq('11111')); + expect(sdk.currentRegion).toEqual('eu-bla-5'); }); - test('passing profile does not use EnvironmentCredentials', async () => { + test('passing profile skips EnvironmentCredentials', async () => { // GIVEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'foo' }); + prepareCreds({ + fakeSts, + credentials: { + foo: { aws_access_key_id: 'access', $account: '11111' }, + }, + }); + const provider = await providerFromProfile('foo'); const environmentCredentialsPrototype = (new AWS.EnvironmentCredentials('AWS')).constructor.prototype; @@ -166,424 +158,510 @@ describe('with default config files', () => { refresh.mockImplementation((callback: (err?: Error) => void) => callback(new Error('This function should not have been called'))); // WHEN - await provider.defaultAccount(); + expect((await provider.defaultAccount())?.accountId).toEqual(uniq('11111')); expect(refresh).not.toHaveBeenCalled(); }); }); - test('mixed profile credentials', async () => { + test('supports profile spread over config_file and credentials_file', async () => { // WHEN + prepareCreds({ + fakeSts, + credentials: { + foo: { aws_access_key_id: 'fooccess', $account: '22222' }, + }, + config: { + 'default': { region: 'eu-bla-5' }, + 'profile foo': { region: 'eu-west-1' }, + }, + }); const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'foo' }); // THEN expect(provider.defaultRegion).toEqual('eu-west-1'); - await expect(provider.defaultAccount()).resolves.toEqual({ accountId: `${uid}the_account_#`, partition: 'aws-here' }); - const sdk = await provider.forEnvironment(defaultEnv, Mode.ForReading); - expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(`${uid}fooccess`); + await expect(provider.defaultAccount()).resolves.toEqual({ accountId: uniq('22222'), partition: 'aws' }); + + const sdk = await provider.forEnvironment(env(uniq('22222')), Mode.ForReading); + expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(uniq('fooccess')); }); - test('pure config credentials', async () => { + test('supports profile only in config_file', async () => { // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'boo' }); + prepareCreds({ + fakeSts, + config: { + 'default': { region: 'eu-bla-5' }, + 'profile foo': { aws_access_key_id: 'fooccess', $account: '22222' }, + }, + }); + const provider = await providerFromProfile('foo'); // THEN expect(provider.defaultRegion).toEqual('eu-bla-5'); // Fall back to default config - await expect(provider.defaultAccount()).resolves.toEqual({ accountId: `${uid}the_account_#`, partition: 'aws-here' }); - const sdk = await provider.forEnvironment(defaultEnv, Mode.ForReading); - expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(`${uid}booccess`); + await expect(provider.defaultAccount()).resolves.toEqual({ accountId: uniq('22222'), partition: 'aws' }); + + const sdk = await provider.forEnvironment(env(uniq('22222')), Mode.ForReading); + expect(sdkConfig(sdk).credentials!.accessKeyId).toEqual(uniq('fooccess')); }); - test('mfa_serial in profile will ask user for token', async () => { - // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'mfa-role' }); + test('can assume-role configured in config', async () => { + // GIVEN + prepareCreds({ + fakeSts, + credentials: { + assumer: { aws_access_key_id: 'assumer', $account: '11111' }, + }, + config: { + 'default': { region: 'eu-bla-5' }, + 'profile assumer': { region: 'us-east-2' }, + 'profile assumable': { + role_arn: 'arn:aws:iam::66666:role/Assumable', + source_profile: 'assumer', + $account: '66666', + $fakeStsOptions: { allowedAccounts: ['11111'] }, + }, + }, + }); + const provider = await providerFromProfile('assumable'); - const promptlyMockCalls = (promptly.prompt as jest.Mock).mock.calls.length; + // WHEN + const sdk = await provider.forEnvironment(env(uniq('66666')), Mode.ForReading); // THEN - await expect(() => provider.withAssumedRole('arn:aws:iam::account:role/role', undefined, undefined, Mode.ForReading)) - .rejects - .toThrowError(); - // Mock response was set to fail to make sure we don't call STS - // Make sure the MFA mock was called during this test - expect((promptly.prompt as jest.Mock).mock.calls.length).toBe(promptlyMockCalls + 1); - }); - - test('different account throws', async () => { - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'boo' }); - - await expect(provider.forEnvironment({ ...defaultEnv, account: `${uid}some_account_#` }, Mode.ForReading)).rejects.toThrow('Need to perform AWS calls'); + expect((await sdk.currentAccount()).accountId).toEqual(uniq('66666')); }); - test('even when using a profile to assume another profile, STS calls goes through the proxy', async () => { - // Messy mocking - let called = false; - jest.mock('proxy-agent', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - class FakeAgent extends require('https').Agent { - public addRequest(_: any, __: any) { - // FIXME: this error takes 6 seconds to be completely handled. It - // might be retries in the SDK somewhere, or something about the Node - // event loop. I've spent an hour trying to figure it out and I can't, - // and I gave up. We'll just have to live with this until someone gets - // inspired. - const error = new Error('ABORTED BY TEST'); - (error as any).code = 'RequestAbortedError'; - (error as any).retryable = false; - called = true; - throw error; - } - } - return FakeAgent; + test('can assume role even if [default] profile is missing', async () => { + // GIVEN + prepareCreds({ + fakeSts, + credentials: { + assumer: { aws_access_key_id: 'assumer', $account: '22222' }, + assumable: { role_arn: 'arn:aws:iam::12356789012:role/Assumable', source_profile: 'assumer', $account: '22222' }, + }, + config: { + 'profile assumable': { region: 'eu-bla-5' }, + }, }); // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - profile: 'assumable', - httpOptions: { - proxyAddress: 'http://DOESNTMATTER/', + const provider = await providerFromProfile('assumable'); + + // THEN + expect((await provider.defaultAccount())?.accountId).toEqual(uniq('22222')); + }); + + test('mfa_serial in profile will ask user for token', async () => { + // GIVEN + prepareCreds({ + fakeSts, + credentials: { + assumer: { aws_access_key_id: 'assumer', $account: '66666' }, + }, + config: { + 'default': { region: 'eu-bla-5' }, + 'profile assumer': { region: 'us-east-2' }, + 'profile mfa-role': { + role_arn: 'arn:aws:iam::66666:role/Assumable', + source_profile: 'assumer', + mfa_serial: 'arn:aws:iam::account:mfa/user', + $account: '66666', + }, }, }); + const provider = await providerFromProfile('mfa-role'); - await provider.defaultAccount(); + const promptlyMockCalls = (promptly.prompt as jest.Mock).mock.calls.length; + + // THEN + const sdk = await provider.forEnvironment(env(uniq('66666')), Mode.ForReading); + expect((await sdk.currentAccount()).accountId).toEqual(uniq('66666')); + expect(fakeSts.assumedRoles[0]).toEqual(expect.objectContaining({ + roleArn: 'arn:aws:iam::66666:role/Assumable', + serialNumber: 'arn:aws:iam::account:mfa/user', + tokenCode: '1234', + })); - // THEN -- the fake proxy agent got called, we don't care about the result - expect(called).toEqual(true); + // Mock response was set to fail to make sure we don't call STS + // Make sure the MFA mock was called during this test + expect((promptly.prompt as jest.Mock).mock.calls.length).toBe(promptlyMockCalls + 1); + }); + }); + + // For DefaultSynthesis we will do an assume-role after having gotten base credentials + describe('when CDK AssumeRoles', () => { + beforeEach(() => { + // All these tests share that 'arn:aws:role' is a role into account 88888 which can be assumed from 11111 + fakeSts.registerRole(uniq('88888'), 'arn:aws:role', { allowedAccounts: [uniq('11111')] }); }); test('error we get from assuming a role is useful', async () => { // GIVEN - // Because of the way ChainableTemporaryCredentials gets its STS client, it's not mockable - // using 'mock-aws-sdk'. So instead, we have to mess around with its internals. - function makeAssumeRoleFail(s: ISDK) { - (s as any).credentials.service.assumeRole = jest.fn().mockImplementation((_request, cb) => { - cb(new Error('Nope!')); - }); - } - - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - httpOptions: { - proxyAddress: 'http://localhost:8080/', + prepareCreds({ + fakeSts, + config: { + default: { aws_access_key_id: 'foo' }, }, }); + const provider = await providerFromProfile(undefined); // WHEN - const sdk = await provider.withAssumedRole('bla.role.arn', undefined, undefined, Mode.ForReading); - makeAssumeRoleFail(sdk); + const promise = provider.forEnvironment(env(uniq('88888')), Mode.ForReading, { + assumeRoleArn: 'doesnotexist.role.arn', + }); // THEN - error message contains both a helpful hint and the underlying AssumeRole message - await expect(sdk.s3().listBuckets().promise()).rejects.toThrow('did you bootstrap'); - await expect(sdk.s3().listBuckets().promise()).rejects.toThrow('Nope!'); + await expect(promise).rejects.toThrow('did you bootstrap'); + await expect(promise).rejects.toThrow('doesnotexist.role.arn'); }); test('assuming a role sanitizes the username into the session name', async () => { // GIVEN - SDKMock.restore(); + prepareCreds({ + fakeSts, + config: { + default: { aws_access_key_id: 'foo', $account: '11111' }, + }, + }); await withMocked(os, 'userInfo', async (userInfo) => { userInfo.mockReturnValue({ username: 'skål', uid: 1, gid: 1, homedir: '/here', shell: '/bin/sh' }); - await withMocked((new AWS.STS()).constructor.prototype, 'assumeRole', async (assumeRole) => { - let assumeRoleRequest; + // WHEN + const provider = await providerFromProfile(undefined); - assumeRole.mockImplementation(function ( - this: any, - request: AWS.STS.Types.AssumeRoleRequest, - cb?: (err: Error | null, x: AWS.STS.Types.AssumeRoleResponse) => void) { + const sdk = await provider.forEnvironment(env(uniq('88888')), Mode.ForReading, { assumeRoleArn: 'arn:aws:role' }) as SDK; + await sdk.currentAccount(); - // Part of the request is stored on "this" - assumeRoleRequest = { ...this.config.params, ...request }; + // THEN + expect(fakeSts.assumedRoles[0]).toEqual(expect.objectContaining({ + roleSessionName: 'aws-cdk-sk@l', + })); + }); + }); - const response = { - Credentials: { AccessKeyId: `${uid}aid`, Expiration: new Date(), SecretAccessKey: 's', SessionToken: '' }, - }; - if (cb) { cb(null, response); } - return { promise: () => Promise.resolve(response) }; - }); + test('even if current credentials are for the wrong account, we will still use them to AssumeRole', async () => { + // GIVEN + prepareCreds({ + fakeSts, + config: { + default: { aws_access_key_id: 'foo', $account: '11111' }, + }, + }); + const provider = await providerFromProfile(undefined); - // WHEN - const provider = new SdkProvider(new AWS.CredentialProviderChain([() => new AWS.Credentials({ accessKeyId: 'a', secretAccessKey: 's' })]), 'eu-somewhere'); - const sdk = await provider.withAssumedRole('bla.role.arn', undefined, undefined, Mode.ForReading); + // WHEN + const sdk = await provider.forEnvironment(env(uniq('88888')), Mode.ForReading, { assumeRoleArn: 'arn:aws:role' }) as SDK; - await sdk.currentCredentials(); + // THEN + expect((await sdk.currentAccount()).accountId).toEqual(uniq('88888')); + }); - expect(assumeRoleRequest).toEqual(expect.objectContaining({ - RoleSessionName: 'aws-cdk-sk@l', - })); - }); + test('if AssumeRole fails but current credentials are for the right account, we will still use them', async () => { + // GIVEN + prepareCreds({ + fakeSts, + config: { + default: { aws_access_key_id: 'foo', $account: '88888' }, + }, }); + const provider = await providerFromProfile(undefined); + + // WHEN - assumeRole fails because the role can only be assumed from account 11111 + const sdk = await provider.forEnvironment(env(uniq('88888')), Mode.ForReading, { assumeRoleArn: 'arn:aws:role' }) as SDK; + + // THEN + expect((await sdk.currentAccount()).accountId).toEqual(uniq('88888')); }); }); describe('Plugins', () => { test('does not use plugins if current credentials are for expected account', async () => { - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions }); - await provider.forEnvironment(defaultEnv, Mode.ForReading); + prepareCreds({ + fakeSts, + config: { + default: { aws_access_key_id: 'foo', $account: '11111' }, + }, + }); + const provider = await providerFromProfile(undefined); + await provider.forEnvironment(env(uniq('11111')), Mode.ForReading); expect(pluginQueried).toEqual(false); }); - test('uses plugin for other account', async () => { - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions }); - await provider.forEnvironment({ ...defaultEnv, account: `${uid}plugin_account_#` }, Mode.ForReading); + test('uses plugin for account 99999', async () => { + const provider = await providerFromProfile(undefined); + await provider.forEnvironment(env(uniq('99999')), Mode.ForReading); expect(pluginQueried).toEqual(true); }); test('can assume role with credentials from plugin', async () => { - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions }); - await provider.withAssumedRole('arn:aws:iam::12356789012:role/Assumable', undefined, pluginEnv, Mode.ForReading); - expect(pluginQueried).toEqual(true); - }); - }); -}); + fakeSts.registerRole(uniq('99999'), 'arn:aws:iam::99999:role/Assumable'); -test('can assume role without a [default] profile', async () => { - // GIVEN - bockfs({ - '/home/me/.bxt/credentials': dedent(` - [assumer] - aws_access_key_id=${uid}assumer - aws_secret_access_key=secret - - [assumable] - role_arn=arn:aws:iam::12356789012:role/Assumable - source_profile=assumer - `), - '/home/me/.bxt/config': dedent(` - [profile assumable] - region=eu-bla-5 - `), - }); + const provider = await providerFromProfile(undefined); + await provider.forEnvironment(env(uniq('99999')), Mode.ForReading, { + assumeRoleArn: 'arn:aws:iam::99999:role/Assumable', + }); - SDKMock.mock('STS', 'assumeRole', (_request: AWS.STS.AssumeRoleRequest, cb: AwsCallback) => { - return cb(null, { - Credentials: { - AccessKeyId: `${uid}access`, // Needs UID in here otherwise key will be cached - Expiration: new Date(Date.now() + 10000), - SecretAccessKey: 'b', - SessionToken: 'c', - }, + expect(fakeSts.assumedRoles[0]).toEqual(expect.objectContaining({ + roleArn: 'arn:aws:iam::99999:role/Assumable', + })); + expect(pluginQueried).toEqual(true); }); - }); - // Set environment variables that we want - process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); - process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); - - // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - profile: 'assumable', - }); - - const account = await provider.defaultAccount(); + test('even if AssumeRole fails but current credentials are from a plugin, we will still use them', async () => { + const provider = await providerFromProfile(undefined); + const sdk = await provider.forEnvironment(env(uniq('99999')), Mode.ForReading, { assumeRoleArn: 'does:not:exist' }); - // THEN - expect(account?.accountId).toEqual(`${uid}the_account_#`); -}); + // THEN + expect((await sdk.currentAccount()).accountId).toEqual(uniq('99999')); + }); -test('can assume role with ecs credentials', async () => { + test('plugins are still queried even if current credentials are expired (or otherwise invalid)', async () => { + // GIVEN + process.env.AWS_ACCESS_KEY_ID = `${uid}akid`; + process.env.AWS_SECRET_ACCESS_KEY = 'sekrit'; + const provider = await providerFromProfile(undefined); - return withMocked(AWS.ECSCredentials.prototype, 'needsRefresh', async (needsRefresh) => { + // WHEN + await provider.forEnvironment(env(uniq('99999')), Mode.ForReading); - // GIVEN - bockfs({ - '/home/me/.bxt/credentials': '', - '/home/me/.bxt/config': dedent(` - [profile ecs] - role_arn=arn:aws:iam::12356789012:role/Assumable - credential_source = EcsContainer - `), + // THEN + expect(pluginQueried).toEqual(true); }); + }); - // Set environment variables that we want - process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); - process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); + describe('support for credential_source', () => { + test('can assume role with ecs credentials', async () => { + return withMocked(AWS.ECSCredentials.prototype, 'needsRefresh', async (needsRefresh) => { + // GIVEN + prepareCreds({ + config: { + 'profile ecs': { role_arn: 'arn:aws:iam::12356789012:role/Assumable', credential_source: 'EcsContainer', $account: '22222' }, + }, + }); + const provider = await providerFromProfile('ecs'); - // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - profile: 'ecs', - }); + // WHEN + await provider.defaultAccount(); - await provider.defaultAccount(); + // THEN + expect(needsRefresh).toHaveBeenCalled(); + }); - // THEN - // expect(account?.accountId).toEqual(`${uid}the_account_#`); - expect(needsRefresh).toHaveBeenCalled(); + }); - }); + test('can assume role with ec2 credentials', async () => { + return withMocked(AWS.EC2MetadataCredentials.prototype, 'needsRefresh', async (needsRefresh) => { + // GIVEN + prepareCreds({ + config: { + 'profile ecs': { role_arn: 'arn:aws:iam::12356789012:role/Assumable', credential_source: 'Ec2InstanceMetadata', $account: '22222' }, + }, + }); + const provider = await providerFromProfile('ecs'); -}); + // WHEN + await provider.defaultAccount(); -test('can assume role with ec2 credentials', async () => { + // THEN + expect(needsRefresh).toHaveBeenCalled(); - return withMocked(AWS.EC2MetadataCredentials.prototype, 'needsRefresh', async (needsRefresh) => { + }); - // GIVEN - bockfs({ - '/home/me/.bxt/credentials': '', - '/home/me/.bxt/config': dedent(` - [profile ecs] - role_arn=arn:aws:iam::12356789012:role/Assumable - credential_source = Ec2InstanceMetadata - `), }); - // Set environment variables that we want - process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); - process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); + test('can assume role with env credentials', async () => { + return withMocked(AWS.EnvironmentCredentials.prototype, 'needsRefresh', async (needsRefresh) => { + // GIVEN + prepareCreds({ + config: { + 'profile ecs': { role_arn: 'arn:aws:iam::12356789012:role/Assumable', credential_source: 'Environment', $account: '22222' }, + }, + }); + const provider = await providerFromProfile('ecs'); + + // WHEN + await provider.defaultAccount(); - // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - profile: 'ecs', + // THEN + expect(needsRefresh).toHaveBeenCalled(); + }); }); - await provider.defaultAccount(); + test('assume fails with unsupported credential_source', async () => { + // GIVEN + prepareCreds({ + config: { + 'profile ecs': { role_arn: 'arn:aws:iam::12356789012:role/Assumable', credential_source: 'unsupported', $account: '22222' }, + }, + }); + const provider = await providerFromProfile('ecs'); - // THEN - // expect(account?.accountId).toEqual(`${uid}the_account_#`); - expect(needsRefresh).toHaveBeenCalled(); + // WHEN + const account = await provider.defaultAccount(); + // THEN + expect(account?.accountId).toEqual(undefined); + }); }); -}); - -test('can assume role with env credentials', async () => { - - return withMocked(AWS.EnvironmentCredentials.prototype, 'needsRefresh', async (needsRefresh) => { - + test('defaultAccount returns undefined if STS call fails', async () => { // GIVEN - bockfs({ - '/home/me/.bxt/credentials': '', - '/home/me/.bxt/config': dedent(` - [profile ecs] - role_arn=arn:aws:iam::12356789012:role/Assumable - credential_source = Environment - `), - }); - - // Set environment variables that we want - process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); - process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); + process.env.AWS_ACCESS_KEY_ID = `${uid}akid`; + process.env.AWS_SECRET_ACCESS_KEY = 'sekrit'; // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - profile: 'ecs', - }); - - await provider.defaultAccount(); + const provider = await providerFromProfile(undefined); // THEN - // expect(account?.accountId).toEqual(`${uid}the_account_#`); - expect(needsRefresh).toHaveBeenCalled(); - + await expect(provider.defaultAccount()).resolves.toBe(undefined); }); - }); -test('assume fails with unsupported credential_source', async () => { - // GIVEN - bockfs({ - '/home/me/.bxt/credentials': '', - '/home/me/.bxt/config': dedent(` - [profile assumable] - role_arn=arn:aws:iam::12356789012:role/Assumable - credential_source = unsupported - `), +test('even when using a profile to assume another profile, STS calls goes through the proxy', async () => { + prepareCreds({ + credentials: { + assumer: { aws_access_key_id: 'assumer' }, + }, + config: { + 'default': { region: 'eu-bla-5' }, + 'profile assumable': { role_arn: 'arn:aws:iam::66666:role/Assumable', source_profile: 'assumer', $account: '66666' }, + 'profile assumer': { region: 'us-east-2' }, + }, }); - SDKMock.mock('STS', 'assumeRole', (_request: AWS.STS.AssumeRoleRequest, cb: AwsCallback) => { - return cb(null, { - Credentials: { - AccessKeyId: `${uid}access`, // Needs UID in here otherwise key will be cached - Expiration: new Date(Date.now() + 10000), - SecretAccessKey: 'b', - SessionToken: 'c', - }, - }); + // Messy mocking + let called = false; + jest.mock('proxy-agent', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + class FakeAgent extends require('https').Agent { + public addRequest(_: any, __: any) { + // FIXME: this error takes 6 seconds to be completely handled. It + // might be retries in the SDK somewhere, or something about the Node + // event loop. I've spent an hour trying to figure it out and I can't, + // and I gave up. We'll just have to live with this until someone gets + // inspired. + const error = new Error('ABORTED BY TEST'); + (error as any).code = 'RequestAbortedError'; + (error as any).retryable = false; + called = true; + throw error; + } + } + return FakeAgent; }); - // Set environment variables that we want - process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); - process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); - // WHEN const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile: 'assumable', + httpOptions: { + proxyAddress: 'http://DOESNTMATTER/', + }, }); - const account = await provider.defaultAccount(); + await provider.defaultAccount(); - // THEN - expect(account?.accountId).toEqual(undefined); + // THEN -- the fake proxy agent got called, we don't care about the result + expect(called).toEqual(true); }); -test('defaultAccount returns undefined if STS call fails', async () => { - // GIVEN - process.env.AWS_ACCESS_KEY_ID = `${uid}akid`; - process.env.AWS_SECRET_ACCESS_KEY = 'sekrit'; - getCallerIdentityError = new Error('Something is wrong here'); - - // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ - ...defaultCredOptions, - }); - - // THEN - await expect(provider.defaultAccount()).resolves.toBe(undefined); -}); - -test('plugins are still queried even if current credentials are expired', async () => { - // GIVEN - process.env.AWS_ACCESS_KEY_ID = `${uid}akid`; - process.env.AWS_SECRET_ACCESS_KEY = 'sekrit'; - getCallerIdentityError = new Error('Something is wrong here'); - - // WHEN - const provider = await SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions }); - await provider.forEnvironment({ ...defaultEnv, account: `${uid}plugin_account_#` }, Mode.ForReading); - - // THEN - expect(pluginQueried).toEqual(true); -}); +/** + * Use object hackery to get the credentials out of the SDK object + */ +function sdkConfig(sdk: ISDK): ConfigurationOptions { + return (sdk as any).config; +} /** - * Strip shared whitespace from the start of lines + * Fixture for SDK auth for this test suite + * + * Has knowledge of the cache buster, will write proper fake config files and + * register users and roles in FakeSts at the same time. */ -function dedent(x: string): string { - const lines = x.split('\n'); - while (lines.length > 0 && lines[0].trim() === '') { lines.shift(); } - while (lines.length > 0 && lines[lines.length - 1].trim() === '') { lines.pop(); } - - const wsRe = /^\s*/; - const lineParts: Array<[string, string]> = x.split('\n').map(s => { - const ws = wsRe.exec(s)![0]; - return [ws, s.substr(ws.length)]; +function prepareCreds(options: PrepareCredsOptions) { + function convertSections(sections?: Record) { + const ret = []; + for (const [profile, user] of Object.entries(sections ?? {})) { + ret.push(`[${profile}]`); + + if (isProfileRole(user)) { + ret.push(`role_arn=${user.role_arn}`); + if ('source_profile' in user) { + ret.push(`source_profile=${user.source_profile}`); + } + if ('credential_source' in user) { + ret.push(`credential_source=${user.credential_source}`); + } + if (user.mfa_serial) { + ret.push(`mfa_serial=${user.mfa_serial}`); + } + options.fakeSts?.registerRole(uniq(user.$account ?? '00000'), user.role_arn, { + ...user.$fakeStsOptions, + allowedAccounts: user.$fakeStsOptions?.allowedAccounts?.map(uniq), + }); + } else { + if (user.aws_access_key_id) { + ret.push(`aws_access_key_id=${uniq(user.aws_access_key_id)}`); + ret.push('aws_secret_access_key=secret'); + options.fakeSts?.registerUser(uniq(user.$account ?? '00000'), uniq(user.aws_access_key_id), user.$fakeStsOptions); + } + } + + if (user.region) { + ret.push(`region=${user.region}`); + } + } + return ret.join('\n'); + } + + bockfs({ + '/home/me/.bxt/credentials': convertSections(options.credentials), + '/home/me/.bxt/config': convertSections(options.config), }); - if (lineParts.length === 0) { return ''; } // Reduce won't work well in this case + // Set environment variables that we want + process.env.AWS_CONFIG_FILE = bockfs.path('/home/me/.bxt/config'); + process.env.AWS_SHARED_CREDENTIALS_FILE = bockfs.path('/home/me/.bxt/credentials'); +} - // Calculate common whitespace only for non-empty lines - const sharedWs = lineParts.reduce((commonWs: string, [ws, text]) => text !== '' ? commonPrefix(commonWs, ws) : commonWs, lineParts[0][0]); - return lines.map(s => s.substr(sharedWs.length)).join('\n'); +interface PrepareCredsOptions { + /** + * Write the aws/credentials file + */ + readonly credentials?: Record; + + /** + * Write the aws/config file + */ + readonly config?: Record; + + /** + * If given, add users to FakeSTS + */ + readonly fakeSts?: FakeSts; } -/** - * Use object hackery to get the credentials out of the SDK object - */ -function sdkConfig(sdk: ISDK): ConfigurationOptions { - return (sdk as any).config; +interface ProfileUser { + readonly aws_access_key_id?: string; + readonly $account?: string; + readonly region?: string; + readonly $fakeStsOptions?: RegisterUserOptions; } -function commonPrefix(a: string, b: string): string { - const N = Math.min(a.length, b.length); - for (let i = 0; i < N; i++) { - if (a[i] !== b[i]) { return a.substring(0, i); } - } - return a.substr(N); +type ProfileRole = { + readonly role_arn: string; + readonly mfa_serial?: string; + readonly $account: string; + readonly region?: string; + readonly $fakeStsOptions?: RegisterRoleOptions; +} & ({ readonly source_profile: string } | { readonly credential_source: string }); + +function isProfileRole(x: ProfileUser | ProfileRole): x is ProfileRole { + return 'role_arn' in x; } + +function providerFromProfile(profile: string | undefined) { + return SdkProvider.withAwsCliCompatibleDefaults({ ...defaultCredOptions, profile }); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 769da3dd542e8..b45567e77170e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2104,6 +2104,11 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" +app-root-path@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.2.1.tgz#d0df4a682ee408273583d43f6f79e9892624bc9a" + integrity sha512-91IFKeKk7FjfmezPKkwtaRvSpnUc4gDwPAjA1YZ9Gn0q0PPeW+vbeUsZuyDwjI7+QTHhcLen2v25fi/AmhvbJA== + append-transform@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab" @@ -2341,6 +2346,21 @@ aws-sdk-mock@^5.1.0: sinon "^9.0.1" traverse "^0.6.6" +aws-sdk@^2.596.0: + version "2.808.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.808.0.tgz#ab535c09f1ca607e41feaf37e61e96c2d87a0a23" + integrity sha512-RJpQ2PyQ8fM+PV9NeDlgA77D1B0wVNkqe/pxu9lZ8zqnYy3DvqYYHmK8gwA9nmTB0OLHFo8FAIKMB/5fvm0AfQ== + dependencies: + buffer "4.9.2" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + aws-sdk@^2.637.0, aws-sdk@^2.804.0: version "2.804.0" resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.804.0.tgz#ff7e6f91b86b4878ec69e3de895c10eb8203fc4b" @@ -3784,6 +3804,16 @@ dot-prop@^5.1.0: dependencies: is-obj "^2.0.0" +dotenv-json@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dotenv-json/-/dotenv-json-1.0.0.tgz#fc7f672aafea04bed33818733b9f94662332815c" + integrity sha512-jAssr+6r4nKhKRudQ0HOzMskOFFi9+ubXWwmrSGJFgTvpjyPXCXsCsYbjif6mXp7uxA7xY3/LGaiTQukZzSbOQ== + +dotenv@^8.0.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" + integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== + dotgitignore@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/dotgitignore/-/dotgitignore-2.1.0.tgz#a4b15a4e4ef3cf383598aaf1dfa4a04bcc089b7b" @@ -4007,6 +4037,11 @@ escodegen@^1.14.1, escodegen@^1.8.1: optionalDependencies: source-map "~0.6.1" +eslint-config-standard@^14.1.1: + version "14.1.1" + resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.1.1.tgz#830a8e44e7aef7de67464979ad06b406026c56ea" + integrity sha512-Z9B+VR+JIXRxz21udPTL9HpFMyoMUEeX1G251EQ6e05WD9aPVtVBn09XUmZ259wCMlCDmYDSZG62Hhm+ZTJcUg== + eslint-import-resolver-node@^0.3.4: version "0.3.4" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717" @@ -4034,6 +4069,14 @@ eslint-module-utils@^2.6.0: debug "^2.6.9" pkg-dir "^2.0.0" +eslint-plugin-es@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893" + integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ== + dependencies: + eslint-utils "^2.0.0" + regexpp "^3.0.0" + eslint-plugin-import@^2.22.1: version "2.22.1" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" @@ -4060,11 +4103,33 @@ eslint-plugin-jest@^24.1.3: dependencies: "@typescript-eslint/experimental-utils" "^4.0.1" +eslint-plugin-node@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d" + integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g== + dependencies: + eslint-plugin-es "^3.0.0" + eslint-utils "^2.0.0" + ignore "^5.1.1" + minimatch "^3.0.4" + resolve "^1.10.1" + semver "^6.1.0" + +eslint-plugin-promise@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" + integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== + eslint-plugin-rulesdir@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/eslint-plugin-rulesdir/-/eslint-plugin-rulesdir-0.1.0.tgz#ad144d7e98464fda82963eff3fab331aecb2bf08" integrity sha1-rRRNfphGT9qClj7/P6szGuyyvwg= +eslint-plugin-standard@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.1.0.tgz#0c3bf3a67e853f8bbbc580fb4945fbf16f41b7c5" + integrity sha512-ZL7+QRixjTR6/528YNGyDotyffm5OQst/sGxKDwGb9Uqs4In5Egi4+jbobhqJoyoCM6/7v/1A5fhQ7ScMtDjaQ== + eslint-scope@^5.0.0, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -5175,7 +5240,7 @@ ignore@^4.0.3, ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.4, ignore@^5.1.8, ignore@~5.1.8: +ignore@^5.1.1, ignore@^5.1.4, ignore@^5.1.8, ignore@~5.1.8: version "5.1.8" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== @@ -5361,7 +5426,7 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" -is-core-module@^2.0.0: +is-core-module@^2.0.0, is-core-module@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== @@ -6446,6 +6511,24 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +lambda-leak@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lambda-leak/-/lambda-leak-2.0.0.tgz#771985d3628487f6e885afae2b54510dcfb2cd7e" + integrity sha1-dxmF02KEh/boha+uK1RRDc+yzX4= + +lambda-tester@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/lambda-tester/-/lambda-tester-3.6.0.tgz#ceb7d4f4f0da768487a05cff37dcd088508b5247" + integrity sha512-F2ZTGWCLyIR95o/jWK46V/WnOCFAEUG/m/V7/CLhPJ7PCM+pror1rZ6ujP3TkItSGxUfpJi0kqwidw+M/nEqWw== + dependencies: + app-root-path "^2.2.1" + dotenv "^8.0.0" + dotenv-json "^1.0.0" + lambda-leak "^2.0.0" + semver "^6.1.1" + uuid "^3.3.2" + vandium-utils "^1.1.1" + lazystream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" @@ -8621,6 +8704,14 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.13.1, resolve@^1.17 dependencies: path-parse "^1.0.6" +resolve@^1.10.1: + version "1.19.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c" + integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg== + dependencies: + is-core-module "^2.1.0" + path-parse "^1.0.6" + resolve@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.18.1.tgz#018fcb2c5b207d2a6424aee361c5a266da8f4130" @@ -8749,7 +8840,7 @@ sax@1.2.1: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= -sax@>=0.6.0: +sax@>=0.6.0, sax@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -8778,7 +8869,7 @@ semver@7.x, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== -semver@^6.0.0, semver@^6.2.0, semver@^6.3.0: +semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.2.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -10149,6 +10240,11 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" +vandium-utils@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vandium-utils/-/vandium-utils-1.2.0.tgz#44735de4b7641a05de59ebe945f174e582db4f59" + integrity sha1-RHNd5LdkGgXeWevpRfF05YLbT1k= + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" @@ -10402,6 +10498,13 @@ ws@^7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.0.tgz#a5dd76a24197940d4a8bb9e0e152bb4503764da7" integrity sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ== +xml-js@^1.6.11: + version "1.6.11" + resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" + integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== + dependencies: + sax "^1.2.4" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"