cdk-rds-sql 7.3.1 → 7.3.2

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,329 +0,0 @@
1
- import * as fs from "fs"
2
- import { ConnectionOptions } from "tls"
3
- import { format as pgFormat } from "node-pg-format"
4
- import { Client, ClientConfig } from "pg"
5
- import { AbstractEngine, EngineConnectionConfig } from "./engine.abstract"
6
- import {
7
- EngineDatabaseProperties,
8
- EngineRoleProperties,
9
- EngineSchemaProperties,
10
- EngineSqlProperties,
11
- } from "./types"
12
-
13
- export class PostgresqlEngine extends AbstractEngine {
14
- createDatabase(resourceId: string, props: EngineDatabaseProperties): string | string[] {
15
- const owner = props?.Owner
16
- if (owner) {
17
- return [
18
- pgFormat("create database %I", resourceId),
19
- pgFormat("alter database %I owner to %I", resourceId, owner),
20
- ]
21
- } else {
22
- return pgFormat("create database %I", resourceId)
23
- }
24
- }
25
-
26
- updateDatabase(
27
- resourceId: string,
28
- oldResourceId: string,
29
- props: EngineDatabaseProperties
30
- ): string[] {
31
- const statements: string[] = []
32
- if (resourceId !== oldResourceId) {
33
- if (props?.MasterOwner) {
34
- statements.push(
35
- pgFormat("alter database %I owner to %I", oldResourceId, props.MasterOwner)
36
- )
37
- }
38
- statements.push(
39
- pgFormat("alter database %I rename to %I", oldResourceId, resourceId)
40
- )
41
- }
42
- const owner = props?.Owner
43
- if (owner) {
44
- statements.push(pgFormat("alter database %I owner to %I", resourceId, props.Owner))
45
- }
46
- return statements
47
- }
48
-
49
- deleteDatabase(resourceId: string, masterUser: string): string[] {
50
- return [
51
- pgFormat(
52
- "select pg_terminate_backend(pg_stat_activity.pid) from pg_stat_activity where datname = %L",
53
- resourceId
54
- ),
55
- pgFormat(
56
- "DO $$BEGIN\nIF EXISTS (select from pg_database WHERE datname = '%s') THEN alter database %I owner to %I; END IF;\nEND$$;",
57
- resourceId,
58
- resourceId,
59
- masterUser
60
- ),
61
- pgFormat("drop database if exists %I", resourceId),
62
- ]
63
- }
64
-
65
- async createRole(resourceId: string, props: EngineRoleProperties): Promise<string[]> {
66
- const sql = ["start transaction"]
67
-
68
- if (props?.EnableIamAuth) {
69
- // Create role for IAM authentication
70
- sql.push(pgFormat("create role %I with login", resourceId))
71
- sql.push(pgFormat("grant rds_iam to %I", resourceId))
72
- } else {
73
- // Create role with password authentication
74
- if (!props?.PasswordArn) throw "No PasswordArn provided"
75
- const password = await this.getPassword(props.PasswordArn)
76
- if (!password) {
77
- throw `Cannot parse password from ${props.PasswordArn}`
78
- }
79
- sql.push(pgFormat("create role %I with login password %L", resourceId, password))
80
- }
81
-
82
- if (props?.DatabaseName) {
83
- sql.push(
84
- pgFormat(
85
- `DO $$
86
- BEGIN
87
- IF EXISTS (select from pg_database where datname = '%s' and datistemplate = false) THEN
88
- grant connect on database %I to %I;
89
- END IF;
90
- END$$;`,
91
- props.DatabaseName,
92
- props.DatabaseName,
93
- resourceId
94
- )
95
- )
96
- }
97
-
98
- sql.push("commit")
99
- return sql
100
- }
101
-
102
- async updateRole(
103
- resourceId: string,
104
- oldResourceId: string,
105
- props: EngineRoleProperties,
106
- oldProps: EngineRoleProperties
107
- ): Promise<string[]> {
108
- const sql = ["start transaction"]
109
-
110
- if (oldResourceId !== resourceId) {
111
- sql.push(pgFormat("alter role %I rename to %I", oldResourceId, resourceId))
112
- }
113
-
114
- // Handle authentication method changes
115
- if (props?.EnableIamAuth && !oldProps?.EnableIamAuth) {
116
- // Switching from password to IAM auth
117
- sql.push(pgFormat("grant rds_iam to %I", resourceId))
118
- } else if (!props?.EnableIamAuth && oldProps?.EnableIamAuth) {
119
- // Switching from IAM to password auth
120
- sql.push(pgFormat("revoke rds_iam from %I", resourceId))
121
- if (props?.PasswordArn) {
122
- const password = await this.getPassword(props.PasswordArn)
123
- if (!password) {
124
- throw `Cannot parse password from ${props.PasswordArn}`
125
- }
126
- sql.push(pgFormat("alter role %I with password %L", resourceId, password))
127
- }
128
- } else if (!props?.EnableIamAuth && props?.PasswordArn) {
129
- // Update password for password auth
130
- const password = await this.getPassword(props.PasswordArn)
131
- if (!password) {
132
- throw `Cannot parse password from ${props.PasswordArn}`
133
- }
134
- sql.push(pgFormat("alter role %I with password %L", resourceId, password))
135
- }
136
-
137
- // Check if database name has changed
138
- if (
139
- oldProps?.DatabaseName &&
140
- props?.DatabaseName &&
141
- oldProps.DatabaseName !== props.DatabaseName
142
- ) {
143
- // Revoke from old database
144
- sql.push(
145
- pgFormat(
146
- `DO $$
147
- BEGIN
148
- IF EXISTS (select from pg_database where datname = '%s' and datistemplate = false) THEN
149
- revoke connect on database %I from %I;
150
- END IF;
151
- END$$;`,
152
- oldProps.DatabaseName,
153
- oldProps.DatabaseName,
154
- resourceId
155
- )
156
- )
157
- }
158
-
159
- if (props?.DatabaseName) {
160
- sql.push(
161
- pgFormat(
162
- `DO $$
163
- BEGIN
164
- IF EXISTS (select from pg_database where datname = '%s' and datistemplate = false) THEN
165
- grant connect on database %I to %I;
166
- END IF;
167
- END$$;`,
168
- props.DatabaseName,
169
- props.DatabaseName,
170
- resourceId
171
- )
172
- )
173
- }
174
-
175
- sql.push("commit")
176
- return sql
177
- }
178
-
179
- async deleteRole(resourceId: string, props: EngineRoleProperties): Promise<string[]> {
180
- return [
181
- "start transaction",
182
- pgFormat(
183
- `DO $$
184
- BEGIN
185
- IF EXISTS (select from pg_catalog.pg_roles WHERE rolname = '%s') AND EXISTS (select from pg_database WHERE datname = '%s') THEN
186
- revoke all privileges on database %I from %I;
187
- END IF;
188
- END$$;`,
189
- resourceId,
190
- props?.DatabaseName,
191
- props?.DatabaseName,
192
- resourceId
193
- ),
194
- pgFormat("drop role if exists %I", resourceId),
195
- "commit",
196
- ]
197
- }
198
-
199
- createSchema(resourceId: string, props: EngineSchemaProperties): string[] {
200
- const sql: string[] = [pgFormat("create schema if not exists %I", resourceId)]
201
- if (props?.RoleName) {
202
- this.grantRoleForSchema(resourceId, props.RoleName).forEach((stmt) =>
203
- sql.push(stmt)
204
- )
205
- }
206
- return sql
207
- }
208
-
209
- updateSchema(
210
- resourceId: string,
211
- oldResourceId: string,
212
- props: EngineSchemaProperties
213
- ): string[] {
214
- const sql: string[] = []
215
- if (props?.RoleName) {
216
- this.revokeRoleFromSchema(oldResourceId, props.RoleName).forEach((stmt) =>
217
- sql.push(stmt)
218
- )
219
- }
220
- sql.push(pgFormat("alter schema %I rename to %I", oldResourceId, resourceId))
221
- if (props?.RoleName) {
222
- this.grantRoleForSchema(resourceId, props.RoleName).forEach((stmt) =>
223
- sql.push(stmt)
224
- )
225
- }
226
- return sql
227
- }
228
-
229
- deleteSchema(resourceId: string, props: EngineSchemaProperties): string[] {
230
- const sql: string[] = []
231
- if (props?.RoleName) {
232
- this.revokeRoleFromSchema(resourceId, props.RoleName).forEach((stmt) =>
233
- sql.push(stmt)
234
- )
235
- }
236
- sql.push(pgFormat("drop schema if exists %I cascade", resourceId))
237
- return sql
238
- }
239
-
240
- createSql(_resourceId: string, props: EngineSqlProperties): string {
241
- return props?.Statement || ""
242
- }
243
-
244
- updateSql(
245
- _resourceId: string,
246
- _oldResourceId: string,
247
- props: EngineSqlProperties
248
- ): string {
249
- return props?.Statement || ""
250
- }
251
-
252
- deleteSql(_resourceId: string, props: EngineSqlProperties): string {
253
- return props?.Rollback || ""
254
- }
255
-
256
- private grantRoleForSchema(schema: string, roleName: string): string[] {
257
- return [
258
- pgFormat("GRANT USAGE ON SCHEMA %I TO %I", schema, roleName),
259
- pgFormat("GRANT CREATE ON SCHEMA %I TO %I", schema, roleName),
260
- ]
261
- }
262
-
263
- private revokeRoleFromSchema(schema: string, roleName: string): string[] {
264
- return [
265
- pgFormat("REVOKE CREATE ON SCHEMA %I FROM %I", schema, roleName),
266
- pgFormat("REVOKE ALL ON SCHEMA %I FROM %I", schema, roleName),
267
- ]
268
- }
269
-
270
- createIamGrant(_roleName: string, _iamArn: string): string | string[] {
271
- throw new Error("IAM grants are only supported with DSQL clusters")
272
- }
273
-
274
- updateIamGrant(
275
- _roleName: string,
276
- _oldRoleName: string,
277
- _iamArn: string,
278
- _oldIamArn: string
279
- ): string | string[] {
280
- throw new Error("IAM grants are only supported with DSQL clusters")
281
- }
282
-
283
- deleteIamGrant(_roleName: string, _iamArn: string): string | string[] {
284
- throw new Error("IAM grants are only supported with DSQL clusters")
285
- }
286
-
287
- async executeSQL(sql: string | string[], config: EngineConnectionConfig): Promise<any> {
288
- const isSslEnabled = process.env.SSL ? JSON.parse(process.env.SSL) : true
289
- const ssl: ConnectionOptions | false = isSslEnabled
290
- ? {
291
- ca: fs.readFileSync(`${process.env.LAMBDA_TASK_ROOT}/global-bundle.pem`),
292
- rejectUnauthorized: true,
293
- }
294
- : false
295
-
296
- const params: ClientConfig = {
297
- host: config.host,
298
- port: config.port,
299
- user: config.user,
300
- password: config.password,
301
- database: config.database,
302
- connectionTimeoutMillis: Number(process.env.CONNECTION_TIMEOUT) ?? 30000,
303
- ssl,
304
- }
305
-
306
- this.log(
307
- `Connecting to PostgreSQL host ${params.host}:${params.port}${
308
- ssl ? " using a secure connection" : ""
309
- }, database ${params.database} as ${params.user}`
310
- )
311
- this.log("Executing SQL", sql)
312
-
313
- const pg_client = new Client(params)
314
- await pg_client.connect()
315
- try {
316
- if (typeof sql === "string") {
317
- await pg_client.query(sql)
318
- } else if (sql) {
319
- await Promise.all(
320
- sql.map((statement) => {
321
- return pg_client.query(statement)
322
- })
323
- )
324
- }
325
- } finally {
326
- await pg_client.end()
327
- }
328
- }
329
- }
@@ -1,349 +0,0 @@
1
- import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"
2
- import { createConnection } from "mysql2/promise"
3
- import { GenericContainer, StartedTestContainer } from "testcontainers"
4
- import { handler } from "./handler"
5
- import { createRequest, updateRequest, deleteRequest } from "./util"
6
-
7
- jest.mock("@aws-sdk/client-secrets-manager")
8
- const SecretsManagerClientMock = SecretsManagerClient as jest.MockedClass<
9
- typeof SecretsManagerClient
10
- >
11
-
12
- const DB_PORT = 3306
13
- const DB_MASTER_USERNAME = "root"
14
- const DB_MASTER_PASSWORD = "masterpwd"
15
- const DB_DEFAULT_DB = "dummy"
16
-
17
- let mysqlContainer: StartedTestContainer
18
- let mysqlHost: string
19
- let mysqlPort: number
20
-
21
- beforeAll(async () => {
22
- process.env.LOGGER = "true"
23
- process.env.SSL = "false"
24
- process.env.CONNECTION_TIMEOUT = "5000"
25
- mysqlContainer = await new GenericContainer("mysql:8")
26
- .withExposedPorts(DB_PORT)
27
- .withEnvironment({
28
- MYSQL_ROOT_PASSWORD: DB_MASTER_PASSWORD,
29
- MYSQL_DATABASE: DB_DEFAULT_DB,
30
- MYSQL_INITDB_SKIP_TZINFO: "true",
31
- })
32
- .start()
33
- mysqlHost = mysqlContainer.getHost()
34
- mysqlPort = mysqlContainer.getMappedPort(DB_PORT)
35
-
36
- // Set up the mock after we have the host and port values
37
- SecretsManagerClientMock.prototype.send.mockImplementation(() => {
38
- return {
39
- SecretString: JSON.stringify({
40
- host: mysqlHost,
41
- port: mysqlPort,
42
- username: DB_MASTER_USERNAME,
43
- password: DB_MASTER_PASSWORD,
44
- dbname: DB_DEFAULT_DB,
45
- engine: "mysql",
46
- dbClusterIdentifier: "dummy",
47
- }),
48
- }
49
- })
50
- }, 60000)
51
-
52
- afterAll(async () => {
53
- if (mysqlContainer) {
54
- await mysqlContainer.stop()
55
- }
56
- })
57
-
58
- beforeEach(async () => {
59
- jest.clearAllMocks()
60
-
61
- // Clean up databases and users created by tests
62
- const connection = await newConnection()
63
-
64
- try {
65
- // Drop all databases except system ones and default
66
- const [databases] = await connection.query(
67
- "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', ?)",
68
- [DB_DEFAULT_DB]
69
- )
70
-
71
- for (const db of databases) {
72
- await connection.query(`DROP DATABASE IF EXISTS \`${db.SCHEMA_NAME}\``)
73
- }
74
-
75
- // Drop all users except system ones
76
- const [users] = await connection.query(
77
- "SELECT User FROM mysql.user WHERE User NOT IN ('mysql.sys', 'mysql.session', 'mysql.infoschema', 'root')"
78
- )
79
-
80
- for (const user of users) {
81
- await connection.query(`DROP USER IF EXISTS '${user.User}'@'%'`)
82
- }
83
- } finally {
84
- await connection.end()
85
- }
86
- })
87
-
88
- // Helper functions for MySQL tests
89
- async function databaseExists(connection: any, dbName: string): Promise<boolean> {
90
- const [rows] = await connection.query(
91
- "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?",
92
- [dbName]
93
- )
94
- return rows.length > 0
95
- }
96
-
97
- async function userExists(connection: any, username: string): Promise<boolean> {
98
- const [rows] = await connection.query("SELECT User FROM mysql.user WHERE User = ?", [
99
- username,
100
- ])
101
- return rows.length > 0
102
- }
103
-
104
- async function tableExists(
105
- connection: any,
106
- dbName: string,
107
- tableName: string
108
- ): Promise<boolean> {
109
- const [rows] = await connection.query(
110
- "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
111
- [dbName, tableName]
112
- )
113
- return rows.length > 0
114
- }
115
-
116
- async function rowCount(connection: any, tableName: string): Promise<number> {
117
- const [rows] = await connection.query(`SELECT COUNT(*) as count FROM ${tableName}`)
118
- return rows[0].count
119
- }
120
-
121
- async function newConnection(config?: any): Promise<any> {
122
- const conn = await createConnection({
123
- host: mysqlHost,
124
- port: mysqlPort,
125
- database: config && config.database ? config.database : DB_DEFAULT_DB,
126
- user: config && config.user ? config.user : DB_MASTER_USERNAME,
127
- password: config && config.password ? config.password : DB_MASTER_PASSWORD,
128
- })
129
- return conn
130
- }
131
-
132
- test("database", async () => {
133
- const oldDatabaseName = "mydb"
134
- const newDatabaseName = "mydb2"
135
- const create = createRequest("database", oldDatabaseName)
136
- await handler(create)
137
- expect(SecretsManagerClientMock.prototype.send).toHaveBeenCalledTimes(1)
138
- const connection = await newConnection()
139
- try {
140
- expect(await databaseExists(connection, oldDatabaseName)).toEqual(true)
141
-
142
- const update = updateRequest("database", oldDatabaseName, newDatabaseName)
143
- await expect(handler(update)).rejects.toThrow(
144
- "Renaming database is not supported in MySQL."
145
- )
146
-
147
- const remove = deleteRequest("database", oldDatabaseName)
148
- await handler(remove)
149
- expect(await databaseExists(connection, newDatabaseName)).toEqual(false)
150
- } finally {
151
- await connection.end()
152
- }
153
- })
154
-
155
- test("database with owner", async () => {
156
- const databaseName = "mydb"
157
- const userName = "example"
158
-
159
- // First create user
160
- const create_role = createRequest("role", userName, {
161
- PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
162
- })
163
- await handler(create_role)
164
-
165
- // Then create database with owner
166
- const create_db = createRequest("database", databaseName, { Owner: userName })
167
- await handler(create_db)
168
-
169
- const connection = await newConnection()
170
- try {
171
- expect(await databaseExists(connection, databaseName)).toEqual(true)
172
-
173
- // Create a table in the new database
174
- const create_table = createRequest("sql", "", {
175
- DatabaseName: databaseName,
176
- Statement: "CREATE TABLE t(i INT)",
177
- })
178
- await handler(create_table)
179
-
180
- // Verify we can login as owner and access the table
181
- const userConn = await newConnection({
182
- user: userName,
183
- database: databaseName,
184
- })
185
-
186
- try {
187
- expect(await tableExists(userConn, databaseName, "t")).toEqual(true)
188
- // Test we can insert data
189
- await userConn.query("INSERT INTO t VALUES (1), (2), (3)")
190
- expect(await rowCount(userConn, "t")).toEqual(3)
191
- } finally {
192
- await userConn.end()
193
- }
194
-
195
- // Test database rename
196
- const oldDatabaseName = databaseName
197
- const newDatabaseName = "mydb2"
198
-
199
- const update = updateRequest("database", oldDatabaseName, newDatabaseName, {
200
- Owner: userName,
201
- })
202
- await expect(handler(update)).rejects.toThrow(
203
- "Renaming database is not supported in MySQL."
204
- )
205
- } finally {
206
- await connection.end()
207
- }
208
- })
209
-
210
- describe("User creation", () => {
211
- it("can create a user", async () => {
212
- const oldRoleName = "testuser"
213
- const newRoleName = "testuser2"
214
-
215
- // Create role. We must specify a database as in mysql you cannot
216
- // connect without a database.
217
- const create = createRequest("role", oldRoleName, {
218
- PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
219
- DatabaseName: DB_DEFAULT_DB,
220
- })
221
- await handler(create)
222
- expect(SecretsManagerClientMock.prototype.send).toHaveBeenCalledTimes(2)
223
-
224
- const connection = await newConnection()
225
- try {
226
- expect(await userExists(connection, oldRoleName)).toEqual(true)
227
-
228
- // Attempt to connect as this user
229
- const userConn = await newConnection({
230
- user: oldRoleName,
231
- })
232
- await userConn.end()
233
-
234
- // Update role - rename
235
- const update = updateRequest("role", oldRoleName, newRoleName, {
236
- PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
237
- })
238
- await handler(update)
239
-
240
- expect(await userExists(connection, oldRoleName)).toEqual(false)
241
- expect(await userExists(connection, newRoleName)).toEqual(true)
242
-
243
- // Delete role
244
- const remove = deleteRequest("role", newRoleName)
245
- await handler(remove)
246
- expect(await userExists(connection, newRoleName)).toEqual(false)
247
- } finally {
248
- await connection.end()
249
- }
250
- })
251
-
252
- it("can change a user's password", async () => {
253
- const userName = "pwduser"
254
-
255
- // Create user
256
- const create = createRequest("role", userName, {
257
- PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
258
- DatabaseName: DB_DEFAULT_DB,
259
- })
260
- await handler(create)
261
- expect(SecretsManagerClientMock.prototype.send).toHaveBeenCalledTimes(2)
262
-
263
- const connection = await newConnection()
264
- try {
265
- expect(await userExists(connection, userName)).toEqual(true)
266
-
267
- // Test we can connect with the initial password
268
- const userConn = await newConnection({
269
- user: userName,
270
- })
271
- await userConn.end()
272
-
273
- // Update role - change password
274
- const update = updateRequest("role", userName, userName, {
275
- PasswordArn: "arn:aws:secretsmanager:us-east-1:123456789:secret:dummy",
276
- })
277
- await handler(update)
278
-
279
- // Password changed but should still be able to connect
280
- const userConn2 = await newConnection({
281
- user: userName,
282
- })
283
- await userConn2.end()
284
- } finally {
285
- await connection.end()
286
- }
287
- })
288
- })
289
-
290
- test("sql execution", async () => {
291
- const databaseName = "sqltest"
292
-
293
- // Create database
294
- const create_db = createRequest("database", databaseName)
295
- await handler(create_db)
296
-
297
- // Execute SQL to create table
298
- const create_table = createRequest("sql", "", {
299
- DatabaseName: databaseName,
300
- Statement: "CREATE TABLE test_table (id INT, name VARCHAR(50))",
301
- })
302
- await handler(create_table)
303
-
304
- // Execute multiple SQL statements
305
- const create_multiple_tables = createRequest("sql", "", {
306
- DatabaseName: databaseName,
307
- Statement: "CREATE TABLE t1 (id INT); CREATE TABLE t2 (id INT);",
308
- })
309
- await handler(create_multiple_tables)
310
-
311
- const connection = await newConnection({ database: databaseName })
312
- try {
313
- expect(await tableExists(connection, databaseName, "test_table")).toEqual(true)
314
-
315
- // Execute SQL to insert data
316
- const insert_data = createRequest("sql", "", {
317
- DatabaseName: databaseName,
318
- Statement: "INSERT INTO test_table VALUES (1, 'Test 1'), (2, 'Test 2')",
319
- })
320
- await handler(insert_data)
321
-
322
- expect(await rowCount(connection, "test_table")).toEqual(2)
323
-
324
- // Execute SQL with rollback for deletion test
325
- const update_sql = updateRequest("sql", "dummy", "dummy", {
326
- DatabaseName: databaseName,
327
- Statement: "UPDATE test_table SET name = 'Updated' WHERE id = 1",
328
- Rollback: "UPDATE test_table SET name = 'Test 1' WHERE id = 1",
329
- })
330
- await handler(update_sql)
331
-
332
- // Verify update happened
333
- const [rows] = await connection.query("SELECT name FROM test_table WHERE id = 1")
334
- expect(rows[0].name).toEqual("Updated")
335
-
336
- // Test rollback on delete
337
- const delete_sql = deleteRequest("sql", "dummy", {
338
- DatabaseName: databaseName,
339
- Rollback: "UPDATE test_table SET name = 'Rollback test' WHERE id = 1",
340
- })
341
- await handler(delete_sql)
342
-
343
- // Verify rollback executed
344
- const [updated] = await connection.query("SELECT name FROM test_table WHERE id = 1")
345
- expect(updated[0].name).toEqual("Rollback test")
346
- } finally {
347
- await connection.end()
348
- }
349
- })