Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(redshift): manage database users and tables via cdk #15931

Merged
merged 45 commits into from
Sep 13, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1d923e3
feat(redshift): manage database users and tables via custom resources
BenChaimberg Aug 6, 2021
9d3827d
add cluster and databaseName to interfaces
BenChaimberg Aug 7, 2021
6fef1ec
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Aug 7, 2021
2708e9b
remove outdated docs
BenChaimberg Aug 7, 2021
015d05d
update integ test
BenChaimberg Aug 7, 2021
1f58168
move database props to separate file to pass monocdk build
BenChaimberg Aug 7, 2021
06a7570
add to README
BenChaimberg Aug 8, 2021
a474705
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Aug 24, 2021
9d67a38
updated documentation and linting
BenChaimberg Aug 24, 2021
3bb16b1
update integ tests
BenChaimberg Aug 24, 2021
f228c18
move user table privileges to separate construct
BenChaimberg Aug 24, 2021
bd1fef5
only update table if columns added (replace in all other cases); add …
BenChaimberg Aug 29, 2021
e889618
make DatabaseQuery generic, strongly type handlers, don't serialize p…
BenChaimberg Aug 30, 2021
10bb1e7
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Aug 30, 2021
b9f5a73
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Sep 4, 2021
1824a94
only use Table.grant to exemplify granting privileges
BenChaimberg Sep 4, 2021
a05d90d
DatabaseProps -> DatabaseOptions
BenChaimberg Sep 4, 2021
94b9429
enable strict rosetta compilation
BenChaimberg Sep 4, 2021
ddbebb6
make UserTablePrivileges private
BenChaimberg Sep 4, 2021
f3d1546
rename provider files and handlers
BenChaimberg Sep 4, 2021
8845907
linting
BenChaimberg Sep 4, 2021
192e0d0
add aws-sdk to dev deps
BenChaimberg Sep 4, 2021
1374ced
more DatabaseProps -> DatabaseOptions
BenChaimberg Sep 5, 2021
b16f694
make readme compile
BenChaimberg Sep 5, 2021
130c955
update integ test
BenChaimberg Sep 5, 2021
e98ac39
Revert "update integ test"
BenChaimberg Sep 5, 2021
24c63b4
add more provider tests and fixes
BenChaimberg Sep 5, 2021
e446f32
linting
BenChaimberg Sep 5, 2021
d30531e
update integ test
BenChaimberg Sep 7, 2021
6e75d11
document reasons for not granting duplicate privileges in multiple apps
BenChaimberg Sep 9, 2021
156e72e
remove TODOs in code
BenChaimberg Sep 9, 2021
b7610dc
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Sep 9, 2021
1a44e32
rewrite opening paragraph in readme
BenChaimberg Sep 9, 2021
378f101
lint readme snippets
BenChaimberg Sep 9, 2021
307214f
reduce table privileges to combine actions across multiple grant calls
BenChaimberg Sep 9, 2021
83b933a
remove magic handler names and replace with enum
BenChaimberg Sep 9, 2021
2a5f636
add tests to entrypoint of database query provider
BenChaimberg Sep 9, 2021
67114bc
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Sep 10, 2021
ada89e7
ignore asset hash in integration tests
BenChaimberg Sep 10, 2021
df7b1df
Merge branch 'master' of github.com:aws/aws-cdk into chaimber/redshif…
BenChaimberg Sep 10, 2021
f533242
fix location of handler name
BenChaimberg Sep 11, 2021
2152d53
refactor in prep of correctly generating table name
BenChaimberg Sep 11, 2021
f5ab16d
properly generate table name so update replacements succeed
BenChaimberg Sep 11, 2021
17b9e2f
Merge branch 'master' into chaimber/redshift-database-query
BenChaimberg Sep 13, 2021
8add00c
typo
BenChaimberg Sep 13, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion packages/@aws-cdk/aws-redshift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,106 @@ cluster.addRotationMultiUser('MyUser', {
});
```

This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project.
## Database Resources

This module allows for the creation of non-CloudFormation database resources via [custom
resources](https://docs.aws.amazon.com/cdk/api/latest/docs/custom-resources-readme.html).
These resources include database users and database tables. Access to the cluster is
granted via administrator credentials; these can be supplied explicitly through the
`adminUser` property. Alternatively, they can be automatically pulled from the Redshift
cluster destination if the cluster generated the password for its own administrator
credentials (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)).
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved

### 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
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
credentials.

```ts
new redshift.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
import * as kms from '@aws-cdk/aws-kms';

