Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/aws-cdk/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from './deployments';
export * from './aws-auth';
export * from './cloud-assembly';
export * from './notices';
export * from './resource-filter';

export * from '../../../@aws-cdk/toolkit-lib/lib/api/diff';
export * from '../../../@aws-cdk/toolkit-lib/lib/api/io';
Expand Down
172 changes: 172 additions & 0 deletions packages/aws-cdk/lib/api/resource-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { ResourceDifference } from '@aws-cdk/cloudformation-diff';
import { ToolkitError } from '@aws-cdk/toolkit-lib';

/**
* Represents a resource filter pattern
*/
export interface ResourceFilter {
/**
* The resource type pattern (e.g., 'AWS::Lambda::Function')
*/
resourceType: string;

/**
* Optional property path (e.g., 'Properties.Code.S3Key')
*/
propertyPath?: string;
}

/**
* Parses a filter string into a ResourceFilter object
*/
export function parseResourceFilter(filter: string): ResourceFilter {
const parts = filter.split('.');
const resourceType = parts[0];

if (!resourceType) {
throw new ToolkitError(`Invalid resource filter: '${filter}'. Must specify at least a resource type.`);
}

const propertyPath = parts.length > 1 ? parts.slice(1).join('.') : undefined;

return {
resourceType,
propertyPath,
};
}

/**
* Checks if a resource type matches a filter pattern
*/
export function matchesResourceType(resourceType: string, pattern: string): boolean {
if (pattern === '*') {
return true;
}

if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
return resourceType.startsWith(prefix);
}

return resourceType === pattern;
}

/**
* Checks if a property change matches a filter
*/
export function matchesPropertyFilter(
resourceType: string,
propertyName: string,
filter: ResourceFilter,
): boolean {
// First check if resource type matches
if (!matchesResourceType(resourceType, filter.resourceType)) {
return false;
}

// If no property path specified in filter, any property change is allowed
if (!filter.propertyPath) {
return true;
}

// Check if the property path matches
const filterPath = filter.propertyPath.startsWith('Properties.')
? filter.propertyPath.slice('Properties.'.length)
: filter.propertyPath;

return propertyName === filterPath || propertyName.startsWith(filterPath + '.');
}

/**
* Validates resource changes against allowed filters
*/
export function validateResourceChanges(
resourceChanges: { [logicalId: string]: ResourceDifference },
allowedFilters: string[],
): { isValid: boolean; violations: string[] } {
if (allowedFilters.length === 0) {
return { isValid: true, violations: [] };
}

const filters = allowedFilters.map(parseResourceFilter);
const violations: string[] = [];

for (const [logicalId, change] of Object.entries(resourceChanges)) {
const resourceType = change.resourceType;

if (!resourceType) {
continue;
}

// Check if the resource type change itself is allowed
let resourceTypeAllowed = false;
for (const filter of filters) {
if (matchesResourceType(resourceType, filter.resourceType) && !filter.propertyPath) {
resourceTypeAllowed = true;
break;
}
}

// If it's a resource addition/removal, check resource type level permission
if (change.isAddition || change.isRemoval) {
if (!resourceTypeAllowed) {
const action = change.isAddition ? 'addition' : 'removal';
violations.push(`${logicalId} (${resourceType}): ${action} not allowed by filters`);
}
continue;
}

// For updates, check each property change
const propertyUpdates = change.propertyUpdates;
for (const [propertyName] of Object.entries(propertyUpdates)) {
let propertyAllowed = false;

for (const filter of filters) {
if (matchesPropertyFilter(resourceType, propertyName, filter)) {
propertyAllowed = true;
break;
}
}

if (!propertyAllowed) {
violations.push(`${logicalId} (${resourceType}): property '${propertyName}' change not allowed by filters`);
}
}

// Check other changes (non-property changes)
const otherChanges = change.otherChanges;
if (Object.keys(otherChanges).length > 0 && !resourceTypeAllowed) {
violations.push(`${logicalId} (${resourceType}): non-property changes not allowed by filters`);
}
}

return {
isValid: violations.length === 0,
violations,
};
}

/**
* Formats violation messages for display to the user
*/
export function formatViolationMessage(
violations: string[],
allowedFilters: string[],
): string {
const lines = [
'❌ Deployment aborted: Detected changes to resources outside allowed filters',
'',
'Allowed resource changes:',
...allowedFilters.map(filter => ` • ${filter}`),
'',
'Detected changes that violate the filter:',
...violations.map(violation => ` • ${violation}`),
'',
'To proceed with these changes, either:',
' 1. Review and remove the unwanted changes from your CDK code',
' 2. Update your --allow-resource-changes filters to include these resource types',
' 3. Remove the --allow-resource-changes option to deploy all changes',
];

return lines.join('\n');
}
55 changes: 55 additions & 0 deletions packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { Bootstrapper } from '../api/bootstrap';
import { ExtendedStackSelection, StackCollection } from '../api/cloud-assembly';
import type { Deployments, SuccessfulDeployStackResult } from '../api/deployments';
import { mappingsByEnvironment, parseMappingGroups } from '../api/refactor';
import { validateResourceChanges, formatViolationMessage } from '../api/resource-filter';
import { type Tag } from '../api/tags';
import { StackActivityProgress } from '../commands/deploy';
import { listStacks } from '../commands/list-stacks';
Expand Down Expand Up @@ -272,6 +273,17 @@ export class CdkToolkit {
contextLines,
quiet,
});

