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', + }); + }); +});