diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fbd89a09be23..0386d2dbfb9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.41.0](https://github.com/aws/aws-cdk/compare/v1.40.0...v1.41.0) (2020-05-21) + + +### Features + +* **cloudtrail:** create cloudwatch event without needing to create a Trail ([#8076](https://github.com/aws/aws-cdk/issues/8076)) ([0567a23](https://github.com/aws/aws-cdk/commit/0567a2360ac713e3171c9a82767611174dadb6c6)), closes [#6716](https://github.com/aws/aws-cdk/issues/6716) +* **cognito:** user pool - case sensitivity for sign in ([460394f](https://github.com/aws/aws-cdk/commit/460394f3dc4737cee80504d6c8ef106ecc3b67d5)), closes [#7988](https://github.com/aws/aws-cdk/issues/7988) [#7235](https://github.com/aws/aws-cdk/issues/7235) +* **core:** CfnJson enables intrinsics in hash keys ([#8099](https://github.com/aws/aws-cdk/issues/8099)) ([195cd40](https://github.com/aws/aws-cdk/commit/195cd405d9f0869875de2ec78661aee3af2c7c7d)), closes [#8084](https://github.com/aws/aws-cdk/issues/8084) +* **secretsmanager:** adds grantWrite to Secret ([#7858](https://github.com/aws/aws-cdk/issues/7858)) ([3fed84b](https://github.com/aws/aws-cdk/commit/3fed84ba9eec3f53c662966e366aa629209b7bf5)) +* **sns:** add support for subscription DLQ in SNS ([383cdb8](https://github.com/aws/aws-cdk/commit/383cdb86effeafdf5d0767ed379b16b3d78a933b)) +* **stepfunctions:** new service integration classes for Lambda, SNS, and SQS ([#7946](https://github.com/aws/aws-cdk/issues/7946)) ([c038848](https://github.com/aws/aws-cdk/commit/c0388483524832ca7863de4ee9c472b8ab39de8e)), closes [#6715](https://github.com/aws/aws-cdk/issues/6715) [#6489](https://github.com/aws/aws-cdk/issues/6489) + + +### Bug Fixes + +* **apigateway:** contextAccountId in AccessLogField incorrectly resolves to requestId ([7b89e80](https://github.com/aws/aws-cdk/commit/7b89e805c716fa73d41cc97fcb728634e7a59136)), closes [#7952](https://github.com/aws/aws-cdk/issues/7952) [#7951](https://github.com/aws/aws-cdk/issues/7951) +* **autoscaling:** add noDevice as a volume type ([#7253](https://github.com/aws/aws-cdk/issues/7253)) ([751958b](https://github.com/aws/aws-cdk/commit/751958b69225fdfc52622781c618f5a77f881fb6)), closes [#7242](https://github.com/aws/aws-cdk/issues/7242) + ## [1.40.0](https://github.com/aws/aws-cdk/compare/v1.39.0...v1.40.0) (2020-05-20) diff --git a/lerna.json b/lerna.json index b32aeb94bcf30..baa3940d032fb 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "tools/*" ], "rejectCycles": "true", - "version": "1.40.0" + "version": "1.41.0" } diff --git a/packages/@aws-cdk/aws-cloudtrail/README.md b/packages/@aws-cdk/aws-cloudtrail/README.md index 11f2f93760ff9..313f425125fb3 100644 --- a/packages/@aws-cdk/aws-cloudtrail/README.md +++ b/packages/@aws-cdk/aws-cloudtrail/README.md @@ -51,7 +51,9 @@ const trail = new cloudtrail.Trail(this, 'CloudTrail', { ``` This creates the same setup as above - but also logs events to a created CloudWatch Log stream. -By default, the created log group has a retention period of 365 Days, but this is also configurable. +By default, the created log group has a retention period of 365 Days, but this is also configurable +via the `cloudWatchLogsRetention` property. If you would like to specify the log group explicitly, +use the `cloudwatchLogGroup` property. For using CloudTrail event selector to log specific S3 events, you can use the `CloudTrailProps` configuration object. diff --git a/packages/@aws-cdk/aws-cloudtrail/lib/cloudtrail.ts b/packages/@aws-cdk/aws-cloudtrail/lib/cloudtrail.ts index d12c7be68dd8b..471587a0696ef 100644 --- a/packages/@aws-cdk/aws-cloudtrail/lib/cloudtrail.ts +++ b/packages/@aws-cdk/aws-cloudtrail/lib/cloudtrail.ts @@ -63,12 +63,19 @@ export interface TrailProps { readonly sendToCloudWatchLogs?: boolean; /** - * How long to retain logs in CloudWatchLogs. Ignored if sendToCloudWatchLogs is false + * How long to retain logs in CloudWatchLogs. + * Ignored if sendToCloudWatchLogs is false or if cloudWatchLogGroup is set. * - * @default logs.RetentionDays.OneYear + * @default logs.RetentionDays.ONE_YEAR */ readonly cloudWatchLogsRetention?: logs.RetentionDays; + /** + * Log Group to which CloudTrail to push logs to. Ignored if sendToCloudWatchLogs is set to false. + * @default - a new log group is created and used. + */ + readonly cloudWatchLogGroup?: logs.ILogGroup; + /** The AWS Key Management Service (AWS KMS) key ID that you want to use to encrypt CloudTrail logs. * * @default - No encryption. @@ -171,6 +178,12 @@ export class Trail extends Resource { */ public readonly trailSnsTopicArn: string; + /** + * The CloudWatch log group to which CloudTrail events are sent. + * `undefined` if `sendToCloudWatchLogs` property is false. + */ + public readonly logGroup?: logs.ILogGroup; + private s3bucket: s3.IBucket; private eventSelectors: EventSelector[] = []; @@ -200,19 +213,22 @@ export class Trail extends Resource { }, })); - let logGroup: logs.CfnLogGroup | undefined; let logsRole: iam.IRole | undefined; if (props.sendToCloudWatchLogs) { - logGroup = new logs.CfnLogGroup(this, 'LogGroup', { - retentionInDays: props.cloudWatchLogsRetention || logs.RetentionDays.ONE_YEAR, - }); + if (props.cloudWatchLogGroup) { + this.logGroup = props.cloudWatchLogGroup; + } else { + this.logGroup = new logs.LogGroup(this, 'LogGroup', { + retention: props.cloudWatchLogsRetention ?? logs.RetentionDays.ONE_YEAR, + }); + } logsRole = new iam.Role(this, 'LogsRole', { assumedBy: cloudTrailPrincipal }); logsRole.addToPolicy(new iam.PolicyStatement({ actions: ['logs:PutLogEvents', 'logs:CreateLogStream'], - resources: [logGroup.attrArn], + resources: [this.logGroup.logGroupArn], })); } @@ -234,8 +250,8 @@ export class Trail extends Resource { kmsKeyId: props.kmsKey && props.kmsKey.keyArn, s3BucketName: this.s3bucket.bucketName, s3KeyPrefix: props.s3KeyPrefix, - cloudWatchLogsLogGroupArn: logGroup && logGroup.attrArn, - cloudWatchLogsRoleArn: logsRole && logsRole.roleArn, + cloudWatchLogsLogGroupArn: this.logGroup?.logGroupArn, + cloudWatchLogsRoleArn: logsRole?.roleArn, snsTopicName: props.snsTopic, eventSelectors: this.eventSelectors, }); diff --git a/packages/@aws-cdk/aws-cloudtrail/test/cloudtrail.test.ts b/packages/@aws-cdk/aws-cloudtrail/test/cloudtrail.test.ts index 019b0a9472626..bfcb91c06ddba 100644 --- a/packages/@aws-cdk/aws-cloudtrail/test/cloudtrail.test.ts +++ b/packages/@aws-cdk/aws-cloudtrail/test/cloudtrail.test.ts @@ -2,7 +2,7 @@ import { SynthUtils } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import * as iam from '@aws-cdk/aws-iam'; import * as lambda from '@aws-cdk/aws-lambda'; -import { RetentionDays } from '@aws-cdk/aws-logs'; +import { LogGroup, RetentionDays } from '@aws-cdk/aws-logs'; import * as s3 from '@aws-cdk/aws-s3'; import { Stack } from '@aws-cdk/core'; import { ReadWriteType, Trail } from '../lib'; @@ -176,7 +176,7 @@ describe('cloudtrail', () => { Effect: 'Allow', Action: ['logs:PutLogEvents', 'logs:CreateLogStream'], Resource: { - 'Fn::GetAtt': ['MyAmazingCloudTrailLogGroupAAD65144', 'Arn'], + 'Fn::GetAtt': ['MyAmazingCloudTrailLogGroup2BE67F87', 'Arn'], }, }], }, @@ -205,6 +205,44 @@ describe('cloudtrail', () => { const trail: any = SynthUtils.synthesize(stack).template.Resources.MyAmazingCloudTrail54516E8D; expect(trail.DependsOn).toEqual([logsRolePolicyName, logsRoleName, 'MyAmazingCloudTrailS3Policy39C120B0']); }); + + test('enabled and with custom log group', () => { + const stack = getTestStack(); + const cloudWatchLogGroup = new LogGroup(stack, 'MyLogGroup', { + retention: RetentionDays.FIVE_DAYS, + }); + new Trail(stack, 'MyAmazingCloudTrail', { + sendToCloudWatchLogs: true, + cloudWatchLogsRetention: RetentionDays.ONE_WEEK, + cloudWatchLogGroup, + }); + + expect(stack).toHaveResource('AWS::Logs::LogGroup', { + RetentionInDays: 5, + }); + + expect(stack).toHaveResource('AWS::CloudTrail::Trail', { + CloudWatchLogsLogGroupArn: stack.resolve(cloudWatchLogGroup.logGroupArn), + }); + + expect(stack).toHaveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Resource: stack.resolve(cloudWatchLogGroup.logGroupArn), + }], + }, + }); + }); + + test('disabled', () => { + const stack = getTestStack(); + const t = new Trail(stack, 'MyAmazingCloudTrail', { + sendToCloudWatchLogs: false, + cloudWatchLogsRetention: RetentionDays.ONE_WEEK, + }); + expect(t.logGroup).toBeUndefined(); + expect(stack).not.toHaveResource('AWS::Logs::LogGroup'); + }); }); describe('with event selectors', () => { diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/lib/appscaling.ts b/packages/@aws-cdk/aws-cloudwatch-actions/lib/appscaling.ts index bcb4b2ed1c581..2241796f47d2b 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/lib/appscaling.ts +++ b/packages/@aws-cdk/aws-cloudwatch-actions/lib/appscaling.ts @@ -9,6 +9,10 @@ export class ApplicationScalingAction implements cloudwatch.IAlarmAction { constructor(private readonly stepScalingAction: appscaling.StepScalingAction) { } + /** + * Returns an alarm action configuration to use an ApplicationScaling StepScalingAction + * as an alarm action + */ public bind(_scope: cdk.Construct, _alarm: cloudwatch.IAlarm): cloudwatch.AlarmActionConfig { return { alarmActionArn: this.stepScalingAction.scalingPolicyArn }; } diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/lib/autoscaling.ts b/packages/@aws-cdk/aws-cloudwatch-actions/lib/autoscaling.ts index 577f56bd47fd5..5ec6e62fe246c 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/lib/autoscaling.ts +++ b/packages/@aws-cdk/aws-cloudwatch-actions/lib/autoscaling.ts @@ -9,6 +9,10 @@ export class AutoScalingAction implements cloudwatch.IAlarmAction { constructor(private readonly stepScalingAction: autoscaling.StepScalingAction) { } + /** + * Returns an alarm action configuration to use an AutoScaling StepScalingAction + * as an alarm action + */ public bind(_scope: cdk.Construct, _alarm: cloudwatch.IAlarm): cloudwatch.AlarmActionConfig { return { alarmActionArn: this.stepScalingAction.scalingPolicyArn }; } diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/lib/sns.ts b/packages/@aws-cdk/aws-cloudwatch-actions/lib/sns.ts index 0067cf4518c28..deb882be507b3 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/lib/sns.ts +++ b/packages/@aws-cdk/aws-cloudwatch-actions/lib/sns.ts @@ -9,6 +9,9 @@ export class SnsAction implements cloudwatch.IAlarmAction { constructor(private readonly topic: sns.ITopic) { } + /** + * Returns an alarm action configuration to use an SNS topic as an alarm action + */ public bind(_scope: Construct, _alarm: cloudwatch.IAlarm): cloudwatch.AlarmActionConfig { return { alarmActionArn: this.topic.topicArn }; } diff --git a/packages/@aws-cdk/aws-cloudwatch-actions/package.json b/packages/@aws-cdk/aws-cloudwatch-actions/package.json index eefec8c4a581d..8f8d1edd98766 100644 --- a/packages/@aws-cdk/aws-cloudwatch-actions/package.json +++ b/packages/@aws-cdk/aws-cloudwatch-actions/package.json @@ -90,13 +90,6 @@ "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "stable", - "awslint": { - "exclude": [ - "docs-public-apis:@aws-cdk/aws-cloudwatch-actions.ApplicationScalingAction.bind", - "docs-public-apis:@aws-cdk/aws-cloudwatch-actions.AutoScalingAction.bind", - "docs-public-apis:@aws-cdk/aws-cloudwatch-actions.SnsAction.bind" - ] - }, "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md index 22764ebe22de1..4ef361dd5512c 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md @@ -64,16 +64,17 @@ updated to allow the network traffic. #### Conditions It's possible to route traffic to targets based on conditions in the incoming -HTTP request. Path- and host-based conditions are supported. For example, the -following will route requests to the indicated AutoScalingGroup only if the -requested host in the request is either for `example.com/ok` or -`example.com/path`: +HTTP request. For example, the following will route requests to the indicated +AutoScalingGroup only if the requested host in the request is either for +`example.com/ok` or `example.com/path`: ```ts listener.addTargets('Example.Com Fleet', { priority: 10, - pathPatterns: ['/ok', '/path'], - hostHeader: 'example.com', + conditions: [ + ListenerCondition.hostHeaders(['example.com']), + ListenerCondition.pathPatterns(['/ok', '/path']), + ], port: 8080, targets: [asg] }); @@ -126,8 +127,10 @@ Here's an example of serving a fixed response at the `/ok` URL: ```ts listener.addAction('Fixed', { - pathPatterns: ['/ok'], priority: 10, + conditions: [ + ListenerCondition.pathPatterns(['/ok']), + ], action: ListenerAction.fixedResponse(200, { contentType: elbv2.ContentType.TEXT_PLAIN, messageBody: 'OK', diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener-rule.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener-rule.ts index c2eab3160fd6a..a8de884e9f2c0 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener-rule.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener-rule.ts @@ -4,6 +4,7 @@ import { IListenerAction } from '../shared/listener-action'; import { IApplicationListener } from './application-listener'; import { ListenerAction } from './application-listener-action'; import { IApplicationTargetGroup } from './application-target-group'; +import { ListenerCondition } from './conditions'; /** * Basic properties for defining a rule on a listener @@ -58,6 +59,15 @@ export interface BaseApplicationListenerRuleProps { */ readonly redirectResponse?: RedirectResponse; + /** + * Rule applies if matches the conditions. + * + * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html + * + * @default - No conditions. + */ + readonly conditions?: ListenerCondition[]; + /** * Rule applies if the requested host matches the indicated host * @@ -66,6 +76,7 @@ export interface BaseApplicationListenerRuleProps { * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#host-conditions * * @default - No host condition. + * @deprecated Use `conditions` instead. */ readonly hostHeader?: string; @@ -74,7 +85,7 @@ export interface BaseApplicationListenerRuleProps { * * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#path-conditions * @default - No path condition. - * @deprecated Use `pathPatterns` instead. + * @deprecated Use `conditions` instead. */ readonly pathPattern?: string; @@ -85,6 +96,7 @@ export interface BaseApplicationListenerRuleProps { * * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#path-conditions * @default - No path conditions. + * @deprecated Use `conditions` instead. */ readonly pathPatterns?: string[]; } @@ -187,7 +199,8 @@ export class ApplicationListenerRule extends cdk.Construct { */ public readonly listenerRuleArn: string; - private readonly conditions: {[key: string]: string[] | undefined} = {}; + private readonly conditions: ListenerCondition[]; + private readonly legacyConditions: {[key: string]: string[]} = {}; private readonly listener: IApplicationListener; private action?: IListenerAction; @@ -195,9 +208,11 @@ export class ApplicationListenerRule extends cdk.Construct { constructor(scope: cdk.Construct, id: string, props: ApplicationListenerRuleProps) { super(scope, id); + this.conditions = props.conditions || []; + const hasPathPatterns = props.pathPatterns || props.pathPattern; - if (!props.hostHeader && !hasPathPatterns) { - throw new Error('At least one of \'hostHeader\', \'pathPattern\' or \'pathPatterns\' is required when defining a load balancing rule.'); + if (this.conditions.length === 0 && !props.hostHeader && !hasPathPatterns) { + throw new Error('At least one of \'conditions\', \'hostHeader\', \'pathPattern\' or \'pathPatterns\' is required when defining a load balancing rule.'); } const possibleActions: Array = ['action', 'targetGroups', 'fixedResponse', 'redirectResponse']; @@ -248,9 +263,25 @@ export class ApplicationListenerRule extends cdk.Construct { /** * Add a non-standard condition to this rule + * + * If the condition conflicts with an already set condition, it will be overwritten by the one you specified. + * + * @deprecated use `addCondition` instead. */ public setCondition(field: string, values: string[] | undefined) { - this.conditions[field] = values; + if (values === undefined) { + delete this.legacyConditions[field]; + return; + } + + this.legacyConditions[field] = values; + } + + /** + * Add a non-standard condition to this rule + */ + public addCondition(condition: ListenerCondition) { + this.conditions.push(condition); } /** @@ -322,20 +353,28 @@ export class ApplicationListenerRule extends cdk.Construct { if (this.action === undefined) { return ['Listener rule needs at least one action']; } + + const legacyConditionFields = Object.keys(this.legacyConditions); + if (legacyConditionFields.length === 0 && this.conditions.length === 0) { + return ['Listener rule needs at least one condition']; + } + return []; } /** * Render the conditions for this rule */ - private renderConditions() { - const ret = new Array<{ field: string, values: string[] }>(); - for (const [field, values] of Object.entries(this.conditions)) { - if (values !== undefined) { - ret.push({ field, values }); - } - } - return ret; + private renderConditions(): any { + const legacyConditions = Object.entries(this.legacyConditions).map(([field, values]) => { + return { field, values }; + }); + const conditions = this.conditions.map(condition => condition.renderRawCondition()); + + return [ + ...legacyConditions, + ...conditions, + ]; } } diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts index c3560eb519993..c087f690f70e5 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts @@ -10,6 +10,7 @@ import { ApplicationListenerCertificate } from './application-listener-certifica import { ApplicationListenerRule, FixedResponse, RedirectResponse, validateFixedResponse, validateRedirectResponse } from './application-listener-rule'; import { IApplicationLoadBalancer } from './application-load-balancer'; import { ApplicationTargetGroup, IApplicationLoadBalancerTarget, IApplicationTargetGroup } from './application-target-group'; +import { ListenerCondition } from './conditions'; /** * Basic properties for an ApplicationListener @@ -274,6 +275,7 @@ export class ApplicationListener extends BaseListener implements IApplicationLis // TargetGroup.registerListener is called inside ApplicationListenerRule. new ApplicationListenerRule(this, id + 'Rule', { listener: this, + conditions: props.conditions, hostHeader: props.hostHeader, pathPattern: props.pathPattern, pathPatterns: props.pathPatterns, @@ -319,6 +321,7 @@ export class ApplicationListener extends BaseListener implements IApplicationLis }); this.addTargetGroups(id, { + conditions: props.conditions, hostHeader: props.hostHeader, pathPattern: props.pathPattern, pathPatterns: props.pathPatterns, @@ -618,6 +621,15 @@ export interface AddRuleProps { */ readonly priority?: number; + /** + * Rule applies if matches the conditions. + * + * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html + * + * @default - No conditions. + */ + readonly conditions?: ListenerCondition[]; + /** * Rule applies if the requested host matches the indicated host * @@ -628,6 +640,7 @@ export interface AddRuleProps { * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#host-conditions * * @default No host condition + * @deprecated Use `conditions` instead. */ readonly hostHeader?: string; @@ -640,7 +653,7 @@ export interface AddRuleProps { * * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#path-conditions * @default No path condition - * @deprecated Use `pathPatterns` instead. + * @deprecated Use `conditions` instead. */ readonly pathPattern?: string; @@ -653,6 +666,7 @@ export interface AddRuleProps { * * @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-listeners.html#path-conditions * @default - No path condition. + * @deprecated Use `conditions` instead. */ readonly pathPatterns?: string[]; } @@ -770,7 +784,11 @@ export interface AddRedirectResponseProps extends AddRuleProps, RedirectResponse } function checkAddRuleProps(props: AddRuleProps) { - if ((props.hostHeader !== undefined || props.pathPattern !== undefined || props.pathPatterns !== undefined) !== (props.priority !== undefined)) { - throw new Error('Setting \'pathPattern\' or \'hostHeader\' also requires \'priority\', and vice versa'); + const conditionsCount = props.conditions?.length || 0; + const hasAnyConditions = conditionsCount !== 0 || + props.hostHeader !== undefined || props.pathPattern !== undefined || props.pathPatterns !== undefined; + const hasPriority = props.priority !== undefined; + if (hasAnyConditions !== hasPriority) { + throw new Error('Setting \'conditions\', \'pathPattern\' or \'hostHeader\' also requires \'priority\', and vice versa'); } } diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/conditions.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/conditions.ts new file mode 100644 index 0000000000000..ba5ebce6f70e4 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/conditions.ts @@ -0,0 +1,190 @@ +/** + * ListenerCondition providers definition. + */ +export abstract class ListenerCondition { + /** + * Create a host-header listener rule condition + * + * @param values Hosts for host headers + */ + public static hostHeaders(values: string[]): ListenerCondition { + return new HostHeaderListenerCondition(values); + } + + /** + * Create a http-header listener rule condition + * + * @param name HTTP header name + * @param values HTTP header values + */ + public static httpHeader(name: string, values: string[]): ListenerCondition { + return new HttpHeaderListenerCondition(name, values); + } + + /** + * Create a http-request-method listener rule condition + * + * @param values HTTP request methods + */ + public static httpRequestMethods(values: string[]): ListenerCondition { + return new HttpRequestMethodListenerCondition(values); + } + + /** + * Create a path-pattern listener rule condition + * + * @param values Path patterns + */ + public static pathPatterns(values: string[]): ListenerCondition { + return new PathPatternListenerCondition(values); + } + + /** + * Create a query-string listener rule condition + * + * @param values Query string key/value pairs + */ + public static queryStrings(values: QueryStringCondition[]): ListenerCondition { + return new QueryStringListenerCondition(values); + } + + /** + * Create a source-ip listener rule condition + * + * @param values Source ips + */ + public static sourceIps(values: string[]): ListenerCondition { + return new SourceIpListenerCondition(values); + } + + /** + * Render the raw Cfn listener rule condition object. + */ + public abstract renderRawCondition(): any; +} + +/** + * Properties for the key/value pair of the query string + */ +export interface QueryStringCondition { + /** + * The query string key for the condition + * + * @default - Any key can be matched. + */ + readonly key?: string; + + /** + * The query string value for the condition + */ + readonly value: string; +} + +/** + * Host header config of the listener rule condition + */ +class HostHeaderListenerCondition extends ListenerCondition { + constructor(public readonly values: string[]) { + super(); + } + + public renderRawCondition(): any { + return { + field: 'host-header', + hostHeaderConfig: { + values: this.values, + }, + }; + } +} + +/** + * HTTP header config of the listener rule condition + */ +class HttpHeaderListenerCondition extends ListenerCondition { + constructor(public readonly name: string, public readonly values: string[]) { + super(); + } + + public renderRawCondition(): any { + return { + field: 'http-header', + httpHeaderConfig: { + httpHeaderName: this.name, + values: this.values, + }, + }; + } +} + +/** + * HTTP reqeust method config of the listener rule condition + */ +class HttpRequestMethodListenerCondition extends ListenerCondition { + constructor(public readonly values: string[]) { + super(); + } + + public renderRawCondition(): any { + return { + field: 'http-request-method', + httpRequestMethodConfig: { + values: this.values, + }, + }; + } +} + +/** + * Path pattern config of the listener rule condition + */ +class PathPatternListenerCondition extends ListenerCondition { + constructor(public readonly values: string[]) { + super(); + } + + public renderRawCondition(): any { + return { + field: 'path-pattern', + pathPatternConfig: { + values: this.values, + }, + }; + } +} + +/** + * Query string config of the listener rule condition + */ +class QueryStringListenerCondition extends ListenerCondition { + constructor(public readonly values: QueryStringCondition[]) { + super(); + } + + public renderRawCondition(): any { + return { + field: 'query-string', + queryStringConfig: { + values: this.values, + }, + }; + } +} + +/** + * Source ip config of the listener rule condition + */ +class SourceIpListenerCondition extends ListenerCondition { + constructor(public readonly values: string[]) { + super(); + } + + public renderRawCondition(): any { + return { + field: 'source-ip', + sourceIpConfig: { + values: this.values, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/index.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/index.ts index 1442972c82a50..9f8833b15bfda 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/index.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/index.ts @@ -7,6 +7,7 @@ export * from './alb/application-listener-rule'; export * from './alb/application-load-balancer'; export * from './alb/application-target-group'; export * from './alb/application-listener-action'; +export * from './alb/conditions'; export * from './nlb/network-listener'; export * from './nlb/network-load-balancer'; diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/test.listener.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/test.listener.ts index 7588119a3762f..825e4472db7cb 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/test.listener.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/test.listener.ts @@ -993,7 +993,253 @@ export = { test.done(); }, - 'Add path patterns to imported application listener'(test: Test) { + 'Add additonal condition to listener rule'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Stack'); + const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc }); + const group1 = new elbv2.ApplicationTargetGroup(stack, 'Group1', { vpc, port: 80 }); + const group2 = new elbv2.ApplicationTargetGroup(stack, 'Group2', { vpc, port: 81, protocol: elbv2.ApplicationProtocol.HTTP }); + + // WHEN + const listener = lb.addListener('Listener', { + port: 443, + certificateArns: ['cert1'], + defaultTargetGroups: [group2], + }); + listener.addTargetGroups('TargetGroup1', { + priority: 10, + conditions: [ + elbv2.ListenerCondition.hostHeaders(['app.test']), + elbv2.ListenerCondition.httpHeader('Accept', ['application/vnd.myapp.v2+json']), + ], + targetGroups: [group1], + }); + listener.addTargetGroups('TargetGroup2', { + priority: 20, + conditions: [ + elbv2.ListenerCondition.hostHeaders(['app.test']), + ], + targetGroups: [group2], + }); + + // THEN + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 10, + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: ['app.test'], + }, + }, + { + Field: 'http-header', + HttpHeaderConfig: { + HttpHeaderName: 'Accept', + Values: ['application/vnd.myapp.v2+json'], + }, + }, + ], + })); + + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 20, + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: ['app.test'], + }, + }, + ], + })); + + test.done(); + }, + + 'Add multiple additonal condition to listener rule'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Stack'); + const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc }); + const group1 = new elbv2.ApplicationTargetGroup(stack, 'Group1', { vpc, port: 80 }); + const group2 = new elbv2.ApplicationTargetGroup(stack, 'Group2', { vpc, port: 81, protocol: elbv2.ApplicationProtocol.HTTP }); + const group3 = new elbv2.ApplicationTargetGroup(stack, 'Group3', { vpc, port: 82, protocol: elbv2.ApplicationProtocol.HTTP }); + + // WHEN + const listener = lb.addListener('Listener', { + port: 443, + certificateArns: ['cert1'], + defaultTargetGroups: [group3], + }); + listener.addTargetGroups('TargetGroup1', { + priority: 10, + conditions: [ + elbv2.ListenerCondition.hostHeaders(['app.test']), + elbv2.ListenerCondition.sourceIps(['192.0.2.0/24']), + elbv2.ListenerCondition.queryStrings([{ key: 'version', value: '2' }, { value: 'foo*' }]), + ], + targetGroups: [group1], + }); + listener.addTargetGroups('TargetGroup2', { + priority: 20, + conditions: [ + elbv2.ListenerCondition.hostHeaders(['app.test']), + elbv2.ListenerCondition.httpHeader('Accept', ['application/vnd.myapp.v2+json']), + ], + targetGroups: [group1], + }); + listener.addTargetGroups('TargetGroup3', { + priority: 30, + conditions: [ + elbv2.ListenerCondition.hostHeaders(['app.test']), + elbv2.ListenerCondition.httpRequestMethods(['PUT', 'COPY', 'LOCK', 'MKCOL', 'MOVE', 'PROPFIND', 'PROPPATCH', 'UNLOCK']), + ], + targetGroups: [group2], + }); + listener.addTargetGroups('TargetGroup4', { + priority: 40, + conditions: [ + elbv2.ListenerCondition.hostHeaders(['app.test']), + ], + targetGroups: [group3], + }); + + // THEN + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 10, + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: ['app.test'], + }, + }, + { + Field: 'source-ip', + SourceIpConfig: { + Values: ['192.0.2.0/24'], + }, + }, + { + Field: 'query-string', + QueryStringConfig: { + Values: [ + { + Key: 'version', + Value: '2', + }, + { + Value: 'foo*', + }, + ], + }, + }, + ], + })); + + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 20, + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: ['app.test'], + }, + }, + { + Field: 'http-header', + HttpHeaderConfig: { + HttpHeaderName: 'Accept', + Values: ['application/vnd.myapp.v2+json'], + }, + }, + ], + })); + + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 30, + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: ['app.test'], + }, + }, + { + Field: 'http-request-method', + HttpRequestMethodConfig: { + Values: ['PUT', 'COPY', 'LOCK', 'MKCOL', 'MOVE', 'PROPFIND', 'PROPPATCH', 'UNLOCK'], + }, + }, + ], + })); + + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 40, + Conditions: [ + { + Field: 'host-header', + HostHeaderConfig: { + Values: ['app.test'], + }, + }, + ], + })); + + test.done(); + }, + + 'Can exist together legacy style conditions and modan style conditions'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Stack'); + const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc }); + const group1 = new elbv2.ApplicationTargetGroup(stack, 'Group1', { vpc, port: 80 }); + const group2 = new elbv2.ApplicationTargetGroup(stack, 'Group2', { vpc, port: 81, protocol: elbv2.ApplicationProtocol.HTTP }); + + // WHEN + const listener = lb.addListener('Listener', { + port: 443, + certificateArns: ['cert1'], + defaultTargetGroups: [group2], + }); + listener.addTargetGroups('TargetGroup1', { + hostHeader: 'app.test', + pathPattern: '/test', + conditions: [ + elbv2.ListenerCondition.sourceIps(['192.0.2.0/24']), + ], + priority: 10, + targetGroups: [group1], + }); + + // THEN + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::ListenerRule', { + Priority: 10, + Conditions: [ + { + Field: 'host-header', + Values: ['app.test'], + }, + { + Field: 'path-pattern', + Values: ['/test'], + }, + { + Field: 'source-ip', + SourceIpConfig: { + Values: ['192.0.2.0/24'], + }, + }, + ], + })); + + test.done(); + }, + + 'Add condition to imported application listener'(test: Test) { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'Stack'); diff --git a/packages/@aws-cdk/aws-events/lib/rule.ts b/packages/@aws-cdk/aws-events/lib/rule.ts index e8e7cfcfa3431..582cd570e8bc8 100644 --- a/packages/@aws-cdk/aws-events/lib/rule.ts +++ b/packages/@aws-cdk/aws-events/lib/rule.ts @@ -244,7 +244,7 @@ export class Rule extends Resource implements IRule { }); new CfnEventBusPolicy(eventBusPolicyStack, 'GivePermToOtherAccount', { action: 'events:PutEvents', - statementId: 'MySid', + statementId: `Allow-account-${sourceAccount}`, principal: sourceAccount, }); } diff --git a/packages/@aws-cdk/aws-events/test/test.rule.ts b/packages/@aws-cdk/aws-events/test/test.rule.ts index b478fb10ec0fa..304bf91ed4dcb 100644 --- a/packages/@aws-cdk/aws-events/test/test.rule.ts +++ b/packages/@aws-cdk/aws-events/test/test.rule.ts @@ -717,7 +717,7 @@ export = { const eventBusPolicyStack = app.node.findChild(`EventBusPolicy-${sourceAccount}-us-west-2-${targetAccount}`) as cdk.Stack; expect(eventBusPolicyStack).to(haveResourceLike('AWS::Events::EventBusPolicy', { 'Action': 'events:PutEvents', - 'StatementId': 'MySid', + 'StatementId': `Allow-account-${sourceAccount}`, 'Principal': sourceAccount, })); diff --git a/packages/@aws-cdk/aws-iam/README.md b/packages/@aws-cdk/aws-iam/README.md index 1145fbe14f0f5..3908a58d668e4 100644 --- a/packages/@aws-cdk/aws-iam/README.md +++ b/packages/@aws-cdk/aws-iam/README.md @@ -198,6 +198,11 @@ const principal = new iam.AccountPrincipal('123456789000') .withConditions({ StringEquals: { foo: "baz" } }); ``` +> NOTE: If you need to define an IAM condition that uses a token (such as a +> deploy-time attribute of another resource) in a JSON map key, use `CfnJson` to +> render this condition. See [this test](./test/integ-condition-with-ref.ts) for +> an example. + The `WebIdentityPrincipal` class can be used as a principal for web identities like Cognito, Amazon, Google or Facebook, for example: diff --git a/packages/@aws-cdk/aws-iam/lib/principals.ts b/packages/@aws-cdk/aws-iam/lib/principals.ts index 15f39638f5509..53e7aca1968d7 100644 --- a/packages/@aws-cdk/aws-iam/lib/principals.ts +++ b/packages/@aws-cdk/aws-iam/lib/principals.ts @@ -211,8 +211,24 @@ export class PrincipalWithConditions implements IPrincipal { Object.entries(principalConditions).forEach(([operator, condition]) => { mergedConditions[operator] = condition; }); + Object.entries(additionalConditions).forEach(([operator, condition]) => { - mergedConditions[operator] = { ...mergedConditions[operator], ...condition }; + // merge the conditions if one of the additional conditions uses an + // operator that's already used by the principal's conditions merge the + // inner structure. + const existing = mergedConditions[operator]; + if (!existing) { + mergedConditions[operator] = condition; + return; // continue + } + + // if either the existing condition or the new one contain unresolved + // tokens, fail the merge. this is as far as we go at this point. + if (cdk.Token.isUnresolved(condition) || cdk.Token.isUnresolved(existing)) { + throw new Error(`multiple "${operator}" conditions cannot be merged if one of them contains an unresolved token`); + } + + mergedConditions[operator] = { ...existing, ...condition }; }); return mergedConditions; } diff --git a/packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.expected.json b/packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.expected.json new file mode 100644 index 0000000000000..db82b0544e2bd --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.expected.json @@ -0,0 +1,165 @@ +{ + "Parameters": { + "PrincipalTag": { + "Type": "String", + "Default": "developer" + }, + "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3BucketEF5DD638": { + "Type": "String", + "Description": "S3 bucket for asset \"e02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57\"" + }, + "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3VersionKey2EA0DB2E": { + "Type": "String", + "Description": "S3 key for asset version \"e02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57\"" + }, + "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57ArtifactHash95B71D2D": { + "Type": "String", + "Description": "Artifact hash for asset \"e02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57\"" + } + }, + "Resources": { + "PrincipalTagCondition94CCB594": { + "Type": "Custom::AWSCDKCfnJson", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57", + "Arn" + ] + }, + "Value": { + "Fn::Join": [ + "", + [ + "{\"aws:PrincipalTag/", + { + "Ref": "PrincipalTag" + }, + "\":\"true\"}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + } + }, + "AWSCDKCfnUtilsProviderCustomResourceProviderHandlerCF82AA57": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3BucketEF5DD638" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3VersionKey2EA0DB2E" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameterse02a38b06730095e29b3afe60b65afcdc3a4ad4716c2f21de5fd5dc58e194f57S3VersionKey2EA0DB2E" + } + ] + } + ] + } + ] + ] + } + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867", + "Arn" + ] + }, + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "AWSCDKCfnUtilsProviderCustomResourceProviderRoleFE0EE867" + ] + }, + "MyRoleF48FFE04": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "Fn::GetAtt": [ + "PrincipalTagCondition94CCB594", + "Value" + ] + } + }, + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.ts b/packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.ts new file mode 100644 index 0000000000000..576f6bd835083 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/test/integ.condition-with-ref.ts @@ -0,0 +1,26 @@ +import { App, CfnJson, CfnParameter, Construct, Stack } from '@aws-cdk/core'; +import { AccountRootPrincipal, Role } from '../lib'; + +class MyStack extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + const tagName = new CfnParameter(this, 'PrincipalTag', { default: 'developer' }); + + const stringEquals = new CfnJson(this, 'PrincipalTagCondition', { + value: { + [`aws:PrincipalTag/${tagName.valueAsString}`]: 'true', + }, + }); + + const principal = new AccountRootPrincipal().withConditions({ + StringEquals: stringEquals, + }); + + new Role(this, 'MyRole', { assumedBy: principal }); + } +} + +const app = new App(); +new MyStack(app, 'test-condition-with-ref'); +app.synth(); diff --git a/packages/@aws-cdk/aws-iam/test/policy-document.test.ts b/packages/@aws-cdk/aws-iam/test/policy-document.test.ts index 2ea6c9848be0b..8de3aa2f56b0f 100644 --- a/packages/@aws-cdk/aws-iam/test/policy-document.test.ts +++ b/packages/@aws-cdk/aws-iam/test/policy-document.test.ts @@ -1,8 +1,8 @@ import '@aws-cdk/assert/jest'; import { Lazy, Stack, Token } from '@aws-cdk/core'; import { - AccountPrincipal, Anyone, AnyPrincipal, ArnPrincipal, CanonicalUserPrincipal, CompositePrincipal, Effect, - FederatedPrincipal, IPrincipal, PolicyDocument, PolicyStatement, PrincipalPolicyFragment, ServicePrincipal, + AccountPrincipal, Anyone, AnyPrincipal, ArnPrincipal, CanonicalUserPrincipal, CompositePrincipal, + Effect, FederatedPrincipal, IPrincipal, PolicyDocument, PolicyStatement, PrincipalPolicyFragment, ServicePrincipal, } from '../lib'; describe('IAM policy document', () => { @@ -543,6 +543,45 @@ describe('IAM policy document', () => { }); }); + test('tokens can be used in conditions', () => { + // GIVEN + const stack = new Stack(); + const statement = new PolicyStatement(); + + // WHEN + const p = new ArnPrincipal('arn:of:principal').withConditions({ + StringEquals: Lazy.anyValue({ produce: () => ({ goo: 'zar' })}), + }); + + statement.addPrincipals(p); + + // THEN + const resolved = stack.resolve(statement.toStatementJson()); + expect(resolved).toEqual({ + Condition: { + StringEquals: { + goo: 'zar', + }, + }, + Effect: 'Allow', + Principal: { + AWS: 'arn:of:principal', + }, + }); + }); + + test('conditions cannot be merged if they include tokens', () => { + const p = new FederatedPrincipal('fed', { + StringEquals: { foo: 'bar' }, + }).withConditions({ + StringEquals: Lazy.anyValue({ produce: () => ({ goo: 'zar' })}), + }); + + const statement = new PolicyStatement(); + + expect(() => statement.addPrincipals(p)).toThrow(/multiple "StringEquals" conditions cannot be merged if one of them contains an unresolved token/); + }); + test('values passed to `withConditions` overwrite values from the wrapped principal ' + 'when keys conflict within an operator', () => { const p = new FederatedPrincipal('fed', { diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 4808b94857a04..2790600cc3da3 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -815,3 +815,41 @@ const stack = new Stack(app, 'StackName', { ``` By default, termination protection is disabled. + +### CfnJson + +`CfnJson` allows you to postpone the resolution of a JSON blob from +deployment-time. This is useful in cases where the CloudFormation JSON template +cannot express a certain value. + +A common example is to use `CfnJson` in order to render a JSON map which needs +to use intrinsic functions in keys. Since JSON map keys must be strings, it is +impossible to use intrinsics in keys and `CfnJson` can help. + +The following example defines an IAM role which can only be assumed by +principals that are tagged with a specific tag. + +```ts +const tagParam = new CfnParameter(this, 'TagName'); + +const stringEquals = new CfnJson(this, 'ConditionJson', { + value: { + [`aws:PrincipalTag/${tagParam.valueAsString}`]: true + }, +}); + +const principal = new AccountRootPrincipal().withConditions({ + StringEquals: stringEquals, +}); + +new Role(this, 'MyRole', { assumedBy: principal }); +``` + +**Explanation**: since in this example we pass the tag name through a parameter, it +can only be resolved during deployment. The resolved value can be represented in +the template through a `{ "Ref": "TagName" }`. However, since we want to use +this value inside a [`aws:PrincipalTag/TAG-NAME`](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-principaltag) +IAM operator, we need it in the *key* of a `StringEquals` condition. JSON keys +*must be* strings, so to circumvent this limitation, we use `CfnJson` +to "delay" the rendition of this template section to deploy-time. This means +that the value of `StringEquals` in the template will be `{ "Fn::GetAtt": [ "ConditionJson", "Value" ] }`, and will only "expand" to the operator we synthesized during deployment. diff --git a/packages/@aws-cdk/core/lib/cfn-json.ts b/packages/@aws-cdk/core/lib/cfn-json.ts new file mode 100644 index 0000000000000..eee32038a0099 --- /dev/null +++ b/packages/@aws-cdk/core/lib/cfn-json.ts @@ -0,0 +1,76 @@ +import { Construct } from './construct-compat'; +import { CustomResource } from './custom-resource'; +import { CfnUtilsProvider } from './private/cfn-utils-provider'; +import { CfnUtilsResourceType } from './private/cfn-utils-provider/consts'; +import { Reference } from './reference'; +import { IResolvable, IResolveContext } from './resolvable'; +import { Stack } from './stack'; +import { captureStackTrace } from './stack-trace'; + +export interface CfnJsonProps { + /** + * The value to resolve. Can be any JavaScript object, including tokens and + * references in keys or values. + */ + readonly value: any; +} + +/** + * Captures a synthesis-time JSON object a CloudFormation reference which + * resolves during deployment to the resolved values of the JSON object. + * + * The main use case for this is to overcome a limitation in CloudFormation that + * does not allow using intrinsic functions as dictionary keys (because + * dictionary keys in JSON must be strings). Specifically this is common in IAM + * conditions such as `StringEquals: { lhs: "rhs" }` where you want "lhs" to be + * a reference. + * + * This object is resolvable, so it can be used as a value. + * + * This construct is backed by a custom resource. + */ +export class CfnJson extends Construct implements IResolvable { + public readonly creationStack: string[] = []; + + /** + * An Fn::GetAtt to the JSON object passed through `value` and resolved during + * synthesis. + * + * Normally there is no need to use this property since `CfnJson` is an + * IResolvable, so it can be simply used as a value. + */ + private readonly value: Reference; + + private readonly jsonString: string; + + constructor(scope: Construct, id: string, props: CfnJsonProps) { + super(scope, id); + + this.creationStack = captureStackTrace(); + + // stringify the JSON object in a token-aware way. + this.jsonString = Stack.of(this).toJsonString(props.value); + + const resource = new CustomResource(this, 'Resource', { + serviceToken: CfnUtilsProvider.getOrCreate(this), + resourceType: CfnUtilsResourceType.CFN_JSON, + properties: { + Value: this.jsonString, + }, + }); + + this.value = resource.getAtt('Value'); + } + + /** + * This is required in case someone JSON.stringifys an object which refrences + * this object. Otherwise, we'll get a cyclic JSON reference. + */ + public toJSON() { + return this.jsonString; + } + + public resolve(_: IResolveContext): any { + return this.value; + } +} diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 3c3cf2cd442ad..4e55122d5616f 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -25,6 +25,7 @@ export * from './stack'; export * from './cfn-element'; export * from './cfn-dynamic-reference'; export * from './cfn-tag'; +export * from './cfn-json'; export * from './removal-policy'; export * from './arn'; export * from './duration'; diff --git a/packages/@aws-cdk/core/lib/private/cfn-utils-provider.ts b/packages/@aws-cdk/core/lib/private/cfn-utils-provider.ts new file mode 100644 index 0000000000000..dae7253720041 --- /dev/null +++ b/packages/@aws-cdk/core/lib/private/cfn-utils-provider.ts @@ -0,0 +1,14 @@ +import { Construct } from '../construct-compat'; +import { CustomResourceProvider, CustomResourceProviderRuntime } from '../custom-resource-provider'; + +/** + * A custom resource provider for CFN utilities such as `CfnJson`. + */ +export class CfnUtilsProvider extends Construct { + public static getOrCreate(scope: Construct) { + return CustomResourceProvider.getOrCreate(scope, 'AWSCDKCfnUtilsProvider', { + runtime: CustomResourceProviderRuntime.NODEJS_12, + codeDirectory: `${__dirname}/cfn-utils-provider`, + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/private/cfn-utils-provider/consts.ts b/packages/@aws-cdk/core/lib/private/cfn-utils-provider/consts.ts new file mode 100644 index 0000000000000..b1571cabd5b42 --- /dev/null +++ b/packages/@aws-cdk/core/lib/private/cfn-utils-provider/consts.ts @@ -0,0 +1,9 @@ +/** + * Supported resource type. + */ +export const enum CfnUtilsResourceType { + /** + * CfnJson + */ + CFN_JSON = 'Custom::AWSCDKCfnJson' +} diff --git a/packages/@aws-cdk/core/lib/private/cfn-utils-provider/index.ts b/packages/@aws-cdk/core/lib/private/cfn-utils-provider/index.ts new file mode 100644 index 0000000000000..87bd6bb070e16 --- /dev/null +++ b/packages/@aws-cdk/core/lib/private/cfn-utils-provider/index.ts @@ -0,0 +1,22 @@ +import { CfnUtilsResourceType } from './consts'; + +/** + * Parses the value of "Value" and reflects it back as attribute. + */ +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) { + + // dispatch based on resource type + if (event.ResourceType === CfnUtilsResourceType.CFN_JSON) { + return cfnJsonHandler(event); + } + + throw new Error(`unexpected resource type "${event.ResourceType}`); +} + +function cfnJsonHandler(event: AWSLambda.CloudFormationCustomResourceEvent) { + return { + Data: { + Value: JSON.parse(event.ResourceProperties.Value), + }, + }; +} diff --git a/packages/@aws-cdk/core/lib/private/resolve.ts b/packages/@aws-cdk/core/lib/private/resolve.ts index 6ba2800e5a1fb..11cd65cc332b3 100644 --- a/packages/@aws-cdk/core/lib/private/resolve.ts +++ b/packages/@aws-cdk/core/lib/private/resolve.ts @@ -148,7 +148,7 @@ export function resolve(obj: any, options: IResolveOptions): any { for (const key of Object.keys(obj)) { const resolvedKey = makeContext()[0].resolve(key); if (typeof(resolvedKey) !== 'string') { - throw new Error(`"${key}" is used as the key in a map so must resolve to a string, but it resolves to: ${JSON.stringify(resolvedKey)}`); + throw new Error(`"${key}" is used as the key in a map so must resolve to a string, but it resolves to: ${JSON.stringify(resolvedKey)}. Consider using "CfnJson" to delay resolution to deployment-time`); } const value = makeContext(key)[0].resolve(obj[key]); diff --git a/packages/@aws-cdk/core/package.json b/packages/@aws-cdk/core/package.json index 91cc2fd4acf13..485821ebe8fb7 100644 --- a/packages/@aws-cdk/core/package.json +++ b/packages/@aws-cdk/core/package.json @@ -109,7 +109,8 @@ "construct-ctor:@aws-cdk/core.CustomResourceProvider", "construct-interface-extends-iconstruct:@aws-cdk/core.ICustomResourceProvider", "props-physical-name:@aws-cdk/core.CustomResourceProps", - "integ-return-type:@aws-cdk/core.IStackSynthesizer.bind" + "integ-return-type:@aws-cdk/core.IStackSynthesizer.bind", + "props-no-any:@aws-cdk/core.CfnJsonProps.value" ] }, "scripts": { diff --git a/packages/@aws-cdk/core/test/test.cfn-json.ts b/packages/@aws-cdk/core/test/test.cfn-json.ts new file mode 100644 index 0000000000000..02d3187482490 --- /dev/null +++ b/packages/@aws-cdk/core/test/test.cfn-json.ts @@ -0,0 +1,92 @@ +import { Test } from 'nodeunit'; +import { App, CfnResource, Lazy, Stack } from '../lib'; +import { CfnJson } from '../lib/cfn-json'; +import { CfnUtilsResourceType } from '../lib/private/cfn-utils-provider/consts'; +import { handler } from '../lib/private/cfn-utils-provider/index'; + +export = { + + 'resolves to a fn::getatt'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'test'); + + // WHEN + const json = new CfnJson(stack, 'MyCfnJson', { + value: { + hello: 1234, + world: { bar: 1234 }, + }, + }); + + // THEN + const template = app.synth().getStackArtifact(stack.artifactId).template; + + // input is stringified + test.deepEqual(template.Resources.MyCfnJson248769BB.Properties.Value, '{"hello":1234,"world":{"bar":1234}}'); + + // output is basically an Fn::GetAtt + test.deepEqual(stack.resolve(json), { 'Fn::GetAtt': [ 'MyCfnJson248769BB', 'Value' ] }); + + test.done(); + }, + + 'tokens and intrinsics can be used freely in keys or values'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'test'); + const other = new CfnResource(stack, 'Other', { type: 'MyResource' }); + + // WHEN + new CfnJson(stack, 'MyCfnJson', { + value: { + [other.ref]: 1234, + world: { + bar: `this is a ${Lazy.stringValue({ produce: () => 'I am lazy' })}`, + }, + }, + }); + + // THEN + const template = app.synth().getStackArtifact(stack.artifactId).template; + + test.deepEqual(template.Resources.MyCfnJson248769BB.Properties.Value, { + 'Fn::Join': [ '', [ '{"', { Ref: 'Other' }, '":1234,"world":{"bar":"this is a I am lazy"}}' ] ], + }); + test.done(); + }, + + 'JSON.stringify() will return the CFN-stringified value to avoid circular references'(test: Test) { + // GIVEN + const stack = new Stack(); + const res = new CfnResource(stack, 'MyResource', { type: 'Foo' }); + const cfnjson = new CfnJson(stack, 'MyCfnJson', { + value: { + [`ref=${res.ref}`]: `this is a ${Lazy.stringValue({ produce: () => 'I am lazy' })}`, + }, + }); + + // WHEN + const str = JSON.stringify(cfnjson); + + // THEN + test.ok(typeof(str) === 'string'); + test.deepEqual(stack.resolve(str), { + 'Fn::Join': [ '', [ '"{"ref=', { Ref: 'MyResource' }, '":"this is a I am lazy"}"' ] ], + }); + + test.done(); + }, + + async 'resource provider simply parses json and reflects back as an attribute'(test: Test) { + const input = { foo: 1234 }; + const response = await handler({ + ResourceType: CfnUtilsResourceType.CFN_JSON, + ResourceProperties: { + Value: JSON.stringify(input), + }, + } as any); + test.deepEqual(input, response.Data.Value); + test.done(); + }, +}; \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/cxapp/exec.ts b/packages/aws-cdk/lib/api/cxapp/exec.ts index db4d75f86fc38..93ddb1264f2ff 100644 --- a/packages/aws-cdk/lib/api/cxapp/exec.ts +++ b/packages/aws-cdk/lib/api/cxapp/exec.ts @@ -81,7 +81,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom if (error.message.includes(cxschema.VERSION_MISMATCH)) { // this means the CLI version is too old. // we instruct the user to upgrade. - throw new Error(`${error.message}.\nPlease upgrade your CLI in order to interact with this app.`); + throw new Error(`This CDK CLI is not compatible with the CDK library used by your application. Please upgrade the CLI to the latest version.\n(${error.message})`); } throw error; } diff --git a/packages/aws-cdk/test/api/exec.test.ts b/packages/aws-cdk/test/api/exec.test.ts index 057e50cb8e938..ec653da6d6a77 100644 --- a/packages/aws-cdk/test/api/exec.test.ts +++ b/packages/aws-cdk/test/api/exec.test.ts @@ -72,8 +72,8 @@ test('cli throws when manifest version > schema version', async () => { mockVersionNumber.restore(); } - const expectedError = `Cloud assembly schema version mismatch: Maximum schema version supported is ${currentSchemaVersion}, but found ${mockManifestVersion}.` - + '\nPlease upgrade your CLI in order to interact with this app.'; + const expectedError = 'This CDK CLI is not compatible with the CDK library used by your application. Please upgrade the CLI to the latest version.' + + `\n(Cloud assembly schema version mismatch: Maximum schema version supported is ${currentSchemaVersion}, but found ${mockManifestVersion})`; config.settings.set(['app'], 'cdk.out'); diff --git a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts index a84d942c6ae52..3c674470abe4c 100644 --- a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts @@ -92,6 +92,27 @@ test('deploy new style synthesis to new style bootstrap', async () => { }); }); +test('deploy old style synthesis to new style bootstrap', async () => { + const bootstrapStackName = fullStackName('bootstrap-stack'); + + await cdk(['bootstrap', + '--toolkit-stack-name', bootstrapStackName, + '--qualifier', QUALIFIER, + '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess', + ], { + modEnv: { + CDK_NEW_BOOTSTRAP: '1', + }, + }); + + // Deploy stack that uses file assets + await cdkDeploy('lambda', { + options: [ + '--toolkit-stack-name', bootstrapStackName, + ], + }); +}); + test('deploying new style synthesis to old style bootstrap fails', async () => { const bootstrapStackName = fullStackName('bootstrap-stack'); diff --git a/packages/aws-cdk/test/integ/cli/cli.integtest.ts b/packages/aws-cdk/test/integ/cli/cli.integtest.ts index a6cc887384353..731f901d90a0d 100644 --- a/packages/aws-cdk/test/integ/cli/cli.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/cli.integtest.ts @@ -316,8 +316,14 @@ test('deploy with role', async () => { options: ['--role-arn', roleArn], }); + // Immediately delete the stack again before we delete the role. + // + // Since roles are sticky, if we delete the role before the stack, subsequent DeleteStack + // operations will fail when CloudFormation tries to assume the role that's already gone. + await cdkDestroy('test-2'); + } finally { - deleteRole(); + await deleteRole(); } async function deleteRole() {