From a9d51185a144cd4962c85227ae5b904510399fa4 Mon Sep 17 00:00:00 2001 From: Ben Chaimberg Date: Mon, 13 Sep 2021 14:40:49 -0400 Subject: [PATCH] feat(redshift): manage database users and tables via cdk (#15931) This feature allows users to manage Redshift database resources, such as users, tables, and grants, within their CDK application. Because these resources do not have CloudFormation handlers, this feature leverages custom resources and the Amazon Redshift Data API for creation and modification. The generic construct for this type of resource is `DatabaseQuery`. This construct provides the base functionality required for interacting with Redshift database resources, including configuring administrator credentials, creating a custom resource handler, and granting necessary IAM permissions. The custom resource handler code contains utility functions for executing query statements against the Redshift database. Specific resources that use the `DatabaseQuery` construct, such as `User` and `Table` are responsible for providing the following to `DatabaseQuery`: generic database configuration properties, specific configuration properties that will get passed to the custom resource handler (eg., `username` for `User`). Specific resources are also responsible for writing the lifecycle-management code within the handler. In general, this consists of: configuration extraction (eg., pulling `username` from the `AWSLambda.CloudFormationCustomResourceEvent` passed to the handler) and one method for each lifecycle event (create, update, delete) that queries the database using calls to the generic utility function. Users have a fairly simple lifecycle that allows them to be created, deleted, and updated when a secret containing a password is updated (secret rotation has not been implemented yet). Because of #9815, the custom resource provider queries Secrets Manager in order to access the password. Tables have a more complicated lifecycle because we want to allow columns to be added to the table without resource replacement, as well as ensuring that dropped columns do not lose data. For these reasons, we generate a unique name per-deployment when the table name is requested to be generated by the end user. We also notify create a new table (using a new generated name) if a column is to be dropped and let CFN lifecycle rules dictate whether the old table should be removed or kept. User privileges on tables are implemented via the `UserTablePrivileges` construct. This construct is located in the `private` directory to ensure that it is not exported for direct public use. This means that user privileges must be managed through the `Table.grant` method or the `User.addTablePrivileges` method. Thus, each `User` will have at most one `UserTablePrivileges` construct to manage its privileges. This is to avoid a situation where privileges could be erroneously removed when the same privilege is managed from two different CDK applications. For more details, see the README, under "Granting Privileges". ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-redshift/README.md | 201 ++- .../aws-redshift/lib/database-options.ts | 26 + packages/@aws-cdk/aws-redshift/lib/index.ts | 3 + .../database-query-provider/handler-name.ts | 5 + .../private/database-query-provider/index.ts | 20 + .../database-query-provider/privileges.ts | 70 + .../private/database-query-provider/table.ts | 75 + .../private/database-query-provider/user.ts | 82 + .../private/database-query-provider/util.ts | 40 + .../lib/private/database-query.ts | 105 ++ .../aws-redshift/lib/private/handler-props.ts | 31 + .../aws-redshift/lib/private/privileges.ts | 101 ++ packages/@aws-cdk/aws-redshift/lib/table.ts | 222 +++ packages/@aws-cdk/aws-redshift/lib/user.ts | 186 +++ packages/@aws-cdk/aws-redshift/package.json | 15 +- .../aws-redshift/rosetta/cluster.ts-fixture | 20 + .../aws-redshift/rosetta/default.ts-fixture | 11 + .../database-query-provider/index.test.ts | 50 + .../privileges.test.ts | 163 ++ .../database-query-provider/table.test.ts | 202 +++ .../test/database-query-provider/user.test.ts | 163 ++ .../aws-redshift/test/database-query.test.ts | 200 +++ .../test/integ.database.expected.json | 1377 +++++++++++++++++ .../aws-redshift/test/integ.database.ts | 44 + .../aws-redshift/test/privileges.test.ts | 113 ++ .../@aws-cdk/aws-redshift/test/table.test.ts | 138 ++ .../@aws-cdk/aws-redshift/test/user.test.ts | 215 +++ 27 files changed, 3861 insertions(+), 17 deletions(-) create mode 100644 packages/@aws-cdk/aws-redshift/lib/database-options.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/handler-name.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/index.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/privileges.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/table.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/user.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/util.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/database-query.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/handler-props.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/private/privileges.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/table.ts create mode 100644 packages/@aws-cdk/aws-redshift/lib/user.ts create mode 100644 packages/@aws-cdk/aws-redshift/rosetta/cluster.ts-fixture create mode 100644 packages/@aws-cdk/aws-redshift/rosetta/default.ts-fixture create mode 100644 packages/@aws-cdk/aws-redshift/test/database-query-provider/index.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/database-query-provider/privileges.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/database-query-provider/table.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/database-query-provider/user.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/database-query.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/integ.database.expected.json create mode 100644 packages/@aws-cdk/aws-redshift/test/integ.database.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/privileges.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/table.test.ts create mode 100644 packages/@aws-cdk/aws-redshift/test/user.test.ts diff --git a/packages/@aws-cdk/aws-redshift/README.md b/packages/@aws-cdk/aws-redshift/README.md index 576068b02f818..8ff734a6be255 100644 --- a/packages/@aws-cdk/aws-redshift/README.md +++ b/packages/@aws-cdk/aws-redshift/README.md @@ -26,15 +26,16 @@ To set up a Redshift cluster, define a `Cluster`. It will be launched in a VPC. You can specify a VPC, otherwise one will be created. The nodes are always launched in private subnets and are encrypted by default. -``` typescript -import redshift = require('@aws-cdk/aws-redshift'); -... -const cluster = new redshift.Cluster(this, 'Redshift', { - masterUser: { - masterUsername: 'admin', - }, - vpc - }); +```ts +import * as ec2 from '@aws-cdk/aws-ec2'; + +const vpc = new ec2.Vpc(this, 'Vpc'); +const cluster = new Cluster(this, 'Redshift', { + masterUser: { + masterUsername: 'admin', + }, + vpc +}); ``` By default, the master password will be generated and stored in AWS Secrets Manager. @@ -49,13 +50,13 @@ Depending on your use case, you can make the cluster publicly accessible with th To control who can access the cluster, use the `.connections` attribute. Redshift Clusters have a default port, so you don't need to specify the port: -```ts -cluster.connections.allowFromAnyIpv4('Open to the world'); +```ts fixture=cluster +cluster.connections.allowDefaultPortFromAnyIpv4('Open to the world'); ``` The endpoint to access your database cluster will be available as the `.clusterEndpoint` attribute: -```ts +```ts fixture=cluster cluster.clusterEndpoint.socketAddress; // "HOSTNAME:PORT" ``` @@ -63,16 +64,184 @@ cluster.clusterEndpoint.socketAddress; // "HOSTNAME:PORT" When the master password is generated and stored in AWS Secrets Manager, it can be rotated automatically: -```ts +```ts fixture=cluster cluster.addRotationSingleUser(); // Will rotate automatically after 30 days ``` The multi user rotation scheme is also available: -```ts +```ts fixture=cluster +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; + cluster.addRotationMultiUser('MyUser', { - secret: myImportedSecret + secret: secretsmanager.Secret.fromSecretNameV2(this, 'Imported Secret', 'my-secret'), +}); +``` + +## Database Resources + +This module allows for the creation of non-CloudFormation database resources such as users +and tables. This allows you to manage identities, permissions, and stateful resources +within your Redshift cluster from your CDK application. + +Because these resources are not available in CloudFormation, this library leverages +[custom +resources](https://docs.aws.amazon.com/cdk/api/latest/docs/custom-resources-readme.html) +to manage them. In addition to the IAM permissions required to make Redshift service +calls, the execution role for the custom resource handler requires database credentials to +create resources within the cluster. + +These database credentials can be supplied explicitly through the `adminUser` properties +of the various database resource constructs. Alternatively, the credentials can be +automatically pulled from the Redshift cluster's default administrator +credentials. However, this option is only available if the password for the credentials +was generated by the CDK application (ie., no value vas provided for [the `masterPassword` +property](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-redshift.Login.html#masterpasswordspan-classapi-icon-api-icon-experimental-titlethis-api-element-is-experimental-it-may-change-without-noticespan) +of +[`Cluster.masterUser`](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-redshift.Cluster.html#masteruserspan-classapi-icon-api-icon-experimental-titlethis-api-element-is-experimental-it-may-change-without-noticespan)). + +### Creating Users + +Create a user within a Redshift cluster database by instantiating a `User` construct. This +will generate a username and password, store the credentials in a [AWS Secrets Manager +`Secret`](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-secretsmanager.Secret.html), +and make a query to the Redshift cluster to create a new database user with the +credentials. + +```ts fixture=cluster +new User(this, 'User', { + cluster: cluster, + databaseName: 'databaseName', +}); +``` + +By default, the user credentials are encrypted with your AWS account's default Secrets +Manager encryption key. You can specify the encryption key used for this purpose by +supplying a key in the `encryptionKey` property. + +```ts fixture=cluster +import * as kms from '@aws-cdk/aws-kms'; + +const encryptionKey = new kms.Key(this, 'Key'); +new User(this, 'User', { + encryptionKey: encryptionKey, + cluster: cluster, + databaseName: 'databaseName', +}); +``` + +By default, a username is automatically generated from the user construct ID and its path +in the construct tree. You can specify a particular username by providing a value for the +`username` property. Usernames must be valid identifiers; see: [Names and +identifiers](https://docs.aws.amazon.com/redshift/latest/dg/r_names.html) in the *Amazon +Redshift Database Developer Guide*. + +```ts fixture=cluster +new User(this, 'User', { + username: 'myuser', + cluster: cluster, + databaseName: 'databaseName', +}); +``` + +The user password is generated by AWS Secrets Manager using the default configuration +found in +[`secretsmanager.SecretStringGenerator`](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-secretsmanager.SecretStringGenerator.html), +except with password length `30` and some SQL-incompliant characters excluded. The +plaintext for the password will never be present in the CDK application; instead, a +[CloudFormation Dynamic +Reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.html) +will be used wherever the password value is required. + +### Creating Tables + +Create a table within a Redshift cluster database by instantiating a `Table` +construct. This will make a query to the Redshift cluster to create a new database table +with the supplied schema. + +```ts fixture=cluster +new Table(this, 'Table', { + tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], + cluster: cluster, + databaseName: 'databaseName', +}); +``` + +### Granting Privileges + +You can give a user privileges to perform certain actions on a table by using the +`Table.grant()` method. + +```ts fixture=cluster +const user = new User(this, 'User', { + cluster: cluster, + databaseName: 'databaseName', +}); +const table = new Table(this, 'Table', { + tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], + cluster: cluster, + databaseName: 'databaseName', +}); + +table.grant(user, TableAction.DROP, TableAction.SELECT); +``` + +Take care when managing privileges via the CDK, as attempting to manage a user's +privileges on the same table in multiple CDK applications could lead to accidentally +overriding these permissions. Consider the following two CDK applications which both refer +to the same user and table. In application 1, the resources are created and the user is +given `INSERT` permissions on the table: + +```ts fixture=cluster +const databaseName = 'databaseName'; +const username = 'myuser' +const tableName = 'mytable' + +const user = new User(this, 'User', { + username: username, + cluster: cluster, + databaseName: databaseName, +}); +const table = new Table(this, 'Table', { + tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], + cluster: cluster, + databaseName: databaseName, +}); +table.grant(user, TableAction.INSERT); +``` + +In application 2, the resources are imported and the user is given `INSERT` permissions on +the table: + +```ts fixture=cluster +const databaseName = 'databaseName'; +const username = 'myuser' +const tableName = 'mytable' + +const user = User.fromUserAttributes(this, 'User', { + username: username, + password: SecretValue.plainText('NOT_FOR_PRODUCTION'), + cluster: cluster, + databaseName: databaseName, +}); +const table = Table.fromTableAttributes(this, 'Table', { + tableName: tableName, + tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], + cluster: cluster, + databaseName: 'databaseName', }); +table.grant(user, TableAction.INSERT); ``` -This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. +Both applications attempt to grant the user the appropriate privilege on the table by +submitting a `GRANT USER` SQL query to the Redshift cluster. Note that the latter of these +two calls will have no effect since the user has already been granted the privilege. + +Now, if application 1 were to remove the call to `grant`, a `REVOKE USER` SQL query is +submitted to the Redshift cluster. In general, application 1 does not know that +application 2 has also granted this permission and thus cannot decide not to issue the +revocation. This leads to the undesirable state where application 2 still contains the +call to `grant` but the user does not have the specified permission. + +Note that this does not occur when duplicate privileges are granted within the same +application, as such privileges are de-duplicated before any SQL query is submitted. diff --git a/packages/@aws-cdk/aws-redshift/lib/database-options.ts b/packages/@aws-cdk/aws-redshift/lib/database-options.ts new file mode 100644 index 0000000000000..b7eb21e57e24c --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/database-options.ts @@ -0,0 +1,26 @@ +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import { ICluster } from './cluster'; + +/** + * Properties for accessing a Redshift database + */ +export interface DatabaseOptions { + /** + * The cluster containing the database. + */ + readonly cluster: ICluster; + + /** + * The name of the database. + */ + readonly databaseName: string; + + /** + * The secret containing credentials to a Redshift user with administrator privileges. + * + * Secret JSON schema: `{ username: string; password: string }`. + * + * @default - the admin secret is taken from the cluster + */ + readonly adminUser?: secretsmanager.ISecret; +} diff --git a/packages/@aws-cdk/aws-redshift/lib/index.ts b/packages/@aws-cdk/aws-redshift/lib/index.ts index 8a8fc89428ce3..ec552d2da8c3c 100644 --- a/packages/@aws-cdk/aws-redshift/lib/index.ts +++ b/packages/@aws-cdk/aws-redshift/lib/index.ts @@ -1,8 +1,11 @@ export * from './cluster'; export * from './parameter-group'; +export * from './database-options'; export * from './database-secret'; export * from './endpoint'; export * from './subnet-group'; +export * from './table'; +export * from './user'; // AWS::Redshift CloudFormation Resources: export * from './redshift.generated'; diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/handler-name.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/handler-name.ts new file mode 100644 index 0000000000000..b758fb5819063 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/handler-name.ts @@ -0,0 +1,5 @@ +export enum HandlerName { + User = 'user', + Table = 'table', + UserTablePrivileges = 'user-table-privileges', +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/index.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/index.ts new file mode 100644 index 0000000000000..60eb2a009173c --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/index.ts @@ -0,0 +1,20 @@ +/* eslint-disable-next-line import/no-unresolved */ +import * as AWSLambda from 'aws-lambda'; +import { HandlerName } from './handler-name'; +import { handler as managePrivileges } from './privileges'; +import { handler as manageTable } from './table'; +import { handler as manageUser } from './user'; + +const HANDLERS: { [key in HandlerName]: ((props: any, event: AWSLambda.CloudFormationCustomResourceEvent) => Promise) } = { + [HandlerName.Table]: manageTable, + [HandlerName.User]: manageUser, + [HandlerName.UserTablePrivileges]: managePrivileges, +}; + +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) { + const subHandler = HANDLERS[event.ResourceProperties.handler as HandlerName]; + if (!subHandler) { + throw new Error(`Requested handler ${event.ResourceProperties.handler} is not in supported set: ${JSON.stringify(Object.keys(HANDLERS))}`); + } + return subHandler(event.ResourceProperties, event); +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/privileges.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/privileges.ts new file mode 100644 index 0000000000000..9f2064d0e5e5a --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/privileges.ts @@ -0,0 +1,70 @@ +/* eslint-disable-next-line import/no-unresolved */ +import * as AWSLambda from 'aws-lambda'; +import { TablePrivilege, UserTablePrivilegesHandlerProps } from '../handler-props'; +import { ClusterProps, executeStatement, makePhysicalId } from './util'; + +export async function handler(props: UserTablePrivilegesHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) { + const username = props.username; + const tablePrivileges = props.tablePrivileges; + const clusterProps = props; + + if (event.RequestType === 'Create') { + await grantPrivileges(username, tablePrivileges, clusterProps); + return { PhysicalResourceId: makePhysicalId(username, clusterProps, event.RequestId) }; + } else if (event.RequestType === 'Delete') { + await revokePrivileges(username, tablePrivileges, clusterProps); + return; + } else if (event.RequestType === 'Update') { + const { replace } = await updatePrivileges( + username, + tablePrivileges, + clusterProps, + event.OldResourceProperties as UserTablePrivilegesHandlerProps & ClusterProps, + ); + const physicalId = replace ? makePhysicalId(username, clusterProps, event.RequestId) : event.PhysicalResourceId; + return { PhysicalResourceId: physicalId }; + } else { + /* eslint-disable-next-line dot-notation */ + throw new Error(`Unrecognized event type: ${event['RequestType']}`); + } +} + +async function revokePrivileges(username: string, tablePrivileges: TablePrivilege[], clusterProps: ClusterProps) { + await Promise.all(tablePrivileges.map(({ tableName, actions }) => { + return executeStatement(`REVOKE ${actions.join(', ')} ON ${tableName} FROM ${username}`, clusterProps); + })); +} + +async function grantPrivileges(username: string, tablePrivileges: TablePrivilege[], clusterProps: ClusterProps) { + await Promise.all(tablePrivileges.map(({ tableName, actions }) => { + return executeStatement(`GRANT ${actions.join(', ')} ON ${tableName} TO ${username}`, clusterProps); + })); +} + +async function updatePrivileges( + username: string, + tablePrivileges: TablePrivilege[], + clusterProps: ClusterProps, + oldResourceProperties: UserTablePrivilegesHandlerProps & ClusterProps, +): Promise<{ replace: boolean }> { + const oldClusterProps = oldResourceProperties; + if (clusterProps.clusterName !== oldClusterProps.clusterName || clusterProps.databaseName !== oldClusterProps.databaseName) { + await grantPrivileges(username, tablePrivileges, clusterProps); + return { replace: true }; + } + + const oldUsername = oldResourceProperties.username; + if (oldUsername !== username) { + await grantPrivileges(username, tablePrivileges, clusterProps); + return { replace: true }; + } + + const oldTablePrivileges = oldResourceProperties.tablePrivileges; + if (oldTablePrivileges !== tablePrivileges) { + await revokePrivileges(username, oldTablePrivileges, clusterProps); + await grantPrivileges(username, tablePrivileges, clusterProps); + return { replace: false }; + } + + return { replace: false }; +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/table.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/table.ts new file mode 100644 index 0000000000000..a2e2a4dc4bee9 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/table.ts @@ -0,0 +1,75 @@ +/* eslint-disable-next-line import/no-unresolved */ +import * as AWSLambda from 'aws-lambda'; +import { Column } from '../../table'; +import { TableHandlerProps } from '../handler-props'; +import { ClusterProps, executeStatement } from './util'; + +export async function handler(props: TableHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) { + const tableNamePrefix = props.tableName.prefix; + const tableNameSuffix = props.tableName.generateSuffix ? `${event.RequestId.substring(0, 8)}` : ''; + const tableColumns = props.tableColumns; + const clusterProps = props; + + if (event.RequestType === 'Create') { + const tableName = await createTable(tableNamePrefix, tableNameSuffix, tableColumns, clusterProps); + return { PhysicalResourceId: tableName }; + } else if (event.RequestType === 'Delete') { + await dropTable(event.PhysicalResourceId, clusterProps); + return; + } else if (event.RequestType === 'Update') { + const tableName = await updateTable( + event.PhysicalResourceId, + tableNamePrefix, + tableNameSuffix, + tableColumns, + clusterProps, + event.OldResourceProperties as TableHandlerProps & ClusterProps, + ); + return { PhysicalResourceId: tableName }; + } else { + /* eslint-disable-next-line dot-notation */ + throw new Error(`Unrecognized event type: ${event['RequestType']}`); + } +} + +async function createTable(tableNamePrefix: string, tableNameSuffix: string, tableColumns: Column[], clusterProps: ClusterProps): Promise { + const tableName = tableNamePrefix + tableNameSuffix; + const tableColumnsString = tableColumns.map(column => `${column.name} ${column.dataType}`).join(); + await executeStatement(`CREATE TABLE ${tableName} (${tableColumnsString})`, clusterProps); + return tableName; +} + +async function dropTable(tableName: string, clusterProps: ClusterProps) { + await executeStatement(`DROP TABLE ${tableName}`, clusterProps); +} + +async function updateTable( + tableName: string, + tableNamePrefix: string, + tableNameSuffix: string, + tableColumns: Column[], + clusterProps: ClusterProps, + oldResourceProperties: TableHandlerProps & ClusterProps, +): Promise { + const oldClusterProps = oldResourceProperties; + if (clusterProps.clusterName !== oldClusterProps.clusterName || clusterProps.databaseName !== oldClusterProps.databaseName) { + return createTable(tableNamePrefix, tableNameSuffix, tableColumns, clusterProps); + } + + const oldTableNamePrefix = oldResourceProperties.tableName.prefix; + if (tableNamePrefix !== oldTableNamePrefix) { + return createTable(tableNamePrefix, tableNameSuffix, tableColumns, clusterProps); + } + + const oldTableColumns = oldResourceProperties.tableColumns; + if (!oldTableColumns.every(oldColumn => tableColumns.some(column => column.name === oldColumn.name && column.dataType === oldColumn.dataType))) { + return createTable(tableNamePrefix, tableNameSuffix, tableColumns, clusterProps); + } + + const additions = tableColumns.filter(column => { + return !oldTableColumns.some(oldColumn => column.name === oldColumn.name && column.dataType === oldColumn.dataType); + }).map(column => `ADD ${column.name} ${column.dataType}`); + await Promise.all(additions.map(addition => executeStatement(`ALTER TABLE ${tableName} ${addition}`, clusterProps))); + + return tableName; +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/user.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/user.ts new file mode 100644 index 0000000000000..707af78714e43 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/user.ts @@ -0,0 +1,82 @@ +/* eslint-disable-next-line import/no-unresolved */ +import * as AWSLambda from 'aws-lambda'; +/* eslint-disable-next-line import/no-extraneous-dependencies */ +import * as SecretsManager from 'aws-sdk/clients/secretsmanager'; +import { UserHandlerProps } from '../handler-props'; +import { ClusterProps, executeStatement, makePhysicalId } from './util'; + +const secretsManager = new SecretsManager(); + +export async function handler(props: UserHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) { + const username = props.username; + const passwordSecretArn = props.passwordSecretArn; + const clusterProps = props; + + if (event.RequestType === 'Create') { + await createUser(username, passwordSecretArn, clusterProps); + return { PhysicalResourceId: makePhysicalId(username, clusterProps, event.RequestId), Data: { username: username } }; + } else if (event.RequestType === 'Delete') { + await dropUser(username, clusterProps); + return; + } else if (event.RequestType === 'Update') { + const { replace } = await updateUser(username, passwordSecretArn, clusterProps, event.OldResourceProperties as UserHandlerProps & ClusterProps); + const physicalId = replace ? makePhysicalId(username, clusterProps, event.RequestId) : event.PhysicalResourceId; + return { PhysicalResourceId: physicalId, Data: { username: username } }; + } else { + /* eslint-disable-next-line dot-notation */ + throw new Error(`Unrecognized event type: ${event['RequestType']}`); + } +} + +async function dropUser(username: string, clusterProps: ClusterProps) { + await executeStatement(`DROP USER ${username}`, clusterProps); +} + +async function createUser(username: string, passwordSecretArn: string, clusterProps: ClusterProps) { + const password = await getPasswordFromSecret(passwordSecretArn); + + await executeStatement(`CREATE USER ${username} PASSWORD '${password}'`, clusterProps); +} + +async function updateUser( + username: string, + passwordSecretArn: string, + clusterProps: ClusterProps, + oldResourceProperties: UserHandlerProps & ClusterProps, +): Promise<{ replace: boolean }> { + const oldClusterProps = oldResourceProperties; + if (clusterProps.clusterName !== oldClusterProps.clusterName || clusterProps.databaseName !== oldClusterProps.databaseName) { + await createUser(username, passwordSecretArn, clusterProps); + return { replace: true }; + } + + const oldUsername = oldResourceProperties.username; + const oldPasswordSecretArn = oldResourceProperties.passwordSecretArn; + const oldPassword = await getPasswordFromSecret(oldPasswordSecretArn); + const password = await getPasswordFromSecret(passwordSecretArn); + + if (username !== oldUsername) { + await createUser(username, passwordSecretArn, clusterProps); + return { replace: true }; + } + + if (password !== oldPassword) { + await executeStatement(`ALTER USER ${username} PASSWORD '${password}'`, clusterProps); + return { replace: false }; + } + + return { replace: false }; +} + +async function getPasswordFromSecret(passwordSecretArn: string): Promise { + const secretValue = await secretsManager.getSecretValue({ + SecretId: passwordSecretArn, + }).promise(); + const secretString = secretValue.SecretString; + if (!secretString) { + throw new Error(`Secret string for ${passwordSecretArn} was empty`); + } + const { password } = JSON.parse(secretString); + + return password; +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/util.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/util.ts new file mode 100644 index 0000000000000..d834cd474f986 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/util.ts @@ -0,0 +1,40 @@ +/* eslint-disable-next-line import/no-extraneous-dependencies */ +import * as RedshiftData from 'aws-sdk/clients/redshiftdata'; +import { DatabaseQueryHandlerProps } from '../handler-props'; + +const redshiftData = new RedshiftData(); + +export type ClusterProps = Omit; + +export async function executeStatement(statement: string, clusterProps: ClusterProps): Promise { + const executeStatementProps = { + ClusterIdentifier: clusterProps.clusterName, + Database: clusterProps.databaseName, + SecretArn: clusterProps.adminUserArn, + Sql: statement, + }; + const executedStatement = await redshiftData.executeStatement(executeStatementProps).promise(); + if (!executedStatement.Id) { + throw new Error('Service error: Statement execution did not return a statement ID'); + } + await waitForStatementComplete(executedStatement.Id); +} + +const waitTimeout = 100; +async function waitForStatementComplete(statementId: string): Promise { + await new Promise((resolve: (value: void) => void) => { + setTimeout(() => resolve(), waitTimeout); + }); + const statement = await redshiftData.describeStatement({ Id: statementId }).promise(); + if (statement.Status !== 'FINISHED' && statement.Status !== 'FAILED' && statement.Status !== 'ABORTED') { + return waitForStatementComplete(statementId); + } else if (statement.Status === 'FINISHED') { + return; + } else { + throw new Error(`Statement status was ${statement.Status}: ${statement.Error}`); + } +} + +export function makePhysicalId(resourceName: string, clusterProps: ClusterProps, requestId: string): string { + return `${clusterProps.clusterName}:${clusterProps.databaseName}:${resourceName}:${requestId}`; +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/database-query.ts b/packages/@aws-cdk/aws-redshift/lib/private/database-query.ts new file mode 100644 index 0000000000000..2f724334b637a --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/database-query.ts @@ -0,0 +1,105 @@ +import * as path from 'path'; +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import * as cdk from '@aws-cdk/core'; +import * as customresources from '@aws-cdk/custom-resources'; +import { Construct } from 'constructs'; +import { Cluster } from '../cluster'; +import { DatabaseOptions } from '../database-options'; +import { DatabaseQueryHandlerProps } from './handler-props'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +export interface DatabaseQueryProps extends DatabaseOptions { + readonly handler: string; + readonly properties: HandlerProps; + /** + * The policy to apply when this resource is removed from the application. + * + * @default cdk.RemovalPolicy.Destroy + */ + readonly removalPolicy?: cdk.RemovalPolicy; +} + +export class DatabaseQuery extends CoreConstruct implements iam.IGrantable { + readonly grantPrincipal: iam.IPrincipal; + readonly ref: string; + + private readonly resource: cdk.CustomResource; + + constructor(scope: Construct, id: string, props: DatabaseQueryProps) { + super(scope, id); + + const adminUser = this.getAdminUser(props); + const handler = new lambda.SingletonFunction(this, 'Handler', { + code: lambda.Code.fromAsset(path.join(__dirname, 'database-query-provider')), + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + timeout: cdk.Duration.minutes(1), + uuid: '3de5bea7-27da-4796-8662-5efb56431b5f', + lambdaPurpose: 'Query Redshift Database', + }); + handler.addToRolePolicy(new iam.PolicyStatement({ + actions: ['redshift-data:DescribeStatement', 'redshift-data:ExecuteStatement'], + resources: ['*'], + })); + adminUser.grantRead(handler); + + const provider = new customresources.Provider(this, 'Provider', { + onEventHandler: handler, + }); + + const queryHandlerProps: DatabaseQueryHandlerProps & HandlerProps = { + handler: props.handler, + clusterName: props.cluster.clusterName, + adminUserArn: adminUser.secretArn, + databaseName: props.databaseName, + ...props.properties, + }; + this.resource = new cdk.CustomResource(this, 'Resource', { + resourceType: 'Custom::RedshiftDatabaseQuery', + serviceToken: provider.serviceToken, + removalPolicy: props.removalPolicy, + properties: queryHandlerProps, + }); + + this.grantPrincipal = handler.grantPrincipal; + this.ref = this.resource.ref; + } + + public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { + this.resource.applyRemovalPolicy(policy); + } + + public getAtt(attributeName: string): cdk.Reference { + return this.resource.getAtt(attributeName); + } + + public getAttString(attributeName: string): string { + return this.resource.getAttString(attributeName); + } + + private getAdminUser(props: DatabaseOptions): secretsmanager.ISecret { + const cluster = props.cluster; + let adminUser = props.adminUser; + if (!adminUser) { + if (cluster instanceof Cluster) { + if (cluster.secret) { + adminUser = cluster.secret; + } else { + throw new Error( + 'Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster did not generate admin user credentials (they were provided explicitly)', + ); + } + } else { + throw new Error( + 'Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster was imported', + ); + } + } + return adminUser; + } +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/handler-props.ts b/packages/@aws-cdk/aws-redshift/lib/private/handler-props.ts new file mode 100644 index 0000000000000..b00cc667a2ced --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/handler-props.ts @@ -0,0 +1,31 @@ +import { Column } from '../table'; + +export interface DatabaseQueryHandlerProps { + readonly handler: string; + readonly clusterName: string; + readonly adminUserArn: string; + readonly databaseName: string; +} + +export interface UserHandlerProps { + readonly username: string; + readonly passwordSecretArn: string; +} + +export interface TableHandlerProps { + readonly tableName: { + readonly prefix: string; + readonly generateSuffix: boolean; + }; + readonly tableColumns: Column[]; +} + +export interface TablePrivilege { + readonly tableName: string; + readonly actions: string[]; +} + +export interface UserTablePrivilegesHandlerProps { + readonly username: string; + readonly tablePrivileges: TablePrivilege[]; +} diff --git a/packages/@aws-cdk/aws-redshift/lib/private/privileges.ts b/packages/@aws-cdk/aws-redshift/lib/private/privileges.ts new file mode 100644 index 0000000000000..e8d9ed13d13dc --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/private/privileges.ts @@ -0,0 +1,101 @@ +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { DatabaseOptions } from '../database-options'; +import { ITable, TableAction } from '../table'; +import { IUser } from '../user'; +import { DatabaseQuery } from './database-query'; +import { HandlerName } from './database-query-provider/handler-name'; +import { TablePrivilege as SerializedTablePrivilege, UserTablePrivilegesHandlerProps } from './handler-props'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * The Redshift table and action that make up a privilege that can be granted to a Redshift user. + */ +export interface TablePrivilege { + /** + * The table on which privileges will be granted. + */ + readonly table: ITable; + + /** + * The actions that will be granted. + */ + readonly actions: TableAction[]; +} + +/** + * Properties for specifying privileges granted to a Redshift user on Redshift tables. + */ +export interface UserTablePrivilegesProps extends DatabaseOptions { + /** + * The user to which privileges will be granted. + */ + readonly user: IUser; + + /** + * The privileges to be granted. + * + * @default [] - use `addPrivileges` to grant privileges after construction + */ + readonly privileges?: TablePrivilege[]; +} + +/** + * Privileges granted to a Redshift user on Redshift tables. + * + * This construct is located in the `private` directory to ensure that it is not exported for direct public use. This + * means that user privileges must be managed through the `Table.grant` method or the `User.addTablePrivileges` + * method. Thus, each `User` will have at most one `UserTablePrivileges` construct to manage its privileges. For details + * on why this is a Good Thing, see the README, under "Granting Privileges". + */ +export class UserTablePrivileges extends CoreConstruct { + private privileges: TablePrivilege[]; + + constructor(scope: Construct, id: string, props: UserTablePrivilegesProps) { + super(scope, id); + + this.privileges = props.privileges ?? []; + + new DatabaseQuery(this, 'Resource', { + ...props, + handler: HandlerName.UserTablePrivileges, + properties: { + username: props.user.username, + tablePrivileges: cdk.Lazy.any({ + produce: () => { + const reducedPrivileges = this.privileges.reduce((privileges, { table, actions }) => { + const tableName = table.tableName; + if (!(tableName in privileges)) { + privileges[tableName] = []; + } + actions = actions.concat(privileges[tableName]); + if (actions.includes(TableAction.ALL)) { + actions = [TableAction.ALL]; + } + if (actions.includes(TableAction.UPDATE) || actions.includes(TableAction.DELETE)) { + actions.push(TableAction.SELECT); + } + privileges[tableName] = Array.from(new Set(actions)); + return privileges; + }, {} as { [key: string]: TableAction[] }); + const serializedPrivileges: SerializedTablePrivilege[] = Object.entries(reducedPrivileges).map(([tableName, actions]) => ({ + tableName: tableName, + actions: actions.map(action => TableAction[action]), + })); + return serializedPrivileges; + }, + }) as any, + }, + }); + } + + /** + * Grant this user additional privileges. + */ + addPrivileges(table: ITable, ...actions: TableAction[]): void { + this.privileges.push({ table, actions }); + } +} diff --git a/packages/@aws-cdk/aws-redshift/lib/table.ts b/packages/@aws-cdk/aws-redshift/lib/table.ts new file mode 100644 index 0000000000000..337abdedd00a1 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/table.ts @@ -0,0 +1,222 @@ +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { ICluster } from './cluster'; +import { DatabaseOptions } from './database-options'; +import { DatabaseQuery } from './private/database-query'; +import { HandlerName } from './private/database-query-provider/handler-name'; +import { TableHandlerProps } from './private/handler-props'; +import { IUser } from './user'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * An action that a Redshift user can be granted privilege to perform on a table. + */ +export enum TableAction { + /** + * Grants privilege to select data from a table or view using a SELECT statement. + */ + SELECT, + + /** + * Grants privilege to load data into a table using an INSERT statement or a COPY statement. + */ + INSERT, + + /** + * Grants privilege to update a table column using an UPDATE statement. + */ + UPDATE, + + /** + * Grants privilege to delete a data row from a table. + */ + DELETE, + + /** + * Grants privilege to drop a table. + */ + DROP, + + /** + * Grants privilege to create a foreign key constraint. + * + * You need to grant this privilege on both the referenced table and the referencing table; otherwise, the user can't create the constraint. + */ + REFERENCES, + + /** + * Grants all available privileges at once to the specified user or user group. + */ + ALL +} + +/** + * A column in a Redshift table. + */ +export interface Column { + /** + * The name of the column. + */ + readonly name: string; + + /** + * The data type of the column. + */ + readonly dataType: string; +} + +/** + * Properties for configuring a Redshift table. + */ +export interface TableProps extends DatabaseOptions { + /** + * The name of the table. + * + * @default - a name is generated + */ + readonly tableName?: string; + + /** + * The columns of the table. + */ + readonly tableColumns: Column[]; + + /** + * The policy to apply when this resource is removed from the application. + * + * @default cdk.RemovalPolicy.Retain + */ + readonly removalPolicy?: cdk.RemovalPolicy; +} + +/** + * Represents a table in a Redshift database. + */ +export interface ITable extends cdk.IConstruct { + /** + * Name of the table. + */ + readonly tableName: string; + + /** + * The columns of the table. + */ + readonly tableColumns: Column[]; + + /** + * The cluster where the table is located. + */ + readonly cluster: ICluster; + + /** + * The name of the database where the table is located. + */ + readonly databaseName: string; + + /** + * Grant a user privilege to access this table. + */ + grant(user: IUser, ...actions: TableAction[]): void; +} + +/** + * A full specification of a Redshift table that can be used to import it fluently into the CDK application. + */ +export interface TableAttributes { + /** + * Name of the table. + */ + readonly tableName: string; + + /** + * The columns of the table. + */ + readonly tableColumns: Column[]; + + /** + * The cluster where the table is located. + */ + readonly cluster: ICluster; + + /** + * The name of the database where the table is located. + */ + readonly databaseName: string; +} + +abstract class TableBase extends CoreConstruct implements ITable { + abstract readonly tableName: string; + abstract readonly tableColumns: Column[]; + abstract readonly cluster: ICluster; + abstract readonly databaseName: string; + grant(user: IUser, ...actions: TableAction[]) { + user.addTablePrivileges(this, ...actions); + } +} + +/** + * A table in a Redshift cluster. + */ +export class Table extends TableBase { + /** + * Specify a Redshift table using a table name and schema that already exists. + */ + static fromTableAttributes(scope: Construct, id: string, attrs: TableAttributes): ITable { + return new class extends TableBase { + readonly tableName = attrs.tableName; + readonly tableColumns = attrs.tableColumns; + readonly cluster = attrs.cluster; + readonly databaseName = attrs.databaseName; + }(scope, id); + } + + readonly tableName: string; + readonly tableColumns: Column[]; + readonly cluster: ICluster; + readonly databaseName: string; + + private resource: DatabaseQuery; + + constructor(scope: Construct, id: string, props: TableProps) { + super(scope, id); + + this.tableColumns = props.tableColumns; + this.cluster = props.cluster; + this.databaseName = props.databaseName; + + this.resource = new DatabaseQuery(this, 'Resource', { + removalPolicy: cdk.RemovalPolicy.RETAIN, + ...props, + handler: HandlerName.Table, + properties: { + tableName: { + prefix: props.tableName ?? cdk.Names.uniqueId(this), + generateSuffix: !props.tableName, + }, + tableColumns: this.tableColumns, + }, + }); + + this.tableName = this.resource.ref; + } + + /** + * Apply the given removal policy to this resource + * + * The Removal Policy controls what happens to this resource when it stops + * being managed by CloudFormation, either because you've removed it from the + * CDK application or because you've made a change that requires the resource + * to be replaced. + * + * The resource can be destroyed (`RemovalPolicy.DESTROY`), or left in your AWS + * account for data recovery and cleanup later (`RemovalPolicy.RETAIN`). + * + * This resource is retained by default. + */ + public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { + this.resource.applyRemovalPolicy(policy); + } +} diff --git a/packages/@aws-cdk/aws-redshift/lib/user.ts b/packages/@aws-cdk/aws-redshift/lib/user.ts new file mode 100644 index 0000000000000..3b5c8d0829ef8 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/lib/user.ts @@ -0,0 +1,186 @@ +import * as kms from '@aws-cdk/aws-kms'; +import * as cdk from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { ICluster } from './cluster'; +import { DatabaseOptions } from './database-options'; +import { DatabaseSecret } from './database-secret'; +import { DatabaseQuery } from './private/database-query'; +import { HandlerName } from './private/database-query-provider/handler-name'; +import { UserHandlerProps } from './private/handler-props'; +import { UserTablePrivileges } from './private/privileges'; +import { ITable, TableAction } from './table'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Properties for configuring a Redshift user. + */ +export interface UserProps extends DatabaseOptions { + /** + * The name of the user. + * + * For valid values, see: https://docs.aws.amazon.com/redshift/latest/dg/r_names.html + * + * @default - a name is generated + */ + readonly username?: string; + + /** + * KMS key to encrypt the generated secret. + * + * @default - the default AWS managed key is used + */ + readonly encryptionKey?: kms.IKey; + + /** + * The policy to apply when this resource is removed from the application. + * + * @default cdk.RemovalPolicy.Destroy + */ + readonly removalPolicy?: cdk.RemovalPolicy; +} + +/** + * Represents a user in a Redshift database. + */ +export interface IUser extends cdk.IConstruct { + /** + * The name of the user. + */ + readonly username: string; + + /** + * The password of the user. + */ + readonly password: cdk.SecretValue; + + /** + * The cluster where the table is located. + */ + readonly cluster: ICluster; + + /** + * The name of the database where the table is located. + */ + readonly databaseName: string; + + /** + * Grant this user privilege to access a table. + */ + addTablePrivileges(table: ITable, ...actions: TableAction[]): void; +} + +/** + * A full specification of a Redshift user that can be used to import it fluently into the CDK application. + */ +export interface UserAttributes extends DatabaseOptions { + /** + * The name of the user. + */ + readonly username: string; + + /** + * The password of the user. + * + * Do not put passwords in CDK code directly. + */ + readonly password: cdk.SecretValue; +} + +abstract class UserBase extends CoreConstruct implements IUser { + abstract readonly username: string; + abstract readonly password: cdk.SecretValue; + abstract readonly cluster: ICluster; + abstract readonly databaseName: string; + + /** + * The tables that user will have access to + */ + private privileges?: UserTablePrivileges; + + protected abstract readonly databaseProps: DatabaseOptions; + + addTablePrivileges(table: ITable, ...actions: TableAction[]): void { + if (!this.privileges) { + this.privileges = new UserTablePrivileges(this, 'TablePrivileges', { + ...this.databaseProps, + user: this, + }); + } + + this.privileges.addPrivileges(table, ...actions); + } +} + +/** + * A user in a Redshift cluster. + */ +export class User extends UserBase { + /** + * Specify a Redshift user using credentials that already exist. + */ + static fromUserAttributes(scope: Construct, id: string, attrs: UserAttributes): IUser { + return new class extends UserBase { + readonly username = attrs.username; + readonly password = attrs.password; + readonly cluster = attrs.cluster; + readonly databaseName = attrs.databaseName; + protected readonly databaseProps = attrs; + }(scope, id); + } + + readonly username: string; + readonly password: cdk.SecretValue; + readonly cluster: ICluster; + readonly databaseName: string; + protected databaseProps: DatabaseOptions; + + private resource: DatabaseQuery; + + constructor(scope: Construct, id: string, props: UserProps) { + super(scope, id); + + this.databaseProps = props; + this.cluster = props.cluster; + this.databaseName = props.databaseName; + + const username = props.username ?? cdk.Names.uniqueId(this).toLowerCase(); + const secret = new DatabaseSecret(this, 'Secret', { + username, + encryptionKey: props.encryptionKey, + }); + const attachedSecret = secret.attach(props.cluster); + this.password = attachedSecret.secretValueFromJson('password'); + + this.resource = new DatabaseQuery(this, 'Resource', { + ...this.databaseProps, + handler: HandlerName.User, + properties: { + username, + passwordSecretArn: attachedSecret.secretArn, + }, + }); + attachedSecret.grantRead(this.resource); + + this.username = this.resource.getAttString('username'); + } + + /** + * Apply the given removal policy to this resource + * + * The Removal Policy controls what happens to this resource when it stops + * being managed by CloudFormation, either because you've removed it from the + * CDK application or because you've made a change that requires the resource + * to be replaced. + * + * The resource can be destroyed (`RemovalPolicy.DESTROY`), or left in your AWS + * account for data recovery and cleanup later (`RemovalPolicy.RETAIN`). + * + * This resource is destroyed by default. + */ + public applyRemovalPolicy(policy: cdk.RemovalPolicy): void { + this.resource.applyRemovalPolicy(policy); + } +} diff --git a/packages/@aws-cdk/aws-redshift/package.json b/packages/@aws-cdk/aws-redshift/package.json index 3bf492f83ee7b..71042529a3e69 100644 --- a/packages/@aws-cdk/aws-redshift/package.json +++ b/packages/@aws-cdk/aws-redshift/package.json @@ -28,7 +28,14 @@ ] } }, - "projectReferences": true + "projectReferences": true, + "metadata": { + "jsii": { + "rosetta": { + "strict": true + } + } + } }, "repository": { "type": "git", @@ -75,7 +82,9 @@ "devDependencies": { "@aws-cdk/assertions": "0.0.0", "@types/jest": "^26.0.24", + "aws-sdk": "^2.848.0", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "jest": "^26.6.3", "pkglint": "0.0.0" @@ -84,9 +93,11 @@ "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.3.69" }, "homepage": "https://github.com/aws/aws-cdk", @@ -94,9 +105,11 @@ "@aws-cdk/aws-ec2": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.3.69" }, "engines": { diff --git a/packages/@aws-cdk/aws-redshift/rosetta/cluster.ts-fixture b/packages/@aws-cdk/aws-redshift/rosetta/cluster.ts-fixture new file mode 100644 index 0000000000000..82d98ca3e381e --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/rosetta/cluster.ts-fixture @@ -0,0 +1,20 @@ +// Fixture with cluster already created +import { Construct, SecretValue, Stack } from '@aws-cdk/core'; +import { Vpc } from '@aws-cdk/aws-ec2'; +import { Cluster, Table, TableAction, User } from '@aws-cdk/aws-redshift'; + +class Fixture extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + const vpc = new Vpc(this, 'Vpc'); + const cluster = new Cluster(this, 'Cluster', { + vpc, + masterUser: { + masterUsername: 'admin', + }, + }); + + /// here + } +} diff --git a/packages/@aws-cdk/aws-redshift/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-redshift/rosetta/default.ts-fixture new file mode 100644 index 0000000000000..928b036cf2611 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/rosetta/default.ts-fixture @@ -0,0 +1,11 @@ +// Fixture with packages imported, but nothing else +import { Construct, Stack } from '@aws-cdk/core'; +import { Cluster } from '@aws-cdk/aws-redshift'; + +class Fixture extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + /// here + } +} diff --git a/packages/@aws-cdk/aws-redshift/test/database-query-provider/index.test.ts b/packages/@aws-cdk/aws-redshift/test/database-query-provider/index.test.ts new file mode 100644 index 0000000000000..18091a6627167 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/database-query-provider/index.test.ts @@ -0,0 +1,50 @@ +/* eslint-disable-next-line import/no-unresolved */ +import type * as AWSLambda from 'aws-lambda'; + +const resourceProperties = { + handler: 'table', + ServiceToken: '', +}; +const requestId = 'requestId'; +const baseEvent: AWSLambda.CloudFormationCustomResourceEvent = { + ResourceProperties: resourceProperties, + RequestType: 'Create', + ServiceToken: '', + ResponseURL: '', + StackId: '', + RequestId: requestId, + LogicalResourceId: '', + ResourceType: '', +}; + +const mockSubHandler = jest.fn(); +jest.mock('../../lib/private/database-query-provider/table', () => ({ + __esModule: true, + handler: mockSubHandler, +})); +import { handler } from '../../lib/private/database-query-provider/index'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('calls sub handler', async () => { + const event = baseEvent; + + await handler(event); + + expect(mockSubHandler).toHaveBeenCalled(); +}); + +test('throws with unregistered subhandler', async () => { + const event = { + ...baseEvent, + ResourceProperties: { + ...resourceProperties, + handler: 'unregistered', + }, + }; + + await expect(handler(event)).rejects.toThrow(/Requested handler unregistered is not in supported set/); + expect(mockSubHandler).not.toHaveBeenCalled(); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/database-query-provider/privileges.test.ts b/packages/@aws-cdk/aws-redshift/test/database-query-provider/privileges.test.ts new file mode 100644 index 0000000000000..daa3835b89f24 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/database-query-provider/privileges.test.ts @@ -0,0 +1,163 @@ +/* eslint-disable-next-line import/no-unresolved */ +import type * as AWSLambda from 'aws-lambda'; + +const username = 'username'; +const tableName = 'tableName'; +const tablePrivileges = [{ tableName, actions: ['INSERT', 'SELECT'] }]; +const clusterName = 'clusterName'; +const adminUserArn = 'adminUserArn'; +const databaseName = 'databaseName'; +const physicalResourceId = 'PhysicalResourceId'; +const resourceProperties = { + username, + tablePrivileges, + clusterName, + adminUserArn, + databaseName, + ServiceToken: '', +}; +const requestId = 'requestId'; +const genericEvent: AWSLambda.CloudFormationCustomResourceEventCommon = { + ResourceProperties: resourceProperties, + ServiceToken: '', + ResponseURL: '', + StackId: '', + RequestId: requestId, + LogicalResourceId: '', + ResourceType: '', +}; + +const mockExecuteStatement = jest.fn(() => ({ promise: jest.fn(() => ({ Id: 'statementId' })) })); +jest.mock('aws-sdk/clients/redshiftdata', () => class { + executeStatement = mockExecuteStatement; + describeStatement = () => ({ promise: jest.fn(() => ({ Status: 'FINISHED' })) }); +}); +import { handler as managePrivileges } from '../../lib/private/database-query-provider/privileges'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('create', () => { + const baseEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { + RequestType: 'Create', + ...genericEvent, + }; + + test('serializes properties in statement and creates physical resource ID', async () => { + const event = baseEvent; + + await expect(managePrivileges(resourceProperties, event)).resolves.toEqual({ + PhysicalResourceId: 'clusterName:databaseName:username:requestId', + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `GRANT INSERT, SELECT ON ${tableName} TO ${username}`, + })); + }); +}); + +describe('delete', () => { + const baseEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { + RequestType: 'Delete', + PhysicalResourceId: physicalResourceId, + ...genericEvent, + }; + + test('executes statement', async () => { + const event = baseEvent; + + await managePrivileges(resourceProperties, event); + + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`, + })); + }); +}); + +describe('update', () => { + const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { + RequestType: 'Update', + OldResourceProperties: resourceProperties, + PhysicalResourceId: physicalResourceId, + ...genericEvent, + }; + + test('replaces if cluster name changes', async () => { + const newClusterName = 'newClusterName'; + const newResourceProperties = { + ...resourceProperties, + clusterName: newClusterName, + }; + + await expect(managePrivileges(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + ClusterIdentifier: newClusterName, + Sql: expect.stringMatching(/GRANT/), + })); + }); + + test('does not replace if admin user ARN changes', async () => { + const newAdminUserArn = 'newAdminUserArn'; + const newResourceProperties = { + ...resourceProperties, + adminUserArn: newAdminUserArn, + }; + + await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).not.toHaveBeenCalled(); + }); + + test('replaces if database name changes', async () => { + const newDatabaseName = 'newDatabaseName'; + const newResourceProperties = { + ...resourceProperties, + databaseName: newDatabaseName, + }; + + await expect(managePrivileges(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Database: newDatabaseName, + Sql: expect.stringMatching(/GRANT/), + })); + }); + + test('replaces if user name changes', async () => { + const newUsername = 'newUsername'; + const newResourceProperties = { + ...resourceProperties, + username: newUsername, + }; + + await expect(managePrivileges(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: expect.stringMatching(new RegExp(`GRANT .* TO ${newUsername}`)), + })); + }); + + test('does not replace when privileges change', async () => { + const newTableName = 'newTableName'; + const newTablePrivileges = [{ tableName: newTableName, actions: ['DROP'] }]; + const newResourceProperties = { + ...resourceProperties, + tablePrivileges: newTablePrivileges, + }; + + await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`, + })); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `GRANT DROP ON ${newTableName} TO ${username}`, + })); + }); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/database-query-provider/table.test.ts b/packages/@aws-cdk/aws-redshift/test/database-query-provider/table.test.ts new file mode 100644 index 0000000000000..956efca1ab81f --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/database-query-provider/table.test.ts @@ -0,0 +1,202 @@ +/* eslint-disable-next-line import/no-unresolved */ +import type * as AWSLambda from 'aws-lambda'; + +const tableNamePrefix = 'tableNamePrefix'; +const tableColumns = [{ name: 'col1', dataType: 'varchar(1)' }]; +const clusterName = 'clusterName'; +const adminUserArn = 'adminUserArn'; +const databaseName = 'databaseName'; +const physicalResourceId = 'PhysicalResourceId'; +const resourceProperties = { + tableName: { + prefix: tableNamePrefix, + generateSuffix: true, + }, + tableColumns, + clusterName, + adminUserArn, + databaseName, + ServiceToken: '', +}; +const requestId = 'requestId'; +const requestIdTruncated = 'requestI'; +const genericEvent: AWSLambda.CloudFormationCustomResourceEventCommon = { + ResourceProperties: resourceProperties, + ServiceToken: '', + ResponseURL: '', + StackId: '', + RequestId: requestId, + LogicalResourceId: '', + ResourceType: '', +}; + +const mockExecuteStatement = jest.fn(() => ({ promise: jest.fn(() => ({ Id: 'statementId' })) })); +jest.mock('aws-sdk/clients/redshiftdata', () => class { + executeStatement = mockExecuteStatement; + describeStatement = () => ({ promise: jest.fn(() => ({ Status: 'FINISHED' })) }); +}); +import { handler as manageTable } from '../../lib/private/database-query-provider/table'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('create', () => { + const baseEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { + RequestType: 'Create', + ...genericEvent, + }; + + test('serializes properties in statement and creates physical resource ID', async () => { + const event = baseEvent; + + await expect(manageTable(resourceProperties, event)).resolves.toEqual({ + PhysicalResourceId: `${tableNamePrefix}${requestIdTruncated}`, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `CREATE TABLE ${tableNamePrefix}${requestIdTruncated} (col1 varchar(1))`, + })); + }); + + test('does not modify table name if no suffix generation requested', async () => { + const event = baseEvent; + const newResourceProperties = { + ...resourceProperties, + tableName: { + ...resourceProperties.tableName, + generateSuffix: false, + }, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.toEqual({ + PhysicalResourceId: tableNamePrefix, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `CREATE TABLE ${tableNamePrefix} (col1 varchar(1))`, + })); + }); +}); + +describe('delete', () => { + const baseEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { + RequestType: 'Delete', + PhysicalResourceId: physicalResourceId, + ...genericEvent, + }; + + test('executes statement', async () => { + const event = baseEvent; + + await manageTable(resourceProperties, event); + + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `DROP TABLE ${physicalResourceId}`, + })); + }); +}); + +describe('update', () => { + const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { + RequestType: 'Update', + OldResourceProperties: resourceProperties, + PhysicalResourceId: physicalResourceId, + ...genericEvent, + }; + + test('replaces if cluster name changes', async () => { + const newClusterName = 'newClusterName'; + const newResourceProperties = { + ...resourceProperties, + clusterName: newClusterName, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + ClusterIdentifier: newClusterName, + Sql: expect.stringMatching(new RegExp(`CREATE TABLE ${tableNamePrefix}${requestIdTruncated}`)), + })); + }); + + test('does not replace if admin user ARN changes', async () => { + const newAdminUserArn = 'newAdminUserArn'; + const newResourceProperties = { + ...resourceProperties, + adminUserArn: newAdminUserArn, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).not.toHaveBeenCalled(); + }); + + test('replaces if database name changes', async () => { + const newDatabaseName = 'newDatabaseName'; + const newResourceProperties = { + ...resourceProperties, + databaseName: newDatabaseName, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Database: newDatabaseName, + Sql: expect.stringMatching(new RegExp(`CREATE TABLE ${tableNamePrefix}${requestIdTruncated}`)), + })); + }); + + test('replaces if table name changes', async () => { + const newTableNamePrefix = 'newTableNamePrefix'; + const newResourceProperties = { + ...resourceProperties, + tableName: { + ...resourceProperties.tableName, + prefix: newTableNamePrefix, + }, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: expect.stringMatching(new RegExp(`CREATE TABLE ${newTableNamePrefix}${requestIdTruncated}`)), + })); + }); + + test('replaces if table columns change', async () => { + const newTableColumnName = 'col2'; + const newTableColumnDataType = 'varchar(1)'; + const newTableColumns = [{ name: newTableColumnName, dataType: newTableColumnDataType }]; + const newResourceProperties = { + ...resourceProperties, + tableColumns: newTableColumns, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `CREATE TABLE ${tableNamePrefix}${requestIdTruncated} (${newTableColumnName} ${newTableColumnDataType})`, + })); + }); + + test('does not replace if table columns added', async () => { + const newTableColumnName = 'col2'; + const newTableColumnDataType = 'varchar(1)'; + const newTableColumns = [{ name: 'col1', dataType: 'varchar(1)' }, { name: newTableColumnName, dataType: newTableColumnDataType }]; + const newResourceProperties = { + ...resourceProperties, + tableColumns: newTableColumns, + }; + + await expect(manageTable(newResourceProperties, event)).resolves.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `ALTER TABLE ${physicalResourceId} ADD ${newTableColumnName} ${newTableColumnDataType}`, + })); + }); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/database-query-provider/user.test.ts b/packages/@aws-cdk/aws-redshift/test/database-query-provider/user.test.ts new file mode 100644 index 0000000000000..87c3bdd0043de --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/database-query-provider/user.test.ts @@ -0,0 +1,163 @@ +/* eslint-disable-next-line import/no-unresolved */ +import type * as AWSLambda from 'aws-lambda'; + +const password = 'password'; +const username = 'username'; +const passwordSecretArn = 'passwordSecretArn'; +const clusterName = 'clusterName'; +const adminUserArn = 'adminUserArn'; +const databaseName = 'databaseName'; +const physicalResourceId = 'PhysicalResourceId'; +const resourceProperties = { + username, + passwordSecretArn, + clusterName, + adminUserArn, + databaseName, + ServiceToken: '', +}; +const requestId = 'requestId'; +const genericEvent: AWSLambda.CloudFormationCustomResourceEventCommon = { + ResourceProperties: resourceProperties, + ServiceToken: '', + ResponseURL: '', + StackId: '', + RequestId: requestId, + LogicalResourceId: '', + ResourceType: '', +}; + +const mockExecuteStatement = jest.fn(() => ({ promise: jest.fn(() => ({ Id: 'statementId' })) })); +jest.mock('aws-sdk/clients/redshiftdata', () => class { + executeStatement = mockExecuteStatement; + describeStatement = () => ({ promise: jest.fn(() => ({ Status: 'FINISHED' })) }); +}); +const mockGetSecretValue = jest.fn(() => ({ promise: jest.fn(() => ({ SecretString: JSON.stringify({ password }) })) })); +jest.mock('aws-sdk/clients/secretsmanager', () => class { + getSecretValue = mockGetSecretValue; +}); +import { handler as manageUser } from '../../lib/private/database-query-provider/user'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('create', () => { + const baseEvent: AWSLambda.CloudFormationCustomResourceCreateEvent = { + RequestType: 'Create', + ...genericEvent, + }; + + test('serializes properties in statement and creates physical resource ID', async () => { + const event = baseEvent; + + await expect(manageUser(resourceProperties, event)).resolves.toEqual({ + PhysicalResourceId: 'clusterName:databaseName:username:requestId', + Data: { + username: username, + }, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: `CREATE USER username PASSWORD '${password}'`, + })); + }); +}); + +describe('delete', () => { + const baseEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent = { + RequestType: 'Delete', + PhysicalResourceId: physicalResourceId, + ...genericEvent, + }; + + test('executes statement', async () => { + const event = baseEvent; + + await manageUser(resourceProperties, event); + + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: 'DROP USER username', + })); + }); +}); + +describe('update', () => { + const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { + RequestType: 'Update', + OldResourceProperties: resourceProperties, + PhysicalResourceId: physicalResourceId, + ...genericEvent, + }; + + test('replaces if cluster name changes', async () => { + const newClusterName = 'newClusterName'; + const newResourceProperties = { + ...resourceProperties, + clusterName: newClusterName, + }; + + await expect(manageUser(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + ClusterIdentifier: newClusterName, + Sql: expect.stringMatching(/CREATE USER/), + })); + }); + + test('does not replace if admin user ARN changes', async () => { + const newAdminUserArn = 'newAdminUserArn'; + const newResourceProperties = { + ...resourceProperties, + adminUserArn: newAdminUserArn, + }; + + await expect(manageUser(newResourceProperties, event)).resolves.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).not.toHaveBeenCalled(); + }); + + test('replaces if database name changes', async () => { + const newDatabaseName = 'newDatabaseName'; + const newResourceProperties = { + ...resourceProperties, + databaseName: newDatabaseName, + }; + + await expect(manageUser(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Database: newDatabaseName, + Sql: expect.stringMatching(/CREATE USER/), + })); + }); + + test('replaces if user name changes', async () => { + const newUsername = 'newUsername'; + const newResourceProperties = { + ...resourceProperties, + username: newUsername, + }; + + await expect(manageUser(newResourceProperties, event)).resolves.not.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: expect.stringMatching(new RegExp(`CREATE USER ${newUsername}`)), + })); + }); + + test('does not replace if password changes', async () => { + const newPassword = 'newPassword'; + mockGetSecretValue.mockImplementationOnce(() => ({ promise: jest.fn(() => ({ SecretString: JSON.stringify({ password: newPassword }) })) })); + + await expect(manageUser(resourceProperties, event)).resolves.toMatchObject({ + PhysicalResourceId: physicalResourceId, + }); + expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({ + Sql: expect.stringMatching(new RegExp(`ALTER USER ${username} PASSWORD '${password}'`)), + })); + }); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/database-query.test.ts b/packages/@aws-cdk/aws-redshift/test/database-query.test.ts new file mode 100644 index 0000000000000..1b3bfe76d2e3e --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/database-query.test.ts @@ -0,0 +1,200 @@ +import { Match, Template } from '@aws-cdk/assertions'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import * as cdk from '@aws-cdk/core'; +import * as redshift from '../lib'; +import { DatabaseQuery, DatabaseQueryProps } from '../lib/private/database-query'; + +describe('database query', () => { + let stack: cdk.Stack; + let vpc: ec2.Vpc; + let cluster: redshift.ICluster; + let minimalProps: DatabaseQueryProps; + + beforeEach(() => { + stack = new cdk.Stack(); + vpc = new ec2.Vpc(stack, 'VPC'); + cluster = new redshift.Cluster(stack, 'Cluster', { + vpc: vpc, + masterUser: { + masterUsername: 'admin', + }, + }); + minimalProps = { + cluster: cluster, + databaseName: 'databaseName', + handler: 'handler', + properties: {}, + }; + }); + + describe('admin user', () => { + it('takes from cluster by default', () => { + new DatabaseQuery(stack, 'Query', { + ...minimalProps, + }); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + adminUserArn: { Ref: 'ClusterSecretAttachment769E6258' }, + }); + }); + + it('grants read permission to handler', () => { + new DatabaseQuery(stack, 'Query', { + ...minimalProps, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([{ + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: { Ref: 'ClusterSecretAttachment769E6258' }, + }]), + }, + Roles: [{ Ref: 'QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717' }], + }); + }); + + it('uses admin user if provided', () => { + cluster = new redshift.Cluster(stack, 'Cluster With Provided Admin Secret', { + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + masterUser: { + masterUsername: 'admin', + masterPassword: cdk.SecretValue.plainText('INSECURE_NOT_FOR_PRODUCTION'), + }, + publiclyAccessible: true, + }); + + new DatabaseQuery(stack, 'Query', { + ...minimalProps, + adminUser: secretsmanager.Secret.fromSecretNameV2(stack, 'Imported Admin User', 'imported-admin-secret'), + cluster, + }); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + adminUserArn: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':secretsmanager:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':secret:imported-admin-secret', + ], + ], + }, + }); + }); + + it('throws error if admin user not provided and cluster was provided a admin password', () => { + cluster = new redshift.Cluster(stack, 'Cluster With Provided Admin Secret', { + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + masterUser: { + masterUsername: 'admin', + masterPassword: cdk.SecretValue.plainText('INSECURE_NOT_FOR_PRODUCTION'), + }, + publiclyAccessible: true, + }); + + expect(() => new DatabaseQuery(stack, 'Query', { + ...minimalProps, + cluster, + })).toThrowError('Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster did not generate admin user credentials (they were provided explicitly)'); + }); + + it('throws error if admin user not provided and cluster was imported', () => { + cluster = redshift.Cluster.fromClusterAttributes(stack, 'Imported Cluster', { + clusterName: 'imported-cluster', + clusterEndpointAddress: 'imported-cluster.abcdefghijk.xx-west-1.redshift.amazonaws.com', + clusterEndpointPort: 5439, + }); + + expect(() => new DatabaseQuery(stack, 'Query', { + ...minimalProps, + cluster, + })).toThrowError('Administrative access to the Redshift cluster is required but an admin user secret was not provided and the cluster was imported'); + }); + }); + + it('provides database params to Lambda handler', () => { + new DatabaseQuery(stack, 'Query', { + ...minimalProps, + }); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + clusterName: { + Ref: 'ClusterEB0386A7', + }, + adminUserArn: { + Ref: 'ClusterSecretAttachment769E6258', + }, + databaseName: 'databaseName', + handler: 'handler', + }); + }); + + it('grants statement permissions to handler', () => { + new DatabaseQuery(stack, 'Query', { + ...minimalProps, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([{ + Action: ['redshift-data:DescribeStatement', 'redshift-data:ExecuteStatement'], + Effect: 'Allow', + Resource: '*', + }]), + }, + Roles: [{ Ref: 'QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717' }], + }); + }); + + it('passes removal policy through', () => { + new DatabaseQuery(stack, 'Query', { + ...minimalProps, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { + DeletionPolicy: 'Delete', + }); + }); + + it('passes applyRemovalPolicy through', () => { + const query = new DatabaseQuery(stack, 'Query', { + ...minimalProps, + }); + + query.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); + + Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { + DeletionPolicy: 'Delete', + }); + }); + + it('passes gettAtt through', () => { + const query = new DatabaseQuery(stack, 'Query', { + ...minimalProps, + }); + + expect(stack.resolve(query.getAtt('attribute'))).toStrictEqual({ 'Fn::GetAtt': ['Query435140A1', 'attribute'] }); + expect(stack.resolve(query.getAttString('attribute'))).toStrictEqual({ 'Fn::GetAtt': ['Query435140A1', 'attribute'] }); + }); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/integ.database.expected.json b/packages/@aws-cdk/aws-redshift/test/integ.database.expected.json new file mode 100644 index 0000000000000..b346d3e7abfb3 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/integ.database.expected.json @@ -0,0 +1,1377 @@ +{ + "Resources": { + "Vpc8378EB38": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet1" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet1" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet1RouteTableAssociation97140677": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet1DefaultRoute3DA9E72A": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet1" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet1" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet2Subnet691E08A3": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet2" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet2" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet2RouteTableAssociationDD5762D8": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet2DefaultRoute97F91067": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet2" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet2" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet3SubnetBE12F0B6": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet3" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet3RouteTable93458DBB": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet3" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet3RouteTableAssociation1F1EDF02": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet3RouteTable93458DBB" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet3SubnetBE12F0B6" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet3DefaultRoute4697774F": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet3RouteTable93458DBB" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet3EIP3A666A23": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet3" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPublicSubnet3NATGateway7640CD1D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "VpcPublicSubnet3SubnetBE12F0B6" + }, + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet3EIP3A666A23", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PublicSubnet3" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PrivateSubnet1" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PrivateSubnet1" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet1RouteTableAssociation70C59FA6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet1DefaultRouteBE02A9ED": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet1NATGateway4D7517AA" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet2Subnet3788AAA1": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PrivateSubnet2" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PrivateSubnet2" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet2RouteTableAssociationA89CAD56": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet2DefaultRoute060D2087": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet2NATGateway9182C01D" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet3SubnetF258B56E": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PrivateSubnet3" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet3RouteTableD98824C7": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc/PrivateSubnet3" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet3RouteTableAssociation16BDDC43": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet3RouteTableD98824C7" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet3SubnetF258B56E" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcPrivateSubnet3DefaultRoute94B74F0D": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet3RouteTableD98824C7" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet3NATGateway7640CD1D" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-redshift-cluster-database/Vpc" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ClusterSubnetsDCFA5CB7": { + "Type": "AWS::Redshift::ClusterSubnetGroup", + "Properties": { + "Description": "Subnets for Cluster Redshift cluster", + "SubnetIds": [ + { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + { + "Ref": "VpcPublicSubnet3SubnetBE12F0B6" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ClusterSecurityGroup0921994B": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Redshift security group", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ClusterSecret6368BD0F": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "GenerateSecretString": { + "ExcludeCharacters": "\"@/\\ '", + "GenerateStringKey": "password", + "PasswordLength": 30, + "SecretStringTemplate": "{\"username\":\"admin\"}" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ClusterSecretAttachment769E6258": { + "Type": "AWS::SecretsManager::SecretTargetAttachment", + "Properties": { + "SecretId": { + "Ref": "ClusterSecret6368BD0F" + }, + "TargetId": { + "Ref": "ClusterEB0386A7" + }, + "TargetType": "AWS::Redshift::Cluster" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "ClusterEB0386A7": { + "Type": "AWS::Redshift::Cluster", + "Properties": { + "ClusterType": "multi-node", + "DBName": "my_db", + "MasterUsername": { + "Fn::Join": [ + "", + [ + "{{resolve:secretsmanager:", + { + "Ref": "ClusterSecret6368BD0F" + }, + ":SecretString:username::}}" + ] + ] + }, + "MasterUserPassword": { + "Fn::Join": [ + "", + [ + "{{resolve:secretsmanager:", + { + "Ref": "ClusterSecret6368BD0F" + }, + ":SecretString:password::}}" + ] + ] + }, + "NodeType": "dc2.large", + "AllowVersionUpgrade": true, + "AutomatedSnapshotRetentionPeriod": 1, + "ClusterSubnetGroupName": { + "Ref": "ClusterSubnetsDCFA5CB7" + }, + "Encrypted": true, + "NumberOfNodes": 2, + "PubliclyAccessible": true, + "VpcSecurityGroupIds": [ + { + "Fn::GetAtt": [ + "ClusterSecurityGroup0921994B", + "GroupId" + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserSecretE2C04A69": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "GenerateSecretString": { + "ExcludeCharacters": "\"@/\\ '", + "GenerateStringKey": "password", + "PasswordLength": 30, + "SecretStringTemplate": "{\"username\":\"awscdkredshiftclusterdatabaseuserc17d5ebd\"}" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserSecretAttachment02022609": { + "Type": "AWS::SecretsManager::SecretTargetAttachment", + "Properties": { + "SecretId": { + "Ref": "UserSecretE2C04A69" + }, + "TargetId": { + "Ref": "ClusterEB0386A7" + }, + "TargetType": "AWS::Redshift::Cluster" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserProviderframeworkonEventServiceRole8FBA2FBD": { + "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" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserProviderframeworkonEventServiceRoleDefaultPolicy9A9E044F": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserProviderframeworkonEventServiceRoleDefaultPolicy9A9E044F", + "Roles": [ + { + "Ref": "UserProviderframeworkonEventServiceRole8FBA2FBD" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserProviderframeworkonEvent4EC32885": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "UserProviderframeworkonEventServiceRole8FBA2FBD", + "Arn" + ] + }, + "Description": "AWS CDK resource provider framework - onEvent (aws-cdk-redshift-cluster-database/User/Resource/Provider)", + "Environment": { + "Variables": { + "USER_ON_EVENT_FUNCTION_ARN": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997", + "Arn" + ] + } + } + }, + "Handler": "framework.onEvent", + "Runtime": "nodejs14.x", + "Timeout": 900 + }, + "DependsOn": [ + "UserProviderframeworkonEventServiceRoleDefaultPolicy9A9E044F", + "UserProviderframeworkonEventServiceRole8FBA2FBD" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserFDDCDD17": { + "Type": "Custom::RedshiftDatabaseQuery", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "UserProviderframeworkonEvent4EC32885", + "Arn" + ] + }, + "handler": "user", + "clusterName": { + "Ref": "ClusterEB0386A7" + }, + "adminUserArn": { + "Ref": "ClusterSecretAttachment769E6258" + }, + "databaseName": "my_db", + "username": "awscdkredshiftclusterdatabaseuserc17d5ebd", + "passwordSecretArn": { + "Ref": "UserSecretAttachment02022609" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserTablePrivilegesProviderframeworkonEventServiceRole56BAEC9A": { + "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" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserTablePrivilegesProviderframeworkonEventServiceRoleDefaultPolicy3B6EF50C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserTablePrivilegesProviderframeworkonEventServiceRoleDefaultPolicy3B6EF50C", + "Roles": [ + { + "Ref": "UserTablePrivilegesProviderframeworkonEventServiceRole56BAEC9A" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserTablePrivilegesProviderframeworkonEvent3F5C1851": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "UserTablePrivilegesProviderframeworkonEventServiceRole56BAEC9A", + "Arn" + ] + }, + "Description": "AWS CDK resource provider framework - onEvent (aws-cdk-redshift-cluster-database/User/TablePrivileges/Resource/Provider)", + "Environment": { + "Variables": { + "USER_ON_EVENT_FUNCTION_ARN": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997", + "Arn" + ] + } + } + }, + "Handler": "framework.onEvent", + "Runtime": "nodejs14.x", + "Timeout": 900 + }, + "DependsOn": [ + "UserTablePrivilegesProviderframeworkonEventServiceRoleDefaultPolicy3B6EF50C", + "UserTablePrivilegesProviderframeworkonEventServiceRole56BAEC9A" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "UserTablePrivileges3829D614": { + "Type": "Custom::RedshiftDatabaseQuery", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "UserTablePrivilegesProviderframeworkonEvent3F5C1851", + "Arn" + ] + }, + "handler": "user-table-privileges", + "clusterName": { + "Ref": "ClusterEB0386A7" + }, + "adminUserArn": { + "Ref": "ClusterSecretAttachment769E6258" + }, + "databaseName": "my_db", + "username": { + "Fn::GetAtt": [ + "UserFDDCDD17", + "username" + ] + }, + "tablePrivileges": [ + { + "tableName": { + "Ref": "Table7ABB320E" + }, + "actions": [ + "INSERT", + "DELETE", + "SELECT" + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717": { + "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" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRoleDefaultPolicyDDD1388D": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "redshift-data:DescribeStatement", + "redshift-data:ExecuteStatement" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "Effect": "Allow", + "Resource": { + "Ref": "ClusterSecretAttachment769E6258" + } + }, + { + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "Effect": "Allow", + "Resource": { + "Ref": "UserSecretAttachment02022609" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRoleDefaultPolicyDDD1388D", + "Roles": [ + { + "Ref": "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49S3Bucket148631C8" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49S3VersionKey1A4E04E7" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49S3VersionKey1A4E04E7" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs14.x", + "Timeout": 60 + }, + "DependsOn": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRoleDefaultPolicyDDD1388D", + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "TableProviderframeworkonEventServiceRoleC3128F67": { + "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" + ] + ] + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "TableProviderframeworkonEventServiceRoleDefaultPolicyAD08715D": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "lambda:InvokeFunction", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "TableProviderframeworkonEventServiceRoleDefaultPolicyAD08715D", + "Roles": [ + { + "Ref": "TableProviderframeworkonEventServiceRoleC3128F67" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "TableProviderframeworkonEvent97F3951A": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "TableProviderframeworkonEventServiceRoleC3128F67", + "Arn" + ] + }, + "Description": "AWS CDK resource provider framework - onEvent (aws-cdk-redshift-cluster-database/Table/Resource/Provider)", + "Environment": { + "Variables": { + "USER_ON_EVENT_FUNCTION_ARN": { + "Fn::GetAtt": [ + "QueryRedshiftDatabase3de5bea727da479686625efb56431b5f3DF81997", + "Arn" + ] + } + } + }, + "Handler": "framework.onEvent", + "Runtime": "nodejs14.x", + "Timeout": 900 + }, + "DependsOn": [ + "TableProviderframeworkonEventServiceRoleDefaultPolicyAD08715D", + "TableProviderframeworkonEventServiceRoleC3128F67" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "Table7ABB320E": { + "Type": "Custom::RedshiftDatabaseQuery", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "TableProviderframeworkonEvent97F3951A", + "Arn" + ] + }, + "handler": "table", + "clusterName": { + "Ref": "ClusterEB0386A7" + }, + "adminUserArn": { + "Ref": "ClusterSecretAttachment769E6258" + }, + "databaseName": "my_db", + "tableName": { + "prefix": "awscdkredshiftclusterdatabaseTable24923533", + "generateSuffix": true + }, + "tableColumns": [ + { + "name": "col1", + "dataType": "varchar(4)" + }, + { + "name": "col2", + "dataType": "float" + } + ] + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Parameters": { + "AssetParameters483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49S3Bucket148631C8": { + "Type": "String", + "Description": "S3 bucket for asset \"483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49\"" + }, + "AssetParameters483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49S3VersionKey1A4E04E7": { + "Type": "String", + "Description": "S3 key for asset version \"483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49\"" + }, + "AssetParameters483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49ArtifactHashEB952795": { + "Type": "String", + "Description": "Artifact hash for asset \"483841e46ab98aa099d0371a7800e2ace3ddbbb12cb8efb3162ca172ebdafd49\"" + }, + "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3BucketDC4B98B1": { + "Type": "String", + "Description": "S3 bucket for asset \"daeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1\"" + }, + "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1S3VersionKeyA495226F": { + "Type": "String", + "Description": "S3 key for asset version \"daeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1\"" + }, + "AssetParametersdaeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1ArtifactHashA521A16F": { + "Type": "String", + "Description": "Artifact hash for asset \"daeb79e3cee39c9b902dc0d5c780223e227ed573ea60976252947adab5fb2be1\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-redshift/test/integ.database.ts b/packages/@aws-cdk/aws-redshift/test/integ.database.ts new file mode 100644 index 0000000000000..3a3b955a2b5aa --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/integ.database.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/// !cdk-integ pragma:ignore-assets +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cdk from '@aws-cdk/core'; +import * as constructs from 'constructs'; +import * as redshift from '../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-redshift-cluster-database'); +cdk.Aspects.of(stack).add({ + visit(node: constructs.IConstruct) { + if (cdk.CfnResource.isCfnResource(node)) { + node.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); + } + }, +}); + +const vpc = new ec2.Vpc(stack, 'Vpc'); +const databaseName = 'my_db'; +const cluster = new redshift.Cluster(stack, 'Cluster', { + vpc: vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + masterUser: { + masterUsername: 'admin', + }, + defaultDatabaseName: databaseName, + publiclyAccessible: true, +}); + +const databaseOptions = { + cluster: cluster, + databaseName: databaseName, +}; +const user = new redshift.User(stack, 'User', databaseOptions); +const table = new redshift.Table(stack, 'Table', { + ...databaseOptions, + tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], +}); +table.grant(user, redshift.TableAction.INSERT, redshift.TableAction.DELETE); + +app.synth(); diff --git a/packages/@aws-cdk/aws-redshift/test/privileges.test.ts b/packages/@aws-cdk/aws-redshift/test/privileges.test.ts new file mode 100644 index 0000000000000..91419b2eaa709 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/privileges.test.ts @@ -0,0 +1,113 @@ +import { Template } from '@aws-cdk/assertions'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cdk from '@aws-cdk/core'; +import * as redshift from '../lib'; + +describe('table privileges', () => { + let stack: cdk.Stack; + let vpc: ec2.Vpc; + let cluster: redshift.ICluster; + const databaseName = 'databaseName'; + let databaseOptions: redshift.DatabaseOptions; + const tableColumns = [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }]; + let table: redshift.ITable; + let table2: redshift.ITable; + + beforeEach(() => { + stack = new cdk.Stack(); + vpc = new ec2.Vpc(stack, 'VPC'); + cluster = new redshift.Cluster(stack, 'Cluster', { + vpc: vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + masterUser: { + masterUsername: 'admin', + }, + publiclyAccessible: true, + }); + databaseOptions = { + cluster, + databaseName, + }; + table = redshift.Table.fromTableAttributes(stack, 'Table', { + tableName: 'tableName', + tableColumns, + cluster, + databaseName, + }); + table2 = redshift.Table.fromTableAttributes(stack, 'Table 2', { + tableName: 'tableName2', + tableColumns, + cluster, + databaseName, + }); + }); + + it('adding table privilege creates custom resource', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + user.addTablePrivileges(table, redshift.TableAction.INSERT); + user.addTablePrivileges(table2, redshift.TableAction.SELECT, redshift.TableAction.DROP); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + username: { + 'Fn::GetAtt': [ + 'UserFDDCDD17', + 'username', + ], + }, + tablePrivileges: [{ tableName: 'tableName', actions: ['INSERT'] }, { tableName: 'tableName2', actions: ['SELECT', 'DROP'] }], + }); + }); + + it('table privileges are deduplicated', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + user.addTablePrivileges(table, redshift.TableAction.INSERT, redshift.TableAction.INSERT, redshift.TableAction.DELETE); + user.addTablePrivileges(table, redshift.TableAction.SELECT, redshift.TableAction.DELETE); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + username: { + 'Fn::GetAtt': [ + 'UserFDDCDD17', + 'username', + ], + }, + tablePrivileges: [{ tableName: 'tableName', actions: ['SELECT', 'DELETE', 'INSERT'] }], + }); + }); + + it('table privileges are removed when ALL specified', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + user.addTablePrivileges(table, redshift.TableAction.ALL, redshift.TableAction.INSERT); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + username: { + 'Fn::GetAtt': [ + 'UserFDDCDD17', + 'username', + ], + }, + tablePrivileges: [{ tableName: 'tableName', actions: ['ALL'] }], + }); + }); + + it('SELECT table privilege is added when UPDATE or DELETE is specified', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + user.addTablePrivileges(table, redshift.TableAction.UPDATE); + user.addTablePrivileges(table2, redshift.TableAction.DELETE); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + username: { + 'Fn::GetAtt': [ + 'UserFDDCDD17', + 'username', + ], + }, + tablePrivileges: [{ tableName: 'tableName', actions: ['UPDATE', 'SELECT'] }, { tableName: 'tableName2', actions: ['DELETE', 'SELECT'] }], + }); + }); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/table.test.ts b/packages/@aws-cdk/aws-redshift/test/table.test.ts new file mode 100644 index 0000000000000..97f66b57042f5 --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/table.test.ts @@ -0,0 +1,138 @@ +import { Template } from '@aws-cdk/assertions'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cdk from '@aws-cdk/core'; +import * as redshift from '../lib'; + +describe('cluster table', () => { + const tableName = 'tableName'; + const tableColumns = [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }]; + + let stack: cdk.Stack; + let vpc: ec2.Vpc; + let cluster: redshift.ICluster; + let databaseOptions: redshift.DatabaseOptions; + + beforeEach(() => { + stack = new cdk.Stack(); + vpc = new ec2.Vpc(stack, 'VPC'); + cluster = new redshift.Cluster(stack, 'Cluster', { + vpc: vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + masterUser: { + masterUsername: 'admin', + }, + publiclyAccessible: true, + }); + databaseOptions = { + cluster: cluster, + databaseName: 'databaseName', + }; + }); + + it('creates using custom resource', () => { + new redshift.Table(stack, 'Table', { + ...databaseOptions, + tableColumns, + }); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + tableName: { + prefix: 'Table', + generateSuffix: true, + }, + tableColumns, + }); + }); + + it('tableName property is pulled from custom resource', () => { + const table = new redshift.Table(stack, 'Table', { + ...databaseOptions, + tableColumns, + }); + + expect(stack.resolve(table.tableName)).toStrictEqual({ + Ref: 'Table7ABB320E', + }); + }); + + it('uses table name when provided', () => { + new redshift.Table(stack, 'Table', { + ...databaseOptions, + tableName, + tableColumns, + }); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + tableName: { + prefix: tableName, + generateSuffix: false, + }, + }); + }); + + it('can import from name and columns', () => { + const table = redshift.Table.fromTableAttributes(stack, 'Table', { + tableName, + tableColumns, + cluster, + databaseName: 'databaseName', + }); + + expect(table.tableName).toBe(tableName); + expect(table.tableColumns).toBe(tableColumns); + expect(table.cluster).toBe(cluster); + expect(table.databaseName).toBe('databaseName'); + }); + + it('grant adds privileges to user', () => { + const user = redshift.User.fromUserAttributes(stack, 'User', { + ...databaseOptions, + username: 'username', + password: cdk.SecretValue.plainText('INSECURE_NOT_FOR_PRODUCTION'), + }); + const table = redshift.Table.fromTableAttributes(stack, 'Table', { + tableName, + tableColumns, + cluster, + databaseName: 'databaseName', + }); + + table.grant(user, redshift.TableAction.INSERT); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + handler: 'user-table-privileges', + }); + }); + + it('retains table on deletion by default', () => { + new redshift.Table(stack, 'Table', { + ...databaseOptions, + tableColumns, + }); + + Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { + Properties: { + handler: 'table', + }, + DeletionPolicy: 'Retain', + }); + }); + + it('destroys table on deletion if requested', () => { + const table = new redshift.Table(stack, 'Table', { + ...databaseOptions, + tableColumns, + }); + + table.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); + + Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { + Properties: { + handler: 'table', + }, + DeletionPolicy: 'Delete', + }); + }); +}); diff --git a/packages/@aws-cdk/aws-redshift/test/user.test.ts b/packages/@aws-cdk/aws-redshift/test/user.test.ts new file mode 100644 index 0000000000000..24b9bc748cc8f --- /dev/null +++ b/packages/@aws-cdk/aws-redshift/test/user.test.ts @@ -0,0 +1,215 @@ +import { Match, Template } from '@aws-cdk/assertions'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as kms from '@aws-cdk/aws-kms'; +import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; +import * as cdk from '@aws-cdk/core'; +import * as redshift from '../lib'; + +describe('cluster user', () => { + let stack: cdk.Stack; + let vpc: ec2.Vpc; + let cluster: redshift.ICluster; + const databaseName = 'databaseName'; + let databaseOptions: redshift.DatabaseOptions; + + beforeEach(() => { + stack = new cdk.Stack(); + vpc = new ec2.Vpc(stack, 'VPC'); + cluster = new redshift.Cluster(stack, 'Cluster', { + vpc: vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PUBLIC, + }, + masterUser: { + masterUsername: 'admin', + }, + publiclyAccessible: true, + }); + databaseOptions = { + cluster, + databaseName, + }; + }); + + it('creates using custom resource', () => { + new redshift.User(stack, 'User', databaseOptions); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + passwordSecretArn: { Ref: 'UserSecretAttachment02022609' }, + }); + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([{ + Action: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], + Effect: 'Allow', + Resource: { Ref: 'UserSecretAttachment02022609' }, + }]), + }, + Roles: [{ Ref: 'QueryRedshiftDatabase3de5bea727da479686625efb56431b5fServiceRole0A90D717' }], + }); + }); + + it('creates database secret', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: { + SecretStringTemplate: `{"username":"${cdk.Names.uniqueId(user).toLowerCase()}"}`, + }, + }); + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::SecretTargetAttachment', { + SecretId: { Ref: 'UserSecretE2C04A69' }, + }); + }); + + it('username property is pulled from custom resource', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + expect(stack.resolve(user.username)).toStrictEqual({ + 'Fn::GetAtt': [ + 'UserFDDCDD17', + 'username', + ], + }); + }); + + it('password property is pulled from attached secret', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + expect(stack.resolve(user.password)).toStrictEqual({ + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'UserSecretAttachment02022609', + }, + ':SecretString:password::}}', + ], + ], + }); + }); + + it('uses username when provided', () => { + const username = 'username'; + + new redshift.User(stack, 'User', { + ...databaseOptions, + username, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: { + SecretStringTemplate: `{"username":"${username}"}`, + }, + }); + }); + + it('can import from username and password', () => { + const userSecret = secretsmanager.Secret.fromSecretNameV2(stack, 'User Secret', 'redshift-user-secret'); + + const user = redshift.User.fromUserAttributes(stack, 'User', { + ...databaseOptions, + username: userSecret.secretValueFromJson('username').toString(), + password: userSecret.secretValueFromJson('password'), + }); + + expect(stack.resolve(user.username)).toStrictEqual({ + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:arn:', + { + Ref: 'AWS::Partition', + }, + ':secretsmanager:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':secret:redshift-user-secret:SecretString:username::}}', + ], + ], + }); + expect(stack.resolve(user.password)).toStrictEqual({ + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:arn:', + { + Ref: 'AWS::Partition', + }, + ':secretsmanager:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':secret:redshift-user-secret:SecretString:password::}}', + ], + ], + }); + }); + + it('destroys user on deletion by default', () => { + new redshift.User(stack, 'User', databaseOptions); + + Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { + Properties: { + passwordSecretArn: { Ref: 'UserSecretAttachment02022609' }, + }, + DeletionPolicy: 'Delete', + }); + }); + + it('retains user on deletion if requested', () => { + const user = new redshift.User(stack, 'User', databaseOptions); + + user.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + + Template.fromStack(stack).hasResource('Custom::RedshiftDatabaseQuery', { + Properties: { + passwordSecretArn: { Ref: 'UserSecretAttachment02022609' }, + }, + DeletionPolicy: 'Retain', + }); + }); + + it('uses encryption key if one is provided', () => { + const encryptionKey = new kms.Key(stack, 'Key'); + + new redshift.User(stack, 'User', { + ...databaseOptions, + encryptionKey, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + KmsKeyId: stack.resolve(encryptionKey.keyArn), + }); + }); + + it('addTablePrivileges grants access to table', () => { + const user = redshift.User.fromUserAttributes(stack, 'User', { + ...databaseOptions, + username: 'username', + password: cdk.SecretValue.plainText('INSECURE_NOT_FOR_PRODUCTION'), + }); + const table = redshift.Table.fromTableAttributes(stack, 'Table', { + tableName: 'tableName', + tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }], + cluster, + databaseName: 'databaseName', + }); + + user.addTablePrivileges(table, redshift.TableAction.INSERT); + + Template.fromStack(stack).hasResourceProperties('Custom::RedshiftDatabaseQuery', { + handler: 'user-table-privileges', + }); + }); +});