packages/cdk-assets/lib/aws.ts (217 lines of code) (raw):
import * as os from 'os';
import {
DescribeImagesCommand,
DescribeRepositoriesCommand,
ECRClient,
GetAuthorizationTokenCommand,
} from '@aws-sdk/client-ecr';
import {
GetBucketEncryptionCommand,
GetBucketLocationCommand,
ListObjectsV2Command,
S3Client,
} from '@aws-sdk/client-s3';
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
import type {
AssumeRoleCommandInput,
STSClientConfig,
} from '@aws-sdk/client-sts';
import {
GetCallerIdentityCommand,
STSClient,
} from '@aws-sdk/client-sts';
import { fromNodeProviderChain, fromTemporaryCredentials } from '@aws-sdk/credential-providers';
import { Upload } from '@aws-sdk/lib-storage';
import {
NODE_REGION_CONFIG_FILE_OPTIONS,
NODE_REGION_CONFIG_OPTIONS,
} from '@smithy/config-resolver';
import { loadConfig } from '@smithy/node-config-provider';
import type {
AwsCredentialIdentityProvider,
CompleteMultipartUploadCommandOutput,
DescribeImagesCommandInput,
DescribeImagesCommandOutput,
DescribeRepositoriesCommandInput,
DescribeRepositoriesCommandOutput,
GetAuthorizationTokenCommandInput,
GetAuthorizationTokenCommandOutput,
GetBucketEncryptionCommandInput,
GetBucketEncryptionCommandOutput,
GetBucketLocationCommandInput,
GetBucketLocationCommandOutput,
GetSecretValueCommandInput,
GetSecretValueCommandOutput,
ListObjectsV2CommandInput,
ListObjectsV2CommandOutput,
PutObjectCommandInput,
} from './aws-types';
export type AssumeRoleAdditionalOptions = Partial<
Omit<AssumeRoleCommandInput, 'ExternalId' | 'RoleArn'>
>;
export interface IS3Client {
getBucketEncryption(
input: GetBucketEncryptionCommandInput
): Promise<GetBucketEncryptionCommandOutput>;
getBucketLocation(input: GetBucketLocationCommandInput): Promise<GetBucketLocationCommandOutput>;
listObjectsV2(input: ListObjectsV2CommandInput): Promise<ListObjectsV2CommandOutput>;
upload(input: PutObjectCommandInput): Promise<CompleteMultipartUploadCommandOutput>;
}
export interface IECRClient {
describeImages(input: DescribeImagesCommandInput): Promise<DescribeImagesCommandOutput>;
describeRepositories(
input: DescribeRepositoriesCommandInput
): Promise<DescribeRepositoriesCommandOutput>;
getAuthorizationToken(
input?: GetAuthorizationTokenCommandInput
): Promise<GetAuthorizationTokenCommandOutput>;
}
export interface ISecretsManagerClient {
getSecretValue(input: GetSecretValueCommandInput): Promise<GetSecretValueCommandOutput>;
}
/**
* AWS SDK operations required by Asset Publishing
*/
export interface IAws {
discoverPartition(): Promise<string>;
discoverDefaultRegion(): Promise<string>;
discoverCurrentAccount(): Promise<Account>;
discoverTargetAccount(options: ClientOptions): Promise<Account>;
s3Client(options: ClientOptions): Promise<IS3Client>;
ecrClient(options: ClientOptions): Promise<IECRClient>;
secretsManagerClient(options: ClientOptions): Promise<ISecretsManagerClient>;
}
export interface ClientOptions {
region?: string;
assumeRoleArn?: string;
assumeRoleExternalId?: string;
assumeRoleAdditionalOptions?: AssumeRoleAdditionalOptions;
quiet?: boolean;
}
const USER_AGENT = 'cdk-assets';
interface Configuration {
clientConfig: STSClientConfig;
region?: string;
credentials: AwsCredentialIdentityProvider;
}
/**
* 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;
}
/**
* AWS client using the AWS SDK for JS with no special configuration
*/
export class DefaultAwsClient implements IAws {
private account?: Account;
private config: Configuration;
private readonly mainCredentials: AwsCredentialIdentityProvider;
constructor(private readonly profile?: string) {
const clientConfig: STSClientConfig = {
customUserAgent: USER_AGENT,
};
// storing the main credentials separately because
// the `config` object changes every time we assume the file publishing role.
// TODO refactor to make `config` a readonly property and avoid state mutations.
this.mainCredentials = fromNodeProviderChain({
profile: this.profile,
clientConfig,
});
this.config = {
clientConfig,
credentials: this.mainCredentials,
};
}
public async s3Client(options: ClientOptions): Promise<IS3Client> {
const client = new S3Client(await this.awsOptions(options));
return {
getBucketEncryption: (
input: GetBucketEncryptionCommandInput,
): Promise<GetBucketEncryptionCommandOutput> =>
client.send(new GetBucketEncryptionCommand(input)),
getBucketLocation: (
input: GetBucketLocationCommandInput,
): Promise<GetBucketLocationCommandOutput> =>
client.send(new GetBucketLocationCommand(input)),
listObjectsV2: (input: ListObjectsV2CommandInput): Promise<ListObjectsV2CommandOutput> =>
client.send(new ListObjectsV2Command(input)),
upload: (input: PutObjectCommandInput): Promise<CompleteMultipartUploadCommandOutput> => {
const upload = new Upload({
client,
params: input,
});
return upload.done();
},
};
}
public async ecrClient(options: ClientOptions): Promise<IECRClient> {
const client = new ECRClient(await this.awsOptions(options));
return {
describeImages: (input: DescribeImagesCommandInput): Promise<DescribeImagesCommandOutput> =>
client.send(new DescribeImagesCommand(input)),
describeRepositories: (
input: DescribeRepositoriesCommandInput,
): Promise<DescribeRepositoriesCommandOutput> =>
client.send(new DescribeRepositoriesCommand(input)),
getAuthorizationToken: (
input: GetAuthorizationTokenCommandInput,
): Promise<GetAuthorizationTokenCommandOutput> =>
client.send(new GetAuthorizationTokenCommand(input ?? {})),
};
}
public async secretsManagerClient(options: ClientOptions): Promise<ISecretsManagerClient> {
const client = new SecretsManagerClient(await this.awsOptions(options));
return {
getSecretValue: (input: GetSecretValueCommandInput): Promise<GetSecretValueCommandOutput> =>
client.send(new GetSecretValueCommand(input)),
};
}
public async discoverPartition(): Promise<string> {
return (await this.discoverCurrentAccount()).partition;
}
public async discoverDefaultRegion(): Promise<string> {
return loadConfig(NODE_REGION_CONFIG_OPTIONS, NODE_REGION_CONFIG_FILE_OPTIONS)() || 'us-east-1';
}
public async discoverCurrentAccount(): Promise<Account> {
if (this.account === undefined) {
this.account = await this.getAccount();
}
return this.account;
}
public async discoverTargetAccount(options: ClientOptions): Promise<Account> {
return this.getAccount(await this.awsOptions(options));
}
private async getAccount(options?: ClientOptions): Promise<Account> {
this.config.clientConfig = options ?? this.config.clientConfig;
const stsClient = new STSClient(await this.awsOptions(options));
const command = new GetCallerIdentityCommand();
const response = await stsClient.send(command);
if (!response.Account || !response.Arn) {
throw new Error(`Unrecognized response from STS: '${JSON.stringify(response)}'`);
}
return {
accountId: response.Account!,
partition: response.Arn!.split(':')[1],
};
}
private async awsOptions(options?: ClientOptions) {
const config = this.config;
config.region = options?.region;
if (options) {
config.region = options.region;
if (options.assumeRoleArn) {
config.credentials = fromTemporaryCredentials({
// dont forget the credentials chain.
masterCredentials: this.mainCredentials,
params: {
RoleArn: options.assumeRoleArn,
ExternalId: options.assumeRoleExternalId,
RoleSessionName: `${USER_AGENT}-${safeUsername()}`,
TransitiveTagKeys: options.assumeRoleAdditionalOptions?.Tags
? options.assumeRoleAdditionalOptions.Tags.map((t) => t.Key!)
: undefined,
...options.assumeRoleAdditionalOptions,
},
clientConfig: this.config.clientConfig,
});
}
}
return config;
}
}
/**
* 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';
}
}