cdk-rds-sql 7.0.2 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,7 @@ repos:
35
35
  language: system
36
36
  entry: sh -c 'npx tsc --noEmit'
37
37
  files: \.ts$
38
+ pass_filenames: false
38
39
  - repo: local
39
40
  hooks:
40
41
  - id: synth
@@ -42,6 +43,7 @@ repos:
42
43
  language: system
43
44
  entry: sh -c 'npx cdk synth --quiet --context vpc-id=$VPC_ID'
44
45
  files: src/.*\.ts$
46
+ pass_filenames: false
45
47
  - repo: local
46
48
  hooks:
47
49
  - id: compile-project
@@ -50,6 +52,7 @@ repos:
50
52
  language: system
51
53
  entry: sh -c 'npx projen compile'
52
54
  files: \.ts$
55
+ pass_filenames: false
53
56
  - repo: local
54
57
  hooks:
55
58
  - id: test
@@ -58,3 +61,4 @@ repos:
58
61
  language: system
59
62
  entry: sh -c 'npx jest --coverage=false'
60
63
  files: \.ts$
64
+ pass_filenames: false
package/.prettierignore CHANGED
@@ -10,3 +10,9 @@ tsconfig.json
10
10
  .projen/
11
11
  .eslintrc.json
12
12
  API.md
13
+ cdk.out
14
+ .claude/
15
+ lib/
16
+ cdk.context.json
17
+ src/handler/handler.js
18
+ tsconfig.dev.json
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # About
2
2
 
3
3
  This CDK construct library makes it possible to create databases,
4
- schemas, and roles in an Aurora Serverless v2, RDS Database Cluster or
5
- Database Instance. Both PostgreSQL and MySQL databases are supported.
4
+ schemas, and roles in an Aurora Serverless v2, RDS Database Cluster,
5
+ Database Instance, or AWS DSQL clusters. PostgreSQL, MySQL, and DSQL
6
+ databases are supported.
6
7
 
7
8
  This construct library is intended to be used in enterprise
8
9
  environments, and works in isolated subnets.
@@ -103,20 +104,6 @@ const provider = new Provider(this, "Provider", {
103
104
  })
