Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions cdk-postgresql/lib/database.handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import format from "pg-format";
import { escapeIdentifier } from "pg";
import { getConnectedClient, validateConnection, hashCode } from "./util";
import * as postgres from "./postgres";

Expand Down Expand Up @@ -117,14 +117,11 @@ export const deleteDatabase = async (
// First, drop all remaining DB connections
// Sometimes, DB connections are still alive even though the ECS service has been deleted
await client.query(
format(
"SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname=%L",
name
)
"SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname=$1",
[name]
);
// Then, drop the DB
await client.query(format("DROP DATABASE %I", name));
// await client.query(format("REVOKE %I FROM %I", owner, connection.Username));
await client.query(`DROP DATABASE ${escapeIdentifier(name)}`);
await client.end();
};

Expand All @@ -136,6 +133,8 @@ export const updateDbOwner = async (
console.log(`Updating DB ${name} owner to ${owner}`);
const client = await getConnectedClient(connection);

await client.query(format("ALTER DATABASE %I OWNER TO %I", name, owner));
await client.query(
`ALTER DATABASE ${escapeIdentifier(name)} OWNER TO ${escapeIdentifier(owner)}`
);
await client.end();
};
22 changes: 16 additions & 6 deletions cdk-postgresql/lib/postgres.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { VError } from "verror";
import { Client, DatabaseError } from "pg";
import format from "pg-format";
import { Client, DatabaseError, escapeIdentifier, escapeLiteral } from "pg";
import * as util from "util";

