cdk-rds-sql 7.3.1 → 8.0.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.
@@ -1,391 +0,0 @@
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
- } from "./types"
11
-
12
- export class DsqlEngine extends AbstractEngine {
13
- createDatabase(
14
- _resourceId: string,
15
- _props: EngineDatabaseProperties
16
- ): string | string[] {
17
- throw new Error(
18
- "DSQL does not support creating databases. Database is always 'postgres'."
19
- )
20
- }
21
-
22
- updateDatabase(
23
- _resourceId: string,
24
- _oldResourceId: string,
25
- _props: EngineDatabaseProperties
26
- ): string[] {
27
- throw new Error(
28
- "DSQL does not support database operations. Database is always 'postgres'."
29
- )
30
- }
31
-
32
- deleteDatabase(_resourceId: string, _masterUser: string): string | string[] {
33
- throw new Error(
34
- "DSQL does not support deleting databases. Database is always 'postgres'."
35
- )
36
- }
37
-
38
- async createRole(resourceId: string, props: EngineRoleProperties): Promise<string[]> {
39
- return this.generateCreateRoleSql(resourceId, props?.DatabaseName)
40
- }
41
-
42
- async updateRole(
43
- resourceId: string,
44
- oldResourceId: string,
45
- props: EngineRoleProperties,
46
- oldProps: EngineRoleProperties
47
- ): Promise<string[]> {
48
- const sql: string[] = []
49
-
50
- if (oldResourceId !== resourceId) {
51
- // DSQL doesn't support RENAME, so we create new role and drop old one
52
- // Create new role with old database permissions first
53
- sql.push(...this.generateCreateRoleSql(resourceId, oldProps?.DatabaseName))
54
-
55
- // Add special marker for IAM grant revocation
56
- sql.push(`-- REVOKE_IAM_GRANTS_FOR_ROLE: ${oldResourceId}`)
57
-
58
- // Drop old role
59
- sql.push(...this.generateDropRoleSql(oldResourceId))
60
-
61
- // If database changed as well, grant new database permissions
62
- if (props?.DatabaseName && props.DatabaseName !== oldProps?.DatabaseName) {
63
- sql.push("BEGIN")
64
- sql.push(
65
- pgFormat("GRANT CONNECT ON DATABASE %I TO %I", props.DatabaseName, resourceId)
66
- )
67
- sql.push("COMMIT")
68
- }
69
- } else {
70
- // Only permissions are changing (no rename)
71
- sql.push("BEGIN")
72
-
73
- // Handle database permission changes
74
- if (
75
- oldProps?.DatabaseName &&
76
- props?.DatabaseName &&
77
- oldProps.DatabaseName !== props.DatabaseName
78
- ) {
79
- // Revoke from old database and grant to new
80
- sql.push(
81
- pgFormat(
82
- "REVOKE CONNECT ON DATABASE %I FROM %I",
83
- oldProps.DatabaseName,
84
- resourceId
85
- )
86
- )
87
- sql.push(
88
- pgFormat("GRANT CONNECT ON DATABASE %I TO %I", props.DatabaseName, resourceId)
89
- )
90
- } else if (props?.DatabaseName && !oldProps?.DatabaseName) {
91
- // Grant to new database
92
- sql.push(
93
- pgFormat("GRANT CONNECT ON DATABASE %I TO %I", props.DatabaseName, resourceId)
94
- )
95
- } else if (!props?.DatabaseName && oldProps?.DatabaseName) {
96
- // Revoke from old database
97
- sql.push(
98
- pgFormat(
99
- "REVOKE CONNECT ON DATABASE %I FROM %I",
100
- oldProps.DatabaseName,
101
- resourceId
102
- )
103
- )
104
- }
105
-
106
- sql.push("COMMIT")
107
- }
108
-
109
- return sql
110
- }
111
-
112
- async deleteRole(
113
- resourceId: string,
114
- props: EngineRoleProperties
115
- ): Promise<string | string[]> {
116
- const sql: string[] = []
117
-
118
- // Add special marker for IAM grant revocation
119
- sql.push(`-- REVOKE_IAM_GRANTS_FOR_ROLE: ${resourceId}`)
120
-
121
- // Add the regular drop role SQL
122
- sql.push(...this.generateDropRoleSql(resourceId, props?.DatabaseName))
123
-
124
- return sql
125
- }
126
-
127
- createSchema(resourceId: string, props: EngineSchemaProperties): string | string[] {
128
- const sql: string[] = [pgFormat("CREATE SCHEMA IF NOT EXISTS %I", resourceId)]
129
- if (props?.RoleName) {
130
- this.grantRoleForSchema(resourceId, props.RoleName).forEach((stmt) =>
131
- sql.push(stmt)
132
- )
133
- }
134
- return sql
135
- }
136
-
137
- updateSchema(
138
- resourceId: string,
139
- oldResourceId: string,
140
- props: EngineSchemaProperties
141
- ): string | string[] {
142
- const statements: string[] = []
143
- if (props?.RoleName) {
144
- this.revokeRoleFromSchema(oldResourceId, props.RoleName).forEach((stmt) =>
145
- statements.push(stmt)
146
- )
147
- }
148
- if (resourceId !== oldResourceId) {
149
- statements.push(pgFormat("ALTER SCHEMA %I RENAME TO %I", oldResourceId, resourceId))
150
- }
151
- if (props?.RoleName) {
152
- this.grantRoleForSchema(resourceId, props.RoleName).forEach((stmt) =>
153
- statements.push(stmt)
154
- )
155
- }
156
- return statements
157
- }
158
-
159
- deleteSchema(resourceId: string, props: EngineSchemaProperties): string | string[] {
160
- const statements: string[] = []
161
- if (props?.RoleName) {
162
- this.revokeRoleFromSchema(resourceId, props.RoleName).forEach((stmt) =>
163
- statements.push(stmt)
164
- )
165
- }
166
- statements.push(pgFormat("DROP SCHEMA IF EXISTS %I CASCADE", resourceId))
167
- return statements
168
- }
169
-
170
- createSql(_resourceId: string, props: EngineSqlProperties): string | string[] {
171
- if (!props.Statement) {
172
- throw new Error("Statement is required for SQL resource")
173
- }
174
- return props.Statement
175
- }
176
-
177
- updateSql(
178
- _resourceId: string,
179
- _oldResourceId: string,
180
- props: EngineSqlProperties
181
- ): string | string[] {
182
- if (!props.Statement) {
183
- throw new Error("Statement is required for SQL resource")
184
- }
185
- return props.Statement
186
- }
187
-
188
- deleteSql(_resourceId: string, props: EngineSqlProperties): string | string[] {
189
- if (props.Rollback) {
190
- return props.Rollback
191
- }
192
- return []
193
- }
194
-
195
- createIamGrant(roleName: string, iamArn: string): string | string[] {
196
- if (!iamArn) {
197
- throw new Error("ResourceArn is required for IAM grant")
198
- }
199
- return pgFormat("AWS IAM GRANT %I TO %L", roleName, iamArn)
200
- }
201
-
202
- updateIamGrant(
203
- roleName: string,
204
- oldRoleName: string,
205
- iamArn: string,
206
- oldIamArn: string
207
- ): string | string[] {
208
- const statements: string[] = []
209
-
210
- // Only update if role name or IAM role ARN changed
211
- const roleNameChanged = oldRoleName !== roleName
212
- const arnChanged = oldIamArn !== iamArn
213
-
214
- if (!roleNameChanged && !arnChanged) {
215
- return statements // No changes needed
216
- }
217
-
218
- // Revoke old grant if resource ARN or role name changed
219
- if (oldIamArn) {
220
- statements.push(pgFormat("AWS IAM REVOKE %I FROM %L", oldRoleName, oldIamArn))
221
- }
222
-
223
- // Grant new permissions
224
- if (iamArn) {
225
- statements.push(pgFormat("AWS IAM GRANT %I TO %L", roleName, iamArn))
226
- }
227
-
228
- return statements
229
- }
230
-
231
- deleteIamGrant(roleName: string, iamArn: string): string | string[] {
232
- if (!iamArn) {
233
- throw new Error("ResourceArn is required for IAM grant deletion")
234
- }
235
- return pgFormat("AWS IAM REVOKE %I FROM %L", roleName, iamArn)
236
- }
237
-
238
- async executeSQL(sql: string | string[], config: EngineConnectionConfig): Promise<any> {
239
- this.log("Connecting to DSQL cluster...")
240
-
241
- // For DSQL, we need to generate an IAM auth token
242
- const region = process.env.AWS_REGION
243
- if (!region) {
244
- throw new Error("AWS_REGION environment variable is required for DSQL")
245
- }
246
-
247
- const signer = new DsqlSigner({
248
- hostname: config.host,
249
- region: region,
250
- })
251
- const authToken = await signer.getDbConnectAdminAuthToken()
252
-
253
- const clientConfig: ClientConfig = {
254
- host: config.host,
255
- port: config.port,
256
- user: "admin", // DSQL always uses 'admin' user
257
- password: authToken, // Use IAM auth token as password
258
- database: "postgres", // DSQL always uses 'postgres' database
259
- ssl: {
260
- //ca: fs.readFileSync(`${process.env.LAMBDA_TASK_ROOT}/global-bundle.pem`),
261
- rejectUnauthorized: true,
262
- },
263
- connectionTimeoutMillis: 30000,
264
- query_timeout: 30000,
265
- //statement_timeout: 30000, // not supported on DSQL
266
- }
267
-
268
- const client = new Client(clientConfig)
269
-
270
- try {
271
- await client.connect()
272
- this.log("Connected to DSQL cluster")
273
-
274
- const statements = Array.isArray(sql) ? sql : [sql]
275
- const results = []
276
-
277
- for (const statement of statements) {
278
- if (statement.trim()) {
279
- // Handle special IAM grant revocation marker
280
- if (statement.startsWith("-- REVOKE_IAM_GRANTS_FOR_ROLE:")) {
281
- const roleName = statement
282
- .replace("-- REVOKE_IAM_GRANTS_FOR_ROLE:", "")
283
- .trim()
284
- await this.revokeIamGrantsForRole(client, roleName)
285
- } else if (statement.includes("AWS IAM REVOKE")) {
286
- this.log(`Executing SQL: ${statement}`)
287
- try {
288
- const result = await client.query(statement)
289
- results.push(result)
290
- } catch (error: any) {
291
- if (error.message && error.message.includes("does not exist")) {
292
- this.log(
293
- `Ignoring error for IAM REVOKE (role doesn't exist): ${error.message}`
294
- )
295
- } else {
296
- throw error
297
- }
298
- }
299
- } else {
300
- this.log(`Executing SQL: ${statement}`)
301
- const result = await client.query(statement)
302
- results.push(result)
303
- }
304
- }
305
- }
306
-
307
- this.log("SQL execution completed")
308
- return results.length === 1 ? results[0] : results
309
- } catch (error) {
310
- this.log(`Error executing SQL: ${error}`)
311
- throw error
312
- } finally {
313
- try {
314
- await client.end()
315
- this.log("Disconnected from DSQL cluster")
316
- } catch (error) {
317
- this.log(`Error disconnecting: ${error}`)
318
- }
319
- }
320
- }
321
-
322
- private grantRoleForSchema(schema: string, roleName: string): string[] {
323
- return [
324
- pgFormat("GRANT USAGE ON SCHEMA %I TO %I", schema, roleName),
325
- pgFormat("GRANT CREATE ON SCHEMA %I TO %I", schema, roleName),
326
- ]
327
- }
328
-
329
- private revokeRoleFromSchema(schema: string, roleName: string): string[] {
330
- return [
331
- pgFormat("REVOKE CREATE ON SCHEMA %I FROM %I", schema, roleName),
332
- pgFormat("REVOKE ALL ON SCHEMA %I FROM %I", schema, roleName),
333
- ]
334
- }
335
-
336
- private generateCreateRoleSql(roleName: string, databaseName?: string): string[] {
337
- const sql = ["BEGIN"]
338
- sql.push(pgFormat("CREATE ROLE %I WITH LOGIN", roleName))
339
-
340
- if (databaseName) {
341
- sql.push(pgFormat("GRANT CONNECT ON DATABASE %I TO %I", databaseName, roleName))
342
- }
343
-
344
- sql.push("COMMIT")
345
- return sql
346
- }
347
-
348
- private generateDropRoleSql(roleName: string, databaseName?: string): string[] {
349
- const sql = ["BEGIN"]
350
-
351
- if (databaseName) {
352
- sql.push(
353
- pgFormat("REVOKE ALL PRIVILEGES ON DATABASE %I FROM %I", databaseName, roleName)
354
- )
355
- }
356
-
357
- sql.push(pgFormat("DROP ROLE IF EXISTS %I", roleName))
358
- sql.push("COMMIT")
359
- return sql
360
- }
361
-
362
- private async revokeIamGrantsForRole(client: Client, roleName: string): Promise<void> {
363
- try {
364
- this.log(`Querying IAM grants for role: ${roleName}`)
365
-
366
- // Query the IAM role mappings for this role
367
- const result = await client.query(
368
- pgFormat(
369
- "SELECT arn FROM sys.iam_pg_role_mappings WHERE pg_role_name = %L",
370
- roleName
371
- )
372
- )
373
-
374
- if (result.rows && result.rows.length > 0) {
375
- this.log(`Found ${result.rows.length} IAM grants to revoke for role: ${roleName}`)
376
-
377
- // Revoke each IAM grant
378
- for (const row of result.rows) {
379
- const revokeStatement = pgFormat("AWS IAM REVOKE %I FROM %L", roleName, row.arn)
380
- this.log(`Executing IAM revoke: ${revokeStatement}`)
381
- await client.query(revokeStatement)
382
- }
383
- } else {
384
- this.log(`No IAM grants found for role: ${roleName}`)
385
- }
386
- } catch (error) {
387
- this.log(`Error revoking IAM grants for role ${roleName}: ${error}`)
388
- // Don't throw here - the role might not have any IAM grants, which is fine
389
- }
390
- }
391
- }
@@ -1,23 +0,0 @@
1
- import { AbstractEngine } from "./engine.abstract"
2
- import { DsqlEngine } from "./engine.dsql"
3
- import { MysqlEngine } from "./engine.mysql"
4
- import { PostgresqlEngine } from "./engine.postgresql"
5
-
6
- export class EngineFactory {
7
- static createEngine(engine: string): AbstractEngine {
8
- switch (engine.toLowerCase()) {
9
- case "dsql":
10
- return new DsqlEngine()
11
- case "postgres":
12
- case "postgresql":
13
- case "aurora-postgresql":
14
- return new PostgresqlEngine()
15
- case "mysql":
16
- case "mariadb":
17
- case "aurora-mysql":
18
- return new MysqlEngine()
19
- default:
20
- throw new Error(`Unsupported database engine: ${engine}`)
21
- }
22
- }
23
- }
@@ -1,228 +0,0 @@
1
- import { MysqlEngine } from "./engine.mysql"
2
-
3
- describe("MySQL Engine", () => {
4
- let engine: MysqlEngine
5
-
6
- beforeEach(() => {
7
- engine = new MysqlEngine()
8
- })
9
-
10
- describe("Database", () => {
11
- it("should generate correct SQL for creating a database", () => {
12
- const sql = engine.createDatabase("testdb", {})
13
- expect(Array.isArray(sql)).toBe(true)
14
- expect(sql[0]).toContain("CREATE DATABASE IF NOT EXISTS")
15
- })
16
-
17
- it("should generate correct SQL for creating a database with an owner", () => {
18
- const sql = engine.createDatabase("testdb", { Owner: "testuser" })
19
- expect(Array.isArray(sql)).toBe(true)
20
- expect(sql[0]).toContain("CREATE DATABASE IF NOT EXISTS")
21
- expect(sql[1]).toContain("GRANT ALL PRIVILEGES")
22
- })
23
-
24
- it("should generate correct SQL for deleting a database", () => {
25
- const sql = engine.deleteDatabase("testdb", "masteruser")
26
- expect(Array.isArray(sql)).toBe(true)
27
- expect(sql[0]).toContain("DROP DATABASE IF EXISTS")
28
- })
29
- })
30
-
31
- describe("Role", () => {
32
- it("should throw error when creating a role without password ARN", async () => {
33
- await expect(engine.createRole("testrole", {})).rejects.toThrow(
34
- "No PasswordArn provided"
35
- )
36
- })
37
-
38
- it("should generate correct SQL for renaming a role", async () => {
39
- // Mock getPassword implementation
40
- jest.spyOn(engine as any, "getPassword").mockResolvedValue("test-password")
41
-
42
- const sql = await engine.updateRole(
43
- "newrole",
44
- "oldrole",
45
- {
46
- PasswordArn: "arn:aws:secretsmanager:region:account:secret:name",
47
- },
48
- {}
49
- )
50
-
51
- expect(Array.isArray(sql)).toBe(true)
52
- expect(sql[0]).toContain("CREATE USER IF NOT EXISTS")
53
- expect(sql[1]).toContain("DROP USER IF EXISTS")
54
- expect(sql[2]).toContain("FLUSH PRIVILEGES")
55
- })
56
-
57
- it("should generate correct SQL for deleting a role", async () => {
58
- const sql = await engine.deleteRole("testrole", { DatabaseName: "testdb" })
59
- expect(Array.isArray(sql)).toBe(true)
60
- expect(sql[0]).toContain("REVOKE ALL PRIVILEGES")
61
- expect(sql[1]).toContain("DROP USER IF EXISTS")
62
- })
63
-
64
- it("should generate SQL to revoke privileges on old database when database is changed", async () => {
65
- // Mock getPassword implementation
66
- jest.spyOn(engine as any, "getPassword").mockResolvedValue("test-password")
67
-
68
- const oldProps = {
69
- DatabaseName: "olddb",
70
- PasswordArn: "arn:aws:secretsmanager:region:account:secret:name",
71
- }
72
-
73
- const newProps = {
74
- DatabaseName: "newdb",
75
- PasswordArn: "arn:aws:secretsmanager:region:account:secret:name",
76
- }
77
-
78
- const sql = await engine.updateRole("testrole", "testrole", newProps, oldProps)
79
-
80
- expect(Array.isArray(sql)).toBe(true)
81
-
82
- // Check for revoke statement for the old database
83
- const revokeStatement = sql.find(
84
- (statement) =>
85
- statement.includes("REVOKE ALL PRIVILEGES") && statement.includes("`olddb`")
86
- )
87
- expect(revokeStatement).toBeDefined()
88
-
89
- // Check for grant statement for the new database
90
- const grantStatement = sql.find(
91
- (statement) =>
92
- statement.includes("GRANT ALL PRIVILEGES") && statement.includes("`newdb`")
93
- )
94
- expect(grantStatement).toBeDefined()
95
- })
96
-
97
- describe("IAM Authentication", () => {
98
- it("should create user with IAM authentication", async () => {
99
- const props = {
100
- EnableIamAuth: true,
101
- DatabaseName: "testdb",
102
- }
103
-
104
- const sql = await engine.createRole("iamuser", props)
105
-
106
- expect(Array.isArray(sql)).toBe(true)
107
- expect(sql[0]).toContain(
108
- "CREATE USER IF NOT EXISTS 'iamuser'@'%' IDENTIFIED WITH AWSAuthenticationPlugin as 'RDS'"
109
- )
110
- expect(sql[1]).toContain("GRANT ALL PRIVILEGES ON `testdb`.* TO 'iamuser'@'%'")
111
- expect(sql[2]).toBe("FLUSH PRIVILEGES")
112
- })
113
-
114
- it("should create user without IAM authentication", async () => {
115
- jest.spyOn(engine as any, "getPassword").mockResolvedValue("test-password")
116
-
117
- const props = {
118
- EnableIamAuth: false,
119
- PasswordArn: "arn:aws:secretsmanager:region:account:secret:name",
120
- DatabaseName: "testdb",
121
- }
122
-
123
- const sql = await engine.createRole("passworduser", props)
124
-
125
- expect(Array.isArray(sql)).toBe(true)
126
- expect(sql[0]).toContain(
127
- "CREATE USER IF NOT EXISTS 'passworduser'@'%' IDENTIFIED BY 'test-password'"
128
- )
129
- expect(sql[1]).toContain(
130
- "GRANT ALL PRIVILEGES ON `testdb`.* TO 'passworduser'@'%'"
131
- )
132
- expect(sql[2]).toBe("FLUSH PRIVILEGES")
133
- })
134
-
135
- it("should switch from password to IAM authentication", async () => {
136
- const oldProps = {
137
- EnableIamAuth: false,
138
- PasswordArn: "arn:aws:secretsmanager:region:account:secret:name",
139
- }
140
-
141
- const newProps = {
142
- EnableIamAuth: true,
143
- DatabaseName: "testdb",
144
- }
145
-
146
- const sql = await engine.updateRole(
147
- "switchuser",
148
- "switchuser",
149
- newProps,
150
- oldProps
151
- )
152
-
153
- expect(Array.isArray(sql)).toBe(true)
154
- expect(sql[0]).toContain("DROP USER IF EXISTS 'switchuser'@'%'")
155
- expect(sql[1]).toContain(
156
- "CREATE USER 'switchuser'@'%' IDENTIFIED WITH AWSAuthenticationPlugin as 'RDS'"
157
- )
158
- expect(sql[2]).toContain("GRANT ALL PRIVILEGES ON `testdb`.* TO 'switchuser'@'%'")
159
- expect(sql[3]).toBe("FLUSH PRIVILEGES")
160
- })
161
-
162
- it("should switch from IAM to password authentication", async () => {
163
- jest.spyOn(engine as any, "getPassword").mockResolvedValue("new-password")
164
-
165
- const oldProps = {
166
- EnableIamAuth: true,
167
- }
168
-
169
- const newProps = {
170
- EnableIamAuth: false,
171
- PasswordArn: "arn:aws:secretsmanager:region:account:secret:name",
172
- DatabaseName: "testdb",
173
- }
174
-
175
- const sql = await engine.updateRole(
176
- "switchuser",
177
- "switchuser",
178
- newProps,
179
- oldProps
180
- )
181
-
182
- expect(Array.isArray(sql)).toBe(true)
183
- expect(sql[0]).toContain("DROP USER IF EXISTS 'switchuser'@'%'")
184
- expect(sql[1]).toContain(
185
- "CREATE USER 'switchuser'@'%' IDENTIFIED BY 'new-password'"
186
- )
187
- expect(sql[2]).toContain("GRANT ALL PRIVILEGES ON `testdb`.* TO 'switchuser'@'%'")
188
- expect(sql[3]).toBe("FLUSH PRIVILEGES")
189
- })
190
- })
191
- })
192
-
193
- describe("Schema", () => {
194
- it("should throw an error when trying to create a schema", () => {
195
- expect(() => engine.createSchema("testschema", {})).toThrow("not supported")
196
- })
197
-
198
- it("should throw an error when trying to update a schema", () => {
199
- expect(() => engine.updateSchema("newschema", "oldschema", {})).toThrow(
200
- "not supported"
201
- )
202
- })
203
-
204
- it("should throw an error when trying to delete a schema", () => {
205
- expect(() => engine.deleteSchema("testschema", {})).toThrow("not supported")
206
- })
207
- })
208
-
209
- describe("SQL", () => {
210
- it("should pass through SQL statements for create", () => {
211
- const statement = "SELECT * FROM users"
212
- const sql = engine.createSql("test", { Statement: statement })
213
- expect(sql).toBe(statement)
214
- })
215
-
216
- it("should pass through SQL statements for update", () => {
217
- const statement = "UPDATE users SET name = 'test'"
218
- const sql = engine.updateSql("test", "old", { Statement: statement })
219
- expect(sql).toBe(statement)
220
- })
221
-
222
- it("should pass through rollback SQL for delete", () => {
223
- const rollback = "DROP TABLE users"
224
- const sql = engine.deleteSql("test", { Rollback: rollback })
225
- expect(sql).toBe(rollback)
226
- })
227
- })
228
- })