104
105
  ```
105
106
 
106
- ### Disabling SSL
107
-
108
- The default connection to RDS is ssl enabled. You can disable ssl by
109
- setting the `ssl` option to `false`:
110
-
111
- ```ts
112
- const provider = new Provider(this, "Provider", {
113
- vpc: vpc,
114
- instance: instance,
115
- secret: cluster.secret!,
116
- ssl: false, // default is true
117
- })
118
- ```
119
-
120
107
  ## Roles
121
108
 
122
109
  Create a postgres role (or mysql user) for a cluster as follows:
@@ -388,6 +375,92 @@ initialPolicy: [
388
375
 
389
376
  Note that your VPC will need an SSM Parameters interface endpoint to support this.
390
377
 
378
+ # AWS DSQL Support
379
+
380
+ This construct also supports AWS DSQL. DSQL is a great number of
381
+ limitations around it's postgresql support, so there is some learing
382
+ curve. This library supports creating roles, schemas, and executing
383
+ SQL on DSQL clusters. Creating databases is not supported by DSQL.
384
+
385
+ ## DSQL Provider Setup
386
+
387
+ DSQL clusters don't require a VPC or secrets since they use IAM authentication:
388
+
389
+ ```ts
390
+ import * as dsql from "aws-cdk-lib/aws-dsql"
391
+ import { Provider } from "cdk-rds-sql"
392
+
393
+ const dsqlCluster = new dsql.CfnCluster(this, "DsqlCluster", {
394
+ deletionProtectionEnabled: false,
395
+ })
396
+
397
+ const provider = new Provider(this, "Provider", {
398
+ cluster: dsqlCluster,
399
+ // No VPC or secret needed for DSQL
400
+ })
401
+ ```
402
+
403
+ You can access DSQL from within a VPC, but cdk-rds-sql has not been
404
+ specifically tested with that scenario.
405
+
406
+ ## DSQL Roles
407
+
408
+ DSQL always uses IAM authentication, so the `enableIamAuth` property is ignored:
409
+
410
+ ```ts
411
+ import { Role } from "cdk-rds-sql"
412
+
413
+ const role = new Role(this, "Role", {
414
+ provider: provider,
415
+ roleName: "myrole",
416
+ })
417
+ ```
418
+
419
+ ## Using DSQL
420
+
421
+ Connecting to DSQL requires a couple of things:
422
+
423
+ 1. Your IAM role needs the `dsql:DbConnect` IAM permission to connect to the cluster.
424
+ 2. You probably want a normal postgres role (not "admin") to use from your apps.
425
+ 3. Your IAM role needs to be connected to your postgres role: use the `IamGrant` construct for that:
426
+
427
+ ```ts
428
+ new IamGrant(this, "LambdaIamGrant", {
429
+ provider: provider,
430
+ roleName: role.roleName,
431
+ resourceArn: queryLambda.role!.roleArn,
432
+ })
433
+ ```
434
+
435
+ 4. From your code use the Dsql signer to create the password:
436
+
437
+ ```ts
438
+ import { DsqlSigner } from "@aws-sdk/dsql-signer"
439
+ import { Client } from "pg"
440
+
441
+ const region =
442
+ process.env["AWS_REGION"] || process.env["AWS_DEFAULT_REGION"] || "us-west-2"
443
+
444
+ // Generate DSQL auth token using AWS SDK
445
+ const signer = new DsqlSigner({
446
+ hostname: host,
447
+ region,
448
+ })
449
+
450
+ const authToken = await signer.getDbConnectAuthToken()
451
+
452
+ const client = new Client({
453
+ host,
454
+ port: 5432,
455
+ user: dbUser,
456
+ database: dbName,
457
+ password: authToken,
458
+ ssl: { rejectUnauthorized: false },
459
+ })
460
+
461
+ await client.connect()
462
+ ```
463
+
391
464
  # IPv6
392
465
 
393
466
  If you use the provider in an IPv6 subnet you probably need these settings:
@@ -404,6 +477,20 @@ const provider = new Provider(this, "Provider", {
404
477
  }
405
478
  ```
406
479
 
480
+ # Disabling SSL
481
+
482
+ The default connection to RDS is ssl enabled. You can disable ssl by
483
+ setting the `ssl` option to `false`:
484
+
485
+ ```ts
486
+ const provider = new Provider(this, "Provider", {
487
+ vpc: vpc,
488
+ instance: instance,
489
+ secret: cluster.secret!,
490
+ ssl: false, // default is true
491
+ })
492
+ ```
493
+
407
494
  # Working on this code
408
495
 
409
496
  This code is managed by
@@ -432,6 +519,10 @@ If you want to use an existing vpc:
432
519
 
433
520
  npx cdk deploy --context vpc-id=vpc-0123456789 TestServerlessV2Stack
434
521
 
522
+ You can also test DSQL support with:
523
+
524
+ npx projen integ:deploy:dsql
525
+
435
526
  # To do
436
527
 
437
528
  - Update role: will not revoke connect to previous database if database name has changed.
@@ -8,6 +8,7 @@ import {
8
8
  EngineRoleProperties,
9
9
  EngineSchemaProperties,
10
10
  EngineSqlProperties,
11
+ EngineIamGrantProperties,
11
12
  } from "./types"
12
13
 
