packages/aws-cdk-lib/aws-dynamodb/lib/table.ts (949 lines of code) (raw):

import { Construct } from 'constructs'; import { DynamoDBMetrics } from './dynamodb-canned-metrics.generated'; import { CfnTable, CfnTableProps } from './dynamodb.generated'; import * as perms from './perms'; import { ReplicaProvider } from './replica-provider'; import { EnableScalingProps, IScalableTableAttribute } from './scalable-attribute-api'; import { ScalableTableAttribute } from './scalable-table-attribute'; import { Operation, OperationsMetricOptions, SystemErrorsForOperationsMetricOptions, Attribute, BillingMode, ProjectionType, ITable, SecondaryIndexProps, TableClass, LocalSecondaryIndexProps, TableEncryption, StreamViewType, WarmThroughput, PointInTimeRecoverySpecification, } from './shared'; import * as appscaling from '../../aws-applicationautoscaling'; import * as cloudwatch from '../../aws-cloudwatch'; import * as iam from '../../aws-iam'; import * as kinesis from '../../aws-kinesis'; import * as kms from '../../aws-kms'; import * as s3 from '../../aws-s3'; import { ArnFormat, Resource, Aws, CfnCondition, CfnCustomResource, CfnResource, Duration, Fn, Lazy, Names, RemovalPolicy, Stack, Token, CustomResource, CfnDeletionPolicy, FeatureFlags, } from '../../core'; import { UnscopedValidationError, ValidationError } from '../../core/lib/errors'; import { addConstructMetadata, MethodMetadata } from '../../core/lib/metadata-resource'; import { DYNAMODB_TABLE_RETAIN_TABLE_REPLICA } from '../../cx-api'; const HASH_KEY_TYPE = 'HASH'; const RANGE_KEY_TYPE = 'RANGE'; // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-secondary-indexes const MAX_LOCAL_SECONDARY_INDEX_COUNT = 5; /** * Represents the table schema attributes. */ export interface SchemaOptions { /** * Partition key attribute definition. */ readonly partitionKey: Attribute; /** * Sort key attribute definition. * * @default no sort key */ readonly sortKey?: Attribute; } /** * Type of compression to use for imported data. */ export enum InputCompressionType { /** * GZIP compression. */ GZIP = 'GZIP', /** * ZSTD compression. */ ZSTD = 'ZSTD', /** * No compression. */ NONE = 'NONE', } /** * The options for imported source files in CSV format. */ export interface CsvOptions { /** * The delimiter used for separating items in the CSV file being imported. * * Valid delimiters are as follows: * - comma (`,`) * - tab (`\t`) * - colon (`:`) * - semicolon (`;`) * - pipe (`|`) * - space (` `) * * @default - use comma as a delimiter. */ readonly delimiter?: string; /** * List of the headers used to specify a common header for all source CSV files being imported. * * **NOTE**: If this field is specified then the first line of each CSV file is treated as data instead of the header. * If this field is not specified the the first line of each CSV file is treated as the header. * * @default - the first line of the CSV file is treated as the header */ readonly headerList?: string[]; } /** * The format of the source data. */ export abstract class InputFormat { /** * DynamoDB JSON format. */ public static dynamoDBJson(): InputFormat { return new class extends InputFormat { public _render(): Pick<CfnTable.ImportSourceSpecificationProperty, 'inputFormat' | 'inputFormatOptions'> { return { inputFormat: 'DYNAMODB_JSON', }; } }(); } /** * Amazon Ion format. */ public static ion(): InputFormat { return new class extends InputFormat { public _render(): Pick<CfnTable.ImportSourceSpecificationProperty, 'inputFormat' | 'inputFormatOptions'> { return { inputFormat: 'ION', }; } }(); } /** * CSV format. */ public static csv(options?: CsvOptions): InputFormat { // We are using the .length property to check the length of the delimiter. // Note that .length may not return the expected result for multi-codepoint characters like full-width characters or emojis, // but such characters are not expected to be used as delimiters in this context. if (options?.delimiter && (!this.validCsvDelimiters.includes(options.delimiter) || options.delimiter.length !== 1)) { throw new UnscopedValidationError([ 'Delimiter must be a single character and one of the following:', `${this.readableValidCsvDelimiters.join(', ')},`, `got '${options.delimiter}'`, ].join(' ')); } return new class extends InputFormat { public _render(): Pick<CfnTable.ImportSourceSpecificationProperty, 'inputFormat' | 'inputFormatOptions'> { return { inputFormat: 'CSV', inputFormatOptions: { csv: { delimiter: options?.delimiter, headerList: options?.headerList, }, }, }; } }(); } /** * Valid CSV delimiters. * * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-dynamodb-table-csv.html#cfn-dynamodb-table-csv-delimiter */ private static validCsvDelimiters = [',', '\t', ':', ';', '|', ' ']; private static readableValidCsvDelimiters = ['comma (,)', 'tab (\\t)', 'colon (:)', 'semicolon (;)', 'pipe (|)', 'space ( )']; /** * Render the input format and options. * * @internal */ public abstract _render(): Pick<CfnTable.ImportSourceSpecificationProperty, 'inputFormat' | 'inputFormatOptions'>; } /** * Properties for importing data from the S3. */ export interface ImportSourceSpecification { /** * The compression type of the imported data. * * @default InputCompressionType.NONE */ readonly compressionType?: InputCompressionType; /** * The format of the imported data. */ readonly inputFormat: InputFormat; /** * The S3 bucket that is being imported from. */ readonly bucket: s3.IBucket; /** * The account number of the S3 bucket that is being imported from. * * @default - no value */ readonly bucketOwner?: string; /** * The key prefix shared by all S3 Objects that are being imported. * * @default - no value */ readonly keyPrefix?: string; } /** * The precision associated with the DynamoDB write timestamps that will be replicated to Kinesis. * The default setting for record timestamp precision is microseconds. You can change this setting at any time. * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-dynamodb-table-kinesisstreamspecification.html#aws-properties-dynamodb-table-kinesisstreamspecification-properties */ export enum ApproximateCreationDateTimePrecision { /** * Millisecond precision */ MILLISECOND = 'MILLISECOND', /** * Microsecond precision */ MICROSECOND = 'MICROSECOND', } /** * Properties of a DynamoDB Table * * Use `TableProps` for all table properties */ export interface TableOptions extends SchemaOptions { /** * The read capacity for the table. Careful if you add Global Secondary Indexes, as * those will share the table's provisioned throughput. * * Can only be provided if billingMode is Provisioned. * * @default 5 */ readonly readCapacity?: number; /** * The write capacity for the table. Careful if you add Global Secondary Indexes, as * those will share the table's provisioned throughput. * * Can only be provided if billingMode is Provisioned. * * @default 5 */ readonly writeCapacity?: number; /** * The maximum read request units for the table. Careful if you add Global Secondary Indexes, as * those will share the table's maximum on-demand throughput. * * Can only be provided if billingMode is PAY_PER_REQUEST. * * @default - on-demand throughput is disabled */ readonly maxReadRequestUnits?: number; /** * The write request units for the table. Careful if you add Global Secondary Indexes, as * those will share the table's maximum on-demand throughput. * * Can only be provided if billingMode is PAY_PER_REQUEST. * * @default - on-demand throughput is disabled */ readonly maxWriteRequestUnits?: number; /** * Specify how you are charged for read and write throughput and how you manage capacity. * * @default PROVISIONED if `replicationRegions` is not specified, PAY_PER_REQUEST otherwise */ readonly billingMode?: BillingMode; /** * Specify values to pre-warm you DynamoDB Table * Warm Throughput feature is not available for Global Table replicas using the `Table` construct. To enable Warm Throughput, use the `TableV2` construct instead. * @see http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html#cfn-dynamodb-table-warmthroughput * @default - warm throughput is not configured */ readonly warmThroughput?: WarmThroughput; /** * Whether point-in-time recovery is enabled. * @deprecated use `pointInTimeRecoverySpecification` instead * @default false - point in time recovery is not enabled. */ readonly pointInTimeRecovery?: boolean; /** * Whether point-in-time recovery is enabled * and recoveryPeriodInDays is set. * * @default - point in time recovery is not enabled. */ readonly pointInTimeRecoverySpecification?: PointInTimeRecoverySpecification; /** * Whether server-side encryption with an AWS managed customer master key is enabled. * * This property cannot be set if `encryption` and/or `encryptionKey` is set. * * @default - The table is encrypted with an encryption key managed by DynamoDB, and you are not charged any fee for using it. * * @deprecated This property is deprecated. In order to obtain the same behavior as * enabling this, set the `encryption` property to `TableEncryption.AWS_MANAGED` instead. */ readonly serverSideEncryption?: boolean; /** * Specify the table class. * @default STANDARD */ readonly tableClass?: TableClass; /** * Whether server-side encryption with an AWS managed customer master key is enabled. * * This property cannot be set if `serverSideEncryption` is set. * * > **NOTE**: if you set this to `CUSTOMER_MANAGED` and `encryptionKey` is not * > specified, the key that the Tablet generates for you will be created with * > default permissions. If you are using CDKv2, these permissions will be * > sufficient to enable the key for use with DynamoDB tables. If you are * > using CDKv1, make sure the feature flag * > `@aws-cdk/aws-kms:defaultKeyPolicies` is set to `true` in your `cdk.json`. * * @default - The table is encrypted with an encryption key managed by DynamoDB, and you are not charged any fee for using it. */ readonly encryption?: TableEncryption; /** * External KMS key to use for table encryption. * * This property can only be set if `encryption` is set to `TableEncryption.CUSTOMER_MANAGED`. * * @default - If `encryption` is set to `TableEncryption.CUSTOMER_MANAGED` and this * property is undefined, a new KMS key will be created and associated with this table. * If `encryption` and this property are both undefined, then the table is encrypted with * an encryption key managed by DynamoDB, and you are not charged any fee for using it. */ readonly encryptionKey?: kms.IKey; /** * The name of TTL attribute. * @default - TTL is disabled */ readonly timeToLiveAttribute?: string; /** * When an item in the table is modified, StreamViewType determines what information * is written to the stream for this table. * * @default - streams are disabled unless `replicationRegions` is specified */ readonly stream?: StreamViewType; /** * The removal policy to apply to the DynamoDB Table. * * @default RemovalPolicy.RETAIN */ readonly removalPolicy?: RemovalPolicy; /** * The removal policy to apply to the DynamoDB replica tables. * * @default undefined - use DynamoDB Table's removal policy */ readonly replicaRemovalPolicy?: RemovalPolicy; /** * Regions where replica tables will be created * * @default - no replica tables are created */ readonly replicationRegions?: string[]; /** * The timeout for a table replication operation in a single region. * * @default Duration.minutes(30) */ readonly replicationTimeout?: Duration; /** * [WARNING: Use this flag with caution, misusing this flag may cause deleting existing replicas, refer to the detailed documentation for more information] * Indicates whether CloudFormation stack waits for replication to finish. * If set to false, the CloudFormation resource will mark the resource as * created and replication will be completed asynchronously. This property is * ignored if replicationRegions property is not set. * * WARNING: * DO NOT UNSET this property if adding/removing multiple replicationRegions * in one deployment, as CloudFormation only supports one region replication * at a time. CDK overcomes this limitation by waiting for replication to * finish before starting new replicationRegion. * * If the custom resource which handles replication has a physical resource * ID with the format `region` instead of `tablename-region` (this would happen * if the custom resource hasn't received an event since v1.91.0), DO NOT SET * this property to false without making a change to the table name. * This will cause the existing replicas to be deleted. * * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-globaltable.html#cfn-dynamodb-globaltable-replicas * @default true */ readonly waitForReplicationToFinish?: boolean; /** * Whether CloudWatch contributor insights is enabled. * * @default false */ readonly contributorInsightsEnabled?: boolean; /** * Enables deletion protection for the table. * * @default false */ readonly deletionProtection?: boolean; /** * The properties of data being imported from the S3 bucket source to the table. * * @default - no data import from the S3 bucket */ readonly importSource?: ImportSourceSpecification; /** * Resource policy to assign to table. * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html#cfn-dynamodb-table-resourcepolicy * @default - No resource policy statement */ readonly resourcePolicy?: iam.PolicyDocument; } /** * Properties for a DynamoDB Table */ export interface TableProps extends TableOptions { /** * Enforces a particular physical table name. * @default <generated> */ readonly tableName?: string; /** * Kinesis Data Stream to capture item-level changes for the table. * * @default - no Kinesis Data Stream */ readonly kinesisStream?: kinesis.IStream; /** * Kinesis Data Stream approximate creation timestamp precision * * @default ApproximateCreationDateTimePrecision.MICROSECOND */ readonly kinesisPrecisionTimestamp?: ApproximateCreationDateTimePrecision; } /** * Properties for a global secondary index */ export interface GlobalSecondaryIndexProps extends SecondaryIndexProps, SchemaOptions { /** * The read capacity for the global secondary index. * * Can only be provided if table billingMode is Provisioned or undefined. * * @default 5 */ readonly readCapacity?: number; /** * The write capacity for the global secondary index. * * Can only be provided if table billingMode is Provisioned or undefined. * * @default 5 */ readonly writeCapacity?: number; /** * The maximum read request units for the global secondary index. * * Can only be provided if table billingMode is PAY_PER_REQUEST. * * @default - on-demand throughput is disabled */ readonly maxReadRequestUnits?: number; /** * The maximum write request units for the global secondary index. * * Can only be provided if table billingMode is PAY_PER_REQUEST. * * @default - on-demand throughput is disabled */ readonly maxWriteRequestUnits?: number; /** * The warm throughput configuration for the global secondary index. * * @default - no warm throughput is configured */ readonly warmThroughput?: WarmThroughput; /** * Whether CloudWatch contributor insights is enabled for the specified global secondary index. * * @default false */ readonly contributorInsightsEnabled?: boolean; } /** * Reference to a dynamodb table. */ export interface TableAttributes { /** * The ARN of the dynamodb table. * One of this, or `tableName`, is required. * * @default - no table arn */ readonly tableArn?: string; /** * The table name of the dynamodb table. * One of this, or `tableArn`, is required. * * @default - no table name */ readonly tableName?: string; /** * The ARN of the table's stream. * * @default - no table stream */ readonly tableStreamArn?: string; /** * KMS encryption key, if this table uses a customer-managed encryption key. * * @default - no key */ readonly encryptionKey?: kms.IKey; /** * The name of the global indexes set for this Table. * Note that you need to set either this property, * or `localIndexes`, * if you want methods like grantReadData() * to grant permissions for indexes as well as the table itself. * * @default - no global indexes */ readonly globalIndexes?: string[]; /** * The name of the local indexes set for this Table. * Note that you need to set either this property, * or `globalIndexes`, * if you want methods like grantReadData() * to grant permissions for indexes as well as the table itself. * * @default - no local indexes */ readonly localIndexes?: string[]; /** * If set to true, grant methods always grant permissions for all indexes. * If false is provided, grant methods grant the permissions * only when `globalIndexes` or `localIndexes` is specified. * * @default - false */ readonly grantIndexPermissions?: boolean; } export abstract class TableBase extends Resource implements ITable, iam.IResourceWithPolicy { /** * @attribute */ public abstract readonly tableArn: string; /** * @attribute */ public abstract readonly tableName: string; /** * @attribute */ public abstract readonly tableStreamArn?: string; /** * KMS encryption key, if this table uses a customer-managed encryption key. */ public abstract readonly encryptionKey?: kms.IKey; /** * Resource policy to assign to table. * @attribute */ public abstract resourcePolicy?: iam.PolicyDocument; protected readonly regionalArns = new Array<string>(); /** * Adds an IAM policy statement associated with this table to an IAM * principal's policy. * * If `encryptionKey` is present, appropriate grants to the key needs to be added * separately using the `table.encryptionKey.grant*` methods. * * @param grantee The principal (no-op if undefined) * @param actions The set of actions to allow (i.e. "dynamodb:PutItem", "dynamodb:GetItem", ...) */ public grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { return iam.Grant.addToPrincipalOrResource({ grantee, actions, resourceArns: [ this.tableArn, Lazy.string({ produce: () => this.hasIndex ? `${this.tableArn}/index/*` : Aws.NO_VALUE }), ...this.regionalArns, ...this.regionalArns.map(arn => Lazy.string({ produce: () => this.hasIndex ? `${arn}/index/*` : Aws.NO_VALUE, })), ], resource: this, }); } /** * Adds an IAM policy statement associated with this table's stream to an * IAM principal's policy. * * If `encryptionKey` is present, appropriate grants to the key needs to be added * separately using the `table.encryptionKey.grant*` methods. * * @param grantee The principal (no-op if undefined) * @param actions The set of actions to allow (i.e. "dynamodb:DescribeStream", "dynamodb:GetRecords", ...) */ public grantStream(grantee: iam.IGrantable, ...actions: string[]): iam.Grant { if (!this.tableStreamArn) { throw new ValidationError(`DynamoDB Streams must be enabled on the table ${this.node.path}`, this); } return iam.Grant.addToPrincipal({ grantee, actions, resourceArns: [this.tableStreamArn], scope: this, }); } /** * Permits an IAM principal all data read operations from this table: * BatchGetItem, GetRecords, GetShardIterator, Query, GetItem, Scan, DescribeTable. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ public grantReadData(grantee: iam.IGrantable): iam.Grant { const tableActions = perms.READ_DATA_ACTIONS.concat(perms.DESCRIBE_TABLE); return this.combinedGrant(grantee, { keyActions: perms.KEY_READ_ACTIONS, tableActions }); } /** * Permits an IAM Principal to list streams attached to current dynamodb table. * * @param grantee The principal (no-op if undefined) */ public grantTableListStreams(grantee: iam.IGrantable): iam.Grant { if (!this.tableStreamArn) { throw new ValidationError(`DynamoDB Streams must be enabled on the table ${this.node.path}`, this); } return iam.Grant.addToPrincipal({ grantee, actions: ['dynamodb:ListStreams'], resourceArns: ['*'], }); } /** * Permits an IAM principal all stream data read operations for this * table's stream: * DescribeStream, GetRecords, GetShardIterator, ListStreams. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ public grantStreamRead(grantee: iam.IGrantable): iam.Grant { this.grantTableListStreams(grantee); return this.combinedGrant(grantee, { keyActions: perms.KEY_READ_ACTIONS, streamActions: perms.READ_STREAM_DATA_ACTIONS }); } /** * Permits an IAM principal all data write operations to this table: * BatchWriteItem, PutItem, UpdateItem, DeleteItem, DescribeTable. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ public grantWriteData(grantee: iam.IGrantable): iam.Grant { const tableActions = perms.WRITE_DATA_ACTIONS.concat(perms.DESCRIBE_TABLE); const keyActions = perms.KEY_READ_ACTIONS.concat(perms.KEY_WRITE_ACTIONS); return this.combinedGrant(grantee, { keyActions, tableActions }); } /** * Permits an IAM principal to all data read/write operations to this table. * BatchGetItem, GetRecords, GetShardIterator, Query, GetItem, Scan, * BatchWriteItem, PutItem, UpdateItem, DeleteItem, DescribeTable * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ public grantReadWriteData(grantee: iam.IGrantable): iam.Grant { const tableActions = perms.READ_DATA_ACTIONS.concat(perms.WRITE_DATA_ACTIONS).concat(perms.DESCRIBE_TABLE); const keyActions = perms.KEY_READ_ACTIONS.concat(perms.KEY_WRITE_ACTIONS); return this.combinedGrant(grantee, { keyActions, tableActions }); } /** * Permits all DynamoDB operations ("dynamodb:*") to an IAM principal. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ public grantFullAccess(grantee: iam.IGrantable) { const keyActions = perms.KEY_READ_ACTIONS.concat(perms.KEY_WRITE_ACTIONS); return this.combinedGrant(grantee, { keyActions, tableActions: ['dynamodb:*'] }); } /** * Adds a statement to the resource policy associated with this file system. * A resource policy will be automatically created upon the first call to `addToResourcePolicy`. * * Note that this does not work with imported file systems. * * @param statement The policy statement to add */ public addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { this.resourcePolicy = this.resourcePolicy ?? new iam.PolicyDocument({ statements: [] }); this.resourcePolicy.addStatements(statement); return { statementAdded: true, policyDependable: this, }; } /** * Return the given named metric for this Table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { return new cloudwatch.Metric({ namespace: 'AWS/DynamoDB', metricName, dimensionsMap: { TableName: this.tableName, }, ...props, }).attachTo(this); } /** * Metric for the consumed read capacity units this table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricConsumedReadCapacityUnits(props?: cloudwatch.MetricOptions): cloudwatch.Metric { return this.cannedMetric(DynamoDBMetrics.consumedReadCapacityUnitsSum, props); } /** * Metric for the consumed write capacity units this table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricConsumedWriteCapacityUnits(props?: cloudwatch.MetricOptions): cloudwatch.Metric { return this.cannedMetric(DynamoDBMetrics.consumedWriteCapacityUnitsSum, props); } /** * Metric for the system errors this table * * @deprecated use `metricSystemErrorsForOperations`. */ public metricSystemErrors(props?: cloudwatch.MetricOptions): cloudwatch.Metric { if (!props?.dimensions?.Operation && !props?.dimensionsMap?.Operation) { // 'Operation' must be passed because its an operational metric. throw new ValidationError("'Operation' dimension must be passed for the 'SystemErrors' metric.", this); } const dimensionsMap = { TableName: this.tableName, ...props?.dimensions ?? {}, ...props?.dimensionsMap ?? {}, }; return this.metric('SystemErrors', { statistic: 'sum', ...props, dimensionsMap }); } /** * Metric for the user errors. Note that this metric reports user errors across all * the tables in the account and region the table resides in. * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricUserErrors(props?: cloudwatch.MetricOptions): cloudwatch.Metric { if (props?.dimensions) { throw new ValidationError("'dimensions' is not supported for the 'UserErrors' metric", this); } // overriding 'dimensions' here because this metric is an account metric. // see 'UserErrors' in https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/metrics-dimensions.html return this.metric('UserErrors', { statistic: 'sum', ...props, dimensionsMap: {} }); } /** * Metric for the conditional check failed requests this table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricConditionalCheckFailedRequests(props?: cloudwatch.MetricOptions): cloudwatch.Metric { return this.metric('ConditionalCheckFailedRequests', { statistic: 'sum', ...props }); } /** * How many requests are throttled on this table * * Default: sum over 5 minutes * * @deprecated Do not use this function. It returns an invalid metric. Use `metricThrottledRequestsForOperation` instead. */ public metricThrottledRequests(props?: cloudwatch.MetricOptions): cloudwatch.Metric { return this.metric('ThrottledRequests', { statistic: 'sum', ...props }); } /** * Metric for the successful request latency this table. * * By default, the metric will be calculated as an average over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricSuccessfulRequestLatency(props?: cloudwatch.MetricOptions): cloudwatch.Metric { if (!props?.dimensions?.Operation && !props?.dimensionsMap?.Operation) { throw new ValidationError("'Operation' dimension must be passed for the 'SuccessfulRequestLatency' metric.", this); } const dimensionsMap = { TableName: this.tableName, Operation: props.dimensionsMap?.Operation ?? props.dimensions?.Operation, }; return new cloudwatch.Metric({ ...DynamoDBMetrics.successfulRequestLatencyAverage(dimensionsMap), ...props, dimensionsMap, }).attachTo(this); } /** * How many requests are throttled on this table, for the given operation * * Default: sum over 5 minutes */ public metricThrottledRequestsForOperation(operation: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { return new cloudwatch.Metric({ ...DynamoDBMetrics.throttledRequestsSum({ Operation: operation, TableName: this.tableName }), ...props, }).attachTo(this); } /** * How many requests are throttled on this table. * * This will sum errors across all possible operations. * Note that by default, each individual metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricThrottledRequestsForOperations(props?: OperationsMetricOptions): cloudwatch.IMetric { return this.sumMetricsForOperations('ThrottledRequests', 'Sum of throttled requests across all operations', props); } /** * Metric for the system errors this table. * * This will sum errors across all possible operations. * Note that by default, each individual metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ public metricSystemErrorsForOperations(props?: SystemErrorsForOperationsMetricOptions): cloudwatch.IMetric { return this.sumMetricsForOperations('SystemErrors', 'Sum of errors across all operations', props); } /** * Create a math expression for operations. * * @param metricName The metric name. * @param expressionLabel Label for expression * @param props operation list */ private sumMetricsForOperations(metricName: string, expressionLabel: string, props?: OperationsMetricOptions): cloudwatch.IMetric { if (props?.dimensions?.Operation) { throw new ValidationError("The Operation dimension is not supported. Use the 'operations' property.", this); } const operations = props?.operations ?? Object.values(Operation); const values = this.createMetricsForOperations(metricName, operations, { statistic: 'sum', ...props }); const sum = new cloudwatch.MathExpression({ expression: `${Object.keys(values).join(' + ')}`, usingMetrics: { ...values }, color: props?.color, label: expressionLabel, period: props?.period, }); return sum; } /** * Create a map of metrics that can be used in a math expression. * * Using the return value of this function as the `usingMetrics` property in `cloudwatch.MathExpression` allows you to * use the keys of this map as metric names inside you expression. * * @param metricName The metric name. * @param operations The list of operations to create metrics for. * @param props Properties for the individual metrics. * @param metricNameMapper Mapper function to allow controlling the individual metric name per operation. */ private createMetricsForOperations(metricName: string, operations: Operation[], props?: cloudwatch.MetricOptions, metricNameMapper?: (op: Operation) => string): Record<string, cloudwatch.IMetric> { const metrics: Record<string, cloudwatch.IMetric> = {}; const mapper = metricNameMapper ?? (op => op.toLowerCase()); if (props?.dimensions?.Operation) { throw new ValidationError('Invalid properties. Operation dimension is not supported when calculating operational metrics', this); } for (const operation of operations) { const metric = this.metric(metricName, { ...props, dimensionsMap: { TableName: this.tableName, Operation: operation, ...props?.dimensions, }, }); const operationMetricName = mapper(operation); const firstChar = operationMetricName.charAt(0); if (firstChar === firstChar.toUpperCase()) { // https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html#metric-math-syntax throw new ValidationError(`Mapper generated an illegal operation metric name: ${operationMetricName}. Must start with a lowercase letter`, this); } metrics[operationMetricName] = metric; } return metrics; } protected abstract get hasIndex(): boolean; /** * Adds an IAM policy statement associated with this table to an IAM * principal's policy. * @param grantee The principal (no-op if undefined) * @param opts Options for keyActions, tableActions and streamActions */ private combinedGrant( grantee: iam.IGrantable, opts: { keyActions?: string[]; tableActions?: string[]; streamActions?: string[] }, ): iam.Grant { if (this.encryptionKey && opts.keyActions) { this.encryptionKey.grant(grantee, ...opts.keyActions); } if (opts.tableActions) { const resources = [ this.tableArn, Lazy.string({ produce: () => this.hasIndex ? `${this.tableArn}/index/*` : Aws.NO_VALUE }), ...this.regionalArns, ...this.regionalArns.map(arn => Lazy.string({ produce: () => this.hasIndex ? `${arn}/index/*` : Aws.NO_VALUE, })), ]; const ret = iam.Grant.addToPrincipalOrResource({ grantee, actions: opts.tableActions, resourceArns: resources, resource: this, }); return ret; } if (opts.streamActions) { if (!this.tableStreamArn) { throw new ValidationError(`DynamoDB Streams must be enabled on the table ${this.node.path}`, this); } const resources = [this.tableStreamArn]; const ret = iam.Grant.addToPrincipalOrResource({ grantee, actions: opts.streamActions, resourceArns: resources, resource: this, }); return ret; } throw new ValidationError(`Unexpected 'action', ${opts.tableActions || opts.streamActions}`, this); } private cannedMetric( fn: (dims: { TableName: string }) => cloudwatch.MetricProps, props?: cloudwatch.MetricOptions): cloudwatch.Metric { return new cloudwatch.Metric({ ...fn({ TableName: this.tableName }), ...props, }).attachTo(this); } } /** * Provides a DynamoDB table. */ export class Table extends TableBase { /** * Permits an IAM Principal to list all DynamoDB Streams. * @deprecated Use `#grantTableListStreams` for more granular permission * @param grantee The principal (no-op if undefined) */ public static grantListStreams(grantee: iam.IGrantable): iam.Grant { return iam.Grant.addToPrincipal({ grantee, actions: ['dynamodb:ListStreams'], resourceArns: ['*'], }); } /** * Creates a Table construct that represents an external table via table name. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. * @param tableName The table's name. */ public static fromTableName(scope: Construct, id: string, tableName: string): ITable { return Table.fromTableAttributes(scope, id, { tableName }); } /** * Creates a Table construct that represents an external table via table arn. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. * @param tableArn The table's ARN. */ public static fromTableArn(scope: Construct, id: string, tableArn: string): ITable { return Table.fromTableAttributes(scope, id, { tableArn }); } /** * Creates a Table construct that represents an external table. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. * @param attrs A `TableAttributes` object. */ public static fromTableAttributes(scope: Construct, id: string, attrs: TableAttributes): ITable { class Import extends TableBase { public readonly tableName: string; public readonly tableArn: string; public readonly tableStreamArn?: string; public readonly encryptionKey?: kms.IKey; public resourcePolicy?: iam.PolicyDocument; protected readonly hasIndex = (attrs.grantIndexPermissions ?? false) || (attrs.globalIndexes ?? []).length > 0 || (attrs.localIndexes ?? []).length > 0; constructor(_tableArn: string, tableName: string, tableStreamArn?: string) { super(scope, id); this.tableArn = _tableArn; this.tableName = tableName; this.tableStreamArn = tableStreamArn; this.encryptionKey = attrs.encryptionKey; } } let name: string; let arn: string; const stack = Stack.of(scope); if (!attrs.tableName) { if (!attrs.tableArn) { throw new ValidationError('One of tableName or tableArn is required!', scope); } arn = attrs.tableArn; const maybeTableName = stack.splitArn(attrs.tableArn, ArnFormat.SLASH_RESOURCE_NAME).resourceName; if (!maybeTableName) { throw new ValidationError('ARN for DynamoDB table must be in the form: ...', scope); } name = maybeTableName; } else { if (attrs.tableArn) { throw new ValidationError('Only one of tableArn or tableName can be provided', scope); } name = attrs.tableName; arn = stack.formatArn({ service: 'dynamodb', resource: 'table', resourceName: attrs.tableName, }); } return new Import(arn, name, attrs.tableStreamArn); } public readonly encryptionKey?: kms.IKey; /** * Resource policy to assign to DynamoDB Table. * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-dynamodb-table-resourcepolicy.html * @default - No resource policy statements are added to the created table. */ public resourcePolicy?: iam.PolicyDocument; /** * @attribute */ public readonly tableArn: string; /** * @attribute */ public readonly tableName: string; /** * @attribute */ public readonly tableStreamArn: string | undefined; private readonly table: CfnTable; private readonly keySchema = new Array<CfnTable.KeySchemaProperty>(); private readonly attributeDefinitions = new Array<CfnTable.AttributeDefinitionProperty>(); private readonly globalSecondaryIndexes = new Array<CfnTable.GlobalSecondaryIndexProperty>(); private readonly localSecondaryIndexes = new Array<CfnTable.LocalSecondaryIndexProperty>(); private readonly secondaryIndexSchemas = new Map<string, SchemaOptions>(); private readonly nonKeyAttributes = new Set<string>(); private readonly tablePartitionKey: Attribute; private readonly tableSortKey?: Attribute; private readonly billingMode: BillingMode; private readonly tableScaling: ScalableAttributePair = {}; private readonly indexScaling = new Map<string, ScalableAttributePair>(); private readonly scalingRole: iam.IRole; private readonly globalReplicaCustomResources = new Array<CustomResource>(); constructor(scope: Construct, id: string, props: TableProps) { super(scope, id, { physicalName: props.tableName, }); // Enhanced CDK Analytics Telemetry addConstructMetadata(this, props); const { sseSpecification, encryptionKey } = this.parseEncryption(props); const pointInTimeRecoverySpecification = this.validatePitr(props); let streamSpecification: CfnTable.StreamSpecificationProperty | undefined; if (props.replicationRegions) { if (props.stream && props.stream !== StreamViewType.NEW_AND_OLD_IMAGES) { throw new ValidationError('`stream` must be set to `NEW_AND_OLD_IMAGES` when specifying `replicationRegions`', this); } streamSpecification = { streamViewType: StreamViewType.NEW_AND_OLD_IMAGES }; this.billingMode = props.billingMode ?? BillingMode.PAY_PER_REQUEST; } else { this.billingMode = props.billingMode ?? BillingMode.PROVISIONED; if (props.stream) { streamSpecification = { streamViewType: props.stream }; } } this.validateProvisioning(props); const kinesisStreamSpecification = props.kinesisStream ? { streamArn: props.kinesisStream.streamArn, ...(props.kinesisPrecisionTimestamp && { approximateCreationDateTimePrecision: props.kinesisPrecisionTimestamp }), } : undefined; this.table = new CfnTable(this, 'Resource', { tableName: this.physicalName, keySchema: this.keySchema, attributeDefinitions: this.attributeDefinitions, globalSecondaryIndexes: Lazy.any({ produce: () => this.globalSecondaryIndexes }, { omitEmptyArray: true }), localSecondaryIndexes: Lazy.any({ produce: () => this.localSecondaryIndexes }, { omitEmptyArray: true }), pointInTimeRecoverySpecification: pointInTimeRecoverySpecification, billingMode: this.billingMode === BillingMode.PAY_PER_REQUEST ? this.billingMode : undefined, provisionedThroughput: this.billingMode === BillingMode.PAY_PER_REQUEST ? undefined : { readCapacityUnits: props.readCapacity || 5, writeCapacityUnits: props.writeCapacity || 5, }, ...(props.maxReadRequestUnits || props.maxWriteRequestUnits ? { onDemandThroughput: this.billingMode === BillingMode.PROVISIONED ? undefined : { maxReadRequestUnits: props.maxReadRequestUnits || undefined, maxWriteRequestUnits: props.maxWriteRequestUnits || undefined, }, } : undefined), sseSpecification, streamSpecification, tableClass: props.tableClass, timeToLiveSpecification: props.timeToLiveAttribute ? { attributeName: props.timeToLiveAttribute, enabled: true } : undefined, contributorInsightsSpecification: props.contributorInsightsEnabled !== undefined ? { enabled: props.contributorInsightsEnabled } : undefined, kinesisStreamSpecification: kinesisStreamSpecification, deletionProtectionEnabled: props.deletionProtection, importSourceSpecification: this.renderImportSourceSpecification(props.importSource), resourcePolicy: props.resourcePolicy ? { policyDocument: props.resourcePolicy } : undefined, warmThroughput: props.warmThroughput?? undefined, }); this.table.applyRemovalPolicy(props.removalPolicy); this.encryptionKey = encryptionKey; this.tableArn = this.getResourceArnAttribute(this.table.attrArn, { service: 'dynamodb', resource: 'table', resourceName: this.physicalName, }); this.tableName = this.getResourceNameAttribute(this.table.ref); if (props.tableName) { this.node.addMetadata('aws:cdk:hasPhysicalName', this.tableName); } this.tableStreamArn = streamSpecification ? this.table.attrStreamArn : undefined; this.scalingRole = this.makeScalingRole(); this.addKey(props.partitionKey, HASH_KEY_TYPE); this.tablePartitionKey = props.partitionKey; if (props.sortKey) { this.addKey(props.sortKey, RANGE_KEY_TYPE); this.tableSortKey = props.sortKey; } if (props.replicationRegions && props.replicationRegions.length > 0) { this.createReplicaTables(props.replicationRegions, props.replicationTimeout, props.waitForReplicationToFinish, props.replicaRemovalPolicy); } this.node.addValidation({ validate: () => this.validateTable() }); } /** * Add a global secondary index of table. * * @param props the property of global secondary index */ @MethodMetadata() public addGlobalSecondaryIndex(props: GlobalSecondaryIndexProps) { this.validateProvisioning(props); this.validateIndexName(props.indexName); // build key schema and projection for index const gsiKeySchema = this.buildIndexKeySchema(props.partitionKey, props.sortKey); const gsiProjection = this.buildIndexProjection(props); this.globalSecondaryIndexes.push({ contributorInsightsSpecification: props.contributorInsightsEnabled !== undefined ? { enabled: props.contributorInsightsEnabled } : undefined, indexName: props.indexName, keySchema: gsiKeySchema, projection: gsiProjection, provisionedThroughput: this.billingMode === BillingMode.PAY_PER_REQUEST ? undefined : { readCapacityUnits: props.readCapacity || 5, writeCapacityUnits: props.writeCapacity || 5, }, ...(props.maxReadRequestUnits || props.maxWriteRequestUnits ? { onDemandThroughput: this.billingMode === BillingMode.PROVISIONED ? undefined : { maxReadRequestUnits: props.maxReadRequestUnits || undefined, maxWriteRequestUnits: props.maxWriteRequestUnits || undefined, }, } : undefined), warmThroughput: props.warmThroughput ?? undefined, }); this.secondaryIndexSchemas.set(props.indexName, { partitionKey: props.partitionKey, sortKey: props.sortKey, }); this.indexScaling.set(props.indexName, {}); } /** * Add a local secondary index of table. * * @param props the property of local secondary index */ @MethodMetadata() public addLocalSecondaryIndex(props: LocalSecondaryIndexProps) { // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-secondary-indexes if (this.localSecondaryIndexes.length >= MAX_LOCAL_SECONDARY_INDEX_COUNT) { throw new RangeError(`a maximum number of local secondary index per table is ${MAX_LOCAL_SECONDARY_INDEX_COUNT}`); } this.validateIndexName(props.indexName); // build key schema and projection for index const lsiKeySchema = this.buildIndexKeySchema(this.tablePartitionKey, props.sortKey); const lsiProjection = this.buildIndexProjection(props); this.localSecondaryIndexes.push({ indexName: props.indexName, keySchema: lsiKeySchema, projection: lsiProjection, }); this.secondaryIndexSchemas.set(props.indexName, { partitionKey: this.tablePartitionKey, sortKey: props.sortKey, }); } /** * Enable read capacity scaling for this table * * @returns An object to configure additional AutoScaling settings */ @MethodMetadata() public autoScaleReadCapacity(props: EnableScalingProps): IScalableTableAttribute { if (this.tableScaling.scalableReadAttribute) { throw new ValidationError('Read AutoScaling already enabled for this table', this); } if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new ValidationError('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode', this); } return this.tableScaling.scalableReadAttribute = new ScalableTableAttribute(this, 'ReadScaling', { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}`, dimension: 'dynamodb:table:ReadCapacityUnits', role: this.scalingRole, ...props, }); } /** * Enable write capacity scaling for this table * * @returns An object to configure additional AutoScaling settings for this attribute */ @MethodMetadata() public autoScaleWriteCapacity(props: EnableScalingProps): IScalableTableAttribute { if (this.tableScaling.scalableWriteAttribute) { throw new ValidationError('Write AutoScaling already enabled for this table', this); } if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new ValidationError('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode', this); } this.tableScaling.scalableWriteAttribute = new ScalableTableAttribute(this, 'WriteScaling', { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}`, dimension: 'dynamodb:table:WriteCapacityUnits', role: this.scalingRole, ...props, }); for (const globalReplicaCustomResource of this.globalReplicaCustomResources) { globalReplicaCustomResource.node.addDependency(this.tableScaling.scalableWriteAttribute); } return this.tableScaling.scalableWriteAttribute; } /** * Enable read capacity scaling for the given GSI * * @returns An object to configure additional AutoScaling settings for this attribute */ @MethodMetadata() public autoScaleGlobalSecondaryIndexReadCapacity(indexName: string, props: EnableScalingProps): IScalableTableAttribute { if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new ValidationError('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode', this); } const attributePair = this.indexScaling.get(indexName); if (!attributePair) { throw new ValidationError(`No global secondary index with name ${indexName}`, this); } if (attributePair.scalableReadAttribute) { throw new ValidationError('Read AutoScaling already enabled for this index', this); } return attributePair.scalableReadAttribute = new ScalableTableAttribute(this, `${indexName}ReadScaling`, { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}/index/${indexName}`, dimension: 'dynamodb:index:ReadCapacityUnits', role: this.scalingRole, ...props, }); } /** * Enable write capacity scaling for the given GSI * * @returns An object to configure additional AutoScaling settings for this attribute */ @MethodMetadata() public autoScaleGlobalSecondaryIndexWriteCapacity(indexName: string, props: EnableScalingProps): IScalableTableAttribute { if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new ValidationError('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode', this); } const attributePair = this.indexScaling.get(indexName); if (!attributePair) { throw new ValidationError(`No global secondary index with name ${indexName}`, this); } if (attributePair.scalableWriteAttribute) { throw new ValidationError('Write AutoScaling already enabled for this index', this); } return attributePair.scalableWriteAttribute = new ScalableTableAttribute(this, `${indexName}WriteScaling`, { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}/index/${indexName}`, dimension: 'dynamodb:index:WriteCapacityUnits', role: this.scalingRole, ...props, }); } /** * Get schema attributes of table or index. * * @returns Schema of table or index. */ @MethodMetadata() public schema(indexName?: string): SchemaOptions { if (!indexName) { return { partitionKey: this.tablePartitionKey, sortKey: this.tableSortKey, }; } let schema = this.secondaryIndexSchemas.get(indexName); if (!schema) { throw new ValidationError(`Cannot find schema for index: ${indexName}. Use 'addGlobalSecondaryIndex' or 'addLocalSecondaryIndex' to add index`, this); } return schema; } /** * Validate the table construct. * * @returns an array of validation error message */ private validateTable(): string[] { const errors = new Array<string>(); if (!this.tablePartitionKey) { errors.push('a partition key must be specified'); } if (this.localSecondaryIndexes.length > 0 && !this.tableSortKey) { errors.push('a sort key of the table must be specified to add local secondary indexes'); } if (this.globalReplicaCustomResources.length > 0 && this.billingMode === BillingMode.PROVISIONED) { const writeAutoScaleAttribute = this.tableScaling.scalableWriteAttribute; if (!writeAutoScaleAttribute) { errors.push('A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity. ' + 'Use the autoScaleWriteCapacity() method to enable it.'); } else if (!writeAutoScaleAttribute._scalingPolicyCreated) { errors.push('A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity with a policy. ' + 'Call one of the scaleOn*() methods of the object returned from autoScaleWriteCapacity()'); } } return errors; } /** * Validate read and write capacity are not specified for on-demand tables (billing mode PAY_PER_REQUEST). * * @param props read and write capacity properties */ private validateProvisioning(props: { readCapacity?: number; writeCapacity?: number }): void { if (this.billingMode === BillingMode.PAY_PER_REQUEST) { if (props.readCapacity !== undefined || props.writeCapacity !== undefined) { throw new ValidationError('you cannot provision read and write capacity for a table with PAY_PER_REQUEST billing mode', this); } } } /** * Validate index name to check if a duplicate name already exists. * * @param indexName a name of global or local secondary index */ private validateIndexName(indexName: string) { if (this.secondaryIndexSchemas.has(indexName)) { // a duplicate index name causes validation exception, status code 400, while trying to create CFN stack throw new ValidationError(`a duplicate index name, ${indexName}, is not allowed`, this); } } /** * Validate non-key attributes by checking limits within secondary index, which may vary in future. * * @param nonKeyAttributes a list of non-key attribute names */ private validateNonKeyAttributes(nonKeyAttributes: string[]) { if (this.nonKeyAttributes.size + nonKeyAttributes.length > 100) { // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-secondary-indexes throw new RangeError('a maximum number of nonKeyAttributes across all of secondary indexes is 100'); } // store all non-key attributes nonKeyAttributes.forEach(att => this.nonKeyAttributes.add(att)); } private validatePitr (props: TableProps): PointInTimeRecoverySpecification | undefined { if (props.pointInTimeRecoverySpecification !==undefined && props.pointInTimeRecovery !== undefined) { throw new ValidationError('`pointInTimeRecoverySpecification` and `pointInTimeRecovery` are set. Use `pointInTimeRecoverySpecification` only.', this); } const recoveryPeriodInDays = props.pointInTimeRecoverySpecification?.recoveryPeriodInDays; if (!props.pointInTimeRecoverySpecification?.pointInTimeRecoveryEnabled && recoveryPeriodInDays) { throw new ValidationError('Cannot set `recoveryPeriodInDays` while `pointInTimeRecoveryEnabled` is set to false.', this); } if (recoveryPeriodInDays !== undefined && (recoveryPeriodInDays < 1 || recoveryPeriodInDays > 35 )) { throw new ValidationError('`recoveryPeriodInDays` must be a value between `1` and `35`.', this); } return props.pointInTimeRecoverySpecification ?? (props.pointInTimeRecovery !== undefined ? { pointInTimeRecoveryEnabled: props.pointInTimeRecovery } : undefined); } private buildIndexKeySchema(partitionKey: Attribute, sortKey?: Attribute): CfnTable.KeySchemaProperty[] { this.registerAttribute(partitionKey); const indexKeySchema: CfnTable.KeySchemaProperty[] = [ { attributeName: partitionKey.name, keyType: HASH_KEY_TYPE }, ]; if (sortKey) { this.registerAttribute(sortKey); indexKeySchema.push({ attributeName: sortKey.name, keyType: RANGE_KEY_TYPE }); } return indexKeySchema; } private buildIndexProjection(props: SecondaryIndexProps): CfnTable.ProjectionProperty { if (props.projectionType === ProjectionType.INCLUDE && !props.nonKeyAttributes) { // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-dynamodb-projectionobject.html throw new ValidationError(`non-key attributes should be specified when using ${ProjectionType.INCLUDE} projection type`, this); } if (props.projectionType !== ProjectionType.INCLUDE && props.nonKeyAttributes) { // this combination causes validation exception, status code 400, while trying to create CFN stack throw new ValidationError(`non-key attributes should not be specified when not using ${ProjectionType.INCLUDE} projection type`, this); } if (props.nonKeyAttributes) { this.validateNonKeyAttributes(props.nonKeyAttributes); } return { projectionType: props.projectionType ?? ProjectionType.ALL, nonKeyAttributes: props.nonKeyAttributes ?? undefined, }; } private findKey(keyType: string) { return this.keySchema.find(prop => prop.keyType === keyType); } private addKey(attribute: Attribute, keyType: string) { const existingProp = this.findKey(keyType); if (existingProp) { throw new ValidationError(`Unable to set ${attribute.name} as a ${keyType} key, because ${existingProp.attributeName} is a ${keyType} key`, this); } this.registerAttribute(attribute); this.keySchema.push({ attributeName: attribute.name, keyType, }); return this; } /** * Register the key attribute of table or secondary index to assemble attribute definitions of TableResourceProps. * * @param attribute the key attribute of table or secondary index */ private registerAttribute(attribute: Attribute) { const { name, type } = attribute; const existingDef = this.attributeDefinitions.find(def => def.attributeName === name); if (existingDef && existingDef.attributeType !== type) { throw new ValidationError(`Unable to specify ${name} as ${type} because it was already defined as ${existingDef.attributeType}`, this); } if (!existingDef) { this.attributeDefinitions.push({ attributeName: name, attributeType: type, }); } } /** * Return the role that will be used for AutoScaling */ private makeScalingRole(): iam.IRole { // Use a Service Linked Role. // https://docs.aws.amazon.com/autoscaling/application/userguide/application-auto-scaling-service-linked-roles.html return iam.Role.fromRoleArn(this, 'ScalingRole', Stack.of(this).formatArn({ service: 'iam', region: '', resource: 'role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com', resourceName: 'AWSServiceRoleForApplicationAutoScaling_DynamoDBTable', })); } /** * Creates replica tables * * @param regions regions where to create tables */ private createReplicaTables(regions: string[], timeout?: Duration, waitForReplicationToFinish?: boolean, replicaRemovalPolicy?: RemovalPolicy) { const stack = Stack.of(this); if (!Token.isUnresolved(stack.region) && regions.includes(stack.region)) { throw new ValidationError('`replicationRegions` cannot include the region where this stack is deployed.', this); } const provider = ReplicaProvider.getOrCreate(this, { tableName: this.tableName, regions, timeout }); // Documentation at https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/V2gt_IAM.html // is currently incorrect. AWS Support recommends `dynamodb:*` in both source and destination regions const onEventHandlerPolicy = new SourceTableAttachedPolicy(this, provider.onEventHandler.role!); const isCompleteHandlerPolicy = new SourceTableAttachedPolicy(this, provider.isCompleteHandler.role!); // Permissions in the source region this.grant(onEventHandlerPolicy, 'dynamodb:*'); this.grant(isCompleteHandlerPolicy, 'dynamodb:DescribeTable'); let previousRegion: CustomResource | undefined; let previousRegionCondition: CfnCondition | undefined; // Replica table's removal policy will default to DynamoDB Table's removal policy // unless replica removal policy is specified. const retainReplica = FeatureFlags.of(this).isEnabled(DYNAMODB_TABLE_RETAIN_TABLE_REPLICA); // If feature flag is disabled, never retain replica to maintain backward compatibility const skipReplicaDeletion = retainReplica ? Lazy.any({ produce: () => { // If feature flag is enabled, prioritize replica removal policy if (replicaRemovalPolicy) { return replicaRemovalPolicy == RemovalPolicy.RETAIN; } // Otherwise fall back to source table's removal policy return (this.node.defaultChild as CfnResource).cfnOptions.deletionPolicy === CfnDeletionPolicy.RETAIN; }, }) : false; for (const region of new Set(regions)) { // Remove duplicates // Use multiple custom resources because multiple create/delete // updates cannot be combined in a single API call. const currentRegion = new CustomResource(this, `Replica${region}`, { serviceToken: provider.provider.serviceToken, resourceType: 'Custom::DynamoDBReplica', properties: { TableName: this.tableName, Region: region, ...skipReplicaDeletion && { SkipReplicaDeletion: skipReplicaDeletion }, SkipReplicationCompletedWait: waitForReplicationToFinish == null ? undefined // CFN changes Custom Resource properties to strings anyways, // so let's do that ourselves to make it clear in the handler this is a string, not a boolean : (!waitForReplicationToFinish).toString(), }, }); currentRegion.node.addDependency( onEventHandlerPolicy.policy, isCompleteHandlerPolicy.policy, ); this.globalReplicaCustomResources.push(currentRegion); // Deploy time check to prevent from creating a replica in the region // where this stack is deployed. Only needed for environment agnostic // stacks. let createReplica: CfnCondition | undefined; if (Token.isUnresolved(stack.region)) { createReplica = new CfnCondition(this, `StackRegionNotEquals${region}`, { expression: Fn.conditionNot(Fn.conditionEquals(region, Aws.REGION)), }); const cfnCustomResource = currentRegion.node.defaultChild as CfnCustomResource; cfnCustomResource.cfnOptions.condition = createReplica; } // Save regional arns for grantXxx() methods this.regionalArns.push(stack.formatArn({ region, service: 'dynamodb', resource: 'table', resourceName: this.tableName, })); // We need to create/delete regions sequentially because we cannot // have multiple table updates at the same time. The `isCompleteHandler` // of the provider waits until the replica is in an ACTIVE state. if (previousRegion) { if (previousRegionCondition) { // we can't simply use a Dependency, // because the previousRegion is protected by the "different region" Condition, // and you can't have Fn::If in DependsOn. // Instead, rely on Ref adding a dependency implicitly! const previousRegionCfnResource = previousRegion.node.defaultChild as CfnResource; const currentRegionCfnResource = currentRegion.node.defaultChild as CfnResource; currentRegionCfnResource.addMetadata('DynamoDbReplicationDependency', Fn.conditionIf(previousRegionCondition.logicalId, previousRegionCfnResource.ref, Aws.NO_VALUE)); } else { currentRegion.node.addDependency(previousRegion); } } previousRegion = currentRegion; previousRegionCondition = createReplica; } // Permissions in the destination regions (outside of the loop to // minimize statements in the policy) onEventHandlerPolicy.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['dynamodb:*'], resources: this.regionalArns, })); } /** * Whether this table has indexes */ protected get hasIndex(): boolean { return this.globalSecondaryIndexes.length + this.localSecondaryIndexes.length > 0; } /** * Set up key properties and return the Table encryption property from the * user's configuration. */ private parseEncryption(props: TableProps): { sseSpecification: CfnTableProps['sseSpecification']; encryptionKey?: kms.IKey } { let encryptionType = props.encryption; if (encryptionType != null && props.serverSideEncryption != null) { throw new ValidationError('Only one of encryption and serverSideEncryption can be specified, but both were provided', this); } if (props.serverSideEncryption && props.encryptionKey) { throw new ValidationError('encryptionKey cannot be specified when serverSideEncryption is specified. Use encryption instead', this); } if (encryptionType === undefined) { encryptionType = props.encryptionKey != null // If there is a configured encryptionKey, the encryption is implicitly CUSTOMER_MANAGED ? TableEncryption.CUSTOMER_MANAGED // Otherwise, if severSideEncryption is enabled, it's AWS_MANAGED; else undefined (do not set anything) : props.serverSideEncryption ? TableEncryption.AWS_MANAGED : undefined; } if (encryptionType !== TableEncryption.CUSTOMER_MANAGED && props.encryptionKey) { throw new ValidationError(`encryptionKey cannot be specified unless encryption is set to TableEncryption.CUSTOMER_MANAGED (it was set to ${encryptionType})`, this); } if (encryptionType === TableEncryption.CUSTOMER_MANAGED && props.replicationRegions) { throw new ValidationError('TableEncryption.CUSTOMER_MANAGED is not supported by DynamoDB Global Tables (where replicationRegions was set)', this); } switch (encryptionType) { case TableEncryption.CUSTOMER_MANAGED: const encryptionKey = props.encryptionKey ?? new kms.Key(this, 'Key', { description: `Customer-managed key auto-created for encrypting DynamoDB table at ${this.node.path}`, enableKeyRotation: true, }); return { sseSpecification: { sseEnabled: true, kmsMasterKeyId: encryptionKey.keyArn, sseType: 'KMS' }, encryptionKey, }; case TableEncryption.AWS_MANAGED: // Not specifying "sseType: 'KMS'" here because it would cause phony changes to existing stacks. return { sseSpecification: { sseEnabled: true } }; case TableEncryption.DEFAULT: return { sseSpecification: { sseEnabled: false } }; case undefined: // Not specifying "sseEnabled: false" here because it would cause phony changes to existing stacks. return { sseSpecification: undefined }; default: throw new ValidationError(`Unexpected 'encryptionType': ${encryptionType}`, this); } } private renderImportSourceSpecification( importSource?: ImportSourceSpecification, ): CfnTable.ImportSourceSpecificationProperty | undefined { if (!importSource) return undefined; return { ...importSource.inputFormat._render(), inputCompressionType: importSource.compressionType, s3BucketSource: { s3Bucket: importSource.bucket.bucketName, s3BucketOwner: importSource.bucketOwner, s3KeyPrefix: importSource.keyPrefix, }, }; } } /** * Just a convenient way to keep track of both attributes */ interface ScalableAttributePair { scalableReadAttribute?: ScalableTableAttribute; scalableWriteAttribute?: ScalableTableAttribute; } /** * An inline policy that is logically bound to the source table of a DynamoDB Global Tables * "cluster". This is here to ensure permissions are removed as part of (and not before) the * CleanUp phase of a stack update, when a replica is removed (or the entire "cluster" gets * replaced). * * If statements are added directly to the handler roles (as opposed to in a separate inline * policy resource), new permissions are in effect before clean up happens, and so replicas that * need to be dropped can no longer be due to lack of permissions. */ class SourceTableAttachedPolicy extends Construct implements iam.IGrantable { public readonly grantPrincipal: iam.IPrincipal; public readonly policy: iam.IManagedPolicy; public constructor(sourceTable: Table, role: iam.IRole) { super(sourceTable, `SourceTableAttachedManagedPolicy-${Names.nodeUniqueId(role.node)}`); const policy = new iam.ManagedPolicy(this, 'Resource', { // A CF update of the description property of a managed policy requires // a replacement. Use the table name in the description to force a managed // policy replacement when the table name changes. This way we preserve permissions // to delete old replicas in case of a table replacement. description: `DynamoDB replication managed policy for table ${sourceTable.tableName}`, roles: [role], }); this.policy = policy; this.grantPrincipal = new SourceTableAttachedPrincipal(role, policy); } } /** * An `IPrincipal` entity that can be used as the target of `grant` calls, used by the * `SourceTableAttachedPolicy` class so it can act as an `IGrantable`. */ class SourceTableAttachedPrincipal extends iam.PrincipalBase { public constructor(private readonly role: iam.IRole, private readonly policy: iam.ManagedPolicy) { super(); } public get policyFragment(): iam.PrincipalPolicyFragment { return this.role.policyFragment; } public addToPrincipalPolicy(statement: iam.PolicyStatement): iam.AddToPrincipalPolicyResult { this.policy.addStatements(statement); return { policyDependable: this.policy, statementAdded: true, }; } public dedupeString(): string | undefined { return undefined; } }