const encryptionKey = new kms.Key(this, 'Key');
new redshift.User(stack, '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
new redshift.User(stack, '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
new redshift.Table(this, 'Table', {
cluster: cluster,
databaseName: 'databaseName',
tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }],
});
```

### Granting Privileges

You can give a user privileges to perform certain actions on a table by using the `User.addPrivilege` method.
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved

```ts
const databaseProps = {
cluster: cluster,
databaseName: 'databaseName',
};
const user = new redshift.User(this, 'User', databaseProps);
const table = new redshift.Table(this, 'Table', {
...databaseProps,
tableColumns: [{ name: 'col1', dataType: 'varchar(4)' }, { name: 'col2', dataType: 'float' }],
});

user.addPrivilege(table, Privilege.INSERT, Privilege.UPDATE);
```

Privileges on the table can also be given by using the `Table.grant` method.

```ts
table.grant(user, Privilege.DROP, Privilege.SELECT);
```
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-redshift/lib/database-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as secretsmanager from '@aws-cdk/aws-secretsmanager';
import { ICluster } from './cluster';

/**
* Properties for accessing a Redshift database
*/
export interface DatabaseProps {
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/* eslint-disable-next-line import/no-unresolved */
import * as AWSLambda from 'aws-lambda';
import { ClusterProps, executeStatement, getClusterPropsFromEvent, getResourceNameFromPhysicalId, getResourceProperty, makePhysicalId } from './util';

interface Column {
name: string;
dataType: string;
}

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
const tableColumns = JSON.parse(getResourceProperty('tableColumns', event.ResourceProperties)) as Column[];
const tableName = event.ResourceProperties.tableName;
const clusterProps = getClusterPropsFromEvent(event.ResourceProperties);

if (event.RequestType === 'Create') {
await createTable(tableName, tableColumns, clusterProps);
return { PhysicalResourceId: makePhysicalId(tableName, clusterProps), Data: { tableName: tableName } };
} else if (event.RequestType === 'Delete') {
await dropTable(getResourceNameFromPhysicalId(event.PhysicalResourceId), clusterProps);
return;
} else if (event.RequestType === 'Update') {
await updateTable(tableName, tableColumns, clusterProps, event.OldResourceProperties);
return { PhysicalResourceId: makePhysicalId(tableName, clusterProps), Data: { tableName: tableName } };
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
} else {
/* eslint-disable-next-line dot-notation */
throw new Error(`Unrecognized event type: ${event['RequestType']}`);
}
}

async function createTable(tableName: string, tableColumns: Column[], clusterProps: ClusterProps) {
const tableColumnsString = tableColumns.map(column => `${column.name} ${column.dataType}`).join();
await executeStatement(`CREATE TABLE ${tableName} (${tableColumnsString})`, clusterProps);
}

async function dropTable(tableName: string, clusterProps: ClusterProps) {
await executeStatement(`DROP TABLE ${tableName}`, clusterProps);
}

async function updateTable(tableName: string, tableColumns: Column[], clusterProps: ClusterProps, oldResourceProperties: { [Key: string]: any }) {
const oldClusterProps = getClusterPropsFromEvent(oldResourceProperties);
if (clusterProps !== oldClusterProps) {
return createTable(tableName, tableColumns, clusterProps);
}

const oldTableColumns = JSON.parse(getResourceProperty('tableColumns', oldResourceProperties)) as Column[];
const oldTableName = oldResourceProperties.tableName;

if (tableName !== oldTableName) {
return createTable(tableName, tableColumns, clusterProps);
}

const changes: string[] = [];
tableColumns.forEach(tableColumn => {
const oldTableColumn = oldTableColumns.find(({ name: oldName }) => tableColumn.name === oldName);
if (!oldTableColumn) {
changes.push(`ADD ${tableColumn.name} ${tableColumn.dataType}`);
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
} else {
if (tableColumn.dataType !== oldTableColumn.dataType) {
changes.push(`ALTER COLUMN ${tableColumn.name} TYPE ${tableColumn.dataType}`);
}
}
});
oldTableColumns.forEach(oldTableColumn => {
const tableColumn = tableColumns.find(({ name }) => oldTableColumn.name === name);
if (!tableColumn) {
changes.push(`DROP ${oldTableColumn.name}`);
// TODO: not sure if this is the right choice. can we simply ignore this? will queries break if the schema has extra columns? dropping could lose data "silently"
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
}
});
await Promise.all(changes.map(change => executeStatement(`ALTER TABLE ${tableName} ${change}`, clusterProps)));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* 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 { ClusterProps, executeStatement, getClusterPropsFromEvent, getResourceNameFromPhysicalId, getResourceProperty, makePhysicalId } from './util';

const secretsManager = new SecretsManager();

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
const username = getResourceProperty('username', event.ResourceProperties);
const passwordSecretArn = getResourceProperty('passwordSecretArn', event.ResourceProperties);
const clusterProps = getClusterPropsFromEvent(event.ResourceProperties);

if (event.RequestType === 'Create') {
await createUser(username, passwordSecretArn, clusterProps);
return { PhysicalResourceId: makePhysicalId(username, clusterProps), Data: { username: username } };
} else if (event.RequestType === 'Delete') {
await dropUser(getResourceNameFromPhysicalId(event.PhysicalResourceId), clusterProps);
return;
} else if (event.RequestType === 'Update') {
await updateUser(username, passwordSecretArn, clusterProps, event.OldResourceProperties);
return { PhysicalResourceId: makePhysicalId(username, clusterProps), 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: { [Key: string]: any }) {
const oldClusterProps = getClusterPropsFromEvent(oldResourceProperties);
if (clusterProps !== oldClusterProps) {
await createUser(username, passwordSecretArn, clusterProps);
return;
}

const oldUsername = getResourceProperty('username', oldResourceProperties);
const oldPasswordSecretArn = getResourceProperty('passwordSecretArn', oldResourceProperties);
const oldPassword = await getPasswordFromSecret(oldPasswordSecretArn);
const password = await getPasswordFromSecret(passwordSecretArn);

if (username !== oldUsername) {
await createUser(username, passwordSecretArn, clusterProps);
return;
}

if (password !== oldPassword) {
await executeStatement(`ALTER USER ${username} PASSWORD ${password}`, clusterProps);
}
}

async function getPasswordFromSecret(passwordSecretArn: string): Promise<string> {
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* eslint-disable-next-line import/no-unresolved */
import * as AWSLambda from 'aws-lambda';
import { ClusterProps, executeStatement, getClusterPropsFromEvent, getResourceProperty } from './util';

interface TablePrivilege {
readonly tableName: string;
readonly privileges: string[];
}

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
const username = getResourceProperty('username', event.ResourceProperties);
const tablePrivileges = JSON.parse(getResourceProperty('tablePrivileges', event.ResourceProperties)) as TablePrivilege[];
const clusterProps = getClusterPropsFromEvent(event.ResourceProperties);

if (event.RequestType === 'Create') {
await grantPrivileges(username, tablePrivileges, clusterProps);
return { PhysicalResourceId: username };
} else if (event.RequestType === 'Delete') {
await revokePrivileges(username, tablePrivileges, clusterProps);
return;
} else if (event.RequestType === 'Update') {
await updatePrivileges(username, tablePrivileges, clusterProps, event.OldResourceProperties);
return { PhysicalResourceId: username };
} 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, privileges }) => {
return executeStatement(`REVOKE ${privileges.join(', ')} ON ${tableName} FROM ${username}`, clusterProps);
}));
}

async function grantPrivileges(username: string, tablePrivileges: TablePrivilege[], clusterProps: ClusterProps) {
await Promise.all(tablePrivileges.map(({ tableName, privileges }) => {
return executeStatement(`GRANT ${privileges.join(', ')} ON ${tableName} TO ${username}`, clusterProps);
}));
}

async function updatePrivileges(
username: string,
tablePrivileges: TablePrivilege[],
clusterProps: ClusterProps,
oldResourceProperties: { [Key: string]: any },
) {
const oldUsername = getResourceProperty('username', oldResourceProperties);
const oldTablePrivileges = JSON.parse(getResourceProperty('tablePrivileges', oldResourceProperties)) as TablePrivilege[];
if (oldUsername === username) {
await revokePrivileges(username, oldTablePrivileges, clusterProps);
}
await grantPrivileges(username, tablePrivileges, clusterProps);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* eslint-disable-next-line import/no-unresolved */
import * as AWSLambda from 'aws-lambda';
import { handler as createTable } from './create-table';
import { handler as createUser } from './create-user';
import { handler as grantPrivileges } from './grant-privileges';
import { getResourceProperty } from './util';

const HANDLERS: { [key: string]: ((event: AWSLambda.CloudFormationCustomResourceEvent) => Promise<any>) } = {
'create-table': createTable,
'create-user': createUser,
'grant-privileges': grantPrivileges,
};

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
const subHandler = HANDLERS[getResourceProperty('handler', event.ResourceProperties)];
if (!subHandler) {
throw new Error(`Requested ${process.env.handler} handler is not in supported set: ${JSON.stringify(Object.keys(HANDLERS))}`);
}
return subHandler(event);
}
Loading