diff --git a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts index c2a09073b4cb5..a8a366a2c91c8 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts @@ -290,6 +290,10 @@ export interface CdkModernBootstrapCommandOptions extends CommonCdkBootstrapComm * @default undefined */ readonly usePreviousParameters?: boolean; + + readonly trust?: string[]; + + readonly untrust?: string[]; } export interface CdkGarbageCollectionCommandOptions { @@ -445,6 +449,13 @@ export class TestFixture extends ShellHelper { args.push('--template', options.bootstrapTemplate); } + if (options.trust != null) { + args.push('--trust', options.trust.join(',')); + } + if (options.untrust != null) { + args.push('--untrust', options.untrust.join(',')); + } + return this.cdk(args, { ...options.cliOptions, modEnv: { diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts index e6d2b05903f5e..09992e4d1d798 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts @@ -491,3 +491,28 @@ integTest('create ECR with tag IMMUTABILITY to set on', withoutBootstrap(async ( expect(ecrResponse.repositories?.[0].imageTagMutability).toEqual('IMMUTABLE'); })); +integTest('can remove trusted account', withoutBootstrap(async (fixture) => { + const bootstrapStackName = fixture.bootstrapStackName; + + await fixture.cdkBootstrapModern({ + verbose: false, + toolkitStackName: bootstrapStackName, + cfnExecutionPolicy: 'arn:aws:iam::aws:policy/AdministratorAccess', + trust: ['599757620138', '730170552321'], + }); + + await fixture.cdkBootstrapModern({ + verbose: true, + toolkitStackName: bootstrapStackName, + cfnExecutionPolicy: ' arn:aws:iam::aws:policy/AdministratorAccess', + untrust: ['730170552321'], + }); + + const response = await fixture.aws.cloudFormation.send( + new DescribeStacksCommand({ StackName: bootstrapStackName }), + ); + + const trustedAccounts = response.Stacks?.[0].Parameters?.find(p => p.ParameterKey === 'TrustedAccounts')?.ParameterValue; + expect(trustedAccounts).toEqual('599757620138'); +})); + diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts index fa21570a3f9c9..80b16083b8bba 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts @@ -102,11 +102,24 @@ export class Bootstrapper { // 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 trustedAccounts = params.trustedAccounts ?? splitCfnArray(current.parameters.TrustedAccounts); + 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)); info(`Trusted accounts for deployment: ${trustedAccounts.length > 0 ? trustedAccounts.join(', ') : '(none)'}`); - const trustedAccountsForLookup = - params.trustedAccountsForLookup ?? splitCfnArray(current.parameters.TrustedAccountsForLookup); + const trustedAccountsForLookup = removeUntrusted( + params.trustedAccountsForLookup ?? splitCfnArray(current.parameters.TrustedAccountsForLookup), + ); info( `Trusted accounts for lookup: ${trustedAccountsForLookup.length > 0 ? trustedAccountsForLookup.join(', ') : '(none)'}`, ); @@ -376,3 +389,7 @@ function splitCfnArray(xs: string | undefined): string[] { } return xs.split(','); } + +function intersection(xs: Set, ys: Set): Set { + return new Set(Array.from(xs).filter(x => ys.has(x))); +} diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts index f32608e627a2f..cdea6a6ef4025 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts @@ -102,6 +102,14 @@ export interface BootstrappingParameters { */ readonly trustedAccountsForLookup?: string[]; + /** + * The list of AWS account IDs that should not be trusted by the bootstrapped environment. + * If these accounts are already trusted, they will be removed on bootstrapping. + * + * @default - no account will be untrusted. + */ + readonly untrustedAccounts?: string[]; + /** * The ARNs of the IAM managed policies that should be attached to the role performing CloudFormation deployments. * In most cases, this will be the AdministratorAccess policy. diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index 053fcafe07654..01e5c06c3d761 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -279,6 +279,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { 'execute': { type: 'boolean', desc: 'Whether to execute ChangeSet (--no-execute will NOT execute the ChangeSet)', default: true }, 'trust': { type: 'array', desc: 'The AWS account IDs that should be trusted to perform deployments into this environment (may be repeated, modern bootstrapping only)', default: [] }, 'trust-for-lookup': { type: 'array', desc: 'The AWS account IDs that should be trusted to look up values in this environment (may be repeated, modern bootstrapping only)', default: [] }, + 'untrust': { type: 'array', desc: 'The AWS account IDs that should not be trusted by this environment (may be repeated, modern bootstrapping only)', default: [] }, 'cloudformation-execution-policies': { type: 'array', desc: 'The Managed Policy ARNs that should be attached to the role performing deployments into this environment (may be repeated, modern bootstrapping only)', default: [] }, 'force': { alias: 'f', type: 'boolean', desc: 'Always bootstrap even if it would downgrade template version', default: false }, 'termination-protection': { type: 'boolean', default: undefined, desc: 'Toggle CloudFormation termination protection on the bootstrap stacks' }, diff --git a/packages/aws-cdk/lib/convert-to-user-input.ts b/packages/aws-cdk/lib/convert-to-user-input.ts index 4b400aa844424..65f3dfdbd5b79 100644 --- a/packages/aws-cdk/lib/convert-to-user-input.ts +++ b/packages/aws-cdk/lib/convert-to-user-input.ts @@ -69,6 +69,7 @@ export function convertYargsToUserInput(args: any): UserInput { execute: args.execute, trust: args.trust, trustForLookup: args.trustForLookup, + untrust: args.untrust, cloudformationExecutionPolicies: args.cloudformationExecutionPolicies, force: args.force, terminationProtection: args.terminationProtection, @@ -309,6 +310,7 @@ export function convertConfigToUserInput(config: any): UserInput { execute: config.bootstrap?.execute, trust: config.bootstrap?.trust, trustForLookup: config.bootstrap?.trustForLookup, + untrust: config.bootstrap?.untrust, cloudformationExecutionPolicies: config.bootstrap?.cloudformationExecutionPolicies, force: config.bootstrap?.force, terminationProtection: config.bootstrap?.terminationProtection, diff --git a/packages/aws-cdk/lib/parse-command-line-arguments.ts b/packages/aws-cdk/lib/parse-command-line-arguments.ts index 20b09694290fa..0dbec7f7c2859 100644 --- a/packages/aws-cdk/lib/parse-command-line-arguments.ts +++ b/packages/aws-cdk/lib/parse-command-line-arguments.ts @@ -263,6 +263,13 @@ export function parseCommandLineArguments(args: Array): any { nargs: 1, requiresArg: true, }) + .option('untrust', { + default: [], + type: 'array', + desc: 'The AWS account IDs that should not be trusted by this environment (may be repeated, modern bootstrapping only)', + nargs: 1, + requiresArg: true, + }) .option('cloudformation-execution-policies', { default: [], type: 'array', diff --git a/packages/aws-cdk/lib/user-input.ts b/packages/aws-cdk/lib/user-input.ts index 369a7fe325515..279d0635cca2b 100644 --- a/packages/aws-cdk/lib/user-input.ts +++ b/packages/aws-cdk/lib/user-input.ts @@ -464,6 +464,13 @@ export interface BootstrapOptions { */ readonly trustForLookup?: Array; + /** + * The AWS account IDs that should not be trusted by this environment (may be repeated, modern bootstrapping only) + * + * @default - [] + */ + readonly untrust?: Array; + /** * The Managed Policy ARNs that should be attached to the role performing deployments into this environment (may be repeated, modern bootstrapping only) * diff --git a/packages/aws-cdk/test/api/bootstrap2.test.ts b/packages/aws-cdk/test/api/bootstrap2.test.ts index 1d852649b3eec..51ff61b857914 100644 --- a/packages/aws-cdk/test/api/bootstrap2.test.ts +++ b/packages/aws-cdk/test/api/bootstrap2.test.ts @@ -330,6 +330,75 @@ describe('Bootstrapping v2', () => { // Did not throw }); + test('removes trusted account when it is listed as untrusted', async () => { + // GIVEN + mockTheToolkitInfo({ + Parameters: [ + { + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: 'arn:aws:something', + }, + { + ParameterKey: 'TrustedAccounts', + ParameterValue: '111111111111,222222222222', + }, + ], + }); + + await bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + untrustedAccounts: ['111111111111'], + }, + }); + + expect(mockDeployStack).toHaveBeenCalledWith( + expect.objectContaining({ + parameters: expect.objectContaining({ + TrustedAccounts: '222222222222', + }), + }), + ); + }); + + test('removes trusted account for lookup when it is listed as untrusted', async () => { + // GIVEN + mockTheToolkitInfo({ + Parameters: [ + { + ParameterKey: 'CloudFormationExecutionPolicies', + ParameterValue: 'arn:aws:something', + }, + { + ParameterKey: 'TrustedAccountsForLookup', + ParameterValue: '111111111111,222222222222', + }, + ], + }); + + await bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + untrustedAccounts: ['111111111111'], + }, + }); + + expect(mockDeployStack).toHaveBeenCalledWith( + expect.objectContaining({ + parameters: expect.objectContaining({ + TrustedAccountsForLookup: '222222222222', + }), + }), + ); + }); + + test('do not allow accounts to be listed as both trusted and untrusted', async () => { + await expect(bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + trustedAccountsForLookup: ['123456789012'], + untrustedAccounts: ['123456789012'], + }, + })).rejects.toThrow('Accounts cannot be both trusted and untrusted. Found: 123456789012'); + }); + test('Do not allow downgrading bootstrap stack version', async () => { // GIVEN mockTheToolkitInfo({