13
14
  export interface EngineConnectionConfig {
@@ -74,6 +75,21 @@ export abstract class AbstractEngine {
74
75
  ): string | string[]
75
76
  abstract deleteSql(resourceId: string, props: EngineSqlProperties): string | string[]
76
77
 
78
+ abstract createIamGrant(
79
+ resourceId: string,
80
+ props: EngineIamGrantProperties
81
+ ): string | string[]
82
+ abstract updateIamGrant(
83
+ resourceId: string,
84
+ oldResourceId: string,
85
+ props: EngineIamGrantProperties,
86
+ oldProps: EngineIamGrantProperties
87
+ ): string | string[]
88
+ abstract deleteIamGrant(
89
+ resourceId: string,
90
+ props: EngineIamGrantProperties
91
+ ): string | string[]
92
+
77
93
  abstract executeSQL(
78
94
  sql: string | string[],
79
95
  config: EngineConnectionConfig
@@ -0,0 +1,302 @@
1
+ import { DsqlSigner } from "@aws-sdk/dsql-signer"
2
+ import { format as pgFormat } from "node-pg-format"
3
+ import { Client, ClientConfig } from "pg"
4
+ import { AbstractEngine, EngineConnectionConfig } from "./engine.abstract"
5
+ import {
6
+ EngineDatabaseProperties,
7
+ EngineRoleProperties,
8
+ EngineSchemaProperties,
9
+ EngineSqlProperties,
10
+ EngineIamGrantProperties,
11
+ } from "./types"
12
+
13
+ export class DsqlEngine extends AbstractEngine {
14
+ createDatabase(
15
+ _resourceId: string,
16
+ _props: EngineDatabaseProperties
17
+ ): string | string[] {
18
+ throw new Error(
19
+ "DSQL does not support creating databases. Database is always 'postgres'."
20
+ )
21
+ }
22
+
23
+ updateDatabase(
24
+ _resourceId: string,
25
+ _oldResourceId: string,
26
+ _props: EngineDatabaseProperties
27
+ ): string[] {
28
+ throw new Error(
29
+ "DSQL does not support database operations. Database is always 'postgres'."
30
+ )
31
+ }
32
+
33
+ deleteDatabase(_resourceId: string, _masterUser: string): string | string[] {
34
+ throw new Error(
35
+ "DSQL does not support deleting databases. Database is always 'postgres'."
36
+ )
37
+ }
38
+
39
+ async createRole(resourceId: string, props: EngineRoleProperties): Promise<string[]> {
40
+ const sql = ["BEGIN"]
41
+
42
+ // DSQL always uses IAM authentication, no passwords
43
+ sql.push(pgFormat("CREATE ROLE %I WITH LOGIN", resourceId))
44
+
45
+ if (props?.DatabaseName) {
46
+ sql.push(
47
+ pgFormat("GRANT CONNECT ON DATABASE %I TO %I", props.DatabaseName, resourceId)
48
+ )
49
+ }
50
+
51
+ sql.push("COMMIT")
52
+ return sql
53
+ }
54
+
55
+ async updateRole(
56
+ resourceId: string,
57
+ oldResourceId: string,
58
+ props: EngineRoleProperties,
59
+ oldProps: EngineRoleProperties
60
+ ): Promise<string[]> {
61
+ const sql = ["BEGIN"]
62
+
63
+ if (oldResourceId !== resourceId) {
64
+ sql.push(pgFormat("ALTER ROLE %I RENAME TO %I", oldResourceId, resourceId))
65
+ }
66
+
67
+ // Handle database permission changes
68
+ if (
69
+ oldProps?.DatabaseName &&
70
+ props?.DatabaseName &&
71
+ oldProps.DatabaseName !== props.DatabaseName
72
+ ) {
73
+ // Revoke from old database and grant to new
74
+ sql.push(
75
+ pgFormat(
76
+ "REVOKE CONNECT ON DATABASE %I FROM %I",
77
+ oldProps.DatabaseName,
78
+ resourceId
79
+ )
80
+ )
81
+ sql.push(
82
+ pgFormat("GRANT CONNECT ON DATABASE %I TO %I", props.DatabaseName, resourceId)
83
+ )
84
+ } else if (props?.DatabaseName && !oldProps?.DatabaseName) {
85
+ // Grant to new database
86
+ sql.push(
87
+ pgFormat("GRANT CONNECT ON DATABASE %I TO %I", props.DatabaseName, resourceId)
88
+ )
89
+ } else if (!props?.DatabaseName && oldProps?.DatabaseName) {
90
+ // Revoke from old database
91
+ sql.push(
92
+ pgFormat(
93
+ "REVOKE CONNECT ON DATABASE %I FROM %I",
94
+ oldProps.DatabaseName,
95
+ resourceId
96
+ )
97
+ )
98
+ }
99
+
100
+ sql.push("COMMIT")
101
+ return sql
102
+ }
103
+
104
+ deleteRole(resourceId: string, props: EngineRoleProperties): string | string[] {
105
+ const sql = ["BEGIN"]
106
+
107
+ if (props?.DatabaseName) {
108
+ sql.push(
109
+ pgFormat(
110
+ "REVOKE ALL PRIVILEGES ON DATABASE %I FROM %I",
111
+ props.DatabaseName,
112
+ resourceId
113
+ )
114
+ )
115
+ }
116
+
117
+ sql.push(pgFormat("DROP ROLE IF EXISTS %I", resourceId))
118
+ sql.push("COMMIT")
119
+ return sql
120
+ }
121
+
122
+ createSchema(resourceId: string, props: EngineSchemaProperties): string | string[] {
123
+ const sql: string[] = [pgFormat("CREATE SCHEMA IF NOT EXISTS %I", resourceId)]
124
+ if (props?.RoleName) {
125
+ this.grantRoleForSchema(resourceId, props.RoleName).forEach((stmt) =>
126
+ sql.push(stmt)
127
+ )
128
+ }
129
+ return sql
130
+ }
131
+
132
+ updateSchema(
133
+ resourceId: string,
134
+ oldResourceId: string,
135
+ props: EngineSchemaProperties
136
+ ): string | string[] {
137
+ const statements: string[] = []
138
+ if (props?.RoleName) {
139
+ this.revokeRoleFromSchema(oldResourceId, props.RoleName).forEach((stmt) =>
140
+ statements.push(stmt)
141
+ )
142
+ }
143
+ if (resourceId !== oldResourceId) {
144
+ statements.push(pgFormat("ALTER SCHEMA %I RENAME TO %I", oldResourceId, resourceId))
145
+ }
146
+ if (props?.RoleName) {
147
+ this.grantRoleForSchema(resourceId, props.RoleName).forEach((stmt) =>
148
+ statements.push(stmt)
149
+ )
150
+ }
151
+ return statements
152
+ }
153
+
154
+ deleteSchema(resourceId: string, props: EngineSchemaProperties): string | string[] {
155
+ const statements: string[] = []
156
+ if (props?.RoleName) {
157
+ this.revokeRoleFromSchema(resourceId, props.RoleName).forEach((stmt) =>
158
+ statements.push(stmt)
159
+ )
160
+ }
161
+ statements.push(pgFormat("DROP SCHEMA IF EXISTS %I CASCADE", resourceId))
162
+ return statements
163
+ }
164
+
165
+ createSql(_resourceId: string, props: EngineSqlProperties): string | string[] {
166
+ if (!props.Statement) {
167
+ throw new Error("Statement is required for SQL resource")
168
+ }
169
+ return props.Statement
170
+ }
171
+
172
+ updateSql(
173
+ _resourceId: string,
174
+ _oldResourceId: string,
175
+ props: EngineSqlProperties
176
+ ): string | string[] {
177
+ if (!props.Statement) {
178
+ throw new Error("Statement is required for SQL resource")
179
+ }
180
+ return props.Statement
181
+ }
182
+
183
+ deleteSql(_resourceId: string, props: EngineSqlProperties): string | string[] {
184
+ if (props.Rollback) {
185
+ return props.Rollback
186
+ }
187
+ return []
188
+ }
189
+
190
+ createIamGrant(resourceId: string, props: EngineIamGrantProperties): string | string[] {
191
+ if (!props.ResourceArn) {
192
+ throw new Error("ResourceArn is required for IAM grant")
193
+ }
194
+ return pgFormat("AWS IAM GRANT %I TO %L", resourceId, props.ResourceArn)
195
+ }
196
+
197
+ updateIamGrant(
198
+ resourceId: string,
199
+ oldResourceId: string,
200
+ props: EngineIamGrantProperties,
201
+ oldProps: EngineIamGrantProperties
202
+ ): string | string[] {
203
+ const statements: string[] = []
204
+
205
+ // Revoke old grant if resource ARN or role name changed
206
+ if (oldProps.ResourceArn) {
207
+ statements.push(
208
+ pgFormat("AWS IAM REVOKE %I FROM %L", oldResourceId, oldProps.ResourceArn)
209
+ )
210
+ }
211
+
212
+ // Grant new permissions
213
+ if (props.ResourceArn) {
214
+ statements.push(pgFormat("AWS IAM GRANT %I TO %L", resourceId, props.ResourceArn))
215
+ }
216
+
217
+ return statements
218
+ }
219
+
220
+ deleteIamGrant(resourceId: string, props: EngineIamGrantProperties): string | string[] {
221
+ if (!props.ResourceArn) {
222
+ throw new Error("ResourceArn is required for IAM grant deletion")
223
+ }
224
+ return pgFormat("AWS IAM REVOKE %I FROM %L", resourceId, props.ResourceArn)
225
+ }
226
+
227
+ async executeSQL(sql: string | string[], config: EngineConnectionConfig): Promise<any> {
228
+ this.log("Connecting to DSQL cluster...")
229
+
230
+ // For DSQL, we need to generate an IAM auth token
231
+ const region = process.env.AWS_REGION
232
+ if (!region) {
233
+ throw new Error("AWS_REGION environment variable is required for DSQL")
234
+ }
235
+
236
+ const signer = new DsqlSigner({
237
+ hostname: config.host,
238
+ region: region,
239
+ })
240
+ const authToken = await signer.getDbConnectAdminAuthToken()
241
+
242
+ const clientConfig: ClientConfig = {
243
+ host: config.host,
244
+ port: config.port,
245
+ user: "admin", // DSQL always uses 'admin' user
246
+ password: authToken, // Use IAM auth token as password
247
+ database: "postgres", // DSQL always uses 'postgres' database
248
+ ssl: {
249
+ //ca: fs.readFileSync(`${process.env.LAMBDA_TASK_ROOT}/global-bundle.pem`),
250
+ rejectUnauthorized: true,
251
+ },
252
+ connectionTimeoutMillis: 30000,
253
+ query_timeout: 30000,
254
+ //statement_timeout: 30000, // not supported on DSQL
255
+ }
256
+
257
+ const client = new Client(clientConfig)
258
+
259
+ try {
260
+ await client.connect()
261
+ this.log("Connected to DSQL cluster")
262
+
263
+ const statements = Array.isArray(sql) ? sql : [sql]
264
+ const results = []
265
+
266
+ for (const statement of statements) {
267
+ if (statement.trim()) {
268
+ this.log(`Executing SQL: ${statement}`)
269
+ const result = await client.query(statement)
270
+ results.push(result)
271
+ }
272
+ }
273
+
274
+ this.log("SQL execution completed")
275
+ return results.length === 1 ? results[0] : results
276
+ } catch (error) {
277
+ this.log(`Error executing SQL: ${error}`)
278
+ throw error
279
+ } finally {
280
+ try {
281
+ await client.end()
282
+ this.log("Disconnected from DSQL cluster")
283
+ } catch (error) {
284
+ this.log(`Error disconnecting: ${error}`)
285
+ }
286
+ }
287
+ }
288
+
289
+ private grantRoleForSchema(schema: string, roleName: string): string[] {
290
+ return [
291
+ pgFormat("GRANT USAGE ON SCHEMA %I TO %I", schema, roleName),
292
+ pgFormat("GRANT CREATE ON SCHEMA %I TO %I", schema, roleName),
293
+ ]
294
+ }
295
+
296
+ private revokeRoleFromSchema(schema: string, roleName: string): string[] {
297
+ return [
298
+ pgFormat("REVOKE CREATE ON SCHEMA %I FROM %I", schema, roleName),
299
+ pgFormat("REVOKE ALL ON SCHEMA %I FROM %I", schema, roleName),
300
+ ]
301
+ }
302
+ }
@@ -1,10 +1,13 @@
1
1
  import { AbstractEngine } from "./engine.abstract"
2
+ import { DsqlEngine } from "./engine.dsql"
2
3
  import { MysqlEngine } from "./engine.mysql"
3
4
  import { PostgresqlEngine } from "./engine.postgresql"
4
5
 
5
6
  export class EngineFactory {
6
7
  static createEngine(engine: string): AbstractEngine {
7
8
  switch (engine.toLowerCase()) {
9
+ case "dsql":
10
+ return new DsqlEngine()
8
11
  case "postgres":
9
12
  case "postgresql":
10
13
  case "aurora-postgresql":
@@ -5,6 +5,7 @@ import {
5
5
  EngineRoleProperties,
6
6
  EngineSchemaProperties,
7
7
  EngineSqlProperties,
8
+ EngineIamGrantProperties,
8
9
  } from "./types"
9
10
 
10
11
  export class MysqlEngine extends AbstractEngine {
@@ -183,6 +184,29 @@ export class MysqlEngine extends AbstractEngine {
183
184
  return props?.Rollback || ""
184
185
  }
185
186
 
187
+ createIamGrant(
188
+ _resourceId: string,
189
+ _props: EngineIamGrantProperties
190
+ ): string | string[] {
191
+ throw new Error("IAM grants are only supported with DSQL clusters")
192
+ }
193
+
194
+ updateIamGrant(
195
+ _resourceId: string,
196
+ _oldResourceId: string,
197
+ _props: EngineIamGrantProperties,
198
+ _oldProps: EngineIamGrantProperties
199
+ ): string | string[] {
200
+ throw new Error("IAM grants are only supported with DSQL clusters")
201
+ }
202
+
203
+ deleteIamGrant(
204
+ _resourceId: string,
205
+ _props: EngineIamGrantProperties
206
+ ): string | string[] {
207
+ throw new Error("IAM grants are only supported with DSQL clusters")
208
+ }
209
+
186
210
  async executeSQL(sql: string | string[], config: EngineConnectionConfig): Promise<any> {
187
211
  // Dynamic import to avoid bundling issues
188
212
  const { createConnection } = await import("mysql2/promise")
@@ -8,6 +8,7 @@ import {
8
8
  EngineRoleProperties,
9
9
  EngineSchemaProperties,
10
10
  EngineSqlProperties,
11
+ EngineIamGrantProperties,
11
12
  } from "./types"
12
13
 
13
14
  export class PostgresqlEngine extends AbstractEngine {
@@ -267,6 +268,29 @@ END$$;`,
267
268
  ]
268
269
  }
269
270
 
271
+ createIamGrant(
272
+ _resourceId: string,
273
+ _props: EngineIamGrantProperties
274
+ ): string | string[] {
275
+ throw new Error("IAM grants are only supported with DSQL clusters")
276
+ }
277
+
278
+ updateIamGrant(
279
+ _resourceId: string,
280
+ _oldResourceId: string,
281
+ _props: EngineIamGrantProperties,
282
+ _oldProps: EngineIamGrantProperties
283
+ ): string | string[] {
284
+ throw new Error("IAM grants are only supported with DSQL clusters")
285
+ }
286
+
287
+ deleteIamGrant(
288
+ _resourceId: string,
289
+ _props: EngineIamGrantProperties
290
+ ): string | string[] {
291
+ throw new Error("IAM grants are only supported with DSQL clusters")
292
+ }
293
+
270
294
  async executeSQL(sql: string | string[], config: EngineConnectionConfig): Promise<any> {
271
295
  const isSslEnabled = process.env.SSL ? JSON.parse(process.env.SSL) : true
272
296
  const ssl: ConnectionOptions | false = isSslEnabled