Last active
February 18, 2025 15:33
-
-
Save YasharHND/927ad30c240299ade0b2c162159d432e to your computer and use it in GitHub Desktop.
CDK QuickSight Construct With Permissions (for QS Group) & CfnAnalysis
This file contains hidden or 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
import { NestedStack, type NestedStackProps } from 'aws-cdk-lib'; | |
import { | |
CfnFolder, | |
CfnDataSet, | |
CfnDataSource, | |
CfnTemplate, | |
CfnDashboard, | |
CfnAnalysis, | |
} from 'aws-cdk-lib/aws-quicksight'; | |
import type { Construct } from 'constructs'; | |
import { readFileSync } from 'fs'; | |
import { join } from 'path'; | |
interface QuickSightStackProps extends NestedStackProps { | |
environment: string; | |
} | |
export class QuickSightStack extends NestedStack { | |
constructor(scope: Construct, id: string, props: QuickSightStackProps) { | |
super(scope, id, props); | |
const requiredEnvVars = [ | |
'QS_DASHBOARD_VIEWERS_GROUP_ARN', | |
'QS_DATASOURCE_VPC_CONNECTION_ARN', | |
'DB_HOST', | |
'DB_SCHEMA', | |
'DB_USERNAME_READONLY', | |
'DB_PASSWORD_READONLY', | |
]; | |
const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); | |
if (missingEnvVars.length > 0) { | |
throw new Error(`Missing required environment variables: ${missingEnvVars.join(', ')}`); | |
} | |
this.createQuickSightDashboards(props.environment, process.env.QS_DASHBOARD_VIEWERS_GROUP_ARN!); | |
} | |
private createQuickSightDashboards(env: string, viewersGroupArn: string): void { | |
const mainFolder = this.createQuickSightFolder(env, undefined, undefined, viewersGroupArn); | |
const datasetsFolder = this.createQuickSightFolder(env, 'datasets', mainFolder.attrArn, viewersGroupArn); | |
const dashboardsFolder = this.createQuickSightFolder(env, 'dashboards', mainFolder.attrArn, viewersGroupArn); | |
const dataSource = this.createDataSource(env, viewersGroupArn); | |
this.createAntiPatternDashboard( | |
env, | |
datasetsFolder.attrArn, | |
dashboardsFolder.attrArn, | |
dataSource.attrArn, | |
viewersGroupArn, | |
); | |
} | |
private createDataSource(env: string, viewersGroupArn: string): CfnDataSource { | |
const dataSourceId = `timeback-platform-${env}`; | |
return new CfnDataSource(this, `QuickSightDataSource-${env}`, { | |
awsAccountId: this.account, | |
dataSourceId, | |
name: dataSourceId, | |
type: 'POSTGRESQL', | |
dataSourceParameters: { | |
postgreSqlParameters: { | |
host: process.env.DB_HOST!, | |
database: process.env.DB_SCHEMA!, | |
port: 5432, | |
}, | |
}, | |
credentials: { | |
credentialPair: { | |
username: process.env.DB_USERNAME_READONLY!, | |
password: process.env.DB_PASSWORD_READONLY!, | |
}, | |
}, | |
vpcConnectionProperties: { | |
vpcConnectionArn: process.env.QS_DATASOURCE_VPC_CONNECTION_ARN!, | |
}, | |
permissions: [ | |
{ | |
principal: viewersGroupArn, | |
actions: [ | |
'quicksight:DescribeDataSource', | |
'quicksight:DescribeDataSourcePermissions', | |
'quicksight:PassDataSource', | |
'quicksight:UpdateDataSource', | |
'quicksight:DeleteDataSource', | |
'quicksight:UpdateDataSourcePermissions', | |
], | |
}, | |
], | |
}); | |
} | |
// TODO :: move this & the underlying methods to separate files per each actual dashboard | |
private createAntiPatternDashboard( | |
env: string, | |
datasetsFolderArn: string, | |
dashboardsFolderArn: string, | |
dataSourceArn: string, | |
viewersGroupArn: string, | |
): void { | |
const dataset = this.createAntiPatternDataset(env, datasetsFolderArn, viewersGroupArn, dataSourceArn); | |
const template = this.createAntiPatternTemplate(env, viewersGroupArn); | |
const dashboardName = `timeback-platform-${env}-anti-pattern-dashboard`; | |
new CfnDashboard(this, `QuickSightDashboard-${env}`, { | |
awsAccountId: this.account, | |
dashboardId: dashboardName, | |
name: dashboardName, | |
folderArns: [dashboardsFolderArn], | |
sourceEntity: { | |
sourceTemplate: { | |
arn: template.attrArn, | |
dataSetReferences: [ | |
{ | |
dataSetArn: dataset.attrArn, | |
dataSetPlaceholder: 'anti_pattern_dataset', | |
}, | |
], | |
}, | |
}, | |
permissions: [ | |
{ | |
principal: viewersGroupArn, | |
actions: ['quicksight:DescribeDashboard', 'quicksight:ListDashboardVersions', 'quicksight:QueryDashboard'], | |
}, | |
], | |
tags: [ | |
{ | |
key: 'Version', | |
value: `1-${new Date().toISOString()}`, | |
}, | |
], | |
}); | |
} | |
private createAntiPatternDataset( | |
env: string, | |
folderArn: string, | |
viewersGroupArn: string, | |
dataSourceArn: string, | |
): CfnDataSet { | |
const datasetName = `timeback-platform-${env}-anti-pattern`; | |
// CAUTION :: | |
// 1. Do NOT put semicolon at the end of the file otherwise the query in the dataset will break | |
// 2. Do NOT have extra lines at the end of the file (we're trimming the content but still) | |
const sqlQuery = readFileSync(join(__dirname, 'queries', 'anti-pattern.sql'), 'utf-8').trim(); | |
return new CfnDataSet(this, `QuickSightDataSet-${env}`, { | |
awsAccountId: this.account, | |
dataSetId: datasetName, | |
name: datasetName, | |
importMode: 'DIRECT_QUERY', | |
folderArns: [folderArn], | |
physicalTableMap: { | |
antiPatternTable: { | |
customSql: { | |
dataSourceArn, | |
name: 'anti_pattern_query', | |
sqlQuery, | |
columns: [ | |
{ name: 'anti_pattern_id', type: 'STRING' }, | |
{ name: 'anti_pattern_type', type: 'STRING' }, | |
{ name: 'started_at_time', type: 'DATETIME' }, | |
{ name: 'ended_at_time', type: 'DATETIME' }, | |
{ name: 'duration_seconds', type: 'DECIMAL' }, | |
{ name: 'person_name', type: 'STRING' }, | |
{ name: 'course_name', type: 'STRING' }, | |
{ name: 'subject_name', type: 'STRING' }, | |
{ name: 'software_application_name', type: 'STRING' }, | |
], | |
}, | |
}, | |
}, | |
permissions: [ | |
{ | |
principal: viewersGroupArn, | |
actions: [ | |
'quicksight:DescribeDataSet', | |
'quicksight:DescribeDataSetPermissions', | |
'quicksight:DescribeDataSetRefreshProperties', | |
'quicksight:PassDataSet', | |
'quicksight:DescribeIngestion', | |
'quicksight:ListIngestions', | |
], | |
}, | |
], | |
}); | |
} | |
private createAntiPatternTemplate(env: string, viewersGroupArn: string): CfnTemplate { | |
return new CfnTemplate(this, `QuickSightTemplate-${env}`, { | |
awsAccountId: this.account, | |
templateId: `timeback-platform-${env}-anti-pattern-template`, | |
name: `timeback-platform-${env}-anti-pattern-template`, | |
// Need to use tag to enforce the update on the stack | |
tags: [ | |
{ | |
key: 'Version', | |
value: `1-${new Date().toISOString()}`, | |
}, | |
], | |
definition: { | |
dataSetConfigurations: [ | |
{ | |
placeholder: 'anti_pattern_dataset', | |
dataSetSchema: { | |
columnSchemaList: [ | |
{ name: 'anti_pattern_id', dataType: 'STRING' }, | |
{ name: 'anti_pattern_type', dataType: 'STRING' }, | |
{ name: 'started_at_time', dataType: 'DATETIME' }, | |
{ name: 'ended_at_time', dataType: 'DATETIME' }, | |
{ name: 'duration_seconds', dataType: 'DECIMAL' }, | |
{ name: 'person_name', dataType: 'STRING' }, | |
{ name: 'course_name', dataType: 'STRING' }, | |
{ name: 'subject_name', dataType: 'STRING' }, | |
{ name: 'software_application_name', dataType: 'STRING' }, | |
], | |
}, | |
}, | |
], | |
sheets: [ | |
{ | |
sheetId: 'sheet1', | |
name: 'Anti-Pattern Overview', | |
visuals: [ | |
{ | |
tableVisual: { | |
visualId: 'table1', | |
title: { | |
formatText: { | |
plainText: 'Anti-Pattern Data', | |
}, | |
}, | |
chartConfiguration: { | |
fieldWells: { | |
tableAggregatedFieldWells: { | |
groupBy: [ | |
{ | |
categoricalDimensionField: { | |
fieldId: 'person_name', | |
column: { | |
dataSetIdentifier: 'anti_pattern_dataset', | |
columnName: 'person_name', | |
}, | |
}, | |
}, | |
{ | |
categoricalDimensionField: { | |
fieldId: 'subject_name', | |
column: { | |
dataSetIdentifier: 'anti_pattern_dataset', | |
columnName: 'subject_name', | |
}, | |
}, | |
}, | |
{ | |
categoricalDimensionField: { | |
fieldId: 'course_name', | |
column: { | |
dataSetIdentifier: 'anti_pattern_dataset', | |
columnName: 'course_name', | |
}, | |
}, | |
}, | |
{ | |
categoricalDimensionField: { | |
fieldId: 'anti_pattern_type', | |
column: { | |
dataSetIdentifier: 'anti_pattern_dataset', | |
columnName: 'anti_pattern_type', | |
}, | |
}, | |
}, | |
], | |
values: [ | |
{ | |
numericalMeasureField: { | |
fieldId: 'duration_seconds', | |
column: { | |
dataSetIdentifier: 'anti_pattern_dataset', | |
columnName: 'duration_seconds', | |
}, | |
aggregationFunction: { | |
simpleNumericalAggregation: 'SUM', | |
}, | |
}, | |
}, | |
], | |
}, | |
}, | |
}, | |
}, | |
}, | |
], | |
}, | |
], | |
}, | |
permissions: [ | |
{ | |
principal: viewersGroupArn, | |
actions: ['quicksight:DescribeTemplate'], | |
}, | |
], | |
}); | |
} | |
// Keeping the code in case we need Analysis int the future | |
private createAntiPatternAnalysis( | |
env: string, | |
dataSetArn: string, | |
templateArn: string, | |
viewersGroupArn: string, | |
): CfnAnalysis { | |
const analysisName = `timeback-platform-${env}-anti-pattern-analysis`; | |
return new CfnAnalysis(this, `QuickSightAnalysis-${env}`, { | |
awsAccountId: this.account, | |
analysisId: analysisName, | |
name: analysisName, | |
sourceEntity: { | |
sourceTemplate: { | |
arn: templateArn, | |
dataSetReferences: [ | |
{ | |
dataSetArn, | |
dataSetPlaceholder: 'anti_pattern_dataset', | |
}, | |
], | |
}, | |
}, | |
permissions: [ | |
{ | |
principal: viewersGroupArn, | |
actions: [ | |
'quicksight:DescribeAnalysis', | |
'quicksight:DescribeAnalysisPermissions', | |
'quicksight:QueryAnalysis', | |
'quicksight:RestoreAnalysis', | |
'quicksight:UpdateAnalysis', | |
'quicksight:UpdateAnalysisPermissions', | |
'quicksight:DeleteAnalysis', | |
], | |
}, | |
], | |
}); | |
} | |
private createQuickSightFolder( | |
env: string, | |
subPath?: string, | |
parentFolderArn?: string, | |
viewersGroupArn?: string, | |
): CfnFolder { | |
const baseName = `timeback-platform-${env}`; | |
const folderName = subPath ? `${baseName}-${subPath}` : baseName; | |
const folderId = folderName.toLowerCase().replace(/[^a-z0-9-]/g, '-'); | |
return new CfnFolder(this, `QuickSightFolder-${folderName}`, { | |
awsAccountId: this.account, | |
folderId, | |
name: folderName, | |
folderType: 'SHARED', | |
parentFolderArn, | |
permissions: [ | |
{ | |
principal: viewersGroupArn!, | |
actions: ['quicksight:DescribeFolder'], | |
}, | |
], | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment