Skip to content

Commit 7b00dd1

Browse files
kaizenccgithub-actions
and
github-actions
authored
refactor(cli): require approval is a part of the CliIoHost (#151)
`CliIoHost` now governs when to ask for approval or not. This was previously passed into deploy via deployOptions. - `deployOptions.requireApproval` was deprecated before, and now it is no longer being used (this may have to be pulled into a different change that removes the `requireApproval` option entirely in a minor version bump of toolkit-lib. - `CliIoHost` has a property, `requireApproval` that can be set. When the `requestResponse` API is called, we check to see if the message code is a known `requireApproval` code and if so, see if the request is necessary or if the default can be used. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: github-actions <[email protected]>
1 parent 47a4e1d commit 7b00dd1

File tree

10 files changed

+239
-45
lines changed

10 files changed

+239
-45
lines changed

packages/@aws-cdk/cloud-assembly-schema/lib/integ-tests/commands/common.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export enum RequireApproval {
88
NEVER = 'never',
99

1010
/**
11-
* Prompt for approval for any type of change to the stack
11+
* Prompt for approval for any type of change to the stack
1212
*/
1313
ANYCHANGE = 'any-change',
1414

packages/@aws-cdk/toolkit-lib/CODE_REGISTRY.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
| CDK_TOOLKIT_W5022 | Empty existing stack, stack will be destroyed | warn | n/a |
1818
| CDK_TOOLKIT_I5031 | Informs about any log groups that are traced as part of the deployment | info | n/a |
1919
| CDK_TOOLKIT_I5050 | Confirm rollback during deployment | info | [ConfirmationRequest](https://docs.aws.amazon.com/cdk/api/toolkit-lib/interfaces/ConfirmationRequest.html) |
20-
| CDK_TOOLKIT_I5060 | Confirm deploy security sensitive changes | info | [ConfirmationRequest](https://docs.aws.amazon.com/cdk/api/toolkit-lib/interfaces/ConfirmationRequest.html) |
20+
| CDK_TOOLKIT_I5060 | Confirm deploy security sensitive changes | info | [DeployConfirmationRequest](https://docs.aws.amazon.com/cdk/api/toolkit-lib/interfaces/DeployConfirmationRequest.html) |
2121
| CDK_TOOLKIT_I5100 | Stack deploy progress | info | [StackDeployProgress](https://docs.aws.amazon.com/cdk/api/toolkit-lib/interfaces/StackDeployProgress.html) |
2222
| CDK_TOOLKIT_I5310 | The computed settings used for file watching | debug | [WatchSettings](https://docs.aws.amazon.com/cdk/api/toolkit-lib/interfaces/WatchSettings.html) |
2323
| CDK_TOOLKIT_I5311 | File watching started | info | [FileWatchEvent](https://docs.aws.amazon.com/cdk/api/toolkit-lib/interfaces/FileWatchEvent.html) |

packages/@aws-cdk/toolkit-lib/lib/actions/deploy/index.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api';
22
import type { BaseDeployOptions } from './private/deploy-options';
33
import type { Tag } from '../../api/aws-cdk';
4+
import type { ConfirmationRequest } from '../../toolkit/types';
5+
import type { PermissionChangeType } from '../diff/private/helpers';
46

57
export type DeploymentMethod = DirectDeploymentMethod | ChangeSetDeploymentMethod;
68

@@ -122,8 +124,7 @@ export interface DeployOptions extends BaseDeployOptions {
122124
* Require a confirmation for security relevant changes before continuing with the deployment
123125
*
124126
* @default RequireApproval.NEVER
125-
* @deprecated in future a message containing the full diff will be emitted and a response requested.
126-
* Approval workflows should be implemented in the `IIoHost`.
127+
* @deprecated requireApproval is governed by the `IIoHost`. This property is no longer used.
127128
*/
128129
readonly requireApproval?: RequireApproval;
129130

@@ -228,3 +229,14 @@ export interface StackDeployProgress {
228229
*/
229230
readonly stack: CloudFormationStackArtifact;
230231
}
232+
233+
/**
234+
* Payload for a yes/no confirmation in deploy. Includes information on
235+
* what kind of change is being made.
236+
*/
237+
export interface DeployConfirmationRequest extends ConfirmationRequest {
238+
/**
239+
* The type of change being made to the IAM permissions.
240+
*/
241+
readonly permissionChangeType?: PermissionChangeType;
242+
}
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,43 @@
11
import type { DescribeChangeSetOutput } from '@aws-cdk/cloudformation-diff';
22
import { fullDiff } from '@aws-cdk/cloudformation-diff';
33
import type * as cxapi from '@aws-cdk/cx-api';
4-
import { ToolkitError } from '../../../api/shared-public';
5-
import { RequireApproval } from '../../deploy';
64

75
/**
8-
* Return whether the diff has security-impacting changes that need confirmation
6+
* Different types of permissioning changes in a diff
97
*/
10-
export function diffRequiresApproval(
8+
export enum PermissionChangeType {
9+
/**
10+
* No permission changes
11+
*/
12+
NONE = 'none',
13+
14+
/**
15+
* Permissions are broadening
16+
*/
17+
BROADENING = 'broadening',
18+
19+
/**
20+
* Permissions are changed but not broadening
21+
*/
22+
NON_BROADENING = 'non-broadening',
23+
}
24+
25+
/**
26+
* Return whether the diff has security-impacting changes that need confirmation.
27+
*/
28+
export function determinePermissionType(
1129
oldTemplate: any,
1230
newTemplate: cxapi.CloudFormationStackArtifact,
13-
requireApproval: RequireApproval,
1431
changeSet?: DescribeChangeSetOutput,
15-
): boolean {
16-
// @todo return or print the full diff.
32+
): PermissionChangeType {
33+
// @todo return a printable version of the full diff.
1734
const diff = fullDiff(oldTemplate, newTemplate.template, changeSet);
1835

19-
switch (requireApproval) {
20-
case RequireApproval.NEVER: return false;
21-
case RequireApproval.ANY_CHANGE: return diff.permissionsAnyChanges;
22-
case RequireApproval.BROADENING: return diff.permissionsBroadened;
23-
default: throw new ToolkitError(`Unrecognized approval level: ${requireApproval}`);
36+
if (diff.permissionsBroadened) {
37+
return PermissionChangeType.BROADENING;
38+
} else if (diff.permissionsAnyChanges) {
39+
return PermissionChangeType.NON_BROADENING;
40+
} else {
41+
return PermissionChangeType.NONE;
2442
}
2543
}

packages/@aws-cdk/toolkit-lib/lib/api/io/private/codes.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type * as cxapi from '@aws-cdk/cx-api';
22
import type { SdkTrace } from './sdk-logger';
33
import type { BootstrapEnvironmentProgress } from '../../../actions/bootstrap';
4-
import type { StackDeployProgress } from '../../../actions/deploy';
4+
import type { DeployConfirmationRequest, StackDeployProgress } from '../../../actions/deploy';
55
import type { StackDestroyProgress } from '../../../actions/destroy';
66
import type { StackDetailsPayload } from '../../../actions/list';
77
import type { StackRollbackProgress } from '../../../actions/rollback';
@@ -96,10 +96,10 @@ export const CODES = {
9696
description: 'Confirm rollback during deployment',
9797
interface: 'ConfirmationRequest',
9898
}),
99-
CDK_TOOLKIT_I5060: make.confirm<ConfirmationRequest>({
99+
CDK_TOOLKIT_I5060: make.confirm<DeployConfirmationRequest>({
100100
code: 'CDK_TOOLKIT_I5060',
101101
description: 'Confirm deploy security sensitive changes',
102-
interface: 'ConfirmationRequest',
102+
interface: 'DeployConfirmationRequest',
103103
}),
104104

105105
CDK_TOOLKIT_I5100: make.info<StackDeployProgress>({

packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts

+12-16
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { BootstrapSource } from '../actions/bootstrap';
1111
import { AssetBuildTime, type DeployOptions, RequireApproval } from '../actions/deploy';
1212
import { type ExtendedDeployOptions, buildParameterMap, createHotswapPropertyOverrides, removePublishedAssets } from '../actions/deploy/private';
1313
import { type DestroyOptions } from '../actions/destroy';
14-
import { diffRequiresApproval } from '../actions/diff/private';
14+
import { determinePermissionType } from '../actions/diff/private';
1515
import { type ListOptions } from '../actions/list';
1616
import { type RollbackOptions } from '../actions/rollback';
1717
import { type SynthOptions } from '../actions/synth';
@@ -285,8 +285,6 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
285285

286286
await migrator.tryMigrateResources(stackCollection, options);
287287

288-
const requireApproval = options.requireApproval ?? RequireApproval.NEVER;
289-
290288
const parameterMap = buildParameterMap(options.parameters?.parameters);
291289

292290
const hotswapMode = options.hotswap ?? HotswapMode.FULL_DEPLOYMENT;
@@ -353,19 +351,17 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab
353351
return;
354352
}
355353

356-
if (requireApproval !== RequireApproval.NEVER) {
357-
const currentTemplate = await deployments.readCurrentTemplate(stack);
358-
if (diffRequiresApproval(currentTemplate, stack, requireApproval)) {
359-
const motivation = '"--require-approval" is enabled and stack includes security-sensitive updates.';
360-
const question = `${motivation}\nDo you wish to deploy these changes`;
361-
const confirmed = await ioHost.requestResponse(CODES.CDK_TOOLKIT_I5060.req(question, {
362-
motivation,
363-
concurrency,
364-
}));
365-
if (!confirmed) {
366-
throw new ToolkitError('Aborted by user');
367-
}
368-
}
354+
const currentTemplate = await deployments.readCurrentTemplate(stack);
355+
const permissionChangeType = determinePermissionType(currentTemplate, stack);
356+
const deployMotivation = '"--require-approval" is enabled and stack includes security-sensitive updates.';
357+
const deployQuestion = `${deployMotivation}\nDo you wish to deploy these changes`;
358+
const deployConfirmed = await ioHost.requestResponse(CODES.CDK_TOOLKIT_I5060.req(deployQuestion, {
359+
motivation: deployMotivation,
360+
concurrency,
361+
permissionChangeType: permissionChangeType,
362+
}));
363+
if (!deployConfirmed) {
364+
throw new ToolkitError('Aborted by user');
369365
}
370366

371367
// Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK)

packages/@aws-cdk/toolkit-lib/test/_helpers/test-io-host.ts

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../../lib';
2+
import { RequireApproval } from '../../lib';
23
import { isMessageRelevantForLevel } from '../../lib/api/io/private/level-priority';
34

45
/**
56
* A test implementation of IIoHost that does nothing but can by spied on.
6-
* Optional set a level to filter out all irrelevant messages.
7+
* Optionally set a level to filter out all irrelevant messages.
8+
* Optionally set a approval level.
79
*/
810
export class TestIoHost implements IIoHost {
911
public readonly notifySpy: jest.Mock<any, any, any>;
1012
public readonly requestSpy: jest.Mock<any, any, any>;
1113

14+
public requireDeployApproval = RequireApproval.NEVER;
15+
1216
constructor(public level: IoMessageLevel = 'info') {
1317
this.notifySpy = jest.fn();
1418
this.requestSpy = jest.fn();
@@ -21,9 +25,27 @@ export class TestIoHost implements IIoHost {
2125
}
2226

2327
public async requestResponse<T, U>(msg: IoRequest<T, U>): Promise<U> {
24-
if (isMessageRelevantForLevel(msg, this.level)) {
28+
if (isMessageRelevantForLevel(msg, this.level) && this.needsApproval(msg)) {
2529
this.requestSpy(msg);
2630
}
2731
return msg.defaultResponse;
2832
}
33+
34+
private needsApproval(msg: IoRequest<any, any>): boolean {
35+
// Return true if the code is unrelated to approval
36+
if (!['CDK_TOOLKIT_I5060'].includes(msg.code)) {
37+
return true;
38+
}
39+
40+
switch (this.requireDeployApproval) {
41+
case RequireApproval.NEVER:
42+
return false;
43+
case RequireApproval.ANY_CHANGE:
44+
return true;
45+
case RequireApproval.BROADENING:
46+
return msg.data?.permissionChangeType === 'broadening';
47+
default:
48+
return true;
49+
}
50+
}
2951
}

packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts

+19-8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jest.mock('../../lib/api/aws-cdk', () => {
3333
beforeEach(() => {
3434
ioHost.notifySpy.mockClear();
3535
ioHost.requestSpy.mockClear();
36+
ioHost.requireDeployApproval = RequireApproval.NEVER;
3637
jest.clearAllMocks();
3738
mockFindCloudWatchLogGroups.mockReturnValue({
3839
env: { name: 'Z', account: 'X', region: 'Y' },
@@ -51,35 +52,45 @@ describe('deploy', () => {
5152
successfulDeployment();
5253
});
5354

54-
test('request response when require approval is set', async () => {
55+
test('request response when changes exceed require approval threshold', async () => {
5556
// WHEN
57+
// this is the lowest threshold; always require approval
58+
ioHost.requireDeployApproval = RequireApproval.ANY_CHANGE;
59+
5660
const cx = await builderFixture(toolkit, 'stack-with-role');
57-
await toolkit.deploy(cx, {
58-
requireApproval: RequireApproval.ANY_CHANGE,
59-
});
61+
await toolkit.deploy(cx);
6062

6163
// THEN
6264
expect(ioHost.requestSpy).toHaveBeenCalledWith(expect.objectContaining({
6365
action: 'deploy',
6466
level: 'info',
6567
code: 'CDK_TOOLKIT_I5060',
6668
message: expect.stringContaining('Do you wish to deploy these changes'),
69+
data: expect.objectContaining({
70+
motivation: expect.stringContaining('stack includes security-sensitive updates.'),
71+
permissionChangeType: 'broadening',
72+
}),
6773
}));
6874
});
6975

70-
test('skips response by default', async () => {
76+
test('skips response when changes do not meet require approval threshold', async () => {
7177
// WHEN
78+
// never require approval, so we expect the IoHost to skip
79+
ioHost.requireDeployApproval = RequireApproval.NEVER;
80+
7281
const cx = await builderFixture(toolkit, 'stack-with-role');
73-
await toolkit.deploy(cx, {
74-
requireApproval: RequireApproval.NEVER,
75-
});
82+
await toolkit.deploy(cx);
7683

7784
// THEN
7885
expect(ioHost.requestSpy).not.toHaveBeenCalledWith(expect.objectContaining({
7986
action: 'deploy',
8087
level: 'info',
8188
code: 'CDK_TOOLKIT_I5060',
8289
message: expect.stringContaining('Do you wish to deploy these changes'),
90+
data: expect.objectContaining({
91+
motivation: expect.stringContaining('stack includes security-sensitive updates.'),
92+
permissionChangeType: 'broadening',
93+
}),
8394
}));
8495
});
8596

packages/aws-cdk/lib/toolkit/cli-io-host.ts

+55
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as util from 'node:util';
2+
import { RequireApproval } from '@aws-cdk/cloud-assembly-schema';
23
import * as chalk from 'chalk';
34
import * as promptly from 'promptly';
45
import { ToolkitError } from './error';
@@ -163,6 +164,13 @@ export interface CliIoHostProps {
163164
readonly isCI?: boolean;
164165

165166
/**
167+
* In what scenarios should the CliIoHost ask for approval
168+
*
169+
* @default RequireApproval.BROADENING
170+
*/
171+
readonly requireDeployApproval?: RequireApproval;
172+
173+
/*
166174
* The initial Toolkit action the hosts starts with.
167175
*
168176
* @default StackActivityProgress.BAR
@@ -195,6 +203,7 @@ export class CliIoHost implements IIoHost {
195203
private _isTTY: boolean;
196204
private _logLevel: IoMessageLevel;
197205
private _internalIoHost?: IIoHost;
206+
private _requireDeployApproval: RequireApproval;
198207
private _progress: StackActivityProgress = StackActivityProgress.BAR;
199208

200209
// Stack Activity Printer
@@ -209,6 +218,7 @@ export class CliIoHost implements IIoHost {
209218
this._isTTY = props.isTTY ?? process.stdout.isTTY ?? false;
210219
this._logLevel = props.logLevel ?? 'info';
211220
this._isCI = props.isCI ?? isCI();
221+
this._requireDeployApproval = props.requireDeployApproval ?? RequireApproval.BROADENING;
212222

213223
this.stackProgress = props.stackProgress ?? StackActivityProgress.BAR;
214224
}
@@ -297,6 +307,20 @@ export class CliIoHost implements IIoHost {
297307
this._isTTY = value;
298308
}
299309

310+
/**
311+
* Return the conditions for requiring approval in this CliIoHost.
312+
*/
313+
public get requireDeployApproval() {
314+
return this._requireDeployApproval;
315+
}
316+
317+
/**
318+
* Set the conditions for requiring approval in this CliIoHost.
319+
*/
320+
public set requireDeployApproval(approval: RequireApproval) {
321+
this._requireDeployApproval = approval;
322+
}
323+
300324
/**
301325
* Whether the CliIoHost is running in CI mode. In CI mode, all non-error output goes to stdout instead of stderr.
302326
*/
@@ -394,6 +418,29 @@ export class CliIoHost implements IIoHost {
394418
].includes(msg.code);
395419
}
396420

421+
/**
422+
* Detect special messages encode information about whether or not
423+
* they require approval
424+
*/
425+
private skipApprovalStep(msg: IoRequest<any, any>): boolean {
426+
const approvalToolkitCodes = ['CDK_TOOLKIT_I5060'];
427+
if (!approvalToolkitCodes.includes(msg.code)) {
428+
false;
429+
}
430+
431+
switch (this.requireDeployApproval) {
432+
// Never require approval
433+
case RequireApproval.NEVER:
434+
return true;
435+
// Always require approval
436+
case RequireApproval.ANYCHANGE:
437+
return false;
438+
// Require approval if changes include broadening permissions
439+
case RequireApproval.BROADENING:
440+
return ['none', 'non-broadening'].includes(msg.data?.permissionChangeType);
441+
}
442+
}
443+
397444
/**
398445
* Determines the output stream, based on message level and configuration.
399446
*/
@@ -454,6 +501,14 @@ export class CliIoHost implements IIoHost {
454501
throw new ToolkitError(`${motivation}, but concurrency is greater than 1 so we are unable to get a confirmation from the user`);
455502
}
456503

504+
// Special approval prompt
505+
// Determine if the message needs approval. If it does, continue (it is a basic confirmation prompt)
506+
// If it does not, return success (true). We only check messages with codes that we are aware
507+
// are requires approval codes.
508+
if (this.skipApprovalStep(msg)) {
509+
return true;
510+
}
511+
457512
// Basic confirmation prompt
458513
// We treat all requests with a boolean response as confirmation prompts
459514
if (isConfirmationPrompt(msg)) {

0 commit comments

Comments
 (0)