Skip to content

Commit e1d9ba6

Browse files
feat: allow granting rds_replication
1 parent c4bfd6b commit e1d9ba6

5 files changed

Lines changed: 213 additions & 0 deletions

File tree

‎cdk-postgresql/lib/handler.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import { CloudFormationCustomResourceEvent } from "aws-lambda/trigger/cloudforma
33
import { VError } from "verror";
44
import { handler as dbHandler } from "./database.handler";
55
import { handler as roleHandler } from "./role.handler";
6+
import { handler as roleMembershipHandler } from "./role-membership.handler";
67

78
export const handler = async (event: CloudFormationCustomResourceEvent) => {
89
switch (event.ResourceType) {
910
case "Custom::Postgresql-Role":
1011
return roleHandler(event);
1112
case "Custom::Postgresql-Database":
1213
return dbHandler(event);
14+
case "Custom::Postgresql-RoleMembership":
15+
return roleMembershipHandler(event);
1316
default:
1417
throw new VError(`unexpected ResourceType: ${event.ResourceType}`);
1518
}

‎cdk-postgresql/lib/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./database";
22
export * from "./role";
3+
export * from "./role-membership";
34
export * from "./provider";

‎cdk-postgresql/lib/postgres.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,27 @@ export const createDatabase = async (props: {
5555
`CREATE DATABASE ${escapeIdentifier(name)} WITH OWNER ${escapeIdentifier(owner)}`
5656
);
5757
};
58+
59+
export const grantRoleMembership = async (props: {
60+
client: Client;
61+
role: string;
62+
member: string;
63+
}) => {
64+
const { client, role, member } = props;
65+
66+
await client.query(
67+
`GRANT ${escapeIdentifier(role)} TO ${escapeIdentifier(member)}`
68+
);
69+
};
70+
71+
export const revokeRoleMembership = async (props: {
72+
client: Client;
73+
role: string;
74+
member: string;
75+
}) => {
76+
const { client, role, member } = props;
77+
78+
await client.query(
79+
`REVOKE ${escapeIdentifier(role)} FROM ${escapeIdentifier(member)}`
80+
);
81+
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {
2+
CloudFormationCustomResourceEvent,
3+
CloudFormationCustomResourceCreateEvent,
4+
CloudFormationCustomResourceUpdateEvent,
5+
CloudFormationCustomResourceDeleteEvent,
6+
} from "aws-lambda/trigger/cloudformation-custom-resource";
7+
8+
import { validateConnection, hashCode, getConnectedClient } from "./util";
9+
import { Connection } from "./lambda.types";
10+
import * as postgres from "./postgres";
11+
12+
interface Props {
13+
ServiceToken: string;
14+
Connection: Connection;
15+
Role: string;
16+
Member: string;
17+
}
18+
19+
export const handler = async (event: CloudFormationCustomResourceEvent) => {
20+
switch (event.RequestType) {
21+
case "Create":
22+
return handleCreate(event);
23+
case "Update":
24+
return handleUpdate(event);
25+
case "Delete":
26+
return handleDelete(event);
27+
}
28+
};
29+
30+
const handleCreate = async (event: CloudFormationCustomResourceCreateEvent) => {
31+
const props = event.ResourceProperties as Props;
32+
validateProps(props);
33+
await grantRoleMembership(props.Connection, props.Role, props.Member);
34+
return {
35+
PhysicalResourceId: generatePhysicalId(props),
36+
};
37+
};
38+
39+
/**
40+
* Returning a physical id that encodes both the role and the member makes
41+
* CloudFormation grant the new membership and then revoke the old one whenever
42+
* either of them changes, which is safe because the two are never the same grant.
43+
*/
44+
const handleUpdate = async (event: CloudFormationCustomResourceUpdateEvent) => {
45+
const props = event.ResourceProperties as Props;
46+
validateProps(props);
47+
48+
const physicalResourceId = generatePhysicalId(props);
49+
const isDifferentMembership = physicalResourceId != event.PhysicalResourceId;
50+
51+
if (isDifferentMembership) {
52+
await grantRoleMembership(props.Connection, props.Role, props.Member);
53+
}
54+
55+
return { PhysicalResourceId: physicalResourceId };
56+
};
57+
58+
const handleDelete = async (event: CloudFormationCustomResourceDeleteEvent) => {
59+
const props = event.ResourceProperties as Props;
60+
validateProps(props);
61+
await revokeRoleMembership(props.Connection, props.Role, props.Member);
62+
return {};
63+
};
64+
65+
const validateProps = (props: Props) => {
66+
if (!("Connection" in props)) {
67+
throw "Connection property is required";
68+
}
69+
validateConnection(props.Connection);
70+
71+
if (!("Role" in props)) {
72+
throw "Role property is required";
73+
}
74+
if (!("Member" in props)) {
75+
throw "Member property is required";
76+
}
77+
};
78+
79+
const generatePhysicalId = (props: Props): string => {
80+
const { Host, Port } = props.Connection;
81+
const suffix = Math.abs(
82+
hashCode(`${Host}-${Port}-${props.Role}-${props.Member}`)
83+
);
84+
return `role-membership-${suffix}`;
85+
};
86+
87+
export const grantRoleMembership = async (
88+
connection: Connection,
89+
role: string,
90+
member: string
91+
) => {
92+
console.log(`Granting ${role} to ${member}`);
93+
const client = await getConnectedClient(connection);
94+
95+
await postgres.grantRoleMembership({ client, role, member });
96+
97+
await client.end();
98+
};
99+
100+
export const revokeRoleMembership = async (
101+
connection: Connection,
102+
role: string,
103+
member: string
104+
) => {
105+
console.log(`Revoking ${role} from ${member}`);
106+
const client = await getConnectedClient(connection);
107+
108+
await postgres.revokeRoleMembership({ client, role, member });
109+
110+
await client.end();
111+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Construct } from "constructs";
2+
import * as cdk from "aws-cdk-lib";
3+
import { RemovalPolicy } from "aws-cdk-lib";
4+
import { Provider } from "./provider";
5+
6+
/**
7+
* Predefined roles, listed so they auto-complete in vscode.
8+
*/
9+
export type PredefinedRoleName =
10+
| "rds_replication"
11+
| "rds_superuser"
12+
| "rds_iam"
13+
| "rds_password"
14+
| "pg_monitor"
15+
| "pg_read_all_data"
16+
| "pg_write_all_data"
17+
| "pg_read_all_settings"
18+
| "pg_read_all_stats"
19+
| "pg_stat_scan_tables"
20+
| "pg_signal_backend";
21+
22+
export interface RoleMembershipProps {
23+
/**
24+
* Provider required to connect to the Postgresql server
25+
*/
26+
provider: Provider;
27+
28+
/**
29+
* The role whose membership is granted, as in `GRANT <role> TO <member>`. It
30+
* has to exist already, whether it is predefined or was created elsewhere in
31+
* the stack.
32+
*/
33+
role: PredefinedRoleName | (string & {});
34+
35+
/**
36+
* The role receiving the membership, as in `GRANT <role> TO <member>`.
37+
*/
38+
member: string;
39+
40+
/**
41+
* Policy to apply when the membership is removed from this stack.
42+
*
43+
* @default - The membership will be revoked.
44+
*/
45+
removalPolicy?: RemovalPolicy;
46+
}
47+
48+
/**
49+
* Membership of one Postgresql role in another, which is how a role receives
50+
* privileges it cannot be granted directly. On RDS and Aurora the master role
51+
* cannot grant the replication attribute, so a role that has to read the
52+
* write-ahead log is made a member of `rds_replication` instead.
53+
*/
54+
export class RoleMembership extends Construct {
55+
constructor(scope: Construct, id: string, props: RoleMembershipProps) {
56+
super(scope, id);
57+
58+
const { provider, role, member, removalPolicy } = props;
59+
60+
const cr = new cdk.CustomResource(this, "CustomResource", {
61+
serviceToken: provider.serviceToken,
62+
resourceType: "Custom::Postgresql-RoleMembership",
63+
properties: {
64+
connection: provider.buildConnectionProperty(),
65+
role,
66+
member,
67+
},
68+
pascalCaseProperties: true,
69+
});
70+
71+
cr.applyRemovalPolicy(removalPolicy || cdk.RemovalPolicy.DESTROY);
72+
cr.node.addDependency(provider);
73+
}
74+
}

0 commit comments

Comments
 (0)