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 all 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
201 changes: 185 additions & 16 deletions packages/@aws-cdk/aws-redshift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -49,30 +50,198 @@ 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"
```

## Rotating credentials

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
BenChaimberg marked this conversation as resolved.
Show resolved Hide resolved
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.
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-redshift/lib/database-options.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 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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for User? suffix with secret?

Suggested change
readonly adminUser?: secretsmanager.ISecret;
readonly admin?: secretsmanager.ISecret;

}
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-redshift/lib/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum HandlerName {
User = 'user',
Table = 'table',
UserTablePrivileges = 'user-table-privileges',
}
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 { 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<any>) } = {
[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);
}
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading