packages/@aws-cdk/toolkit-lib/lib/api/bootstrap/bootstrap-environment.ts (310 lines of code) (raw):
import * as path from 'path';
import type * as cxapi from '@aws-cdk/cx-api';
import type { BootstrapEnvironmentOptions, BootstrappingParameters } from './bootstrap-props';
import { BootstrapStack, bootstrapVersionFromTemplate } from './deploy-bootstrap';
import { legacyBootstrapTemplate } from './legacy-template';
import { bundledPackageRootDir, loadStructuredFile, serializeStructure } from '../../util';
import type { SDK, SdkProvider } from '../aws-auth/private';
import type { SuccessfulDeployStackResult } from '../deployments';
import { IO, type IoHelper } from '../io/private';
import { Mode } from '../plugin';
import { ToolkitError } from '../toolkit-error';
import { DEFAULT_TOOLKIT_STACK_NAME } from '../toolkit-info';
export type BootstrapSource = { source: 'legacy' } | { source: 'default' } | { source: 'custom'; templateFile: string };
export class Bootstrapper {
private readonly ioHelper: IoHelper;
constructor(
private readonly source: BootstrapSource = { source: 'default' },
ioHelper: IoHelper,
) {
this.ioHelper = ioHelper;
}
public bootstrapEnvironment(
environment: cxapi.Environment,
sdkProvider: SdkProvider,
options: BootstrapEnvironmentOptions = {},
): Promise<SuccessfulDeployStackResult> {
switch (this.source.source) {
case 'legacy':
return this.legacyBootstrap(environment, sdkProvider, options);
case 'default':
return this.modernBootstrap(environment, sdkProvider, options);
case 'custom':
return this.customBootstrap(environment, sdkProvider, options);
}
}
public async showTemplate(json: boolean) {
const template = await this.loadTemplate();
process.stdout.write(`${serializeStructure(template, json)}\n`);
}
/**
* Deploy legacy bootstrap stack
*
*/
private async legacyBootstrap(
environment: cxapi.Environment,
sdkProvider: SdkProvider,
options: BootstrapEnvironmentOptions = {},
): Promise<SuccessfulDeployStackResult> {
const params = options.parameters ?? {};
if (params.trustedAccounts?.length) {
throw new ToolkitError('--trust can only be passed for the modern bootstrap experience.');
}
if (params.cloudFormationExecutionPolicies?.length) {
throw new ToolkitError('--cloudformation-execution-policies can only be passed for the modern bootstrap experience.');
}
if (params.createCustomerMasterKey !== undefined) {
throw new ToolkitError('--bootstrap-customer-key can only be passed for the modern bootstrap experience.');
}
if (params.qualifier) {
throw new ToolkitError('--qualifier can only be passed for the modern bootstrap experience.');
}
const toolkitStackName = options.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
const current = await BootstrapStack.lookup(sdkProvider, environment, toolkitStackName, this.ioHelper);
return current.update(
await this.loadTemplate(params),
{},
{
...options,
terminationProtection: options.terminationProtection ?? current.terminationProtection,
},
);
}
/**
* Deploy CI/CD-ready bootstrap stack from template
*
*/
private async modernBootstrap(
environment: cxapi.Environment,
sdkProvider: SdkProvider,
options: BootstrapEnvironmentOptions = {},
): Promise<SuccessfulDeployStackResult> {
const params = options.parameters ?? {};
const bootstrapTemplate = await this.loadTemplate();
const toolkitStackName = options.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME;
const current = await BootstrapStack.lookup(sdkProvider, environment, toolkitStackName, this.ioHelper);
const partition = await current.partition();
if (params.createCustomerMasterKey !== undefined && params.kmsKeyId) {
throw new ToolkitError(
"You cannot pass '--bootstrap-kms-key-id' and '--bootstrap-customer-key' together. Specify one or the other",
);
}
// If people re-bootstrap, existing parameter values are reused so that people don't accidentally change the configuration
// on their bootstrap stack (this happens automatically in deployStack). However, to do proper validation on the
// combined arguments (such that if --trust has been given, --cloudformation-execution-policies is necessary as well)
// we need to take this parameter reuse into account.
//
// Ideally we'd do this inside the template, but the `Rules` section of CFN
// templates doesn't seem to be able to express the conditions that we need
// (can't use Fn::Join or reference Conditions) so we do it here instead.
const allTrusted = new Set([
...params.trustedAccounts ?? [],
...params.trustedAccountsForLookup ?? [],
]);
const invalid = intersection(allTrusted, new Set(params.untrustedAccounts));
if (invalid.size > 0) {
throw new ToolkitError(`Accounts cannot be both trusted and untrusted. Found: ${[...invalid].join(',')}`);
}
const removeUntrusted = (accounts: string[]) =>
accounts.filter(acc => !params.untrustedAccounts?.map(String).includes(String(acc)));
const trustedAccounts = removeUntrusted(params.trustedAccounts ?? splitCfnArray(current.parameters.TrustedAccounts));
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(
`Trusted accounts for deployment: ${trustedAccounts.length > 0 ? trustedAccounts.join(', ') : '(none)'}`,
));
const trustedAccountsForLookup = removeUntrusted(
params.trustedAccountsForLookup ?? splitCfnArray(current.parameters.TrustedAccountsForLookup),
);
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(
`Trusted accounts for lookup: ${trustedAccountsForLookup.length > 0 ? trustedAccountsForLookup.join(', ') : '(none)'}`,
));
const cloudFormationExecutionPolicies =
params.cloudFormationExecutionPolicies ?? splitCfnArray(current.parameters.CloudFormationExecutionPolicies);
if (trustedAccounts.length === 0 && cloudFormationExecutionPolicies.length === 0) {
// For self-trust it's okay to default to AdministratorAccess, and it improves the usability of bootstrapping a lot.
//
// We don't actually make the implicitly policy a physical parameter. The template will infer it instead,
// we simply do the UI advertising that behavior here.
//
// If we DID make it an explicit parameter, we wouldn't be able to tell the difference between whether
// we inferred it or whether the user told us, and the sequence:
//
// $ cdk bootstrap
// $ cdk bootstrap --trust 1234
//
// Would leave AdministratorAccess policies with a trust relationship, without the user explicitly
// approving the trust policy.
const implicitPolicy = `arn:${partition}:iam::aws:policy/AdministratorAccess`;
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(
`Using default execution policy of '${implicitPolicy}'. Pass '--cloudformation-execution-policies' to customize.`,
));
} else if (cloudFormationExecutionPolicies.length === 0) {
throw new ToolkitError(
`Please pass \'--cloudformation-execution-policies\' when using \'--trust\' to specify deployment permissions. Try a managed policy of the form \'arn:${partition}:iam::aws:policy/<PolicyName>\'.`,
);
} else {
// Remind people what the current settings are
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg(`Execution policies: ${cloudFormationExecutionPolicies.join(', ')}`));
}
// * If an ARN is given, that ARN. Otherwise:
// * '-' if customerKey = false
// * '' if customerKey = true
// * if customerKey is also not given
// * undefined if we already had a value in place (reusing what we had)
// * '-' if this is the first time we're deploying this stack (or upgrading from old to new bootstrap)
const currentKmsKeyId = current.parameters.FileAssetsBucketKmsKeyId;
const kmsKeyId =
params.kmsKeyId ??
(params.createCustomerMasterKey === true
? CREATE_NEW_KEY
: params.createCustomerMasterKey === false || currentKmsKeyId === undefined
? USE_AWS_MANAGED_KEY
: undefined);
/* A permissions boundary can be provided via:
* - the flag indicating the example one should be used
* - the name indicating the custom permissions boundary to be used
* Re-bootstrapping will NOT be blocked by either tightening or relaxing the permissions' boundary.
*/
// InputPermissionsBoundary is an `any` type and if it is not defined it
// appears as an empty string ''. We need to force it to evaluate an empty string
// as undefined
const currentPermissionsBoundary: string | undefined = current.parameters.InputPermissionsBoundary || undefined;
const inputPolicyName = params.examplePermissionsBoundary
? CDK_BOOTSTRAP_PERMISSIONS_BOUNDARY
: params.customPermissionsBoundary;
let policyName: string | undefined;
if (inputPolicyName) {
// If the example policy is not already in place, it must be created.
const sdk = (await sdkProvider.forEnvironment(environment, Mode.ForWriting)).sdk;
policyName = await this.getPolicyName(environment, sdk, inputPolicyName, partition, params);
}
if (currentPermissionsBoundary !== policyName) {
if (!currentPermissionsBoundary) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(
`Adding new permissions boundary ${policyName}`,
));
} else if (!policyName) {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(
`Removing existing permissions boundary ${currentPermissionsBoundary}`,
));
} else {
await this.ioHelper.notify(IO.DEFAULT_TOOLKIT_WARN.msg(
`Changing permissions boundary from ${currentPermissionsBoundary} to ${policyName}`,
));
}
}
return current.update(
bootstrapTemplate,
{
FileAssetsBucketName: params.bucketName,
FileAssetsBucketKmsKeyId: kmsKeyId,
// Empty array becomes empty string
TrustedAccounts: trustedAccounts.join(','),
TrustedAccountsForLookup: trustedAccountsForLookup.join(','),
CloudFormationExecutionPolicies: cloudFormationExecutionPolicies.join(','),
Qualifier: params.qualifier,
PublicAccessBlockConfiguration:
params.publicAccessBlockConfiguration || params.publicAccessBlockConfiguration === undefined
? 'true'
: 'false',
InputPermissionsBoundary: policyName,
},
{
...options,
terminationProtection: options.terminationProtection ?? current.terminationProtection,
},
);
}
private async getPolicyName(
environment: cxapi.Environment,
sdk: SDK,
permissionsBoundary: string,
partition: string,
params: BootstrappingParameters,
): Promise<string> {
if (permissionsBoundary !== CDK_BOOTSTRAP_PERMISSIONS_BOUNDARY) {
this.validatePolicyName(permissionsBoundary);
return Promise.resolve(permissionsBoundary);
}
// if no Qualifier is supplied, resort to the default one
const arn = await this.getExamplePermissionsBoundary(
params.qualifier ?? 'hnb659fds',
partition,
environment.account,
sdk,
);
const policyName = arn.split('/').pop();
if (!policyName) {
throw new ToolkitError('Could not retrieve the example permission boundary!');
}
return Promise.resolve(policyName);
}
private async getExamplePermissionsBoundary(
qualifier: string,
partition: string,
account: string,
sdk: SDK,
): Promise<string> {
const iam = sdk.iam();
let policyName = `cdk-${qualifier}-permissions-boundary`;
const arn = `arn:${partition}:iam::${account}:policy/${policyName}`;
try {
let getPolicyResp = await iam.getPolicy({ PolicyArn: arn });
if (getPolicyResp.Policy) {
return arn;
}
} catch (e: any) {
// https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetPolicy.html#API_GetPolicy_Errors
if (e.name === 'NoSuchEntity') {
// noop, proceed with creating the policy
} else {
throw e;
}
}
const policyDoc = {
Version: '2012-10-17',
Statement: [
{
Action: ['*'],
Resource: '*',
Effect: 'Allow',
Sid: 'ExplicitAllowAll',
},
{
Condition: {
StringEquals: {
'iam:PermissionsBoundary': `arn:${partition}:iam::${account}:policy/cdk-${qualifier}-permissions-boundary`,
},
},
Action: [
'iam:CreateUser',
'iam:CreateRole',
'iam:PutRolePermissionsBoundary',
'iam:PutUserPermissionsBoundary',
],
Resource: '*',
Effect: 'Allow',
Sid: 'DenyAccessIfRequiredPermBoundaryIsNotBeingApplied',
},
{
Action: [
'iam:CreatePolicyVersion',
'iam:DeletePolicy',
'iam:DeletePolicyVersion',
'iam:SetDefaultPolicyVersion',
],
Resource: `arn:${partition}:iam::${account}:policy/cdk-${qualifier}-permissions-boundary`,
Effect: 'Deny',
Sid: 'DenyPermBoundaryIAMPolicyAlteration',
},
{
Action: ['iam:DeleteUserPermissionsBoundary', 'iam:DeleteRolePermissionsBoundary'],
Resource: '*',
Effect: 'Deny',
Sid: 'DenyRemovalOfPermBoundaryFromAnyUserOrRole',
},
],
};
const request = {
PolicyName: policyName,
PolicyDocument: JSON.stringify(policyDoc),
};
const createPolicyResponse = await iam.createPolicy(request);
if (createPolicyResponse.Policy?.Arn) {
return createPolicyResponse.Policy.Arn;
} else {
throw new ToolkitError(`Could not retrieve the example permission boundary ${arn}!`);
}
}
private validatePolicyName(permissionsBoundary: string) {
// https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreatePolicy.html
// Added support for policy names with a path
// See https://github.com/aws/aws-cdk/issues/26320
const regexp: RegExp = /[\w+\/=,.@-]+/;
const matches = regexp.exec(permissionsBoundary);
if (!(matches && matches.length === 1 && matches[0] === permissionsBoundary)) {
throw new ToolkitError(`The permissions boundary name ${permissionsBoundary} does not match the IAM conventions.`);
}
}
private async customBootstrap(
environment: cxapi.Environment,
sdkProvider: SdkProvider,
options: BootstrapEnvironmentOptions = {},
): Promise<SuccessfulDeployStackResult> {
// Look at the template, decide whether it's most likely a legacy or modern bootstrap
// template, and use the right bootstrapper for that.
const version = bootstrapVersionFromTemplate(await this.loadTemplate());
if (version === 0) {
return this.legacyBootstrap(environment, sdkProvider, options);
} else {
return this.modernBootstrap(environment, sdkProvider, options);
}
}
private async loadTemplate(params: BootstrappingParameters = {}): Promise<any> {
switch (this.source.source) {
case 'custom':
return loadStructuredFile(this.source.templateFile);
case 'default':
return loadStructuredFile(path.join(bundledPackageRootDir(__dirname), 'lib', 'api', 'bootstrap', 'bootstrap-template.yaml'));
case 'legacy':
return legacyBootstrapTemplate(params);
}
}
}
/**
* Magic parameter value that will cause the bootstrap-template.yml to NOT create a CMK but use the default key
*/
const USE_AWS_MANAGED_KEY = 'AWS_MANAGED_KEY';
/**
* Magic parameter value that will cause the bootstrap-template.yml to create a CMK
*/
const CREATE_NEW_KEY = '';
/**
* Parameter value indicating the use of the default, CDK provided permissions boundary for bootstrap-template.yml
*/
const CDK_BOOTSTRAP_PERMISSIONS_BOUNDARY = 'CDK_BOOTSTRAP_PERMISSIONS_BOUNDARY';
/**
* Split an array-like CloudFormation parameter on ,
*
* An empty string is the empty array (instead of `['']`).
*/
function splitCfnArray(xs: string | undefined): string[] {
if (xs === '' || xs === undefined) {
return [];
}
return xs.split(',');
}
function intersection<A>(xs: Set<A>, ys: Set<A>): Set<A> {
return new Set<A>(Array.from(xs).filter(x => ys.has(x)));
}