Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cli): IAM Policy changes not deploying with --hotswap-fallback #28185

Merged
merged 2 commits into from
Nov 30, 2023
Merged
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
15 changes: 12 additions & 3 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { DeployStackResult } from './deploy-stack';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
import { isHotswappableAppSyncChange } from './hotswap/appsync-mapping-templates';
import { isHotswappableCodeBuildProjectChange } from './hotswap/code-build-projects';
import { ICON, ChangeHotswapResult, HotswapMode, HotswappableChange, NonHotswappableChange, HotswappableChangeCandidate, ClassifiedResourceChanges, reportNonHotswappableChange } from './hotswap/common';
import { ICON, ChangeHotswapResult, HotswapMode, HotswappableChange, NonHotswappableChange, HotswappableChangeCandidate, ClassifiedResourceChanges, reportNonHotswappableChange, reportNonHotswappableResource } from './hotswap/common';
import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
import { skipChangeForS3DeployCustomResourcePolicy, isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
import { loadCurrentTemplateWithNestedStacks, NestedStackNames } from './nested-stack-helpers';
import { CloudFormationStack } from './util/cloudformation';
Expand All @@ -35,7 +35,16 @@ const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = {
'AWS::CodeBuild::Project': isHotswappableCodeBuildProjectChange,
'AWS::StepFunctions::StateMachine': isHotswappableStateMachineChange,
'Custom::CDKBucketDeployment': isHotswappableS3BucketDeploymentChange,
'AWS::IAM::Policy': isHotswappableS3BucketDeploymentChange,
'AWS::IAM::Policy': async (
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> => {
// If the policy is for a S3BucketDeploymentChange, we can ignore the change
if (await skipChangeForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate)) {
return [];
}

return reportNonHotswappableResource(change, 'This resource type is not supported for hotswap deployments');
},

'AWS::CDK::Metadata': async () => [],
};
Expand Down
79 changes: 35 additions & 44 deletions packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeHotswapResult, HotswappableChangeCandidate, reportNonHotswappableResource } from './common';
import { ChangeHotswapResult, HotswappableChangeCandidate } from './common';
import { ISDK } from '../aws-auth';
import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-template';

Expand All @@ -9,14 +9,11 @@ import { EvaluateCloudFormationTemplate } from '../evaluate-cloudformation-templ
export const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn';

export async function isHotswappableS3BucketDeploymentChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
_logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
// In old-style synthesis, the policy used by the lambda to copy assets Ref's the assets directly,
// meaning that the changes made to the Policy are artifacts that can be safely ignored
const ret: ChangeHotswapResult = [];
if (change.newValue.Type === 'AWS::IAM::Policy') {
return changeIsForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate);
}

