packages/constructs/L3/analytics/datawarehouse-l3-construct/lib/datawarehouse-l3-construct.ts (704 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { MdaaSecurityGroup, MdaaSecurityGroupRuleProps } from '@aws-mdaa/ec2-constructs';
import { IMdaaRole, MdaaRole } from '@aws-mdaa/iam-constructs';
import { MdaaResolvableRole, MdaaRoleRef } from '@aws-mdaa/iam-role-helper';
import { DECRYPT_ACTIONS, MdaaKmsKey } from '@aws-mdaa/kms-constructs';
import { MdaaL3Construct, MdaaL3ConstructProps } from '@aws-mdaa/l3-construct';
import { MdaaRedshiftCluster, MdaaRedshiftClusterParameterGroup } from '@aws-mdaa/redshift-constructs';
import { RestrictBucketToRoles, RestrictObjectPrefixToRoles } from '@aws-mdaa/s3-bucketpolicy-helper';
import { MdaaBucket } from '@aws-mdaa/s3-constructs';
import {
Cluster,
ClusterSubnetGroup,
ClusterType,
NodeType,
RotationMultiUserOptions,
User,
UserProps,
} from '@aws-cdk/aws-redshift-alpha';
import { Duration, Fn, RemovalPolicy, Stack } from 'aws-cdk-lib';
import { Port, Protocol, Subnet, Vpc } from 'aws-cdk-lib/aws-ec2';
import {
ArnPrincipal,
Effect,
FederatedPrincipal,
ManagedPolicy,
PolicyStatement,
Role,
ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { CfnEventSubscription, CfnEventSubscriptionProps, CfnScheduledAction } from 'aws-cdk-lib/aws-redshift';
import { BlockPublicAccess, Bucket, BucketEncryption, CfnBucket, IBucket } from 'aws-cdk-lib/aws-s3';
import { CfnSecret, ISecret } from 'aws-cdk-lib/aws-secretsmanager';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { MdaaNagSuppressions } from '@aws-mdaa/construct'; //NOSONAR
import { Construct } from 'constructs';
import { ConfigurationElement } from '@aws-mdaa/config';
export interface FederationProps {
/**
* Name of the federation for reference elsewhere in the config.
*/
readonly federationName: string;
/**
* Arn of the IAM Identity Provider through which federation will occur
*/
readonly providerArn: string;
/**
* Deprecated. No Longer used.
*/
readonly url?: string;
}
export interface NagSuppressionProps {
readonly id: string;
readonly reason: string;
}
export interface ScheduledActionProps {
/**
* Named of the scheduled action
*/
readonly name: string;
/**
* Scheduled action is enabled if true
*/
readonly enable: boolean;
/**
* One of "pauseCluster", "resumeCluster"
*/
readonly targetAction: string;
/**
* The scheduled action schedule in cron format
*/
readonly schedule: string;
/**
* The scheduled action Start Date & Time in UTC format from when the scheduled action is effective.
*/
readonly startTime?: string;
/**
* The scheduled action Start Date & Time in UTC format till when the scheduled action is effective.
*/
readonly endTime?: string;
}
export interface SecurityGroupIngressProps {
/**
* CIDR range of the ingres definition
*/
readonly ipv4?: string[];
/**
* Security Group ID of the ingres definition
*/
readonly sg?: string[];
}
export interface DatabaseUsersProps {
/**
* Name of the execution role
*/
readonly userName: string;
/**
* The DB to which the user will be added
*/
readonly dbName: string;
/**
* Characters to exclude in the password
*/
readonly excludeCharacters?: string;
/**
* Number of days between secret rotation
*/
readonly secretRotationDays: number;
/**
* List of roles that need redshift secret access
*/
readonly secretAccessRoles?: MdaaRoleRef[];
}
export interface SnapshotProps {
/**
* The snapshot identifier
*/
readonly snapshotIdentifier?: string;
/**
* The snapshot owner account
*/
readonly ownerAccount?: number;
}
export type EventCategories = 'configuration' | 'management' | 'monitoring' | 'security' | 'pending';
export type EventSeverity = 'ERROR' | 'INFO';
export interface EventNotificationsProps {
readonly eventCategories?: EventCategories[];
readonly severity?: EventSeverity;
readonly email?: string[];
}
export interface DataWarehouseL3ConstructProps extends MdaaL3ConstructProps {
/**
* Set the admin user name for the cluster
*/
readonly adminUsername: string;
/**
* Set the number of days between admin password rotations
*/
readonly adminPasswordRotationDays: number;
/**
* List of federations/roles to be created for federated access to the cluster
*/
readonly federations?: FederationProps[];
/**
* List of admin roles which will be provided access to cluster resources (like KMS/Bucket)
*/
readonly dataAdminRoleRefs: MdaaRoleRef[];
/**
* List of user roles which will be provided access to cluster resources (like KMS/Bucket)
*/
readonly warehouseBucketUserRoleRefs?: MdaaRoleRef[];
/**
* List of external roles which will be associated to the redshift cluster
* If a role requires access to datawarehouse bucket, then role should be added to 'warehouseBucketUserRoles' in application config
*/
readonly executionRoleRefs?: MdaaRoleRef[];
/**
* The ID of the VPC on which the cluster will be deployed.
*/
readonly vpcId: string;
/**
* The ID of the subnets on which the cluster will be deployed.
*/
readonly subnetIds: string[];
/**
* Additional ingress rules to be added to the cluster security group, permitting tcp traffic on the cluster port
*/
readonly securityGroupIngress: SecurityGroupIngressProps;
/**
* Node type of the cluster.
*/
readonly nodeType: string;
/**
* Number of cluster nodes
*/
readonly numberOfNodes: number;
/**
* If enabled, cluster audit logging will be written to an S3 bucket created for this purpose.
* Note that Redshift supports audit logging only to SSE-S3 encrypted buckets, so this audit bucket
* will not be created with SSE-KMS or use a customer master key.
*/
readonly enableAuditLoggingToS3: boolean;
/**
* The cluster port (default: 54390)
*/
readonly clusterPort?: number;
/**
* If true, cluster will be of type MULTI_NODE, otherwise SINGLE_NODE
*/
readonly multiNode?: boolean;
/**
* The preferred maintenance window for the cluster
* Example: 'Sun:23:45-Mon:00:15'
*/
readonly preferredMaintenanceWindow: string;
/**
* Additional parameters for the cluster parameter group. Certain security-sensitive values will be overridden.
*/
readonly parameterGroupParams?: Record<string, string>;
/**
* The cluster workload management configuration.
*/
readonly workloadManagement?: ConfigurationElement[];
/**
* Additional KMS keys which can be used to write to the cluster bucket
*/
readonly additionalBucketKmsKeyArns?: string[];
/**
* List of scheduled actions (pause,resume) which can be applied to the cluster
*/
readonly scheduledActions?: ScheduledActionProps[];
/**
* List of database users to be created in the cluster
*/
readonly databaseUsers?: DatabaseUsersProps[];
/**
* If true(default), a Data Warehouse bucket will be created
*/
readonly createWarehouseBucket?: boolean;
/**
* The number of days that automated snapshots are retained
*/
readonly automatedSnapshotRetentionDays?: number;
/**
* Event notification configuration
*/
readonly eventNotifications?: EventNotificationsProps;
/**
* Allow redshift service to manage the admin password (recommended)
*/
readonly redshiftManageMasterPassword?: boolean;
/**
* databaseName for the db to be created. defaults to "default_db"
*/
readonly dbName?: string;
/**
* SnapshotIdentifier id, if restoring the cluster from snapshot
* Optional - only provide this if restoring from snapshot
*/
readonly snapshotIdentifier?: string;
/**
* ownerAccount Refers to snapshot owner account. Applicable if restoring the cluster from snapshot and snapshot belongs to another account
* Optional - By default, snapshots are searched within current account
*/
readonly snapshotOwnerAccount?: number;
}
//This stack creates all of the resources required for a Data Warehouse
export class DataWarehouseL3Construct extends MdaaL3Construct {
protected readonly props: DataWarehouseL3ConstructProps;
public static readonly defaultClusterPort = 54390;
private dataAdminRoleIds: string[];
private bucketUserRoleIds: string[];
constructor(scope: Construct, id: string, props: DataWarehouseL3ConstructProps) {
super(scope, id, props);
this.props = props;
this.dataAdminRoleIds = this.props.roleHelper
.resolveRoleRefsWithOrdinals(this.props.dataAdminRoleRefs, 'DataAdmin')
.map(x => x.id());
this.bucketUserRoleIds = this.props.roleHelper
.resolveRoleRefsWithOrdinals(this.props.warehouseBucketUserRoleRefs || [], 'BucketUsers')
.map(x => x.id());
const allRoleIds = [...new Set([...this.dataAdminRoleIds, ...this.bucketUserRoleIds])];
//Use some private helper functions to create the warehouse resources
const warehouseKmsKey = this.createWarehouseKMSKey(allRoleIds);
if (this.props.createWarehouseBucket?.valueOf() == undefined || this.props.createWarehouseBucket.valueOf()) {
this.createWarehouseBucket(warehouseKmsKey, allRoleIds);
}
const loggingBucket = this.props.enableAuditLoggingToS3 ? this.createLoggingBucket() : undefined;
const executionRoles = this.props.roleHelper
.resolveRoleRefsWithOrdinals(this.props.executionRoleRefs || [], 'ExecutionRoleArns')
.map(x => MdaaRole.fromRoleArn(this, x.refId(), x.arn()));
const cluster = this.createCluster(warehouseKmsKey, executionRoles, loggingBucket);
// Create Redshift scheduled actions - pause and resume cluster - if any were defined in config for this stack
const scheduledActions = this.createRedshiftScheduledActions(cluster);
if (this.props.eventNotifications) {
this.createClusterEventNotifications(cluster.clusterName, scheduledActions, this.props.eventNotifications);
}
this.createClusterUsers(cluster, warehouseKmsKey);
return this;
}
private createClusterEventNotifications(
clusterName: string,
scheduledActions: CfnScheduledAction[],
eventNotifications: EventNotificationsProps,
) {
const topic = new Topic(this.scope, 'cluster-events-sns-topic', {
topicName: this.props.naming.resourceName('cluster-events'),
});
const enforceSslStatement = new PolicyStatement({
sid: 'EnforceSSL',
effect: Effect.DENY,
actions: [
'sns:Publish',
'sns:RemovePermission',
'sns:SetTopicAttributes',
'sns:DeleteTopic',
'sns:ListSubscriptionsByTopic',
'sns:GetTopicAttributes',
'sns:Receive',
'sns:AddPermission',
'sns:Subscribe',
],
resources: ['*'],
conditions: {
Bool: {
'aws:SecureTransport': 'false',
},
},
});
enforceSslStatement.addAnyPrincipal();
topic.addToResourcePolicy(enforceSslStatement);
MdaaNagSuppressions.addCodeResourceSuppressions(
topic,
[
{
id: 'AwsSolutions-SNS2',
reason: 'Redshift event subscriptions do not currently support an encrypted SNS topic.',
},
{
id: 'NIST.800.53.R5-SNSEncryptedKMS',
reason: 'Redshift event subscriptions do not currently support an encrypted SNS topic.',
},
{
id: 'HIPAA.Security-SNSEncryptedKMS',
reason: 'Redshift event subscriptions do not currently support an encrypted SNS topic.',
},
{
id: 'PCI.DSS.321-SNSEncryptedKMS',
reason: 'Redshift event subscriptions do not currently support an encrypted SNS topic.',
},
],
true,
);
//Allow redshift events to be published to the Topic
const publishPolicyStatement = new PolicyStatement({
sid: 'Publish Policy',
effect: Effect.ALLOW,
actions: [
'SNS:GetTopicAttributes',
'SNS:SetTopicAttributes',
'SNS:AddPermission',
'SNS:RemovePermission',
'SNS:DeleteTopic',
'SNS:Subscribe',
'SNS:ListSubscriptionsByTopic',
'SNS:Publish',
],
resources: [topic.topicArn],
conditions: {
StringEquals: {
'AWS:SourceOwner': this.account,
},
},
});
publishPolicyStatement.addAnyPrincipal();
topic.addToResourcePolicy(publishPolicyStatement);
// subscribe to sns topic if email-ids are present
eventNotifications?.email?.forEach(email => {
topic.addSubscription(new EmailSubscription(email.trim()));
});
const clusterEventNotificationSubProps: CfnEventSubscriptionProps = {
subscriptionName: clusterName,
sourceType: 'cluster',
sourceIds: [clusterName],
severity: eventNotifications.severity,
eventCategories: eventNotifications.eventCategories,
snsTopicArn: topic.topicArn,
};
new CfnEventSubscription(this.scope, `cluster-event-notifications-sub`, clusterEventNotificationSubProps);
const actionEventNotificationSubProps: CfnEventSubscriptionProps = {
subscriptionName: `${clusterName}-scheduled-actions`,
sourceType: 'scheduled-action',
sourceIds: scheduledActions.map(x => x.scheduledActionName),
severity: eventNotifications.severity,
eventCategories: eventNotifications.eventCategories,
snsTopicArn: topic.topicArn,
};
new CfnEventSubscription(this.scope, `scheduled-action-event-notifications-sub`, actionEventNotificationSubProps);
}
//Creates a RedShift cluster
private createCluster(warehouseKmsKey: MdaaKmsKey, executionRoles?: IMdaaRole[], loggingBucket?: IBucket): Cluster {
const vpc = Vpc.fromVpcAttributes(this.scope, `vpc-${this.props.vpcId}`, {
vpcId: this.props.vpcId,
availabilityZones: ['dummy'],
privateSubnetIds: this.props.subnetIds,
});
const subnets = this.props.subnetIds.map(id => Subnet.fromSubnetId(this.scope, `subnet-${id}`, id));
const clusterPort = this.props.clusterPort || DataWarehouseL3Construct.defaultClusterPort;
//Create subnet group
const subnetGroup = new ClusterSubnetGroup(this.scope, 'subnet-group', {
description: this.props.naming.resourceName('subnet-group'),
vpc: vpc,
removalPolicy: RemovalPolicy.RETAIN,
vpcSubnets: {
subnets: subnets,
},
});
const securityGroupIngress: MdaaSecurityGroupRuleProps = {
ipv4: this.props.securityGroupIngress.ipv4?.map(x => {
return {
cidr: x,
port: clusterPort,
protocol: Protocol.TCP,
description: `Redshift Ingress for IPV4 CIDR ${x}`,
};
}),
sg: this.props.securityGroupIngress.sg?.map(x => {
return { sgId: x, port: clusterPort, protocol: Protocol.TCP, description: `Redshift Ingress for SG ${x}` };
}),
};
//Create security group
const securityGroup = new MdaaSecurityGroup(this.scope, 'warehouse-sg', {
naming: this.props.naming,
securityGroupName: 'warehouse-sg',
vpc: vpc,
allowAllOutbound: true,
addSelfReferenceRule: false,
ingressRules: securityGroupIngress,
});
securityGroup.addIngressRule(securityGroup, Port.allTcp(), 'Self-Ref');
let clusterType: ClusterType = ClusterType.MULTI_NODE;
if (this.props.multiNode != undefined) {
clusterType = this.props.multiNode ? ClusterType.MULTI_NODE : ClusterType.SINGLE_NODE;
}
//ClusterParameterGroup
//Override security related parameters
const parameters = this.props.parameterGroupParams || {};
//Inject Workload Management Config into Param Group
parameters['wlm_json_configuration'] = JSON.stringify(this.props.workloadManagement);
const parameterGroup = new MdaaRedshiftClusterParameterGroup(this.scope, 'cluster-param-group', {
parameters: parameters,
naming: this.props.naming,
});
const loggingProperties = loggingBucket
? {
loggingBucket: loggingBucket,
loggingKeyPrefix: 'logging/',
}
: undefined;
const dbName = this.props.dbName || 'default_db';
// if snapshotIdentifier is provided, add to the cluster props
// if snapshotOwnerAccount is provided add it to cluster props
const snapshotProps: { snapshotIdentifier?: string; ownerAccount?: number } = {};
if (this.props.snapshotIdentifier) {
snapshotProps.snapshotIdentifier = this.props.snapshotIdentifier;
}
if (this.props.snapshotOwnerAccount) {
snapshotProps.ownerAccount = this.props.snapshotOwnerAccount;
}
//Create the cluster
const cluster = new MdaaRedshiftCluster(this.scope, 'cluster', {
masterUsername: this.props.adminUsername,
vpc: vpc,
port: clusterPort,
roles: executionRoles,
encryptionKey: warehouseKmsKey,
nodeType: NodeType[this.props.nodeType as keyof typeof NodeType],
numberOfNodes: this.props.numberOfNodes,
securityGroup: securityGroup,
subnetGroup: subnetGroup,
preferredMaintenanceWindow: this.props.preferredMaintenanceWindow,
clusterType: clusterType,
parameterGroup: parameterGroup,
loggingProperties: loggingProperties,
naming: this.props.naming,
adminPasswordRotationDays: this.props.adminPasswordRotationDays,
automatedSnapshotRetentionDays: this.props.automatedSnapshotRetentionDays,
defaultDatabaseName: dbName,
...snapshotProps,
redshiftManageMasterPassword: this.props.redshiftManageMasterPassword,
});
//Roles to grant SAML federated users access to the warehouse
//Establishes trust with SAML identity providers
this.props.federations?.forEach(federation => {
this.createFederation(cluster.clusterName, federation);
});
if (!loggingBucket) {
MdaaNagSuppressions.addCodeResourceSuppressions(
cluster,
[
{
id: 'AwsSolutions-RS5',
reason:
'Audit logging to S3 is disabled in config. Audit logging to system tables is enforced in Construct.',
},
{
id: 'NIST.800.53.R5-RedshiftClusterConfiguration',
reason: 'Audit logging to S3 is disabled in config. Cluster encryption using KMS is enforced in Construct.',
},
],
true,
);
}
return cluster;
}
//This function creates Redshift Users -> Stores & Rotates creds in Secrets Manager -> stores SecretName in SSM
private createClusterUsers(cluster: Cluster, warehouseKmsKey: MdaaKmsKey) {
this.props.databaseUsers?.forEach(databaseUser => {
//Redshift is going to force usernames to lower case.
//Need to make sure username matches between cluster and secret contents.
const username = databaseUser.userName.toLowerCase();
if (username != databaseUser.userName) {
console.log(`Modified configured username ${databaseUser.userName} to ${username} for Redshift compatability`);
}
const userProps: UserProps = {
cluster: cluster,
databaseName: databaseUser.dbName,
username: username,
adminUser: cluster.secret,
encryptionKey: warehouseKmsKey,
excludeCharacters: databaseUser.excludeCharacters,
};
const user = new User(this.scope, 'redshiftdbserviceuser-' + username, userProps);
new StringParameter(user, 'ssmsecret' + username, {
parameterName: this.props.naming.ssmPath(`datawarehouse/secret/${username}`, false),
stringValue: user.secret.secretName,
}); // This causes param collision with two warehouses in the same domain
new StringParameter(user, 'ssmsecretarn' + username, {
parameterName: this.props.naming.ssmPath(`datawarehouse/secretarn/${username}`, false),
stringValue: user.secret.secretArn,
}); // This causes param collision with two warehouses in the same domain
//Redshift DatabaseSecret construct does not currently set the masterarn on the secret string,
//which is required by the multi user rotation function
const cfnUserSecret = user.secret.node.defaultChild as CfnSecret;
const secretStringTemplateString = (cfnUserSecret.generateSecretString as CfnSecret.GenerateSecretStringProperty)
.secretStringTemplate;
const secretStringTemplate = secretStringTemplateString ? JSON.parse(secretStringTemplateString) : undefined;
const secretStringTemplateWithMasterArn = {
...secretStringTemplate,
masterarn: cluster.secret?.secretArn,
};
cfnUserSecret.addPropertyOverride(
'GenerateSecretString.SecretStringTemplate',
JSON.stringify(secretStringTemplateWithMasterArn),
);
if (databaseUser.secretRotationDays > 0) {
const multiUserRotationOptions: RotationMultiUserOptions = {
secret: user.secret,
automaticallyAfter: Duration.days(databaseUser.secretRotationDays),
};
cluster.addRotationMultiUser('multiuserrotation' + username, multiUserRotationOptions);
}
const secretAccessRoles = databaseUser.secretAccessRoles
? [
...this.props.roleHelper.resolveRoleRefsWithOrdinals(databaseUser.secretAccessRoles, 'SecretAccessRole'),
...this.props.roleHelper.resolveRoleRefsWithOrdinals(this.props.dataAdminRoleRefs, 'DataAdmin'),
]
: this.props.roleHelper.resolveRoleRefsWithOrdinals(this.props.dataAdminRoleRefs, 'DataAdmin');
this.assignSecretAcessPolicies(secretAccessRoles, warehouseKmsKey, user.secret);
this.scope.node.children.forEach(child => {
if (child.node.id.startsWith('Query Redshift Database') || child.node.id.startsWith('redshiftdbserviceuser-')) {
MdaaNagSuppressions.addCodeResourceSuppressions(
child,
[
{ id: 'AwsSolutions-IAM4', reason: 'Role is for Custom Resource Provider.' },
{
id: 'NIST.800.53.R5-IAMNoInlinePolicy',
reason: 'Role is for Custom Resource Provider. Inline policy automatically added.',
},
{
id: 'HIPAA.Security-IAMNoInlinePolicy',
reason: 'Role is for Custom Resource Provider. Inline policy automatically added.',
},
{
id: 'PCI.DSS.321-IAMNoInlinePolicy',
reason: 'Role is for Custom Resource Provider. Inline policy automatically added.',
},
{ id: 'AwsSolutions-IAM5', reason: 'Role is for Custom Resource Provider.' },
{ id: 'AwsSolutions-L1', reason: 'Role is for Custom Resource Provider.' },
{
id: 'NIST.800.53.R5-LambdaDLQ',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and error handling will be handled by CloudFormation',
},
{
id: 'NIST.800.53.R5-LambdaInsideVPC',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and will interact only with Redshift/SecretsManager',
},
{
id: 'NIST.800.53.R5-LambdaConcurrency',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and will only execute during stack deployement. Reserved concurrency not appropriate.',
},
{
id: 'HIPAA.Security-LambdaDLQ',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and error handling will be handled by CloudFormation',
},
{
id: 'PCI.DSS.321-LambdaDLQ',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and error handling will be handled by CloudFormation',
},
{
id: 'HIPAA.Security-LambdaInsideVPC',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and will interact only with Redshift/SecretsManager',
},
{
id: 'PCI.DSS.321-LambdaInsideVPC',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and will interact only with Redshift/SecretsManager',
},
{
id: 'HIPAA.Security-LambdaConcurrency',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and will only execute during stack deployement. Reserved concurrency not appropriate.',
},
{
id: 'PCI.DSS.321-LambdaConcurrency',
reason:
'Lambda Function is created by aws-redshift-alpha cdk module and will only execute during stack deployement. Reserved concurrency not appropriate.',
},
],
true,
);
}
});
});
}
//This function creates and assigns ploicies to specified roles for accessing redshift user secrets.
private assignSecretAcessPolicies(
secretAccessRoles: MdaaResolvableRole[],
warehouseKmsKey: MdaaKmsKey,
secret: ISecret,
) {
const arnPrincipals = secretAccessRoles.map(role => new ArnPrincipal(role.arn()));
const secretAccessStatement = new PolicyStatement({
sid: 'AllowSecretUsageForRoles',
effect: Effect.ALLOW,
principals: arnPrincipals,
actions: ['secretsmanager:GetSecretValue'],
resources: ['*'],
});
const kmsUsageStatement = new PolicyStatement({
sid: 'AllowKMSUsageForSecretRoles',
effect: Effect.ALLOW,
principals: arnPrincipals,
actions: DECRYPT_ACTIONS,
resources: ['*'],
});
secret.addToResourcePolicy(secretAccessStatement);
warehouseKmsKey.addToResourcePolicy(kmsUsageStatement);
}
//This function creates an IAM Identity Provider and federation role
private createFederation(clusterName: string, federation: FederationProps): Role {
//Create a role which can be used for accessing redshift
const role = new MdaaRole(this.scope, `federation-role-${federation.federationName}`, {
assumedBy: new FederatedPrincipal(federation.providerArn, {}, 'sts:AssumeRoleWithSAML'),
roleName: federation.federationName,
naming: this.props.naming,
});
const redshiftPolicy = new ManagedPolicy(this.scope, `federation-pol-${federation.federationName}`, {
managedPolicyName: this.props.naming.resourceName(`federation-${federation.federationName}`),
roles: [role],
});
//Allow to describe this cluster
const describeClusterStatement = new PolicyStatement({
effect: Effect.ALLOW,
actions: ['redshift:DescribeClusters'],
resources: [`arn:${this.partition}:redshift:${this.region}:${this.account}:cluster:${clusterName}`],
});
redshiftPolicy.addStatements(describeClusterStatement);
//Allow to fetch credentials for this cluster
const getClusterCredsStatement = new PolicyStatement({
effect: Effect.ALLOW,
actions: ['redshift:GetClusterCredentials'],
resources: [
`arn:${this.partition}:redshift:${this.region}:${this.account}:dbuser:${clusterName}/` + '${redshift:DbUser}',
],
});
getClusterCredsStatement.addCondition('StringEquals', { 'aws:userid': role.roleId + ':${redshift:DbUser}' });
getClusterCredsStatement.addResources(
`arn:${this.partition}:redshift:${this.region}:${this.account}:dbname:${clusterName}/*`,
);
redshiftPolicy.addStatements(getClusterCredsStatement);
//Allow to create user for this cluster
const createUserStatement = new PolicyStatement({
effect: Effect.ALLOW,
actions: ['redshift:CreateClusterUser'],
resources: [
`arn:${this.partition}:redshift:${this.region}:${this.account}:dbuser:${clusterName}/` + '${redshift:DbUser}',
],
});
redshiftPolicy.addStatements(createUserStatement);
//Allow to create user for this cluster
const joinGroupStatement = new PolicyStatement({
effect: Effect.ALLOW,
actions: ['redshift:JoinGroup'],
});
joinGroupStatement.addResources(
`arn:${this.partition}:redshift:${this.region}:${this.account}:dbgroup:${clusterName}/*`,
);
redshiftPolicy.addStatements(joinGroupStatement);
MdaaNagSuppressions.addCodeResourceSuppressions(
redshiftPolicy,
[{ id: 'AwsSolutions-IAM5', reason: 'Wildcard is for group names dynamically generated via SAML federation.' }],
true,
);
return role;
}
private createWarehouseKMSKey(allRoleIds: string[]): MdaaKmsKey {
return new MdaaKmsKey(this.scope, 'warehouse-key', {
alias: 'data-warehouse',
naming: this.props.naming,
keyAdminRoleIds: this.dataAdminRoleIds,
keyUserRoleIds: allRoleIds,
});
}
private createWarehouseBucket(warehouseKmsKey: MdaaKmsKey, allRoleIds: string[]): Bucket {
//This warehouse bucket will be used for data warehouse logging and other S3 offload scenarios
const warehouseBucket = new MdaaBucket(this.scope, 'warehouse-bucket', {
encryptionKey: warehouseKmsKey,
bucketName: 'warehouse',
naming: this.props.naming,
additionalKmsKeyArns: this.props.additionalBucketKmsKeyArns,
});
MdaaNagSuppressions.addCodeResourceSuppressions(
warehouseBucket,
[
{
id: 'NIST.800.53.R5-S3BucketReplicationEnabled',
reason: 'MDAA Warehouse bucket does not use bucket replication.',
},
{
id: 'HIPAA.Security-S3BucketReplicationEnabled',
reason: 'MDAA Warehouse bucket does not use bucket replication.',
},
{
id: 'PCI.DSS.321-S3BucketReplicationEnabled',
reason: 'MDAA Warehouse bucket does not use bucket replication.',
},
],
true,
);
//Enable the bucket key feature which optimizes the bucket for use with KMS
const cfnBucket = warehouseBucket.node.defaultChild as CfnBucket;
cfnBucket.addOverride('Properties.BucketEncryption.ServerSideEncryptionConfiguration.0.BucketKeyEnabled', true);
//Data Admins and Warehouse Execution Role can read/write
const rootPolicy = new RestrictObjectPrefixToRoles({
s3Bucket: warehouseBucket,
s3Prefix: '/',
readWriteRoleIds: this.bucketUserRoleIds,
readWriteSuperRoleIds: this.dataAdminRoleIds,
});
rootPolicy.statements().forEach(statement => warehouseBucket.addToResourcePolicy(statement));
//Default Deny Policy
//Any role not specified in config is explicitely denied access to the bucket
const bucketRestrictPolicy = new RestrictBucketToRoles({
s3Bucket: warehouseBucket,
roleExcludeIds: allRoleIds,
});
warehouseBucket.addToResourcePolicy(bucketRestrictPolicy.denyStatement);
warehouseBucket.addToResourcePolicy(bucketRestrictPolicy.allowStatement);
return warehouseBucket;
}
private createLoggingBucket(): IBucket {
//Replicate behaviour of MdaaBucket but allow for non-KMS encryption (required by Redshift)
const uniqueBucketNamePrefixContext = this.node.tryGetContext(MdaaBucket.UNIQUE_NAME_CONTEXT_KEY);
const uniqueBucketNamePrefix = uniqueBucketNamePrefixContext ? Boolean(uniqueBucketNamePrefixContext) : false;
const prefix = Fn.select(0, Fn.split('-', Fn.select(2, Fn.split('/', Stack.of(this).stackId))));
const bucketName = uniqueBucketNamePrefix
? prefix + '-' + this.props.naming.resourceName('logging', 63)
: this.props.naming.resourceName('logging', 63);
const loggingBucket = new Bucket(this.scope, bucketName, {
bucketName: bucketName,
encryption: BucketEncryption.S3_MANAGED,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
versioned: true,
autoDeleteObjects: false,
removalPolicy: RemovalPolicy.RETAIN,
enforceSSL: true,
});
MdaaNagSuppressions.addCodeResourceSuppressions(
loggingBucket,
[
{ id: 'AwsSolutions-S1', reason: 'Server access logs do not support KMS on targets.' },
{ id: 'NIST.800.53.R5-S3BucketLoggingEnabled', reason: 'Server access logs do not support KMS on targets.' },
{
id: 'NIST.800.53.R5-S3BucketReplicationEnabled',
reason: 'MDAA Warehouse bucket does not use bucket replication.',
},
{
id: 'NIST.800.53.R5-S3DefaultEncryptionKMS',
reason: 'Redshift audit logging does not support KMS-encrypted buckets',
},
{ id: 'HIPAA.Security-S3BucketLoggingEnabled', reason: 'Server access logs do not support KMS on targets.' },
{ id: 'PCI.DSS.321-S3BucketLoggingEnabled', reason: 'Server access logs do not support KMS on targets.' },
{
id: 'HIPAA.Security-S3BucketReplicationEnabled',
reason: 'MDAA Warehouse bucket does not use bucket replication.',
},
{
id: 'PCI.DSS.321-S3BucketReplicationEnabled',
reason: 'MDAA Warehouse bucket does not use bucket replication.',
},
{
id: 'HIPAA.Security-S3DefaultEncryptionKMS',
reason: 'Redshift audit logging does not support KMS-encrypted buckets',
},
{
id: 'PCI.DSS.321-S3DefaultEncryptionKMS',
reason: 'Redshift audit logging does not support KMS-encrypted buckets',
},
],
true,
);
const AllowRedshiftLoggingPut = new PolicyStatement({
sid: 'AllowRedshiftLoggingPut',
effect: Effect.ALLOW,
resources: [loggingBucket.bucketArn + '/*', loggingBucket.bucketArn],
actions: ['s3:PutObject', 's3:GetBucketAcl'],
principals: [
new ServicePrincipal(`redshift.amazonaws.com`),
new ServicePrincipal(`redshift.${this.region}.amazonaws.com`),
],
conditions: {
StringEquals: {
'aws:SourceArn': `arn:${this.partition}:redshift:${this.region}:${
this.account
}:cluster:${this.props.naming.resourceName()}`,
},
},
});
loggingBucket.addToResourcePolicy(AllowRedshiftLoggingPut);
return loggingBucket;
}
private createRedshiftScheduledActions(cluster: Cluster): CfnScheduledAction[] {
// If any scheduled actions are defined in config
if (Array.isArray(this.props.scheduledActions) && this.props.scheduledActions.length > 0) {
// Create a managed policy to grant Pause and Resume access on the cluster in this stack
const pauseResumePolicy = new ManagedPolicy(this.scope, 'redshiftPauseResumePolicy', {
description: 'Allows to Pause and Resume Redshift clusters',
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['redshift:PauseCLuster', 'redshift:ResumeCluster'],
resources: [`arn:${this.partition}:redshift:${this.region}:${this.account}:cluster:${cluster.clusterName}`],
}),
],
});
// Create role for redshift scheduler to pause and resume cluster and attach the above managed policy to it.
const redshiftSchedulerRole = new MdaaRole(this.scope, `scheduler-role`, {
naming: this.props.naming,
assumedBy: new ServicePrincipal('scheduler.redshift.amazonaws.com'),
roleName: 'scheduler',
managedPolicies: [pauseResumePolicy],
});
return this.props.scheduledActions.map(action => {
// Pause action for cluster in this stack
const pauseClusterAction: CfnScheduledAction.ScheduledActionTypeProperty = {
pauseCluster: {
clusterIdentifier: cluster.clusterName,
},
};
// Resume action for cluster in this stack
const resumeClusterAction: CfnScheduledAction.ScheduledActionTypeProperty = {
resumeCluster: {
clusterIdentifier: cluster.clusterName,
},
};
let startTime = action.startTime ? Date.parse(action.startTime) : undefined;
if (startTime && startTime < Date.now()) {
console.log(
`Configured scheduled action startTime (${action.startTime}) is in the past. Setting to one hour from now.`,
);
startTime = Date.now() + 3600000;
}
const targetAction = action.targetAction == 'pauseCluster' ? pauseClusterAction : resumeClusterAction;
// Create Redshift Scheduled Action
return new CfnScheduledAction(this.scope, `scheduled-action-${action.name}`, {
scheduledActionName: this.props.naming.resourceName(action.name, 55),
enable: action.enable,
targetAction: targetAction,
schedule: action.schedule,
startTime: startTime ? new Date(startTime).toISOString() : undefined,
endTime: action.endTime,
iamRole: redshiftSchedulerRole.roleArn,
});
});
}
return [];
}
}