// Validate resource changes against allowed filters
if (options.allowResourceChanges && options.allowResourceChanges.length > 0) {
const validation = validateResourceChanges(formatter.diffs.resources.changes, options.allowResourceChanges);
if (!validation.isValid) {
const violationMessage = formatViolationMessage(validation.violations, options.allowResourceChanges);
await this.ioHost.asIoHelper().defaults.error(violationMessage);
return 1;
}
}

diffs = diff.numStacksWithChanges;
await this.ioHost.asIoHelper().defaults.info(diff.formattedDiff);
}
Expand Down Expand Up @@ -365,6 +377,17 @@ export class CdkToolkit {
contextLines,
quiet,
});

// Validate resource changes against allowed filters
if (options.allowResourceChanges && options.allowResourceChanges.length > 0) {
const validation = validateResourceChanges(formatter.diffs.resources.changes, options.allowResourceChanges);
if (!validation.isValid) {
const violationMessage = formatViolationMessage(validation.violations, options.allowResourceChanges);
await this.ioHost.asIoHelper().defaults.error(violationMessage);
return 1;
}
}

await this.ioHost.asIoHelper().defaults.info(diff.formattedDiff);
diffs += diff.numStacksWithChanges;
}
Expand Down Expand Up @@ -478,6 +501,23 @@ export class CdkToolkit {
return;
}

// Always validate resource changes if filters are specified, regardless of approval settings
if (options.allowResourceChanges && options.allowResourceChanges.length > 0) {
const currentTemplate = await this.props.deployments.readCurrentTemplate(stack);
const formatter = new DiffFormatter({
templateInfo: {
oldTemplate: currentTemplate,
newTemplate: stack,
},
});

const validation = validateResourceChanges(formatter.diffs.resources.changes, options.allowResourceChanges);
if (!validation.isValid) {
const violationMessage = formatViolationMessage(validation.violations, options.allowResourceChanges);
throw new ToolkitError(violationMessage);
}
}

if (requireApproval !== RequireApproval.NEVER) {
const currentTemplate = await this.props.deployments.readCurrentTemplate(stack);
const formatter = new DiffFormatter({
Expand All @@ -486,6 +526,7 @@ export class CdkToolkit {
newTemplate: stack,
},
});

const securityDiff = formatter.formatSecurityDiff();
if (requiresApproval(requireApproval, securityDiff.permissionChangeType)) {
const motivation = '"--require-approval" is enabled and stack includes security-sensitive updates';
Expand Down Expand Up @@ -1580,6 +1621,13 @@ export interface DiffOptions {
* @default false
*/
readonly includeMoves?: boolean;

/**
* Allow only changes to specified resource types or properties
*
* @default []
*/
readonly allowResourceChanges?: string[];
}

interface CfnDeployOptions {
Expand Down Expand Up @@ -1783,6 +1831,13 @@ export interface DeployOptions extends CfnDeployOptions, WatchOptions {
* @default false
*/
readonly ignoreNoStacks?: boolean;

/**
* Allow only changes to specified resource types or properties
*
* @default []
*/
readonly allowResourceChanges?: string[];
}

export interface RollbackOptions {
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export async function makeConfig(): Promise<CliConfig> {
'asset-parallelism': { type: 'boolean', desc: 'Whether to build/publish assets in parallel' },
'asset-prebuild': { type: 'boolean', desc: 'Whether to build all assets before deploying the first stack (useful for failing Docker builds)', default: true },
'ignore-no-stacks': { type: 'boolean', desc: 'Whether to deploy if the app contains no stacks', default: false },
'allow-resource-changes': { type: 'array', desc: 'Allow only changes to specified resource types or properties (e.g., AWS::Lambda::Function, AWS::Lambda::Function.Code.S3Key)', default: [] },
},
arg: {
name: 'STACKS',
Expand Down Expand Up @@ -360,6 +361,7 @@ export async function makeConfig(): Promise<CliConfig> {
'change-set': { type: 'boolean', alias: 'changeset', desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.', default: true },
'import-existing-resources': { type: 'boolean', desc: 'Whether or not the change set imports resources that already exist', default: false },
'include-moves': { type: 'boolean', desc: 'Whether to include moves in the diff', default: false },
'allow-resource-changes': { type: 'array', desc: 'Allow only changes to specified resource types or properties (e.g., AWS::Lambda::Function, AWS::Lambda::Function.Code.S3Key)', default: [] },
},
},
'drift': {
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
toolkitStackName: toolkitStackName,
importExistingResources: args.importExistingResources,
includeMoves: args['include-moves'],
allowResourceChanges: args.allowResourceChanges,
});

case 'drift':
Expand Down Expand Up @@ -410,6 +411,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
? AssetBuildTime.ALL_BEFORE_DEPLOY
: AssetBuildTime.JUST_IN_TIME,
ignoreNoStacks: args.ignoreNoStacks,
allowResourceChanges: args.allowResourceChanges,
});

case 'rollback':
Expand Down
Loading
Loading