if (change.newValue.Type !== 'Custom::CDKBucketDeployment') {
return [];
Expand Down Expand Up @@ -60,60 +57,54 @@ export async function isHotswappableS3BucketDeploymentChange(
return ret;
}

async function changeIsForS3DeployCustomResourcePolicy(
export async function skipChangeForS3DeployCustomResourcePolicy(
iamPolicyLogicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
const roles = change.newValue.Properties?.Roles;
if (!roles) {
return reportNonHotswappableResource(
change,
'This IAM Policy does not have have any Roles',
);
): Promise<boolean> {
if (change.newValue.Type !== 'AWS::IAM::Policy') {
return false;
}
const roles: string[] = change.newValue.Properties?.Roles;

// If no roles are referenced, the policy is definitely not used for a S3Deployment
if (!roles || !roles.length) {
return false;
}

// Check if every role this policy is referenced by is only used for a S3Deployment
for (const role of roles) {
const roleArn = await evaluateCfnTemplate.evaluateCfnExpression(role);
const roleLogicalId = await evaluateCfnTemplate.findLogicalIdForPhysicalName(roleArn);

// We must assume this role is used for something else, because we can't check it
if (!roleLogicalId) {
return reportNonHotswappableResource(
change,
`could not find logicalId for role with name '${roleArn}'`,
);
return false;
}

const roleRefs = evaluateCfnTemplate.findReferencesTo(roleLogicalId);
for (const roleRef of roleRefs) {
// Find all interesting reference to the role
const roleRefs = evaluateCfnTemplate.findReferencesTo(roleLogicalId)
// we are not interested in the reference from the original policy - it always exists
.filter(roleRef => !(roleRef.Type == 'AWS::IAM::Policy' && roleRef.LogicalId === iamPolicyLogicalId));

// Check if the role is only used for S3Deployment
// We know this is the case, if S3Deployment -> Lambda -> Role is satisfied for every reference
// And we have at least one reference.
const isRoleOnlyForS3Deployment = roleRefs.length >= 1 && roleRefs.every(roleRef => {
if (roleRef.Type === 'AWS::Lambda::Function') {
const lambdaRefs = evaluateCfnTemplate.findReferencesTo(roleRef.LogicalId);
for (const lambdaRef of lambdaRefs) {
// If S3Deployment -> Lambda -> Role and IAM::Policy -> Role, then this IAM::Policy change is an
// artifact of old-style synthesis
if (lambdaRef.Type !== 'Custom::CDKBucketDeployment') {
return reportNonHotswappableResource(
change,
`found an AWS::IAM::Policy that has Role '${roleLogicalId}' that is referred to by AWS::Lambda::Function '${roleRef.LogicalId}' that is referred to by ${lambdaRef.Type} '${lambdaRef.LogicalId}', which does not have type 'Custom::CDKBucketDeployment'`,
);
}
}
} else if (roleRef.Type === 'AWS::IAM::Policy') {
if (roleRef.LogicalId !== iamPolicyLogicalId) {
return reportNonHotswappableResource(
change,
`found an AWS::IAM::Policy that has Role '${roleLogicalId}' that is referred to by AWS::IAM::Policy '${roleRef.LogicalId}' that is not the policy of the s3 bucket deployment`,
);
}
} else {
return reportNonHotswappableResource(
change,
`found a resource which refers to the role '${roleLogicalId}' that is not of type AWS::Lambda::Function or AWS::IAM::Policy, so the bucket deployment cannot be hotswapped`,
);
// Every reference must be to the custom resource and at least one reference must be present
return lambdaRefs.length >= 1 && lambdaRefs.every(lambdaRef => lambdaRef.Type === 'Custom::CDKBucketDeployment');
}
return false;
});

// We have determined this role is used for something else, so we can't skip the change
if (!isRoleOnlyForS3Deployment) {
return false;
}
}

// this doesn't block the hotswap, but it also isn't a hotswappable change by itself. Return
// an empty change to signify this.
return [];
// We have checked that any use of this policy is only for S3Deployment and we can safely skip it
return true;
}

function stringifyObject(obj: any): any {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/* eslint-disable import/order */
import * as setup from './hotswap-test-setup';
import { HotswapMode } from '../../../lib/api/hotswap/common';

let hotswapMockSdkProvider: setup.HotswapMockSdkProvider;

beforeEach(() => {
hotswapMockSdkProvider = setup.setupHotswapTests();
});

describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hotswapMode) => {
test('A change to an IAM Policy results in a full deployment for HOTSWAP and a noOp for HOTSWAP_ONLY', async () => {
// GIVEN
setup.setCurrentCfnStackTemplate({
Resources: {
RoleOne: {
Type: 'AWS::IAM::Role',
Properties: {
AssumeRolePolicyDocument: {
Statement: [
{
Action: 'sts:AssumeRole',
Effect: 'Allow',
Principal: {
Service: 'sqs.amazonaws.com',
},
},
],
Version: '2012-10-17',
},
},
},
RoleDefaultPolicy: {
Type: 'AWS::IAM::Policy',
Properties: {
PolicyDocument: {
Statement: [
{
Action: [
'sqs:ChangeMessageVisibility',
'sqs:DeleteMessage',
'sqs:GetQueueAttributes',
'sqs:GetQueueUrl',
'sqs:ReceiveMessage',
],
Effect: 'Allow',
Resource: '*',
},
],
Version: '2012-10-17',
},
PolicyName: 'roleDefaultPolicy',
Roles: [
{
Ref: 'RoleOne',
},
],
},
},
},
});
setup.pushStackResourceSummaries({
LogicalResourceId: 'RoleOne',
PhysicalResourceId: 'RoleOne',
ResourceType: 'AWS::IAM::Role',
ResourceStatus: 'CREATE_COMPLETE',
LastUpdatedTimestamp: new Date(),
});
const cdkStackArtifact = setup.cdkStackArtifactOf({
template: {
Resources: {
RoleOne: {
Type: 'AWS::IAM::Role',
Properties: {
AssumeRolePolicyDocument: {
Statement: [
{
Action: 'sts:AssumeRole',
Effect: 'Allow',
Principal: {
Service: 'sqs.amazonaws.com',
},
},
],
Version: '2012-10-17',
},
},
},
RoleDefaultPolicy: {
Type: 'AWS::IAM::Policy',
Properties: {
PolicyDocument: {
Statement: [
{
Action: [
'sqs:DeleteMessage',
],
Effect: 'Allow',
Resource: '*',
},
],
Version: '2012-10-17',
},
PolicyName: 'roleDefaultPolicy',
Roles: [
{
Ref: 'RoleOne',
},
],
},
},
},
},
});

if (hotswapMode === HotswapMode.FALL_BACK) {
// WHEN
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact);

// THEN
expect(deployStackResult).toBeUndefined();
} else if (hotswapMode === HotswapMode.HOTSWAP_ONLY) {
// WHEN
const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact);

// THEN
expect(deployStackResult).not.toBeUndefined();
expect(deployStackResult?.noOp).toEqual(true);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,26 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot

test('calls the lambdaInvoke() API when it receives an asset difference in two S3 bucket deployments and IAM Policy differences using old-style synthesis', async () => {
// GIVEN
const deploymentLambda2Old = {
Type: 'AWS::Lambda::Function',
Role: {
'Fn::GetAtt': [
'ServiceRole',
'Arn',
],
},
};

const deploymentLambda2New = {
Type: 'AWS::Lambda::Function',
Role: {
'Fn::GetAtt': [
'ServiceRole2',
'Arn',
],
},
};

Comment on lines +621 to +640
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this existing test was unintentionally relying on undefined behavior.

Previously, the stack under would receive a change that changes Policy2 from being a policy that is used exclusively by S3Deployment, to a "free-floating" policy. This was because policy2Old -> policy2New is changing the role from ServiceRole -> ServiceRole2. However ServiceRole2 is not used by the S3Deployment.

Without adjusting the test case, it now would have failed because this new "free-floating" policy would have been detected as non-hotswappable and also not skippable since it is not connected to an S3Deployment anymore.
I believe the intent of the test was to test the skipping (based on test case description and assertions) and this accidentally worked. So I adjusted the test case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

const s3Deployment2Old = {
Type: 'Custom::CDKBucketDeployment',
Properties: {
Expand Down Expand Up @@ -655,7 +675,7 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot
Policy1: policyOld,
Policy2: policy2Old,
S3DeploymentLambda: deploymentLambda,
S3DeploymentLambda2: deploymentLambda,
S3DeploymentLambda2: deploymentLambda2Old,
S3Deployment: s3DeploymentOld,
S3Deployment2: s3Deployment2Old,
},
Expand All @@ -670,7 +690,7 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot
Policy1: policyNew,
Policy2: policy2New,
S3DeploymentLambda: deploymentLambda,
S3DeploymentLambda2: deploymentLambda,
S3DeploymentLambda2: deploymentLambda2New,
S3Deployment: s3DeploymentNew,
S3Deployment2: s3Deployment2New,
},
Expand Down
Loading