const isDatabaseError = (e: any): e is DatabaseError => {
Expand All @@ -14,7 +13,9 @@ export const createRole = async (props: {
}) => {
const { client, name, password } = props;

await client.query(format("CREATE USER %I WITH PASSWORD %L", name, password));
await client.query(
`CREATE USER ${escapeIdentifier(name)} WITH PASSWORD ${escapeLiteral(password)}`
);
};

export const createDatabase = async (props: {
Expand All @@ -24,8 +25,15 @@ export const createDatabase = async (props: {
}) => {
const { client, name, owner } = props;

const grantee = client.user;
if (!grantee) {
throw new VError("the connection has no user to grant the owner role to");
}

try {
await client.query(format("GRANT %I TO %I", owner, client.user));
await client.query(
`GRANT ${escapeIdentifier(owner)} TO ${escapeIdentifier(grantee)}`
);
} catch (e) {
if (!util.types.isNativeError(e)) {
throw e;
Expand All @@ -34,7 +42,7 @@ export const createDatabase = async (props: {
!isDatabaseError(e) ||
!(
e.code === "0LP01" &&
e.message === `role "${owner}" is a member of role "${client.user}"`
e.message === `role "${owner}" is a member of role "${grantee}"`
)
) {
throw new VError(e, "unexpected error while creating grant");
Expand All @@ -43,5 +51,7 @@ export const createDatabase = async (props: {
console.warn(e.message);
}

return client.query(format("CREATE DATABASE %I WITH OWNER %I", name, owner));
return client.query(
`CREATE DATABASE ${escapeIdentifier(name)} WITH OWNER ${escapeIdentifier(owner)}`
);
};
13 changes: 5 additions & 8 deletions cdk-postgresql/lib/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as lambda from "aws-cdk-lib/aws-lambda-nodejs";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as logs from "aws-cdk-lib/aws-logs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as cr from "aws-cdk-lib/custom-resources";
Expand Down Expand Up @@ -97,12 +96,10 @@ export class Provider extends Construct implements iam.IGrantable {
? new ec2.SecurityGroup(this, "HandlerSecurityGroup", { vpc })
: undefined;
const handlerSecurityGroups = handlerSecurityGroup ? [handlerSecurityGroup] : undefined;
const handler = new lambda.NodejsFunction(scope, "handler", {
entry: path.join(__dirname, "..", "dist", "handler.cjs"),
runtime: Runtime.NODEJS_24_X,
bundling: {
nodeModules: ["pg", "pg-format"],
},
const handler = new lambda.Function(scope, "handler", {
code: lambda.Code.fromAsset(path.join(__dirname, "..", "dist", "lambda")),
handler: "index.handler",
Comment thread
pascal-botpress marked this conversation as resolved.
runtime: lambda.Runtime.NODEJS_24_X,
logRetention: logs.RetentionDays.ONE_MONTH,
timeout: cdk.Duration.minutes(15),
vpc,
Expand Down
17 changes: 12 additions & 5 deletions cdk-postgresql/lib/role.handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import format from "pg-format";
import { escapeIdentifier, escapeLiteral } from "pg";

import {
CloudFormationCustomResourceEvent,
Expand Down Expand Up @@ -111,7 +111,7 @@ export const deleteRole = async (connection: Connection, name: string) => {
console.log("Deleting user", name);
const client = await getConnectedClient(connection);

await client.query(format("DROP USER %I", name));
await client.query(`DROP USER ${escapeIdentifier(name)}`);
await client.end();
};

Expand All @@ -123,7 +123,9 @@ export const updateRoleName = async (
console.log(`Updating role name from ${oldName} to ${newName}`);
const client = await getConnectedClient(connection);

await client.query(format("ALTER ROLE %I RENAME TO %I", oldName, newName));
await client.query(
`ALTER ROLE ${escapeIdentifier(oldName)} RENAME TO ${escapeIdentifier(newName)}`
);
await client.end();
};

Expand All @@ -140,8 +142,13 @@ export const updateRolePassword = async (props: {
const { SecretString: password } = await secretsmanager.getSecretValue({
SecretId: passwordArn,
});
if (!password) {
throw new Error(`secret ${passwordArn} has no SecretString value`);
}

await client.query(format("ALTER USER %I WITH PASSWORD %L", name, password));
await client.query(
`ALTER USER ${escapeIdentifier(name)} WITH PASSWORD ${escapeLiteral(password)}`
);
await client.end();
};

Expand All @@ -158,7 +165,7 @@ export const createRole = async (props: {
SecretId: passwordArn,
});
if (!password) {
throw new Error("could not decrypt password");
throw new Error(`secret ${passwordArn} has no SecretString value`);
}

await postgres.createRole({ client, name, password });
Expand Down
4 changes: 1 addition & 3 deletions cdk-postgresql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"check:type": "pnpm exec tsc --noEmit",
"test": "pnpm run test:unit && pnpm run test:integration",
"test:unit": "pnpm run build && pnpm exec vitest --run --project=unit",
"test:integration": "pnpm exec vitest --run --project=integration",
"test:integration": "pnpm run build && pnpm exec vitest --run --project=integration",
"prepublishOnly": "pnpm run build"
},
"peerDependencies": {
Expand All @@ -44,15 +44,13 @@
"dependencies": {
"@aws-sdk/client-secrets-manager": "3.1116.0",
"pg": "8.23.0",
"pg-format": "1.0.4",
"verror": "^1.10.1"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.162",
"@types/node": "22.13.14",
"@types/ms": "^2.1.0",
"@types/pg": "8.23.0",
"@types/pg-format": "1.0.5",
"@types/verror": "^1.10.11",
"aws-cdk-lib": "^2.266.0",
"constructs": "^10.8.1",
Expand Down
52 changes: 52 additions & 0 deletions cdk-postgresql/test/lambda.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createDatabase, createRole } from "../lib/postgres";
import { createSecret, dbExists, getDbOwner, roleExists } from "./helpers";
import { secretsmanager } from "../lib/util";
import { beforeEach, afterEach, describe, test, expect, vi } from "vitest";
import { createRequire } from "node:module";

const DB_PORT = 5432;
const DB_MASTER_USERNAME = "postgres";
Expand Down Expand Up @@ -448,3 +449,54 @@ describe("database", () => {
await masterClient.end();
});
});

// The built asset is what actually gets deployed, and bundling can break it in
// ways the source cannot reproduce, so it gets exercised the way the lambda
// runtime loads it:
describe("built lambda asset", () => {
test("creates a working role", async () => {
// Arrange
const roleName = "assetuser";
const rolePwd = "assetrolepwd";
const rolePasswordArn = await createSecret(secretsmanager, rolePwd);
const event: CreateRoleEvent = {
RequestType: "Create",
ServiceToken: "",
ResponseURL: "",
StackId: "",
RequestId: "",
LogicalResourceId: "",
ResourceType: "Custom::Postgresql-Role",
ResourceProperties: {
ServiceToken: "",
Connection: {
Host: pgHost,
Port: pgPort,
Username: DB_MASTER_USERNAME,
Database: DB_DEFAULT_DB,
PasswordArn: masterPasswordArn,
SSLMode: "disable",
},
Name: roleName,
PasswordArn: rolePasswordArn,
},
};

// Act
const { handler } = createRequire(import.meta.url)("../dist/lambda/index.cjs");
await handler(event);

// Assert
const asNewRole = new Client({
host: pgHost,
port: pgPort,
database: DB_DEFAULT_DB,
user: roleName,
password: rolePwd,
});
await asNewRole.connect();
const { rows } = await asNewRole.query("SELECT current_user");
await asNewRole.end();
expect(rows[0].current_user).toEqual(roleName);
});
});
9 changes: 7 additions & 2 deletions cdk-postgresql/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ export default defineConfig([
shims: true,
dts: true,
},
// The lambda is shipped as a ready-to-deploy asset, so that consumers never
// need esbuild (or Docker) to synthesize a stack. Everything it needs at
// runtime has to be inside the bundle:
{
entry: ["./lib/handler.ts"],
entry: { index: "./lib/handler.ts" },
outDir: "dist/lambda",
format: ["cjs"],
platform: "node",
deps: { alwaysBundle: ["pg", "verror", "@aws-sdk/client-secrets-manager"] },
dts: false,
sourcemap: true,
clean: false,
},
]);
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
{
"private": true,
"devDependencies": {
"esbuild": "^0.28.2"
},
"scripts": {
"build": "pnpm -r --if-present build",
"check": "pnpm -r --if-present check",
Expand Down
24 changes: 2 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.