diff --git a/packages/@aws-cdk/aws-elasticsearch/README.md b/packages/@aws-cdk/aws-elasticsearch/README.md index bba696bd2a6c0..b7902ed654392 100644 --- a/packages/@aws-cdk/aws-elasticsearch/README.md +++ b/packages/@aws-cdk/aws-elasticsearch/README.md @@ -1,12 +1,163 @@ ## Amazon Elasticsearch Service Construct Library + --- -![cfn-resources: Stable](https://img.shields.io/badge/cfn--resources-stable-success.svg?style=for-the-badge) +| Features | Stability | +| --- | --- | +| CFN Resources | ![Stable](https://img.shields.io/badge/stable-success.svg?style=for-the-badge) | +| Higher level constructs for Domain | ![Experimental](https://img.shields.io/badge/experimental-important.svg?style=for-the-badge) | + +> **CFN Resources:** All classes with the `Cfn` prefix in this module ([CFN Resources](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) are always stable and safe to use. -> All classes with the `Cfn` prefix in this module ([CFN Resources](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) are always stable and safe to use. +> **Experimental:** Higher level constructs in this module that are marked as experimental are under active development. They are subject to non-backward compatible changes or removal in any future version. These are not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be announced in the release notes. This means that while you may use them, you may need to update your source code when upgrading to a newer version of this package. --- +Create a development cluster by simply specifying the version: + +```ts +import * as es from '@aws-cdk/aws-elasticsearch'; + +const devDomain = new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, +}); +``` + +Create a production grade cluster by also specifying things like capacity and az distribution + +```ts +const prodDomain = new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, + capacity: { + masterNodes: 5, + dataNodes: 20 + }, + ebs: { + volumeSize: 20 + }, + zoneAwareness: { + availabilityZoneCount: 3 + }, + logging: { + slowSearchLogEnabled: true, + appLogEnabled: true, + slowIndexLogEnabled: true, + }, +}); +``` + +This creates an Elasticsearch cluster and automatically sets up log groups for +logging the domain logs and slow search logs. + +### Importing existing domains + +To import an existing domain into your CDK application, use the `Domain.fromDomainEndpoint` factory method. +This method accepts a domain endpoint of an already existing domain: + +```ts +const domainEndpoint = 'https://my-domain-jcjotrt6f7otem4sqcwbch3c4u.us-east-1.es.amazonaws.com'; +const domain = Domain.fromDomainEndpoint(this, 'ImportedDomain', domainEndpoint); +``` + +### Permissions + +#### IAM + +Helper methods also exist for managing access to the domain. + +```ts +const lambda = new lambda.Function(this, 'Lambda', { /* ... */ }); + +// Grant write access to the app-search index +domain.grantIndexWrite('app-search', lambda); + +// Grant read access to the 'app-search/_search' path +domain.grantPathRead('app-search/_search', lambda); +``` + +### Encryption + +The domain can also be created with encryption enabled: + +```ts +const domain = new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_4, + ebs: { + volumeSize: 100, + volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + }, + nodeToNodeEncryption: true, + encryptionAtRest: { + enabled: true, + }, +}); +``` + +This sets up the domain with node to node encryption and encryption at +rest. You can also choose to supply your own KMS key to use for encryption at +rest. + +### Metrics + +Helper methods exist to access common domain metrics for example: + +```ts +const freeStorageSpace = domain.metricFreeStorageSpace(); +const masterSysMemoryUtilization = domain.metric('MasterSysMemoryUtilization'); +``` + This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. + +### Fine grained access control + +The domain can also be created with a master user configured. The password can +be supplied or dynamically created if not supplied. + +```ts +const domain = new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, + enforceHttps: true, + nodeToNodeEncryption: true, + encryptionAtRest: { + enabled: true, + }, + fineGrainedAccessControl: { + masterUserName: 'master-user', + }, +}); + +const masterUserPassword = domain.masterUserPassword; +``` + +### Using unsigned basic auth + +For convenience, the domain can be configured to allow unsigned HTTP requests +that use basic auth. Unless the domain is configured to be part of a VPC this +means anyone can access the domain using the configured master username and +password. + +To enable unsigned basic auth access the domain is configured with an access +policy that allows anyonmous requests, HTTPS required, node to node encryption, +encryption at rest and fine grained access control. + +If the above settings are not set they will be configured as part of enabling +unsigned basic auth. If they are set with conflicting values, an error will be +thrown. + +If no master user is configured a default master user is created with the +username `admin`. + +If no password is configured a default master user password is created and +stored in the AWS Secrets Manager as secret. The secret has the prefix +`MasterUser`. + +```ts +const domain = new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, + useUnsignedBasicAuth: true, +}); + +const masterUserPassword = domain.masterUserPassword; +``` diff --git a/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts b/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts new file mode 100644 index 0000000000000..406c3c6dd0314 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/lib/domain.ts @@ -0,0 +1,1570 @@ +import { URL } from 'url'; + +import { Metric, MetricOptions, Statistic } from '@aws-cdk/aws-cloudwatch'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as kms from '@aws-cdk/aws-kms'; +import * as logs from '@aws-cdk/aws-logs'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; + +import { ElasticsearchAccessPolicy } from './elasticsearch-access-policy'; +import { CfnDomain } from './elasticsearch.generated'; +import { LogGroupResourcePolicy } from './log-group-resource-policy'; +import * as perms from './perms'; + +/** + * Elasticsearch version + */ +export class ElasticsearchVersion { + /** AWS Elasticsearch 1.5 */ + public static readonly V1_5 = ElasticsearchVersion.of('1.5'); + + /** AWS Elasticsearch 2.3 */ + public static readonly V2_3 = ElasticsearchVersion.of('2.3'); + + /** AWS Elasticsearch 5.1 */ + public static readonly V5_1 = ElasticsearchVersion.of('5.1'); + + /** AWS Elasticsearch 5.3 */ + public static readonly V5_3 = ElasticsearchVersion.of('5.3'); + + /** AWS Elasticsearch 5.5 */ + public static readonly V5_5 = ElasticsearchVersion.of('5.5'); + + /** AWS Elasticsearch 5.6 */ + public static readonly V5_6 = ElasticsearchVersion.of('5.6'); + + /** AWS Elasticsearch 6.0 */ + public static readonly V6_0 = ElasticsearchVersion.of('6.0'); + + /** AWS Elasticsearch 6.2 */ + public static readonly V6_2 = ElasticsearchVersion.of('6.2'); + + /** AWS Elasticsearch 6.3 */ + public static readonly V6_3 = ElasticsearchVersion.of('6.3'); + + /** AWS Elasticsearch 6.4 */ + public static readonly V6_4 = ElasticsearchVersion.of('6.4'); + + /** AWS Elasticsearch 6.5 */ + public static readonly V6_5 = ElasticsearchVersion.of('6.5'); + + /** AWS Elasticsearch 6.7 */ + public static readonly V6_7 = ElasticsearchVersion.of('6.7'); + + /** AWS Elasticsearch 6.8 */ + public static readonly V6_8 = ElasticsearchVersion.of('6.8'); + + /** AWS Elasticsearch 7.1 */ + public static readonly V7_1 = ElasticsearchVersion.of('7.1'); + + /** AWS Elasticsearch 7.4 */ + public static readonly V7_4 = ElasticsearchVersion.of('7.4'); + + /** AWS Elasticsearch 7.7 */ + public static readonly V7_7 = ElasticsearchVersion.of('7.7'); + + /** + * Custom Elasticsearch version + * @param version custom version number + */ + public static of(version: string) { return new ElasticsearchVersion(version); } + + /** + * + * @param version Elasticsearch version number + */ + private constructor(public readonly version: string) { } +} + +/** + * Configures the capacity of the cluster such as the instance type and the + * number of instances. + */ +export interface CapacityConfig { + /** + * The number of instances to use for the master node. + * + * @default - no dedicated master nodes + */ + readonly masterNodes?: number; + + /** + * The hardware configuration of the computer that hosts the dedicated master + * node, such as `m3.medium.elasticsearch`. For valid values, see [Supported + * Instance Types] + * (https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-supported-instance-types.html) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default - r5.large.elasticsearch + */ + readonly masterNodeInstanceType?: string; + + /** + * The number of data nodes (instances) to use in the Amazon ES domain. + * + * @default - 1 + */ + readonly dataNodes?: number; + + /** + * The instance type for your data nodes, such as + * `m3.medium.elasticsearch`. For valid values, see [Supported Instance + * Types](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-supported-instance-types.html) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default - r5.large.elasticsearch + */ + readonly dataNodeInstanceType?: string; +} + +/** + * Specifies zone awareness configuration options. + */ +export interface ZoneAwarenessConfig { + /** + * Indicates whether to enable zone awareness for the Amazon ES domain. + * When you enable zone awareness, Amazon ES allocates the nodes and replica + * index shards that belong to a cluster across two Availability Zones (AZs) + * in the same region to prevent data loss and minimize downtime in the event + * of node or data center failure. Don't enable zone awareness if your cluster + * has no replica index shards or is a single-node cluster. For more information, + * see [Configuring a Multi-AZ Domain] + * (https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html#es-managedomains-multiaz) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default - false + */ + readonly enabled?: boolean; + + /** + * If you enabled multiple Availability Zones (AZs), the number of AZs that you + * want the domain to use. Valid values are 2 and 3. + * + * @default - 2 if zone awareness is enabled. + */ + readonly availabilityZoneCount?: number; +} + +/** + * The configurations of Amazon Elastic Block Store (Amazon EBS) volumes that + * are attached to data nodes in the Amazon ES domain. For more information, see + * [Configuring EBS-based Storage] + * (https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html#es-createdomain-configure-ebs) + * in the Amazon Elasticsearch Service Developer Guide. + */ +export interface EbsOptions { + /** + * Specifies whether Amazon EBS volumes are attached to data nodes in the + * Amazon ES domain. + * + * @default - true + */ + readonly enabled?: boolean; + + /** + * The number of I/O operations per second (IOPS) that the volume + * supports. This property applies only to the Provisioned IOPS (SSD) EBS + * volume type. + * + * @default - iops are not set. + */ + readonly iops?: number; + + /** + * The size (in GiB) of the EBS volume for each data node. The minimum and + * maximum size of an EBS volume depends on the EBS volume type and the + * instance type to which it is attached. For more information, see + * [Configuring EBS-based Storage] + * (https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html#es-createdomain-configure-ebs) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default 10 + */ + readonly volumeSize?: number; + + /** + * The EBS volume type to use with the Amazon ES domain, such as standard, gp2, io1, st1, or sc1. + * For more information, see[Configuring EBS-based Storage] + * (https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html#es-createdomain-configure-ebs) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default gp2 + */ + readonly volumeType?: ec2.EbsDeviceVolumeType; +} + +/** + * Configures log settings for the domain. + */ +export interface LoggingOptions { + /** + * Specify if slow search logging should be set up. + * Requires Elasticsearch version 5.1 or later. + * + * @default - false + */ + readonly slowSearchLogEnabled?: boolean; + + /** + * Log slow searches to this log group. + * + * @default - a new log group is created if slow search logging is enabled + */ + readonly slowSearchLogGroup?: logs.ILogGroup; + + /** + * Specify if slow index logging should be set up. + * Requires Elasticsearch version 5.1 or later. + * + * @default - false + */ + readonly slowIndexLogEnabled?: boolean; + + /** + * Log slow indices to this log group. + * + * @default - a new log group is created if slow index logging is enabled + */ + readonly slowIndexLogGroup?: logs.ILogGroup; + + /** + * Specify if Elasticsearch application logging should be set up. + * Requires Elasticsearch version 5.1 or later. + * + * @default - false + */ + readonly appLogEnabled?: boolean; + + /** + * Log Elasticsearch application logs to this log group. + * + * @default - a new log group is created if app logging is enabled + */ + readonly appLogGroup?: logs.ILogGroup; +} + +/** + * Whether the domain should encrypt data at rest, and if so, the AWS Key + * Management Service (KMS) key to use. Can only be used to create a new domain, + * not update an existing one. Requires Elasticsearch version 5.1 or later. + */ +export interface EncryptionAtRestOptions { + /** + * Specify true to enable encryption at rest. + * + * @default - encryption at rest is disabled. + */ + readonly enabled?: boolean; + + /** + * Supply if using KMS key for encryption at rest. + * + * @default - uses default aws/es KMS key. + */ + readonly kmsKey?: kms.IKey; +} + +/** + * Configures Amazon ES to use Amazon Cognito authentication for Kibana. + * @see https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-cognito-auth.html + */ +export interface CognitoOptions { + /** + * The Amazon Cognito identity pool ID that you want Amazon ES to use for Kibana authentication. + */ + readonly identityPoolId: string; + + /** + * A role that allows Amazon ES to configure your user pool and identity pool. It must have the `AmazonESCognitoAccess` policy attached to it. + * + * @see https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-cognito-auth.html#es-cognito-auth-prereq + */ + readonly role: iam.IRole; + + /** + * The Amazon Cognito user pool ID that you want Amazon ES to use for Kibana authentication. + */ + readonly userPoolId: string; +} + +/** + * The virtual private cloud (VPC) configuration for the Amazon ES domain. For + * more information, see [VPC Support for Amazon Elasticsearch Service + * Domains](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-vpc.html) + * in the Amazon Elasticsearch Service Developer Guide. + */ +export interface VpcOptions { + /** + * The list of security groups that are associated with the VPC endpoints + * for the domain. If you don't provide a security group ID, Amazon ES uses + * the default security group for the VPC. To learn more, see [Security Groups for your VPC] + * (https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html) in the Amazon VPC + * User Guide. + */ + readonly securityGroups: ec2.ISecurityGroup[]; + + /** + * Provide one subnet for each Availability Zone that your domain uses. For + * example, you must specify three subnet IDs for a three Availability Zone + * domain. To learn more, see [VPCs and Subnets] + * (https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Subnets.html) in the + * Amazon VPC User Guide. + */ + readonly subnets: ec2.ISubnet[]; +} + +/** + * The minimum TLS version required for traffic to the domain. + */ +export enum TLSSecurityPolicy { + /** Cipher suite TLS 1.0 */ + TLS_1_0 = 'Policy-Min-TLS-1-0-2019-07', + /** Cipher suite TLS 1.2 */ + TLS_1_2 = 'Policy-Min-TLS-1-2-2019-07' +} + +/** + * Specifies options for fine-grained access control. + */ +export interface AdvancedSecurityOptions { + /** + * ARN for the master user. Only specify this or masterUserName, but not both. + * + * @default - fine-grained access control is disabled + */ + readonly masterUserArn?: string; + + /** + * Username for the master user. Only specify this or masterUserArn, but not both. + * + * @default - fine-grained access control is disabled + */ + readonly masterUserName?: string; + + /** + * Password for the master user. + * + * You can use `SecretValue.plainText` to specify a password in plain text or + * use `secretsmanager.Secret.fromSecretAttributes` to reference a secret in + * Secrets Manager. + * + * @default - A Secrets Manager generated password + */ + readonly masterUserPassword?: cdk.SecretValue; +} + +/** + * Properties for an AWS Elasticsearch Domain. + */ +export interface DomainProps { + /** + * Domain Access policies. + * + * @default - No access policies. + */ + readonly accessPolicies?: iam.PolicyStatement[]; + + /** + * Additional options to specify for the Amazon ES domain. + * + * @default - no advanced options are specified + */ + readonly advancedOptions?: { [key: string]: (string) }; + + /** + * Configures Amazon ES to use Amazon Cognito authentication for Kibana. + * + * @default - Cognito not used for authentication to Kibana. + */ + readonly cognitoKibanaAuth?: CognitoOptions; + + /** + * Enforces a particular physical domain name. + * + * @default - A name will be auto-generated. + */ + readonly domainName?: string; + + /** + * The configurations of Amazon Elastic Block Store (Amazon EBS) volumes that + * are attached to data nodes in the Amazon ES domain. For more information, see + * [Configuring EBS-based Storage] + * (https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-createupdatedomains.html#es-createdomain-configure-ebs) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default - 10 GiB General Purpose (SSD) volumes per node. + */ + readonly ebs?: EbsOptions; + + /** + * The cluster capacity configuration for the Amazon ES domain. + * + * @default - 1 r5.large.elasticsearch data node; no dedicated master nodes. + */ + readonly capacity?: CapacityConfig; + + /** + * The cluster zone awareness configuration for the Amazon ES domain. + * + * @default - no zone awareness (1 AZ) + */ + readonly zoneAwareness?: ZoneAwarenessConfig; + + /** + * The Elasticsearch version that your domain will leverage. + */ + readonly version: ElasticsearchVersion; + + /** + * Encryption at rest options for the cluster. + * + * @default - No encryption at rest + */ + readonly encryptionAtRest?: EncryptionAtRestOptions; + + /** + * Configuration log publishing configuration options. + * + * @default - No logs are published + */ + readonly logging?: LoggingOptions; + + /** + * Specify true to enable node to node encryption. + * Requires Elasticsearch version 6.0 or later. + * + * @default - Node to node encryption is not enabled. + */ + readonly nodeToNodeEncryption?: boolean; + + /** + * The hour in UTC during which the service takes an automated daily snapshot + * of the indices in the Amazon ES domain. Only applies for Elasticsearch + * versions below 5.3. + * + * @default - Hourly automated snapshots not used + */ + readonly automatedSnapshotStartHour?: number; + + /** + * The virtual private cloud (VPC) configuration for the Amazon ES domain. For + * more information, see [VPC Support for Amazon Elasticsearch Service + * Domains](https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-vpc.html) + * in the Amazon Elasticsearch Service Developer Guide. + * + * @default - VPC not used + */ + readonly vpcOptions?: VpcOptions; + + /** + * True to require that all traffic to the domain arrive over HTTPS. + * + * @default - false + */ + readonly enforceHttps?: boolean; + + /** + * The minimum TLS version required for traffic to the domain. + * + * @default - TLSSecurityPolicy.TLS_1_0 + */ + readonly tlsSecurityPolicy?: TLSSecurityPolicy; + + /** + * Specifies options for fine-grained access control. + * Requires Elasticsearch version 6.7 or later. Enabling fine-grained access control + * also requires encryption of data at rest and node-to-node encryption, along with + * enforced HTTPS. + * + * @default - fine-grained access control is disabled + */ + readonly fineGrainedAccessControl?: AdvancedSecurityOptions; + + /** + * Configures the domain so that unsigned basic auth is enabled. If no master user is provided a default master user + * with username `admin` and a dynamically generated password stored in KMS is created. The password can be retrieved + * by getting `masterUserPassword` from the domain instance. + * + * Setting this to true will also add an access policy that allows unsigned + * access, enable node to node encryption, encryption at rest. If conflicting + * settings are encountered (like disabling encryption at rest) enabling this + * setting will cause a failure. + * + * @default - false + */ + readonly useUnsignedBasicAuth?: boolean; +} + +/** + * An interface that represents an Elasticsearch domain - either created with the CDK, or an existing one. + */ +export interface IDomain extends cdk.IResource { + /** + * Arn of the Elasticsearch domain. + * + * @attribute + */ + readonly domainArn: string; + + /** + * Domain name of the Elasticsearch domain. + * + * @attribute + */ + readonly domainName: string; + + /** + * Endpoint of the Elasticsearch domain. + * + * @attribute + */ + readonly domainEndpoint: string; + + /** + * Grant read permissions for this domain and its contents to an IAM + * principal (Role/Group/User). + * + * @param identity The principal + */ + grantRead(identity: iam.IGrantable): iam.Grant; + + /** + * Grant write permissions for this domain and its contents to an IAM + * principal (Role/Group/User). + * + * @param identity The principal + */ + grantWrite(identity: iam.IGrantable): iam.Grant; + + /** + * Grant read/write permissions for this domain and its contents to an IAM + * principal (Role/Group/User). + * + * @param identity The principal + */ + grantReadWrite(identity: iam.IGrantable): iam.Grant; + + /** + * Grant read permissions for an index in this domain to an IAM + * principal (Role/Group/User). + * + * @param index The index to grant permissions for + * @param identity The principal + */ + grantIndexRead(index: string, identity: iam.IGrantable): iam.Grant; + + /** + * Grant write permissions for an index in this domain to an IAM + * principal (Role/Group/User). + * + * @param index The index to grant permissions for + * @param identity The principal + */ + grantIndexWrite(index: string, identity: iam.IGrantable): iam.Grant; + + /** + * Grant read/write permissions for an index in this domain to an IAM + * principal (Role/Group/User). + * + * @param index The index to grant permissions for + * @param identity The principal + */ + grantIndexReadWrite(index: string, identity: iam.IGrantable): iam.Grant; + + /** + * Grant read permissions for a specific path in this domain to an IAM + * principal (Role/Group/User). + * + * @param path The path to grant permissions for + * @param identity The principal + */ + grantPathRead(path: string, identity: iam.IGrantable): iam.Grant; + + /** + * Grant write permissions for a specific path in this domain to an IAM + * principal (Role/Group/User). + * + * @param path The path to grant permissions for + * @param identity The principal + */ + grantPathWrite(path: string, identity: iam.IGrantable): iam.Grant; + + /** + * Grant read/write permissions for a specific path in this domain to an IAM + * principal (Role/Group/User). + * + * @param path The path to grant permissions for + * @param identity The principal + */ + grantPathReadWrite(path: string, identity: iam.IGrantable): iam.Grant; + + /** + * Return the given named metric for this Domain. + */ + metric(metricName: string, props?: MetricOptions): Metric; + + /** + * Metric for the time the cluster status is red. + * + * @default maximum over 5 minutes + */ + metricClusterStatusRed(props?: MetricOptions): Metric; + + /** + * Metric for the time the cluster status is yellow. + * + * @default maximum over 5 minutes + */ + metricClusterStatusYellow(props?: MetricOptions): Metric; + + /** + * Metric for the storage space of nodes in the cluster. + * + * @default minimum over 5 minutes + */ + metricFreeStorageSpace(props?: MetricOptions): Metric; + + /** + * Metric for the cluster blocking index writes. + * + * @default maximum over 1 minute + */ + metricClusterIndexWriteBlocked(props?: MetricOptions): Metric; + + /** + * Metric for the number of nodes. + * + * @default minimum over 1 hour + */ + metricNodes(props?: MetricOptions): Metric; + + /** + * Metric for automated snapshot failures. + * + * @default maximum over 5 minutes + */ + metricAutomatedSnapshotFailure(props?: MetricOptions): Metric; + + /** + * Metric for CPU utilization. + * + * @default maximum over 5 minutes + */ + metricCPUUtilization(props?: MetricOptions): Metric; + + /** + * Metric for JVM memory pressure. + * + * @default maximum over 5 minutes + */ + metricJVMMemoryPressure(props?: MetricOptions): Metric; + + /** + * Metric for master CPU utilization. + * + * @default maximum over 5 minutes + */ + metricMasterCPUUtilization(props?: MetricOptions): Metric; + + /** + * Metric for master JVM memory pressure. + * + * @default maximum over 5 minutes + */ + metricMasterJVMMemoryPressure(props?: MetricOptions): Metric; + + /** + * Metric for KMS key errors. + * + * @default maximum over 5 minutes + */ + metricKMSKeyError(props?: MetricOptions): Metric; + + /** + * Metric for KMS key being inaccessible. + * + * @default maximum over 5 minutes + */ + metricKMSKeyInaccessible(props?: MetricOptions): Metric; + + /** + * Metric for number of searchable documents. + * + * @default maximum over 5 minutes + */ + metricSearchableDocuments(props?: MetricOptions): Metric; + + /** + * Metric for search latency. + * + * @default p99 over 5 minutes + */ + metricSearchLatency(props?: MetricOptions): Metric; + + /** + * Metric for indexing latency. + * + * @default p99 over 5 minutes + */ + metricIndexingLatency(props?: MetricOptions): Metric; +} + + +/** + * A new or imported domain. + */ +abstract class DomainBase extends cdk.Resource implements IDomain { + public abstract readonly domainArn: string; + public abstract readonly domainName: string; + public abstract readonly domainEndpoint: string; + + /** + * Grant read permissions for this domain and its contents to an IAM + * principal (Role/Group/User). + * + * @param identity The principal + */ + grantRead(identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_READ_ACTIONS, + this.domainArn, + `${this.domainArn}/*`, + ); + } + + /** + * Grant write permissions for this domain and its contents to an IAM + * principal (Role/Group/User). + * + * @param identity The principal + */ + grantWrite(identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_WRITE_ACTIONS, + this.domainArn, + `${this.domainArn}/*`, + ); + } + + /** + * Grant read/write permissions for this domain and its contents to an IAM + * principal (Role/Group/User). + * + * @param identity The principal + */ + grantReadWrite(identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_READ_WRITE_ACTIONS, + this.domainArn, + `${this.domainArn}/*`, + ); + } + + /** + * Grant read permissions for an index in this domain to an IAM + * principal (Role/Group/User). + * + * @param index The index to grant permissions for + * @param identity The principal + */ + grantIndexRead(index: string, identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_READ_ACTIONS, + `${this.domainArn}/${index}`, + `${this.domainArn}/${index}/*`, + ); + } + + /** + * Grant write permissions for an index in this domain to an IAM + * principal (Role/Group/User). + * + * @param index The index to grant permissions for + * @param identity The principal + */ + grantIndexWrite(index: string, identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_WRITE_ACTIONS, + `${this.domainArn}/${index}`, + `${this.domainArn}/${index}/*`, + ); + } + + /** + * Grant read/write permissions for an index in this domain to an IAM + * principal (Role/Group/User). + * + * @param index The index to grant permissions for + * @param identity The principal + */ + grantIndexReadWrite(index: string, identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_READ_WRITE_ACTIONS, + `${this.domainArn}/${index}`, + `${this.domainArn}/${index}/*`, + ); + } + + /** + * Grant read permissions for a specific path in this domain to an IAM + * principal (Role/Group/User). + * + * @param path The path to grant permissions for + * @param identity The principal + */ + grantPathRead(path: string, identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_READ_ACTIONS, + `${this.domainArn}/${path}`, + ); + } + + /** + * Grant write permissions for a specific path in this domain to an IAM + * principal (Role/Group/User). + * + * @param path The path to grant permissions for + * @param identity The principal + */ + grantPathWrite(path: string, identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_WRITE_ACTIONS, + `${this.domainArn}/${path}`, + ); + } + + /** + * Grant read/write permissions for a specific path in this domain to an IAM + * principal (Role/Group/User). + * + * @param path The path to grant permissions for + * @param identity The principal + */ + grantPathReadWrite(path: string, identity: iam.IGrantable): iam.Grant { + return this.grant( + identity, + perms.ES_READ_WRITE_ACTIONS, + `${this.domainArn}/${path}`, + ); + } + + /** + * Return the given named metric for this Domain. + */ + public metric(metricName: string, props?: MetricOptions): Metric { + return new Metric({ + namespace: 'AWS/ES', + metricName, + dimensions: { + DomainName: this.domainName, + ClientId: this.stack.account, + }, + ...props, + }); + } + + /** + * Metric for the time the cluster status is red. + * + * @default maximum over 5 minutes + */ + public metricClusterStatusRed(props?: MetricOptions): Metric { + return this.metric('ClusterStatus.red', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for the time the cluster status is yellow. + * + * @default maximum over 5 minutes + */ + public metricClusterStatusYellow(props?: MetricOptions): Metric { + return this.metric('ClusterStatus.yellow', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for the storage space of nodes in the cluster. + * + * @default minimum over 5 minutes + */ + public metricFreeStorageSpace(props?: MetricOptions): Metric { + return this.metric('FreeStorageSpace', { + statistic: Statistic.MINIMUM, + ...props, + }); + } + + /** + * Metric for the cluster blocking index writes. + * + * @default maximum over 1 minute + */ + public metricClusterIndexWriteBlocked(props?: MetricOptions): Metric { + return this.metric('ClusterIndexWriteBlocked', { + statistic: Statistic.MAXIMUM, + period: cdk.Duration.minutes(1), + ...props, + }); + } + + /** + * Metric for the number of nodes. + * + * @default minimum over 1 hour + */ + public metricNodes(props?: MetricOptions): Metric { + return this.metric('Nodes', { + statistic: Statistic.MINIMUM, + period: cdk.Duration.hours(1), + ...props, + }); + } + + /** + * Metric for automated snapshot failures. + * + * @default maximum over 5 minutes + */ + public metricAutomatedSnapshotFailure(props?: MetricOptions): Metric { + return this.metric('AutomatedSnapshotFailure', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for CPU utilization. + * + * @default maximum over 5 minutes + */ + public metricCPUUtilization(props?: MetricOptions): Metric { + return this.metric('CPUUtilization', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for JVM memory pressure. + * + * @default maximum over 5 minutes + */ + public metricJVMMemoryPressure(props?: MetricOptions): Metric { + return this.metric('JVMMemoryPressure', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for master CPU utilization. + * + * @default maximum over 5 minutes + */ + public metricMasterCPUUtilization(props?: MetricOptions): Metric { + return this.metric('MasterCPUUtilization', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for master JVM memory pressure. + * + * @default maximum over 5 minutes + */ + public metricMasterJVMMemoryPressure(props?: MetricOptions): Metric { + return this.metric('MasterJVMMemoryPressure', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for KMS key errors. + * + * @default maximum over 5 minutes + */ + public metricKMSKeyError(props?: MetricOptions): Metric { + return this.metric('KMSKeyError', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for KMS key being inaccessible. + * + * @default maximum over 5 minutes + */ + public metricKMSKeyInaccessible(props?: MetricOptions): Metric { + return this.metric('KMSKeyInaccessible', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for number of searchable documents. + * + * @default maximum over 5 minutes + */ + public metricSearchableDocuments(props?: MetricOptions): Metric { + return this.metric('SearchableDocuments', { + statistic: Statistic.MAXIMUM, + ...props, + }); + } + + /** + * Metric for search latency. + * + * @default p99 over 5 minutes + */ + public metricSearchLatency(props?: MetricOptions): Metric { + return this.metric('SearchLatencyP99', { statistic: 'p99', ...props }); + } + + /** + * Metric for indexing latency. + * + * @default p99 over 5 minutes + */ + public metricIndexingLatency(props?: MetricOptions): Metric { + return this.metric('IndexingLatencyP99', { statistic: 'p99', ...props }); + } + + private grant( + grantee: iam.IGrantable, + domainActions: string[], + resourceArn: string, + ...otherResourceArns: string[] + ): iam.Grant { + const resourceArns = [resourceArn, ...otherResourceArns]; + + const grant = iam.Grant.addToPrincipal({ + grantee, + actions: domainActions, + resourceArns, + scope: this, + }); + + return grant; + } +} + + +/** + * Reference to an Elasticsearch domain. + */ +export interface DomainAttributes { + /** + * The ARN of the Elasticsearch domain. + */ + readonly domainArn: string; + + /** + * The domain endpoint of the Elasticsearch domain. + */ + readonly domainEndpoint: string; +} + + +/** + * Provides an Elasticsearch domain. + */ +export class Domain extends DomainBase implements IDomain { + /** + * Creates a Domain construct that represents an external domain via domain endpoint. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param domainEndpoint The domain's endpoint. + */ + public static fromDomainEndpoint( + scope: Construct, + id: string, + domainEndpoint: string, + ): IDomain { + const stack = cdk.Stack.of(scope); + const domainName = extractNameFromEndpoint(domainEndpoint); + const domainArn = stack.formatArn({ + service: 'es', + resource: 'domain', + resourceName: domainName, + }); + + return Domain.fromDomainAttributes(scope, id, { + domainArn, + domainEndpoint, + }); + } + + /** + * Creates a Domain construct that represents an external domain. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param attrs A `DomainAttributes` object. + */ + public static fromDomainAttributes(scope: Construct, id: string, attrs: DomainAttributes): IDomain { + const { domainArn, domainEndpoint } = attrs; + const domainName = extractNameFromEndpoint(domainEndpoint); + + return new class extends DomainBase { + public readonly domainArn = domainArn; + public readonly domainName = domainName; + public readonly domainEndpoint = domainEndpoint; + + constructor() { super(scope, id); } + }; + } + + public readonly domainArn: string; + public readonly domainName: string; + public readonly domainEndpoint: string; + + /** + * Log group that slow searches are logged to. + * + * @attribute + */ + public readonly slowSearchLogGroup?: logs.ILogGroup; + + /** + * Log group that slow indices are logged to. + * + * @attribute + */ + public readonly slowIndexLogGroup?: logs.ILogGroup; + + /** + * Log group that application logs are logged to. + * + * @attribute + */ + public readonly appLogGroup?: logs.ILogGroup; + + /** + * Master user password if fine grained access control is configured. + */ + public readonly masterUserPassword?: cdk.SecretValue; + + + private readonly domain: CfnDomain; + + constructor(scope: Construct, id: string, props: DomainProps) { + super(scope, id, { + physicalName: props.domainName, + }); + + const defaultInstanceType = 'r5.large.elasticsearch'; + + const dedicatedMasterType = + props.capacity?.masterNodeInstanceType?.toLowerCase() ?? + defaultInstanceType; + const dedicatedMasterCount = props.capacity?.masterNodes ?? 0; + const dedicatedMasterEnabled = dedicatedMasterCount > 0; + + const instanceType = + props.capacity?.dataNodeInstanceType?.toLowerCase() ?? + defaultInstanceType; + const instanceCount = props.capacity?.dataNodes ?? 1; + + const availabilityZoneCount = + props.zoneAwareness?.availabilityZoneCount ?? 2; + + if (![2, 3].includes(availabilityZoneCount)) { + throw new Error('Invalid zone awareness configuration; availabilityZoneCount must be 2 or 3'); + } + + const zoneAwarenessEnabled = + props.zoneAwareness?.enabled ?? + props.zoneAwareness?.availabilityZoneCount != null; + + // If VPC options are supplied ensure that the number of subnets matches the number AZ + if (props.vpcOptions != null && zoneAwarenessEnabled && + new Set(props.vpcOptions?.subnets.map((subnet) => subnet.availabilityZone)).size < availabilityZoneCount) { + throw new Error('When providing vpc options you need to provide a subnet for each AZ you are using'); + }; + + if ([dedicatedMasterType, instanceType].some(t => !t.endsWith('.elasticsearch'))) { + throw new Error('Master and data node instance types must end with ".elasticsearch".'); + } + + const elasticsearchVersion = props.version.version; + const elasticsearchVersionNum = parseVersion(props.version); + + if ( + elasticsearchVersionNum <= 7.7 && + ![ + 1.5, 2.3, 5.1, 5.3, 5.5, 5.6, 6.0, + 6.2, 6.3, 6.4, 6.5, 6.7, 6.8, 7.1, 7.4, + 7.7, + ].includes(elasticsearchVersionNum) + ) { + throw new Error(`Unknown Elasticsearch version: ${elasticsearchVersion}`); + } + + const unsignedBasicAuthEnabled = props.useUnsignedBasicAuth ?? false; + + if (unsignedBasicAuthEnabled) { + if (props.enforceHttps == false) { + throw new Error('You cannot disable HTTPS and use unsigned basic auth'); + } + if (props.nodeToNodeEncryption == false) { + throw new Error('You cannot disable node to node encryption and use unsigned basic auth'); + } + if (props.encryptionAtRest?.enabled == false) { + throw new Error('You cannot disable encryption at rest and use unsigned basic auth'); + } + } + + const unsignedAccessPolicy = new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['es:ESHttp*'], + principals: [new iam.Anyone()], + resources: [cdk.Lazy.stringValue({ produce: () => `${this.domainArn}/*` })], + }); + + const masterUserArn = props.fineGrainedAccessControl?.masterUserArn; + const masterUserNameProps = props.fineGrainedAccessControl?.masterUserName; + // If basic auth is enabled set the user name to admin if no other user info is supplied. + const masterUserName = unsignedBasicAuthEnabled + ? (masterUserArn == null ? (masterUserNameProps ?? 'admin') : undefined) + : masterUserNameProps; + + if (masterUserArn != null && masterUserName != null) { + throw new Error('Invalid fine grained access control settings. Only provide one of master user ARN or master user name. Not both.'); + } + + const advancedSecurityEnabled = (masterUserArn ?? masterUserName) != null; + const internalUserDatabaseEnabled = masterUserName != null; + const masterUserPasswordProp = props.fineGrainedAccessControl?.masterUserPassword; + const createMasterUserPassword = (): cdk.SecretValue => { + return new secretsmanager.Secret(this, 'MasterUser', { + generateSecretString: { + secretStringTemplate: JSON.stringify({ + username: masterUserName, + }), + generateStringKey: 'password', + excludeCharacters: "{}'\\*[]()`", + }, + }) + .secretValueFromJson('password'); + }; + this.masterUserPassword = internalUserDatabaseEnabled ? + (masterUserPasswordProp ?? createMasterUserPassword()) + : undefined; + + const encryptionAtRestEnabled = + props.encryptionAtRest?.enabled ?? (props.encryptionAtRest?.kmsKey != null || unsignedBasicAuthEnabled); + const nodeToNodeEncryptionEnabled = props.nodeToNodeEncryption ?? unsignedBasicAuthEnabled; + const volumeSize = props.ebs?.volumeSize ?? 10; + const volumeType = props.ebs?.volumeType ?? ec2.EbsDeviceVolumeType.GENERAL_PURPOSE_SSD; + const ebsEnabled = props.ebs?.enabled ?? true; + const enforceHttps = props.enforceHttps ?? unsignedBasicAuthEnabled; + + function isInstanceType(t: string): Boolean { + return dedicatedMasterType.startsWith(t) || instanceType.startsWith(t); + }; + + function isSomeInstanceType(...instanceTypes: string[]): Boolean { + return instanceTypes.some(isInstanceType); + }; + + function isEveryInstanceType(...instanceTypes: string[]): Boolean { + return instanceTypes.some(t => dedicatedMasterType.startsWith(t)) + && instanceTypes.some(t => instanceType.startsWith(t)); + }; + + // Validate feature support for the given Elasticsearch version, per + // https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-features-by-version.html + if (elasticsearchVersionNum < 5.1) { + if ( + props.logging?.slowIndexLogEnabled + || props.logging?.appLogEnabled + || props.logging?.slowSearchLogEnabled + ) { + throw new Error('Error and slow logs publishing requires Elasticsearch version 5.1 or later.'); + } + if (props.encryptionAtRest?.enabled) { + throw new Error('Encryption of data at rest requires Elasticsearch version 5.1 or later.'); + } + if (props.cognitoKibanaAuth != null) { + throw new Error('Cognito authentication for Kibana requires Elasticsearch version 5.1 or later.'); + } + if (isSomeInstanceType('c5', 'i3', 'm5', 'r5')) { + throw new Error('C5, I3, M5, and R5 instance types require Elasticsearch version 5.1 or later.'); + } + } + + if (elasticsearchVersionNum < 6.0) { + if (props.nodeToNodeEncryption) { + throw new Error('Node-to-node encryption requires Elasticsearch version 6.0 or later.'); + } + } + + if (elasticsearchVersionNum < 6.7) { + if (unsignedBasicAuthEnabled) { + throw new Error('Using unsigned basic auth requires Elasticsearch version 6.7 or later.'); + } + if (advancedSecurityEnabled) { + throw new Error('Fine-grained access control requires Elasticsearch version 6.7 or later.'); + } + } + + // Validate against instance type restrictions, per + // https://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/aes-supported-instance-types.html + if (isInstanceType('i3') && ebsEnabled) { + throw new Error('I3 instance types do not support EBS storage volumes.'); + } + + if (isSomeInstanceType('m3', 'r3', 't2') && encryptionAtRestEnabled) { + throw new Error('M3, R3, and T2 instance types do not support encryption of data at rest.'); + } + + if (isInstanceType('t2.micro') && elasticsearchVersionNum > 2.3) { + throw new Error('The t2.micro.elasticsearch instance type supports only Elasticsearch 1.5 and 2.3.'); + } + + // Only R3 and I3 support instance storage, per + // https://aws.amazon.com/elasticsearch-service/pricing/ + if (!ebsEnabled && !isEveryInstanceType('r3', 'i3')) { + throw new Error('EBS volumes are required when using instance types other than r3 or i3.'); + } + + // Fine-grained access control requires node-to-node encryption, encryption at rest, + // and enforced HTTPS. + if (advancedSecurityEnabled) { + if (!nodeToNodeEncryptionEnabled) { + throw new Error('Node-to-node encryption is required when fine-grained access control is enabled.'); + } + if (!encryptionAtRestEnabled) { + throw new Error('Encryption-at-rest is required when fine-grained access control is enabled.'); + } + if (!enforceHttps) { + throw new Error('Enforce HTTPS is required when fine-grained access control is enabled.'); + } + } + + let cfnVpcOptions: CfnDomain.VPCOptionsProperty | undefined; + if (props.vpcOptions) { + cfnVpcOptions = { + securityGroupIds: props.vpcOptions.securityGroups.map((sg) => sg.securityGroupId), + subnetIds: props.vpcOptions.subnets.map((subnet) => subnet.subnetId), + }; + } + + // Setup logging + const logGroups: logs.ILogGroup[] = []; + + if (props.logging?.slowSearchLogEnabled) { + this.slowSearchLogGroup = props.logging.slowSearchLogGroup ?? + new logs.LogGroup(scope, 'SlowSearchLogs', { + retention: logs.RetentionDays.ONE_MONTH, + }); + + logGroups.push(this.slowSearchLogGroup); + }; + + if (props.logging?.slowIndexLogEnabled) { + this.slowIndexLogGroup = props.logging.slowIndexLogGroup ?? + new logs.LogGroup(scope, 'SlowIndexLogs', { + retention: logs.RetentionDays.ONE_MONTH, + }); + + logGroups.push(this.slowIndexLogGroup); + }; + + if (props.logging?.appLogEnabled) { + this.appLogGroup = props.logging.appLogGroup ?? + new logs.LogGroup(scope, 'AppLogs', { + retention: logs.RetentionDays.ONE_MONTH, + }); + + logGroups.push(this.appLogGroup); + }; + + let logGroupResourcePolicy: LogGroupResourcePolicy | null = null; + if (logGroups.length > 0) { + const logPolicyStatement = new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['logs:PutLogEvents', 'logs:CreateLogStream'], + resources: logGroups.map((lg) => lg.logGroupArn), + principals: [new iam.ServicePrincipal('es.amazonaws.com')], + }); + + // Use a custom resource to set the log group resource policy since it is not supported by CDK and cfn. + // https://github.com/aws/aws-cdk/issues/5343 + logGroupResourcePolicy = new LogGroupResourcePolicy(this, 'ESLogGroupPolicy', { + policyName: 'ESLogPolicy', + policyStatements: [logPolicyStatement], + }); + } + + // Create the domain + this.domain = new CfnDomain(this, 'Resource', { + domainName: this.physicalName, + elasticsearchVersion, + elasticsearchClusterConfig: { + dedicatedMasterEnabled, + dedicatedMasterCount: dedicatedMasterEnabled + ? dedicatedMasterCount + : undefined, + dedicatedMasterType: dedicatedMasterEnabled + ? dedicatedMasterType + : undefined, + instanceCount, + instanceType, + zoneAwarenessEnabled, + zoneAwarenessConfig: zoneAwarenessEnabled + ? { availabilityZoneCount } + : undefined, + }, + ebsOptions: { + ebsEnabled, + volumeSize: ebsEnabled ? volumeSize : undefined, + volumeType: ebsEnabled ? volumeType : undefined, + iops: ebsEnabled ? props.ebs?.iops : undefined, + }, + encryptionAtRestOptions: { + enabled: encryptionAtRestEnabled, + kmsKeyId: encryptionAtRestEnabled + ? props.encryptionAtRest?.kmsKey?.keyId + : undefined, + }, + nodeToNodeEncryptionOptions: { enabled: nodeToNodeEncryptionEnabled }, + logPublishingOptions: { + ES_APPLICATION_LOGS: { + enabled: this.appLogGroup != null, + cloudWatchLogsLogGroupArn: this.appLogGroup?.logGroupArn, + }, + SEARCH_SLOW_LOGS: { + enabled: this.slowSearchLogGroup != null, + cloudWatchLogsLogGroupArn: this.slowSearchLogGroup?.logGroupArn, + }, + INDEX_SLOW_LOGS: { + enabled: this.slowIndexLogGroup != null, + cloudWatchLogsLogGroupArn: this.slowIndexLogGroup?.logGroupArn, + }, + }, + cognitoOptions: { + enabled: props.cognitoKibanaAuth != null, + identityPoolId: props.cognitoKibanaAuth?.identityPoolId, + roleArn: props.cognitoKibanaAuth?.role.roleArn, + userPoolId: props.cognitoKibanaAuth?.userPoolId, + }, + vpcOptions: cfnVpcOptions, + snapshotOptions: props.automatedSnapshotStartHour + ? { automatedSnapshotStartHour: props.automatedSnapshotStartHour } + : undefined, + domainEndpointOptions: { + enforceHttps, + tlsSecurityPolicy: props.tlsSecurityPolicy ?? TLSSecurityPolicy.TLS_1_0, + }, + advancedSecurityOptions: advancedSecurityEnabled + ? { + enabled: true, + internalUserDatabaseEnabled, + masterUserOptions: { + masterUserArn: masterUserArn, + masterUserName: masterUserName, + masterUserPassword: this.masterUserPassword?.toString(), + }, + } + : undefined, + }); + + if (logGroupResourcePolicy) { this.domain.node.addDependency(logGroupResourcePolicy); } + + if (props.domainName) { this.node.addMetadata('aws:cdk:hasPhysicalName', props.domainName); } + + this.domainName = this.getResourceNameAttribute(this.domain.ref); + + this.domainEndpoint = this.domain.getAtt('DomainEndpoint').toString(); + + this.domainArn = this.getResourceArnAttribute(this.domain.attrArn, { + service: 'es', + resource: 'domain', + resourceName: this.physicalName, + }); + + const accessPolicyStatements: iam.PolicyStatement[] | undefined = unsignedBasicAuthEnabled + ? (props.accessPolicies ?? []).concat(unsignedAccessPolicy) + : props.accessPolicies; + + if (accessPolicyStatements != null) { + const accessPolicy = new ElasticsearchAccessPolicy(this, 'ESAccessPolicy', { + domainName: this.domainName, + domainArn: this.domainArn, + accessPolicies: accessPolicyStatements, + }); + + accessPolicy.node.addDependency(this.domain); + } + } +} + +/** + * Given an Elasticsearch domain endpoint, returns a CloudFormation expression that + * extracts the domain name. + * + * Domain endpoints look like this: + * + * https://example-domain-jcjotrt6f7otem4sqcwbch3c4u.us-east-1.es.amazonaws.com + * https://-..es.amazonaws.com + * + * ..which means that in order to extract the domain name from the endpoint, we can + * split the endpoint using "-" and select the component in index 0. + * + * @param domainEndpoint The Elasticsearch domain endpoint + */ +function extractNameFromEndpoint(domainEndpoint: string) { + const { hostname } = new URL(domainEndpoint); + const domain = hostname.split('.')[0]; + const suffix = '-' + domain.split('-').slice(-1)[0]; + return domain.split(suffix)[0]; +} + +/** + * Converts an Elasticsearch version into a into a decimal number with major and minor version i.e x.y. + * + * @param version The Elasticsearch version object + */ +function parseVersion(version: ElasticsearchVersion): number { + const versionStr = version.version; + const firstDot = versionStr.indexOf('.'); + + if (firstDot < 1) { + throw new Error(`Invalid Elasticsearch version: ${versionStr}. Version string needs to start with major and minor version (x.y).`); + } + + const secondDot = versionStr.indexOf('.', firstDot + 1); + + try { + if (secondDot == -1) { + return parseFloat(versionStr); + } else { + return parseFloat(versionStr.substring(0, secondDot)); + } + } catch (error) { + throw new Error(`Invalid Elasticsearch version: ${versionStr}. Version string needs to start with major and minor version (x.y).`); + } +} diff --git a/packages/@aws-cdk/aws-elasticsearch/lib/elasticsearch-access-policy.ts b/packages/@aws-cdk/aws-elasticsearch/lib/elasticsearch-access-policy.ts new file mode 100644 index 0000000000000..78e0e4e8c5003 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/lib/elasticsearch-access-policy.ts @@ -0,0 +1,50 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import * as cr from '@aws-cdk/custom-resources'; + +/** + * Construction properties for ElasticsearchAccessPolicy + */ +export interface ElasticsearchAccessPolicyProps { + /** + * The Elasticsearch Domain name + */ + readonly domainName: string; + + /** + * The Elasticsearch Domain ARN + */ + readonly domainArn: string; + + /** + * The access policy statements for the Elasticsearch cluster + */ + readonly accessPolicies: iam.PolicyStatement[]; +} + +/** + * Creates LogGroup resource policies. + */ +export class ElasticsearchAccessPolicy extends cr.AwsCustomResource { + constructor(scope: cdk.Construct, id: string, props: ElasticsearchAccessPolicyProps) { + const policyDocument = new iam.PolicyDocument({ + statements: props.accessPolicies, + }); + + super(scope, id, { + resourceType: 'Custom::ElasticsearchAccessPolicy', + onUpdate: { + action: 'updateElasticsearchDomainConfig', + service: 'ES', + parameters: { + DomainName: props.domainName, + AccessPolicies: JSON.stringify(policyDocument.toJSON()), + }, + // this is needed to limit the response body, otherwise it exceeds the CFN 4k limit + outputPath: 'DomainConfig.ElasticsearchClusterConfig.AccessPolicies', + physicalResourceId: cr.PhysicalResourceId.of(`${props.domainName}AccessPolicy`), + }, + policy: cr.AwsCustomResourcePolicy.fromSdkCalls({ resources: [props.domainArn] }), + }); + } +} diff --git a/packages/@aws-cdk/aws-elasticsearch/lib/index.ts b/packages/@aws-cdk/aws-elasticsearch/lib/index.ts index 2ae3564622996..3a3d943f9825d 100644 --- a/packages/@aws-cdk/aws-elasticsearch/lib/index.ts +++ b/packages/@aws-cdk/aws-elasticsearch/lib/index.ts @@ -1,2 +1,4 @@ +export * from './domain'; + // AWS::Elasticsearch CloudFormation Resources: export * from './elasticsearch.generated'; diff --git a/packages/@aws-cdk/aws-elasticsearch/lib/log-group-resource-policy.ts b/packages/@aws-cdk/aws-elasticsearch/lib/log-group-resource-policy.ts new file mode 100644 index 0000000000000..949f03aa61baa --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/lib/log-group-resource-policy.ts @@ -0,0 +1,50 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import * as cr from '@aws-cdk/custom-resources'; + +/** + * Construction properties for LogGroupResourcePolicy + */ +export interface LogGroupResourcePolicyProps { + /** + * The log group resource policy name + */ + readonly policyName: string; + /** + * The policy statements for the log group resource logs + */ + readonly policyStatements: [iam.PolicyStatement]; +} + +/** + * Creates LogGroup resource policies. + */ +export class LogGroupResourcePolicy extends cr.AwsCustomResource { + constructor(scope: cdk.Construct, id: string, props: LogGroupResourcePolicyProps) { + const policyDocument = new iam.PolicyDocument({ + statements: props.policyStatements, + }); + + super(scope, id, { + resourceType: 'Custom::CloudwatchLogResourcePolicy', + onUpdate: { + service: 'CloudWatchLogs', + action: 'putResourcePolicy', + parameters: { + policyName: props.policyName, + policyDocument: JSON.stringify(policyDocument), + }, + physicalResourceId: cr.PhysicalResourceId.of(id), + }, + onDelete: { + service: 'CloudWatchLogs', + action: 'deleteResourcePolicy', + parameters: { + policyName: props.policyName, + }, + ignoreErrorCodesMatching: '400', + }, + policy: cr.AwsCustomResourcePolicy.fromSdkCalls({ resources: ['*'] }), + }); + } +} diff --git a/packages/@aws-cdk/aws-elasticsearch/lib/perms.ts b/packages/@aws-cdk/aws-elasticsearch/lib/perms.ts new file mode 100644 index 0000000000000..f5804c9f059d5 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/lib/perms.ts @@ -0,0 +1,16 @@ +export const ES_READ_ACTIONS = [ + 'es:ESHttpGet', + 'es:ESHttpHead', +]; + +export const ES_WRITE_ACTIONS = [ + 'es:ESHttpDelete', + 'es:ESHttpPost', + 'es:ESHttpPut', + 'es:ESHttpPatch', +]; + +export const ES_READ_WRITE_ACTIONS = [ + ...ES_READ_ACTIONS, + ...ES_WRITE_ACTIONS, +]; diff --git a/packages/@aws-cdk/aws-elasticsearch/package.json b/packages/@aws-cdk/aws-elasticsearch/package.json index c58a9eb82ed20..69947f1186589 100644 --- a/packages/@aws-cdk/aws-elasticsearch/package.json +++ b/packages/@aws-cdk/aws-elasticsearch/package.json @@ -73,15 +73,30 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.4" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", + "@aws-cdk/aws-ec2": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", + "@aws-cdk/aws-secretsmanager": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.4" }, @@ -89,7 +104,12 @@ "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "features": [ + { + "name": "Higher level constructs for Domain", + "stability": "Experimental" + } + ], "awscdkio": { "announce": false } diff --git a/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts b/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts new file mode 100644 index 0000000000000..fc5472affaf6f --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/domain.test.ts @@ -0,0 +1,1060 @@ +import '@aws-cdk/assert/jest'; +import { Metric, Statistic } from '@aws-cdk/aws-cloudwatch'; +import { Subnet, Vpc, EbsDeviceVolumeType } from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import { App, Stack, Duration, SecretValue } from '@aws-cdk/core'; +import { Domain, ElasticsearchVersion } from '../lib'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); + + jest.resetAllMocks(); +}); + +const readActions = ['ESHttpGet', 'ESHttpHead']; +const writeActions = ['ESHttpDelete', 'ESHttpPost', 'ESHttpPut', 'ESHttpPatch']; +const readWriteActions = [ + ...readActions, + ...writeActions, +]; + +test('minimal example renders correctly', () => { + new Domain(stack, 'Domain', { version: ElasticsearchVersion.V7_1 }); + + expect(stack).toHaveResource('AWS::Elasticsearch::Domain', { + CognitoOptions: { + Enabled: false, + }, + EBSOptions: { + EBSEnabled: true, + VolumeSize: 10, + VolumeType: 'gp2', + }, + ElasticsearchClusterConfig: { + DedicatedMasterEnabled: false, + InstanceCount: 1, + InstanceType: 'r5.large.elasticsearch', + ZoneAwarenessEnabled: false, + }, + ElasticsearchVersion: '7.1', + EncryptionAtRestOptions: { + Enabled: false, + }, + LogPublishingOptions: { + ES_APPLICATION_LOGS: { + Enabled: false, + }, + SEARCH_SLOW_LOGS: { + Enabled: false, + }, + INDEX_SLOW_LOGS: { + Enabled: false, + }, + }, + NodeToNodeEncryptionOptions: { + Enabled: false, + }, + }); +}); + +describe('log groups', () => { + + test('slowSearchLogEnabled should create a custom log group', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_4, + logging: { + slowSearchLogEnabled: true, + }, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + LogPublishingOptions: { + ES_APPLICATION_LOGS: { + Enabled: false, + }, + SEARCH_SLOW_LOGS: { + CloudWatchLogsLogGroupArn: { + 'Fn::GetAtt': [ + 'SlowSearchLogsE00DC2E7', + 'Arn', + ], + }, + Enabled: true, + }, + INDEX_SLOW_LOGS: { + Enabled: false, + }, + }, + }); + }); + + test('slowIndexLogEnabled should create a custom log group', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_4, + logging: { + slowIndexLogEnabled: true, + }, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + LogPublishingOptions: { + ES_APPLICATION_LOGS: { + Enabled: false, + }, + SEARCH_SLOW_LOGS: { + Enabled: false, + }, + INDEX_SLOW_LOGS: { + CloudWatchLogsLogGroupArn: { + 'Fn::GetAtt': [ + 'SlowIndexLogsAD49DED0', + 'Arn', + ], + }, + Enabled: true, + }, + }, + }); + }); + + test('appLogEnabled should create a custom log group', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_4, + logging: { + appLogEnabled: true, + }, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + LogPublishingOptions: { + ES_APPLICATION_LOGS: { + CloudWatchLogsLogGroupArn: { + 'Fn::GetAtt': [ + 'AppLogsC5DF83A6', + 'Arn', + ], + }, + Enabled: true, + }, + SEARCH_SLOW_LOGS: { + Enabled: false, + }, + INDEX_SLOW_LOGS: { + Enabled: false, + }, + }, + }); + }); + +}); + +describe('grants', () => { + + test('"grantRead" allows read actions associated with this domain resource', () => { + testGrant(readActions, (p, d) => d.grantRead(p)); + }); + + test('"grantWrite" allows write actions associated with this domain resource', () => { + testGrant(writeActions, (p, d) => d.grantWrite(p)); + }); + + test('"grantReadWrite" allows read and write actions associated with this domain resource', () => { + testGrant(readWriteActions, (p, d) => d.grantReadWrite(p)); + }); + + test('"grantIndexRead" allows read actions associated with an index in this domain resource', () => { + testGrant( + readActions, + (p, d) => d.grantIndexRead('my-index', p), + false, + ['/my-index', '/my-index/*'], + ); + }); + + test('"grantIndexWrite" allows write actions associated with an index in this domain resource', () => { + testGrant( + writeActions, + (p, d) => d.grantIndexWrite('my-index', p), + false, + ['/my-index', '/my-index/*'], + ); + }); + + test('"grantIndexReadWrite" allows read and write actions associated with an index in this domain resource', () => { + testGrant( + readWriteActions, + (p, d) => d.grantIndexReadWrite('my-index', p), + false, + ['/my-index', '/my-index/*'], + ); + }); + + test('"grantPathRead" allows read actions associated with a given path in this domain resource', () => { + testGrant( + readActions, + (p, d) => d.grantPathRead('my-index/my-path', p), + false, + ['/my-index/my-path'], + ); + }); + + test('"grantPathWrite" allows write actions associated with a given path in this domain resource', () => { + testGrant( + writeActions, + (p, d) => d.grantPathWrite('my-index/my-path', p), + false, + ['/my-index/my-path'], + ); + }); + + test('"grantPathReadWrite" allows read and write actions associated with a given path in this domain resource', () => { + testGrant( + readWriteActions, + (p, d) => d.grantPathReadWrite('my-index/my-path', p), + false, + ['/my-index/my-path'], + ); + }); + + test('"grant" for an imported domain', () => { + const domainEndpoint = 'https://test-domain-2w2x2u3tifly-jcjotrt6f7otem4sqcwbch3c4u.testregion.es.amazonaws.com'; + const domain = Domain.fromDomainEndpoint(stack, 'Domain', domainEndpoint); + const user = new iam.User(stack, 'user'); + + domain.grantReadWrite(user); + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'es:ESHttpGet', + 'es:ESHttpHead', + 'es:ESHttpDelete', + 'es:ESHttpPost', + 'es:ESHttpPut', + 'es:ESHttpPatch', + ], + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':es:testregion:1234:domain/test-domain-2w2x2u3tifly', + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':es:testregion:1234:domain/test-domain-2w2x2u3tifly/*', + ], + ], + }, + ], + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'userDefaultPolicy083DF682', + Users: [ + { + Ref: 'user2C2B57AE', + }, + ], + }); + }); + +}); + +describe('metrics', () => { + + test('Can use metricClusterStatusRed on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricClusterStatusRed(), + 'ClusterStatus.red', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricClusterStatusYellow on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricClusterStatusYellow(), + 'ClusterStatus.yellow', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricFreeStorageSpace on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricFreeStorageSpace(), + 'FreeStorageSpace', + Statistic.MINIMUM, + ); + }); + + test('Can use metricClusterIndexWriteBlocked on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricClusterIndexWriteBlocked(), + 'ClusterIndexWriteBlocked', + Statistic.MAXIMUM, + Duration.minutes(1), + ); + }); + + test('Can use metricNodes on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricNodes(), + 'Nodes', + Statistic.MINIMUM, + Duration.hours(1), + ); + }); + + test('Can use metricAutomatedSnapshotFailure on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricAutomatedSnapshotFailure(), + 'AutomatedSnapshotFailure', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricCPUUtilization on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricCPUUtilization(), + 'CPUUtilization', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricJVMMemoryPressure on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricJVMMemoryPressure(), + 'JVMMemoryPressure', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricMasterCPUUtilization on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricMasterCPUUtilization(), + 'MasterCPUUtilization', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricMasterJVMMemoryPressure on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricMasterJVMMemoryPressure(), + 'MasterJVMMemoryPressure', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricKMSKeyError on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricKMSKeyError(), + 'KMSKeyError', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricKMSKeyInaccessible on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricKMSKeyInaccessible(), + 'KMSKeyInaccessible', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricSearchableDocuments on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricSearchableDocuments(), + 'SearchableDocuments', + Statistic.MAXIMUM, + ); + }); + + test('Can use metricSearchLatency on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricSearchLatency(), + 'SearchLatencyP99', + 'p99', + ); + }); + + test('Can use metricIndexingLatency on an Elasticsearch Domain', () => { + testMetric( + (domain) => domain.metricIndexingLatency(), + 'IndexingLatencyP99', + 'p99', + ); + }); + +}); + +describe('import', () => { + + test('static fromDomainEndpoint(endpoint) allows importing an external/existing domain', () => { + const domainName = 'test-domain-2w2x2u3tifly'; + const domainEndpoint = `https://${domainName}-jcjotrt6f7otem4sqcwbch3c4u.testregion.es.amazonaws.com`; + const imported = Domain.fromDomainEndpoint(stack, 'Domain', domainEndpoint); + + expect(imported.domainName).toEqual(domainName); + expect(imported.domainArn).toMatch(RegExp(`es:testregion:1234:domain/${domainName}$`)); + + expect(stack).not.toHaveResource('AWS::Elasticsearch::Domain'); + }); + + test('static fromDomainAttributes(attributes) allows importing an external/existing domain', () => { + const domainName = 'test-domain-2w2x2u3tifly'; + const domainArn = `es:testregion:1234:domain/${domainName}`; + const domainEndpoint = `https://${domainName}-jcjotrt6f7otem4sqcwbch3c4u.testregion.es.amazonaws.com`; + const imported = Domain.fromDomainAttributes(stack, 'Domain', { + domainArn, + domainEndpoint, + }); + + expect(imported.domainName).toEqual(domainName); + expect(imported.domainArn).toEqual(domainArn); + + expect(stack).not.toHaveResource('AWS::Elasticsearch::Domain'); + }); + +}); + +describe('advanced security options', () => { + const masterUserArn = 'arn:aws:iam::123456789012:user/JohnDoe'; + const masterUserName = 'JohnDoe'; + const password = 'password'; + const masterUserPassword = SecretValue.plainText(password); + + test('enable fine-grained access control with a master user ARN', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + fineGrainedAccessControl: { + masterUserArn, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: true, + enforceHttps: true, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + AdvancedSecurityOptions: { + Enabled: true, + InternalUserDatabaseEnabled: false, + MasterUserOptions: { + MasterUserARN: masterUserArn, + }, + }, + EncryptionAtRestOptions: { + Enabled: true, + }, + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + DomainEndpointOptions: { + EnforceHTTPS: true, + }, + }); + }); + + test('enable fine-grained access control with a master user name and password', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + fineGrainedAccessControl: { + masterUserName, + masterUserPassword, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: true, + enforceHttps: true, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + AdvancedSecurityOptions: { + Enabled: true, + InternalUserDatabaseEnabled: true, + MasterUserOptions: { + MasterUserName: masterUserName, + MasterUserPassword: password, + }, + }, + EncryptionAtRestOptions: { + Enabled: true, + }, + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + DomainEndpointOptions: { + EnforceHTTPS: true, + }, + }); + }); + + test('enable fine-grained access control with a master user name and dynamically generated password', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + fineGrainedAccessControl: { + masterUserName, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: true, + enforceHttps: true, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + AdvancedSecurityOptions: { + Enabled: true, + InternalUserDatabaseEnabled: true, + MasterUserOptions: { + MasterUserName: masterUserName, + MasterUserPassword: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'DomainMasterUserBFAFA7D9', + }, + ':SecretString:password::}}', + ], + ], + }, + }, + }, + EncryptionAtRestOptions: { + Enabled: true, + }, + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + DomainEndpointOptions: { + EnforceHTTPS: true, + }, + }); + + expect(stack).toHaveResourceLike('AWS::SecretsManager::Secret', { + GenerateSecretString: { + GenerateStringKey: 'password', + }, + }); + }); + + test('enabling fine-grained access control throws with Elasticsearch < 6.7', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V6_5, + fineGrainedAccessControl: { + masterUserArn, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: true, + enforceHttps: true, + })).toThrow(/Fine-grained access control requires Elasticsearch version 6\.7 or later/); + }); + + test('enabling fine-grained access control throws without node-to-node encryption enabled', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + fineGrainedAccessControl: { + masterUserArn, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: false, + enforceHttps: true, + })).toThrow(/Node-to-node encryption is required when fine-grained access control is enabled/); + }); + + test('enabling fine-grained access control throws without encryption-at-rest enabled', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + fineGrainedAccessControl: { + masterUserArn, + }, + encryptionAtRest: { + enabled: false, + }, + nodeToNodeEncryption: true, + enforceHttps: true, + })).toThrow(/Encryption-at-rest is required when fine-grained access control is enabled/); + }); + + test('enabling fine-grained access control throws without enforceHttps enabled', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + fineGrainedAccessControl: { + masterUserArn, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: true, + enforceHttps: false, + })).toThrow(/Enforce HTTPS is required when fine-grained access control is enabled/); + }); +}); + +describe('custom error responses', () => { + + test('error when availabilityZoneCount does not match vpcOptions.subnets length', () => { + const vpc = new Vpc(stack, 'Vpc'); + + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_4, + zoneAwareness: { + enabled: true, + availabilityZoneCount: 2, + }, + vpcOptions: { + subnets: [ + new Subnet(stack, 'Subnet', { + availabilityZone: 'testaz', + cidrBlock: vpc.vpcCidrBlock, + vpcId: vpc.vpcId, + }), + ], + securityGroups: [], + }, + })).toThrow(/you need to provide a subnet for each AZ you are using/); + }); + + test('error when master or data node instance types do not end with .elasticsearch', () => { + const error = /instance types must end with ".elasticsearch"/; + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V7_4, + capacity: { + masterNodeInstanceType: 'c5.large', + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain2', { + version: ElasticsearchVersion.V7_4, + capacity: { + dataNodeInstanceType: 'c5.2xlarge', + }, + })).toThrow(error); + }); + + test('error when elasticsearchVersion is unsupported/unknown', () => { + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.of('5.4'), + })).toThrow(/Unknown Elasticsearch version: 5\.4/); + }); + + test('error when log publishing is enabled for elasticsearch version < 5.1', () => { + const error = /logs publishing requires Elasticsearch version 5.1 or later/; + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V2_3, + logging: { + appLogEnabled: true, + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain2', { + version: ElasticsearchVersion.V1_5, + logging: { + slowSearchLogEnabled: true, + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain3', { + version: ElasticsearchVersion.V1_5, + logging: { + slowIndexLogEnabled: true, + }, + })).toThrow(error); + }); + + test('error when encryption at rest is enabled for elasticsearch version < 5.1', () => { + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V2_3, + encryptionAtRest: { + enabled: true, + }, + })).toThrow(/Encryption of data at rest requires Elasticsearch version 5.1 or later/); + }); + + test('error when cognito for kibana is enabled for elasticsearch version < 5.1', () => { + const user = new iam.User(stack, 'user'); + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V2_3, + cognitoKibanaAuth: { + identityPoolId: 'test-identity-pool-id', + role: new iam.Role(stack, 'Role', { assumedBy: user }), + userPoolId: 'test-user-pool-id', + }, + })).toThrow(/Cognito authentication for Kibana requires Elasticsearch version 5.1 or later/); + }); + + test('error when C5, I3, M5, or R5 instance types are specified for elasticsearch version < 5.1', () => { + const error = /C5, I3, M5, and R5 instance types require Elasticsearch version 5.1 or later/; + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V2_3, + capacity: { + masterNodeInstanceType: 'c5.medium.elasticsearch', + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain2', { + version: ElasticsearchVersion.V1_5, + capacity: { + dataNodeInstanceType: 'i3.2xlarge.elasticsearch', + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain3', { + version: ElasticsearchVersion.V1_5, + capacity: { + dataNodeInstanceType: 'm5.2xlarge.elasticsearch', + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain4', { + version: ElasticsearchVersion.V1_5, + capacity: { + masterNodeInstanceType: 'r5.2xlarge.elasticsearch', + }, + })).toThrow(error); + }); + + test('error when node to node encryption is enabled for elasticsearch version < 6.0', () => { + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V5_6, + nodeToNodeEncryption: true, + })).toThrow(/Node-to-node encryption requires Elasticsearch version 6.0 or later/); + }); + + test('error when i3 instance types are specified with EBS enabled', () => { + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V7_4, + capacity: { + dataNodeInstanceType: 'i3.2xlarge.elasticsearch', + }, + ebs: { + volumeSize: 100, + volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + }, + })).toThrow(/I3 instance types do not support EBS storage volumes/); + }); + + test('error when m3, r3, or t2 instance types are specified with encryption at rest enabled', () => { + const error = /M3, R3, and T2 instance types do not support encryption of data at rest/; + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V7_4, + capacity: { + masterNodeInstanceType: 'm3.2xlarge.elasticsearch', + }, + encryptionAtRest: { + enabled: true, + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain2', { + version: ElasticsearchVersion.V7_4, + capacity: { + dataNodeInstanceType: 'r3.2xlarge.elasticsearch', + }, + encryptionAtRest: { + enabled: true, + }, + })).toThrow(error); + expect(() => new Domain(stack, 'Domain3', { + version: ElasticsearchVersion.V7_4, + capacity: { + masterNodeInstanceType: 't2.2xlarge.elasticsearch', + }, + encryptionAtRest: { + enabled: true, + }, + })).toThrow(error); + }); + + test('error when t2.micro is specified with elasticsearch version > 2.3', () => { + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V6_7, + capacity: { + masterNodeInstanceType: 't2.micro.elasticsearch', + }, + })).toThrow(/t2.micro.elasticsearch instance type supports only Elasticsearch 1.5 and 2.3/); + }); + + test('error when any instance type other than R3 and I3 are specified without EBS enabled', () => { + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V7_4, + ebs: { + enabled: false, + }, + capacity: { + masterNodeInstanceType: 'm5.large.elasticsearch', + }, + })).toThrow(/EBS volumes are required when using instance types other than r3 or i3/); + }); + + test('error when availabilityZoneCount is not 2 or 3', () => { + const vpc = new Vpc(stack, 'Vpc'); + + expect(() => new Domain(stack, 'Domain1', { + version: ElasticsearchVersion.V7_4, + vpcOptions: { + subnets: [ + new Subnet(stack, 'Subnet1', { + availabilityZone: 'testaz1', + cidrBlock: vpc.vpcCidrBlock, + vpcId: vpc.vpcId, + }), + new Subnet(stack, 'Subnet2', { + availabilityZone: 'testaz2', + cidrBlock: vpc.vpcCidrBlock, + vpcId: vpc.vpcId, + }), + new Subnet(stack, 'Subnet3', { + availabilityZone: 'testaz3', + cidrBlock: vpc.vpcCidrBlock, + vpcId: vpc.vpcId, + }), + new Subnet(stack, 'Subnet4', { + availabilityZone: 'testaz4', + cidrBlock: vpc.vpcCidrBlock, + vpcId: vpc.vpcId, + }), + ], + securityGroups: [], + }, + zoneAwareness: { + availabilityZoneCount: 4, + }, + })).toThrow(/Invalid zone awareness configuration; availabilityZoneCount must be 2 or 3/); + }); + +}); + +test('can specify future version', () => { + new Domain(stack, 'Domain', { version: ElasticsearchVersion.of('8.2') }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + ElasticsearchVersion: '8.2', + }); +}); + +describe('unsigned basic auth', () => { + test('can create a domain with unsigned basic auth', () => { + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + useUnsignedBasicAuth: true, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + AdvancedSecurityOptions: { + Enabled: true, + InternalUserDatabaseEnabled: true, + MasterUserOptions: { + MasterUserName: 'admin', + }, + }, + EncryptionAtRestOptions: { + Enabled: true, + }, + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + DomainEndpointOptions: { + EnforceHTTPS: true, + }, + }); + }); + + test('does not overwrite master user ARN configuration', () => { + const masterUserArn = 'arn:aws:iam::123456789012:user/JohnDoe'; + + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + fineGrainedAccessControl: { + masterUserArn, + }, + useUnsignedBasicAuth: true, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + AdvancedSecurityOptions: { + Enabled: true, + InternalUserDatabaseEnabled: false, + MasterUserOptions: { + MasterUserARN: masterUserArn, + }, + }, + EncryptionAtRestOptions: { + Enabled: true, + }, + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + DomainEndpointOptions: { + EnforceHTTPS: true, + }, + }); + }); + + test('does not overwrite master user name and password', () => { + const masterUserName = 'JohnDoe'; + const password = 'password'; + const masterUserPassword = SecretValue.plainText(password); + + new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_1, + fineGrainedAccessControl: { + masterUserName, + masterUserPassword, + }, + useUnsignedBasicAuth: true, + }); + + expect(stack).toHaveResourceLike('AWS::Elasticsearch::Domain', { + AdvancedSecurityOptions: { + Enabled: true, + InternalUserDatabaseEnabled: true, + MasterUserOptions: { + MasterUserName: masterUserName, + MasterUserPassword: password, + }, + }, + EncryptionAtRestOptions: { + Enabled: true, + }, + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + DomainEndpointOptions: { + EnforceHTTPS: true, + }, + }); + }); + + test('fails to create a domain with unsigned basic auth when enforce HTTPS is disabled', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + useUnsignedBasicAuth: true, + enforceHttps: false, + })).toThrow(/You cannot disable HTTPS and use unsigned basic auth/); + }); + + test('fails to create a domain with unsigned basic auth when node to node encryption is disabled', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + useUnsignedBasicAuth: true, + nodeToNodeEncryption: false, + })).toThrow(/You cannot disable node to node encryption and use unsigned basic auth/); + }); + + test('fails to create a domain with unsigned basic auth when encryption at rest is disabled', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V7_7, + useUnsignedBasicAuth: true, + encryptionAtRest: { enabled: false }, + })).toThrow(/You cannot disable encryption at rest and use unsigned basic auth/); + }); + + test('using unsigned basic auth throws with Elasticsearch < 6.7', () => { + expect(() => new Domain(stack, 'Domain', { + version: ElasticsearchVersion.V6_5, + useUnsignedBasicAuth: true, + })).toThrow(/Using unsigned basic auth requires Elasticsearch version 6\.7 or later./); + }); +}); + + +function testGrant( + expectedActions: string[], + invocation: (user: iam.IPrincipal, domain: Domain) => void, + appliesToDomainRoot: Boolean = true, + paths: string[] = ['/*'], +) { + const domain = new Domain(stack, 'Domain', { version: ElasticsearchVersion.V7_4 }); + const user = new iam.User(stack, 'user'); + + invocation(user, domain); + + const action = expectedActions.length > 1 ? expectedActions.map(a => `es:${a}`) : `es:${expectedActions[0]}`; + const domainArn = { + 'Fn::GetAtt': [ + 'Domain66AC69E0', + 'Arn', + ], + }; + const resolvedPaths = paths.map(path => { + return { + 'Fn::Join': [ + '', + [ + domainArn, + path, + ], + ], + }; + }); + const resource = appliesToDomainRoot + ? [domainArn, ...resolvedPaths] + : resolvedPaths.length > 1 + ? resolvedPaths + : resolvedPaths[0]; + + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: action, + Effect: 'Allow', + Resource: resource, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'userDefaultPolicy083DF682', + Users: [ + { + Ref: 'user2C2B57AE', + }, + ], + }); +} + +function testMetric( + invocation: (domain: Domain) => Metric, + metricName: string, + statistic: string = Statistic.SUM, + period: Duration = Duration.minutes(5), +) { + const domain = new Domain(stack, 'Domain', { version: ElasticsearchVersion.V7_4 }); + + const metric = invocation(domain); + + expect(metric).toMatchObject({ + metricName, + namespace: 'AWS/ES', + period, + statistic, + dimensions: { + ClientId: '1234', + }, + }); + expect(metric.dimensions).toHaveProperty('DomainName'); +} diff --git a/packages/@aws-cdk/aws-elasticsearch/test/elasticsearch-access-policy.test.ts b/packages/@aws-cdk/aws-elasticsearch/test/elasticsearch-access-policy.test.ts new file mode 100644 index 0000000000000..2b815c16b2048 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/elasticsearch-access-policy.test.ts @@ -0,0 +1,61 @@ +import '@aws-cdk/assert/jest'; +import * as iam from '@aws-cdk/aws-iam'; +import { App, Stack } from '@aws-cdk/core'; +import { ElasticsearchAccessPolicy } from '../lib/elasticsearch-access-policy'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); +}); + +test('minimal example renders correctly', () => { + new ElasticsearchAccessPolicy(stack, 'ElasticsearchAccessPolicy', { + domainName: 'TestDomain', + domainArn: 'test:arn', + accessPolicies: [new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['es:ESHttp*'], + principals: [new iam.Anyone()], + resources: ['test:arn'], + + })], + }); + + expect(stack).toHaveResource('Custom::ElasticsearchAccessPolicy', { + ServiceToken: { + 'Fn::GetAtt': [ + 'AWS679f53fac002430cb0da5b7982bd22872D164C4C', + 'Arn', + ], + }, + Create: { + service: 'ES', + action: 'updateElasticsearchDomainConfig', + parameters: { + DomainName: 'TestDomain', + AccessPolicies: '{\"Statement\":[{\"Action\":\"es:ESHttp*\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Resource\":\"test:arn\"}],\"Version\":\"2012-10-17\"}', + }, + outputPath: 'DomainConfig.ElasticsearchClusterConfig.AccessPolicies', + physicalResourceId: { + id: 'TestDomainAccessPolicy', + }, + }, + Update: { + service: 'ES', + action: 'updateElasticsearchDomainConfig', + parameters: { + DomainName: 'TestDomain', + AccessPolicies: '{\"Statement\":[{\"Action\":\"es:ESHttp*\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Resource\":\"test:arn\"}],\"Version\":\"2012-10-17\"}', + }, + outputPath: 'DomainConfig.ElasticsearchClusterConfig.AccessPolicies', + physicalResourceId: { + id: 'TestDomainAccessPolicy', + }, + }, + }); +}); diff --git a/packages/@aws-cdk/aws-elasticsearch/test/elasticsearch.test.ts b/packages/@aws-cdk/aws-elasticsearch/test/elasticsearch.test.ts deleted file mode 100644 index e394ef336bfb4..0000000000000 --- a/packages/@aws-cdk/aws-elasticsearch/test/elasticsearch.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assert/jest'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -}); diff --git a/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.advancedsecurity.expected.json b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.advancedsecurity.expected.json new file mode 100644 index 0000000000000..5a5275e4c52a9 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.advancedsecurity.expected.json @@ -0,0 +1,60 @@ +{ + "Resources": { + "User00B015A1": { + "Type": "AWS::IAM::User" + }, + "Domain66AC69E0": { + "Type": "AWS::Elasticsearch::Domain", + "Properties": { + "AdvancedSecurityOptions": { + "Enabled": true, + "InternalUserDatabaseEnabled": false, + "MasterUserOptions": { + "MasterUserARN": { + "Fn::GetAtt": [ + "User00B015A1", + "Arn" + ] + } + } + }, + "CognitoOptions": { + "Enabled": false + }, + "DomainEndpointOptions": { + "EnforceHTTPS": true, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "ElasticsearchClusterConfig": { + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.elasticsearch", + "ZoneAwarenessEnabled": false + }, + "ElasticsearchVersion": "7.1", + "EncryptionAtRestOptions": { + "Enabled": true + }, + "LogPublishingOptions": { + "ES_APPLICATION_LOGS": { + "Enabled": false + }, + "SEARCH_SLOW_LOGS": { + "Enabled": false + }, + "INDEX_SLOW_LOGS": { + "Enabled": false + } + }, + "NodeToNodeEncryptionOptions": { + "Enabled": true + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.advancedsecurity.ts b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.advancedsecurity.ts new file mode 100644 index 0000000000000..e31fc1ecec6f4 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.advancedsecurity.ts @@ -0,0 +1,27 @@ +import { User } from '@aws-cdk/aws-iam'; +import { App, Construct, Stack, StackProps } from '@aws-cdk/core'; +import * as es from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const user = new User(this, 'User'); + + new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, + fineGrainedAccessControl: { + masterUserArn: user.userArn, + }, + encryptionAtRest: { + enabled: true, + }, + nodeToNodeEncryption: true, + enforceHttps: true, + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-elasticsearch-advancedsecurity'); +app.synth(); diff --git a/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.expected.json b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.expected.json new file mode 100644 index 0000000000000..1956a13379bac --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.expected.json @@ -0,0 +1,292 @@ +{ + "Resources": { + "DomainESLogGroupPolicy5373A2E8": { + "Type": "Custom::CloudwatchLogResourcePolicy", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd22872D164C4C", + "Arn" + ] + }, + "Create": { + "service": "CloudWatchLogs", + "action": "putResourcePolicy", + "parameters": { + "policyName": "ESLogPolicy", + "policyDocument": { + "Fn::Join": [ + "", + [ + "{\"Statement\":[{\"Action\":[\"logs:PutLogEvents\",\"logs:CreateLogStream\"],\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"es.amazonaws.com\"},\"Resource\":[\"", + { + "Fn::GetAtt": [ + "SlowSearchLogsE00DC2E7", + "Arn" + ] + }, + "\",\"", + { + "Fn::GetAtt": [ + "AppLogsC5DF83A6", + "Arn" + ] + }, + "\"]}],\"Version\":\"2012-10-17\"}" + ] + ] + } + }, + "physicalResourceId": { + "id": "ESLogGroupPolicy" + } + }, + "Update": { + "service": "CloudWatchLogs", + "action": "putResourcePolicy", + "parameters": { + "policyName": "ESLogPolicy", + "policyDocument": { + "Fn::Join": [ + "", + [ + "{\"Statement\":[{\"Action\":[\"logs:PutLogEvents\",\"logs:CreateLogStream\"],\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"es.amazonaws.com\"},\"Resource\":[\"", + { + "Fn::GetAtt": [ + "SlowSearchLogsE00DC2E7", + "Arn" + ] + }, + "\",\"", + { + "Fn::GetAtt": [ + "AppLogsC5DF83A6", + "Arn" + ] + }, + "\"]}],\"Version\":\"2012-10-17\"}" + ] + ] + } + }, + "physicalResourceId": { + "id": "ESLogGroupPolicy" + } + }, + "Delete": { + "service": "CloudWatchLogs", + "action": "deleteResourcePolicy", + "parameters": { + "policyName": "ESLogPolicy" + }, + "ignoreErrorCodesMatching": "400" + }, + "InstallLatestAwsSdk": true + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete", + "DependsOn": [ + "DomainESLogGroupPolicyCustomResourcePolicyB35C8E41" + ] + }, + "Domain66AC69E0": { + "Type": "AWS::Elasticsearch::Domain", + "Properties": { + "CognitoOptions": { + "Enabled": false + }, + "DomainEndpointOptions": { + "EnforceHTTPS": false, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "ElasticsearchClusterConfig": { + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.elasticsearch", + "ZoneAwarenessEnabled": false + }, + "ElasticsearchVersion": "7.1", + "EncryptionAtRestOptions": { + "Enabled": true + }, + "LogPublishingOptions": { + "ES_APPLICATION_LOGS": { + "CloudWatchLogsLogGroupArn": { + "Fn::GetAtt": [ + "AppLogsC5DF83A6", + "Arn" + ] + }, + "Enabled": true + }, + "SEARCH_SLOW_LOGS": { + "CloudWatchLogsLogGroupArn": { + "Fn::GetAtt": [ + "SlowSearchLogsE00DC2E7", + "Arn" + ] + }, + "Enabled": true + }, + "INDEX_SLOW_LOGS": { + "Enabled": false + } + }, + "NodeToNodeEncryptionOptions": { + "Enabled": true + } + }, + "DependsOn": [ + "DomainESLogGroupPolicyCustomResourcePolicyB35C8E41", + "DomainESLogGroupPolicy5373A2E8" + ] + }, + "SlowSearchLogsE00DC2E7": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "RetentionInDays": 30 + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "AppLogsC5DF83A6": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "RetentionInDays": 30 + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "DomainESLogGroupPolicyCustomResourcePolicyB35C8E41": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "logs:PutResourcePolicy", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "logs:DeleteResourcePolicy", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "DomainESLogGroupPolicyCustomResourcePolicyB35C8E41", + "Roles": [ + { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + } + ] + } + }, + "AWS679f53fac002430cb0da5b7982bd22872D164C4C": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3Bucket38F1BB8E" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3VersionKeyCCDC67C0" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3VersionKeyCCDC67C0" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2", + "Arn" + ] + }, + "Runtime": "nodejs12.x", + "Timeout": 120 + }, + "DependsOn": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + ] + } + }, + "Parameters": { + "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94ArtifactHash782948FC": { + "Type":"String", + "Description":"Artifact hash for asset \"b64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94\"" + }, + "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3Bucket38F1BB8E": { + "Type": "String", + "Description": "S3 bucket for asset \"b64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94\"" + }, + "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3VersionKeyCCDC67C0": { + "Type": "String", + "Description": "S3 key for asset version \"b64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94\"" + } + } +} diff --git a/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.ts b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.ts new file mode 100644 index 0000000000000..876b2ec4bd139 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.ts @@ -0,0 +1,29 @@ +import { EbsDeviceVolumeType } from '@aws-cdk/aws-ec2'; +import { App, Construct, Stack, StackProps } from '@aws-cdk/core'; +import * as es from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, + ebs: { + volumeSize: 10, + volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD, + }, + logging: { + slowSearchLogEnabled: true, + appLogEnabled: true, + }, + nodeToNodeEncryption: true, + encryptionAtRest: { + enabled: true, + }, + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-elasticsearch'); +app.synth(); diff --git a/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.unsignedbasicauth.expected.json b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.unsignedbasicauth.expected.json new file mode 100644 index 0000000000000..3791b985eddbe --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.unsignedbasicauth.expected.json @@ -0,0 +1,297 @@ +{ + "Resources": { + "DomainMasterUserBFAFA7D9": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "GenerateSecretString": { + "GenerateStringKey": "password", + "ExcludeCharacters": "{}'\\*[]()`", + "SecretStringTemplate": "{\"username\":\"admin\"}" + } + } + }, + "Domain66AC69E0": { + "Type": "AWS::Elasticsearch::Domain", + "Properties": { + "AdvancedSecurityOptions": { + "Enabled": true, + "InternalUserDatabaseEnabled": true, + "MasterUserOptions": { + "MasterUserName": "admin", + "MasterUserPassword": { + "Fn::Join": [ + "", + [ + "{{resolve:secretsmanager:", + { + "Ref": "DomainMasterUserBFAFA7D9" + }, + ":SecretString:password::}}" + ] + ] + } + } + }, + "CognitoOptions": { + "Enabled": false + }, + "DomainEndpointOptions": { + "EnforceHTTPS": true, + "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07" + }, + "EBSOptions": { + "EBSEnabled": true, + "VolumeSize": 10, + "VolumeType": "gp2" + }, + "ElasticsearchClusterConfig": { + "DedicatedMasterEnabled": false, + "InstanceCount": 1, + "InstanceType": "r5.large.elasticsearch", + "ZoneAwarenessEnabled": false + }, + "ElasticsearchVersion": "7.1", + "EncryptionAtRestOptions": { + "Enabled": true + }, + "LogPublishingOptions": { + "ES_APPLICATION_LOGS": { + "Enabled": false + }, + "SEARCH_SLOW_LOGS": { + "Enabled": false + }, + "INDEX_SLOW_LOGS": { + "Enabled": false + } + }, + "NodeToNodeEncryptionOptions": { + "Enabled": true + } + } + }, + "DomainESAccessPolicyCustomResourcePolicy9747FC42": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "es:UpdateElasticsearchDomainConfig", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "Domain66AC69E0", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "DomainESAccessPolicyCustomResourcePolicy9747FC42", + "Roles": [ + { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + } + ] + }, + "DependsOn": [ + "Domain66AC69E0" + ] + }, + "DomainESAccessPolicy89986F33": { + "Type": "Custom::ElasticsearchAccessPolicy", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd22872D164C4C", + "Arn" + ] + }, + "Create": { + "action": "updateElasticsearchDomainConfig", + "service": "ES", + "parameters": { + "DomainName": { + "Ref": "Domain66AC69E0" + }, + "AccessPolicies": { + "Fn::Join": [ + "", + [ + "{\"Statement\":[{\"Action\":\"es:ESHttp*\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Resource\":\"", + { + "Fn::GetAtt": [ + "Domain66AC69E0", + "Arn" + ] + }, + "/*\"}],\"Version\":\"2012-10-17\"}" + ] + ] + } + }, + "outputPath": "DomainConfig.ElasticsearchClusterConfig.AccessPolicies", + "physicalResourceId": { + "id": { + "Fn::Join": [ + "", + [ + { + "Ref": "Domain66AC69E0" + }, + "AccessPolicy" + ] + ] + } + } + }, + "Update": { + "action": "updateElasticsearchDomainConfig", + "service": "ES", + "parameters": { + "DomainName": { + "Ref": "Domain66AC69E0" + }, + "AccessPolicies": { + "Fn::Join": [ + "", + [ + "{\"Statement\":[{\"Action\":\"es:ESHttp*\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Resource\":\"", + { + "Fn::GetAtt": [ + "Domain66AC69E0", + "Arn" + ] + }, + "/*\"}],\"Version\":\"2012-10-17\"}" + ] + ] + } + }, + "outputPath": "DomainConfig.ElasticsearchClusterConfig.AccessPolicies", + "physicalResourceId": { + "id": { + "Fn::Join": [ + "", + [ + { + "Ref": "Domain66AC69E0" + }, + "AccessPolicy" + ] + ] + } + } + }, + "InstallLatestAwsSdk": true + }, + "DependsOn": [ + "DomainESAccessPolicyCustomResourcePolicy9747FC42", + "Domain66AC69E0" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "AWS679f53fac002430cb0da5b7982bd22872D164C4C": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3Bucket38F1BB8E" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3VersionKeyCCDC67C0" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3VersionKeyCCDC67C0" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2", + "Arn" + ] + }, + "Runtime": "nodejs12.x", + "Timeout": 120 + }, + "DependsOn": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + ] + } + }, + "Parameters": { + "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3Bucket38F1BB8E": { + "Type": "String", + "Description": "S3 bucket for asset \"b64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94\"" + }, + "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94S3VersionKeyCCDC67C0": { + "Type": "String", + "Description": "S3 key for asset version \"b64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94\"" + }, + "AssetParametersb64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94ArtifactHash782948FC": { + "Type": "String", + "Description": "Artifact hash for asset \"b64b129569a5ac7a9abf88a18ac0b504d1fb1208872460476ed3fd435830eb94\"" + } + } +} diff --git a/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.unsignedbasicauth.ts b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.unsignedbasicauth.ts new file mode 100644 index 0000000000000..4fb2e382f9537 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/integ.elasticsearch.unsignedbasicauth.ts @@ -0,0 +1,17 @@ +import { App, Construct, Stack, StackProps } from '@aws-cdk/core'; +import * as es from '../lib'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + new es.Domain(this, 'Domain', { + version: es.ElasticsearchVersion.V7_1, + useUnsignedBasicAuth: true, + }); + } +} + +const app = new App(); +new TestStack(app, 'cdk-integ-elasticsearch-unsignedbasicauth'); +app.synth(); diff --git a/packages/@aws-cdk/aws-elasticsearch/test/log-group-resource-policy.test.ts b/packages/@aws-cdk/aws-elasticsearch/test/log-group-resource-policy.test.ts new file mode 100644 index 0000000000000..4fefd1d13f8b6 --- /dev/null +++ b/packages/@aws-cdk/aws-elasticsearch/test/log-group-resource-policy.test.ts @@ -0,0 +1,65 @@ +import '@aws-cdk/assert/jest'; +import * as iam from '@aws-cdk/aws-iam'; +import { App, Stack } from '@aws-cdk/core'; +import { LogGroupResourcePolicy } from '../lib/log-group-resource-policy'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); +}); + +test('minimal example renders correctly', () => { + new LogGroupResourcePolicy(stack, 'LogGroupResourcePolicy', { + policyName: 'TestPolicy', + policyStatements: [new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['logs:PutLogEvents', 'logs:CreateLogStream'], + resources: ['*'], + principals: [new iam.ServicePrincipal('es.amazonaws.com')], + })], + }); + + expect(stack).toHaveResource('Custom::CloudwatchLogResourcePolicy', { + ServiceToken: { + 'Fn::GetAtt': [ + 'AWS679f53fac002430cb0da5b7982bd22872D164C4C', + 'Arn', + ], + }, + Create: { + service: 'CloudWatchLogs', + action: 'putResourcePolicy', + parameters: { + policyName: 'TestPolicy', + policyDocument: '{"Statement":[{"Action":["logs:PutLogEvents","logs:CreateLogStream"],"Effect":"Allow","Principal":{"Service":"es.amazonaws.com"},"Resource":"*"}],"Version":"2012-10-17"}', + }, + physicalResourceId: { + id: 'LogGroupResourcePolicy', + }, + }, + Update: { + service: 'CloudWatchLogs', + action: 'putResourcePolicy', + parameters: { + policyName: 'TestPolicy', + policyDocument: '{"Statement":[{"Action":["logs:PutLogEvents","logs:CreateLogStream"],"Effect":"Allow","Principal":{"Service":"es.amazonaws.com"},"Resource":"*"}],"Version":"2012-10-17"}', + }, + physicalResourceId: { + id: 'LogGroupResourcePolicy', + }, + }, + Delete: { + service: 'CloudWatchLogs', + action: 'deleteResourcePolicy', + parameters: { + policyName: 'TestPolicy', + }, + ignoreErrorCodesMatching: '400', + }, + }); +});