-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(redshift): manage database users and tables via cdk (#15931)
This feature allows users to manage Redshift database resources, such as users, tables, and grants, within their CDK application. Because these resources do not have CloudFormation handlers, this feature leverages custom resources and the Amazon Redshift Data API for creation and modification. The generic construct for this type of resource is `DatabaseQuery`. This construct provides the base functionality required for interacting with Redshift database resources, including configuring administrator credentials, creating a custom resource handler, and granting necessary IAM permissions. The custom resource handler code contains utility functions for executing query statements against the Redshift database. Specific resources that use the `DatabaseQuery` construct, such as `User` and `Table` are responsible for providing the following to `DatabaseQuery`: generic database configuration properties, specific configuration properties that will get passed to the custom resource handler (eg., `username` for `User`). Specific resources are also responsible for writing the lifecycle-management code within the handler. In general, this consists of: configuration extraction (eg., pulling `username` from the `AWSLambda.CloudFormationCustomResourceEvent` passed to the handler) and one method for each lifecycle event (create, update, delete) that queries the database using calls to the generic utility function. Users have a fairly simple lifecycle that allows them to be created, deleted, and updated when a secret containing a password is updated (secret rotation has not been implemented yet). Because of #9815, the custom resource provider queries Secrets Manager in order to access the password. Tables have a more complicated lifecycle because we want to allow columns to be added to the table without resource replacement, as well as ensuring that dropped columns do not lose data. For these reasons, we generate a unique name per-deployment when the table name is requested to be generated by the end user. We also notify create a new table (using a new generated name) if a column is to be dropped and let CFN lifecycle rules dictate whether the old table should be removed or kept. User privileges on tables are implemented via the `UserTablePrivileges` construct. This construct is located in the `private` directory to ensure that it is not exported for direct public use. This means that user privileges must be managed through the `Table.grant` method or the `User.addTablePrivileges` method. Thus, each `User` will have at most one `UserTablePrivileges` construct to manage its privileges. This is to avoid a situation where privileges could be erroneously removed when the same privilege is managed from two different CDK applications. For more details, see the README, under "Granting Privileges". ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
- Loading branch information
1 parent
d5dd2d0
commit a9d5118
Showing
27 changed files
with
3,861 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
5 changes: 5 additions & 0 deletions
5
packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/handler-name.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
} |
20 changes: 20 additions & 0 deletions
20
packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
70 changes: 70 additions & 0 deletions
70
packages/@aws-cdk/aws-redshift/lib/private/database-query-provider/privileges.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
Oops, something went wrong.