packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk-provider.ts (291 lines of code) (raw):
import * as os from 'os';
import type { ContextLookupRoleOptions } from '@aws-cdk/cloud-assembly-schema';
import type { Environment } from '@aws-cdk/cx-api';
import { EnvironmentUtils, UNKNOWN_ACCOUNT, UNKNOWN_REGION } from '@aws-cdk/cx-api';
import type { AssumeRoleCommandInput } from '@aws-sdk/client-sts';
import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';
import type { NodeHttpHandlerOptions } from '@smithy/node-http-handler';
import type { AwsCredentialIdentityProvider, Logger } from '@smithy/types';
import { AwsCliCompatible } from './awscli-compatible';
import { cached } from './cached';
import { CredentialPlugins } from './credential-plugins';
import { makeCachingProvider } from './provider-caching';
import { SDK } from './sdk';
import { callTrace, traceMemberMethods } from './tracing';
import { formatErrorMessage } from '../../util';
import { IO, type IoHelper } from '../io/private';
import { PluginHost, Mode } from '../plugin';
import { AuthenticationError } from '../toolkit-error';
export type AssumeRoleAdditionalOptions = Partial<Omit<AssumeRoleCommandInput, 'ExternalId' | 'RoleArn'>>;
/**
* Options for the default SDK provider
*/
export interface SdkProviderOptions extends SdkProviderServices {
/**
* Profile to read from ~/.aws
*
* @default - No profile
*/
readonly profile?: string;
}
const CACHED_ACCOUNT = Symbol('cached_account');
/**
* SDK configuration for a given environment
* 'forEnvironment' will attempt to assume a role and if it
* is not successful, then it will either:
* 1. Check to see if the default credentials (local credentials the CLI was executed with)
* are for the given environment. If they are then return those.
* 2. If the default credentials are not for the given environment then
* throw an error
*
* 'didAssumeRole' allows callers to whether they are receiving the assume role
* credentials or the default credentials.
*/
export interface SdkForEnvironment {
/**
* The SDK for the given environment
*/
readonly sdk: SDK;
/**
* Whether or not the assume role was successful.
* If the assume role was not successful (false)
* then that means that the 'sdk' returned contains
* the default credentials (not the assume role credentials)
*/
readonly didAssumeRole: boolean;
}
/**
* Creates instances of the AWS SDK appropriate for a given account/region.
*
* Behavior is as follows:
*
* - 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.
*/
@traceMemberMethods
export class SdkProvider {
/**
* Create a new SdkProvider which gets its defaults in a way that behaves like the AWS CLI does
*
* The AWS SDK for JS behaves slightly differently from the AWS CLI in a number of ways; see the
* class `AwsCliCompatible` for the details.
*/
public static async withAwsCliCompatibleDefaults(options: SdkProviderOptions) {
callTrace(SdkProvider.withAwsCliCompatibleDefaults.name, SdkProvider.constructor.name, options.logger);
const config = await new AwsCliCompatible(options.ioHelper, options.requestHandler ?? {}, options.logger).baseConfig(options.profile);
return new SdkProvider(config.credentialProvider, config.defaultRegion, options);
}
public readonly defaultRegion: string;
private readonly defaultCredentialProvider: AwsCredentialIdentityProvider;
private readonly plugins;
private readonly requestHandler: NodeHttpHandlerOptions;
private readonly ioHelper: IoHelper;
private readonly logger?: Logger;
public constructor(
defaultCredentialProvider: AwsCredentialIdentityProvider,
defaultRegion: string | undefined,
services: SdkProviderServices,
) {
this.defaultCredentialProvider = defaultCredentialProvider;
this.defaultRegion = defaultRegion ?? 'us-east-1';
this.requestHandler = services.requestHandler ?? {};
this.ioHelper = services.ioHelper;
this.logger = services.logger;
this.plugins = new CredentialPlugins(services.pluginHost ?? new PluginHost(), this.ioHelper);
}
/**
* Return an SDK which can do operations in the given environment
*
* The `environment` parameter is resolved first (see `resolveEnvironment()`).
*/
public async forEnvironment(
environment: Environment,
mode: Mode,
options?: CredentialsOptions,
quiet = false,
): Promise<SdkForEnvironment> {
const env = await this.resolveEnvironment(environment);
const baseCreds = await this.obtainBaseCredentials(env.account, mode);
// At this point, we need at least SOME credentials
if (baseCreds.source === 'none') {
throw new AuthenticationError(fmtObtainCredentialsError(env.account, baseCreds));
}
// 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 AuthenticationError(fmtObtainCredentialsError(env.account, baseCreds));
}
// Our current credentials must be valid and not expired. Confirm that before we get into doing
// actual CloudFormation calls, which might take a long time to hang.
const sdk = this._makeSdk(baseCreds.credentials, env.region);
await sdk.validateCredentials();
return { sdk, didAssumeRole: false };
}
try {
// We will proceed to AssumeRole using whatever we've been given.
const sdk = await this.withAssumedRole(
baseCreds,
options.assumeRoleArn,
options.assumeRoleExternalId,
options.assumeRoleAdditionalOptions,
env.region,
);
return { sdk, didAssumeRole: true };
} catch (err: any) {
if (err.name === 'ExpiredToken') {
throw err;
}
// 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') {
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(err.message));
const maker = quiet ? IO.DEFAULT_SDK_DEBUG : IO.DEFAULT_SDK_WARN;
await this.ioHelper.notify(maker.msg(
`${fmtObtainedCredentials(baseCreds)} could not be used to assume '${options.assumeRoleArn}', but are for the right account. Proceeding anyway.`,
));
return {
sdk: this._makeSdk(baseCreds.credentials, env.region),
didAssumeRole: false,
};
}
throw err;
}
}
/**
* Return the partition that base credentials are for
*
* Returns `undefined` if there are no base credentials.
*/
public async baseCredentialsPartition(environment: Environment, mode: Mode): Promise<string | undefined> {
const env = await this.resolveEnvironment(environment);
const baseCreds = await this.obtainBaseCredentials(env.account, mode);
if (baseCreds.source === 'none') {
return undefined;
}
return (await this._makeSdk(baseCreds.credentials, env.region).currentAccount()).partition;
}
/**
* Resolve the environment for a stack
*
* Replaces the magic values `UNKNOWN_REGION` and `UNKNOWN_ACCOUNT`
* with the defaults for the current SDK configuration (`~/.aws/config` or
* otherwise).
*
* It is an error if `UNKNOWN_ACCOUNT` is used but the user hasn't configured
* any SDK credentials.
*/
public async resolveEnvironment(env: Environment): Promise<Environment> {
const region = env.region !== UNKNOWN_REGION ? env.region : this.defaultRegion;
const account = env.account !== UNKNOWN_ACCOUNT ? env.account : (await this.defaultAccount())?.accountId;
if (!account) {
throw new AuthenticationError(
'Unable to resolve AWS account to use. It must be either configured when you define your CDK Stack, or through the environment',
);
}
return {
region,
account,
name: EnvironmentUtils.format(account, region),
};
}
/**
* The account we'd auth into if we used default credentials.
*
* Default credentials are the set of ambiently configured credentials using
* one of the environment variables, or ~/.aws/credentials, or the *one*
* profile that was passed into the CLI.
*
* Might return undefined if there are no default/ambient credentials
* available (in which case the user should better hope they have
* credential plugins configured).
*
* Uses a cache to avoid STS calls if we don't need 'em.
*/
public async defaultAccount(): Promise<Account | undefined> {
return cached(this, CACHED_ACCOUNT, async () => {
try {
return await this._makeSdk(this.defaultCredentialProvider, this.defaultRegion).currentAccount();
} catch (e: any) {
// Treat 'ExpiredToken' specially. This is a common situation that people may find themselves in, and
// they are complaining about if we fail 'cdk synth' on them. We loudly complain in order to show that
// the current situation is probably undesirable, but we don't fail.
if (e.name === 'ExpiredToken') {
await this.ioHelper.notify(IO.DEFAULT_SDK_WARN.msg(
'There are expired AWS credentials in your environment. The CDK app will synth without current account information.',
));
return undefined;
}
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(`Unable to determine the default AWS account (${e.name}): ${formatErrorMessage(e)}`));
return undefined;
}
});
}
/**
* Get credentials for the given account ID in the given mode
*
* 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
*/
private async obtainBaseCredentials(accountId: string, mode: Mode): Promise<ObtainBaseCredentialsResult> {
// First try 'current' credentials
const defaultAccountId = (await this.defaultAccount())?.accountId;
if (defaultAccountId === accountId) {
return {
source: 'correctDefault',
credentials: await this.defaultCredentialProvider,
};
}
// Then try the plugins
const pluginCreds = await this.plugins.fetchCredentialsFor(accountId, mode);
if (pluginCreds) {
return { source: 'plugin', ...pluginCreds };
}
// 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.defaultCredentialProvider,
unusedPlugins: this.plugins.availablePluginNames,
};
}
// Apparently we didn't find any at all
return {
source: 'none',
unusedPlugins: this.plugins.availablePluginNames,
};
}
/**
* 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(
mainCredentials: Exclude<ObtainBaseCredentialsResult, { source: 'none' }>,
roleArn: string,
externalId?: string,
additionalOptions?: AssumeRoleAdditionalOptions,
region?: string,
): Promise<SDK> {
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(`Assuming role '${roleArn}'.`));
region = region ?? this.defaultRegion;
const sourceDescription = fmtObtainedCredentials(mainCredentials);
try {
const credentials = await makeCachingProvider(fromTemporaryCredentials({
masterCredentials: mainCredentials.credentials,
params: {
RoleArn: roleArn,
ExternalId: externalId,
RoleSessionName: `aws-cdk-${safeUsername()}`,
...additionalOptions,
TransitiveTagKeys: additionalOptions?.Tags ? additionalOptions.Tags.map((t) => t.Key!) : undefined,
},
clientConfig: {
region,
requestHandler: this.requestHandler,
customUserAgent: 'aws-cdk',
logger: this.logger,
},
logger: this.logger,
}));
// Call the provider at least once here, to catch an error if it occurs
await credentials();
return this._makeSdk(credentials, region);
} catch (err: any) {
if (err.name === 'ExpiredToken') {
throw err;
}
await this.ioHelper.notify(IO.DEFAULT_SDK_DEBUG.msg(`Assuming role failed: ${err.message}`));
throw new AuthenticationError(
[
'Could not assume role in target account',
...(sourceDescription ? [`using ${sourceDescription}`] : []),
err.message,
". Please make sure that this role exists in the account. If it doesn't exist, (re)-bootstrap the environment " +
"with the right '--trust', using the latest version of the CDK CLI.",
].join(' '),
);
}
}
/**
* Factory function that creates a new SDK instance
*
* This is a function here, instead of all the places where this is used creating a `new SDK`
* instance, so that it is trivial to mock from tests.
*
* Use like this:
*
* ```ts
* const mockSdk = jest.spyOn(SdkProvider.prototype, '_makeSdk').mockReturnValue(new MockSdk());
* // ...
* mockSdk.mockRestore();
* ```
*
* @internal
*/
public _makeSdk(
credProvider: AwsCredentialIdentityProvider,
region: string,
) {
return new SDK(credProvider, region, this.requestHandler, this.ioHelper, this.logger);
}
}
/**
* An AWS account
*
* An AWS account always exists in only one partition. Usually we don't care about
* the partition, but when we need to form ARNs we do.
*/
export interface Account {
/**
* The account number
*/
readonly accountId: string;
/**
* The partition ('aws' or 'aws-cn' or otherwise)
*/
readonly partition: string;
}
/**
* Return the username with characters invalid for a RoleSessionName removed
*
* @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html#API_AssumeRole_RequestParameters
*/
function safeUsername() {
try {
return os.userInfo().username.replace(/[^\w+=,.@-]/g, '@');
} catch {
return 'noname';
}
}
/**
* 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;
/**
* Session tags required to assume the given role.
*/
readonly assumeRoleAdditionalOptions?: AssumeRoleAdditionalOptions;
}
/**
* Result of obtaining base credentials
*/
type ObtainBaseCredentialsResult =
| { source: 'correctDefault'; credentials: AwsCredentialIdentityProvider }
| { source: 'plugin'; pluginName: string; credentials: AwsCredentialIdentityProvider }
| {
source: 'incorrectDefault';
credentials: AwsCredentialIdentityProvider;
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<ObtainBaseCredentialsResult, { source: 'none' }>): 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('');
}
}
/**
* Instantiate an SDK for context providers. This function ensures that all
* lookup assume role options are used when context providers perform lookups.
*/
export async function initContextProviderSdk(aws: SdkProvider, options: ContextLookupRoleOptions): Promise<SDK> {
const account = options.account;
const region = options.region;
const creds: CredentialsOptions = {
assumeRoleArn: options.lookupRoleArn,
assumeRoleExternalId: options.lookupRoleExternalId,
assumeRoleAdditionalOptions: options.assumeRoleAdditionalOptions,
};
return (await aws.forEnvironment(EnvironmentUtils.make(account, region), Mode.ForReading, creds)).sdk;
}
export interface SdkProviderServices {
/**
* An IO helper for emitting messages
*/
readonly ioHelper: IoHelper;
/**
* The request handler settings
*/
readonly requestHandler?: NodeHttpHandlerOptions;
/**
* A plugin host
*/
readonly pluginHost?: PluginHost;
/**
* An SDK logger
*/
readonly logger?: Logger;
}