Skip to content

Commit 7e4fdc7

Browse files
authored
fix(redshift): UserTablePriviliges to track changes using table IDs (#26955)
To support solving #24246, there needs to first be a refactor of the UserTablePriviliges to instead record the table id. The reason being that new privileges would be granted/revoked on old tables that now have new names, since a change in table name caused an UPDATE action to be triggered. However the issue remains where, since the table has a new name, the grant/revoke action will be called on an invalid table name (i.e. the old table name). We now use the table id to track tables, therefore preventing UPDATE events to be triggered. This blocks #24308 This was originally PR #24874, however that had closed. @kaizencc requested changes, that I had added in here. This was originally PR #26558, however that had closed. @kaizencc requested changes, that I have already implemented previously. However, he did not review them. BREAKING CHANGE: the behavior of redshift tables has changed. UPDATE action will not be triggered on new table names and instead be triggered on table id changes. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent c48caac commit 7e4fdc7

File tree

30 files changed

+549
-431
lines changed

30 files changed

+549
-431
lines changed

packages/@aws-cdk/aws-redshift-alpha/lib/private/database-query-provider/privileges.ts

+21-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* eslint-disable-next-line import/no-unresolved */
22
import * as AWSLambda from 'aws-lambda';
3+
import { TablePrivilege, UserTablePrivilegesHandlerProps } from '../handler-props';
34
import { executeStatement } from './redshift-data';
45
import { ClusterProps } from './types';
56
import { makePhysicalId } from './util';
6-
import { TablePrivilege, UserTablePrivilegesHandlerProps } from '../handler-props';
77

88
export async function handler(props: UserTablePrivilegesHandlerProps & ClusterProps, event: AWSLambda.CloudFormationCustomResourceEvent) {
99
const username = props.username;
@@ -62,10 +62,26 @@ async function updatePrivileges(
6262
}
6363

6464
const oldTablePrivileges = oldResourceProperties.tablePrivileges;
65-
if (oldTablePrivileges !== tablePrivileges) {
66-
await revokePrivileges(username, oldTablePrivileges, clusterProps);
67-
await grantPrivileges(username, tablePrivileges, clusterProps);
68-
return { replace: false };
65+
const tablesToRevoke = oldTablePrivileges.filter(({ tableId, actions }) => (
66+
tablePrivileges.find(({ tableId: otherTableId, actions: otherActions }) => (
67+
tableId === otherTableId && actions.some(action => !otherActions.includes(action))
68+
))
69+
));
70+
if (tablesToRevoke.length > 0) {
71+
await revokePrivileges(username, tablesToRevoke, clusterProps);
72+
}
73+
74+
const tablesToGrant = tablePrivileges.filter(({ tableId, tableName, actions }) => {
75+
const tableAdded = !oldTablePrivileges.find(({ tableId: otherTableId, tableName: otherTableName }) => (
76+
tableId === otherTableId && tableName === otherTableName
77+
));
78+
const actionsAdded = oldTablePrivileges.find(({ tableId: otherTableId, actions: otherActions }) => (
79+
tableId === otherTableId && otherActions.some(action => !actions.includes(action))
80+
));
81+
return tableAdded || actionsAdded;
82+
});
83+
if (tablesToGrant.length > 0) {
84+
await grantPrivileges(username, tablesToGrant, clusterProps);
6985
}
7086

7187
return { replace: false };

packages/@aws-cdk/aws-redshift-alpha/lib/private/handler-props.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface TableHandlerProps {
2525
}
2626

2727
export interface TablePrivilege {
28+
readonly tableId: string;
2829
readonly tableName: string;
2930
readonly actions: string[];
3031
}

packages/@aws-cdk/aws-redshift-alpha/lib/private/privileges.ts

+19-12
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as cdk from 'aws-cdk-lib/core';
22
import { Construct } from 'constructs';
3-
import { DatabaseQuery } from './database-query';
4-
import { HandlerName } from './database-query-provider/handler-name';
5-
import { TablePrivilege as SerializedTablePrivilege, UserTablePrivilegesHandlerProps } from './handler-props';
63
import { DatabaseOptions } from '../database-options';
74
import { ITable, TableAction } from '../table';
85
import { IUser } from '../user';
6+
import { DatabaseQuery } from './database-query';
7+
import { HandlerName } from './database-query-provider/handler-name';
8+
import { TablePrivilege as SerializedTablePrivilege, UserTablePrivilegesHandlerProps } from './handler-props';
99

1010
/**
1111
* The Redshift table and action that make up a privilege that can be granted to a Redshift user.
@@ -63,23 +63,30 @@ export class UserTablePrivileges extends Construct {
6363
tablePrivileges: cdk.Lazy.any({
6464
produce: () => {
6565
const reducedPrivileges = this.privileges.reduce((privileges, { table, actions }) => {
66-
const tableName = table.tableName;
67-
if (!(tableName in privileges)) {
68-
privileges[tableName] = [];
66+
const tableId = table.node.id;
67+
if (!(tableId in privileges)) {
68+
privileges[tableId] = {
69+
tableName: table.tableName,
70+
actions: [],
71+
};
6972
}
70-
actions = actions.concat(privileges[tableName]);
73+
actions = actions.concat(privileges[tableId].actions);
7174
if (actions.includes(TableAction.ALL)) {
7275
actions = [TableAction.ALL];
7376
}
7477
if (actions.includes(TableAction.UPDATE) || actions.includes(TableAction.DELETE)) {
7578
actions.push(TableAction.SELECT);
7679
}
77-
privileges[tableName] = Array.from(new Set(actions));
80+
privileges[tableId] = {
81+
tableName: table.tableName,
82+
actions: Array.from(new Set(actions)),
83+
};
7884
return privileges;
79-
}, {} as { [key: string]: TableAction[] });
80-
const serializedPrivileges: SerializedTablePrivilege[] = Object.entries(reducedPrivileges).map(([tableName, actions]) => ({
81-
tableName: tableName,
82-
actions: actions.map(action => TableAction[action]),
85+
}, {} as { [key: string]: { tableName: string, actions: TableAction[] } });
86+
const serializedPrivileges: SerializedTablePrivilege[] = Object.entries(reducedPrivileges).map(([tableId, config]) => ({
87+
tableId,
88+
tableName: config.tableName,
89+
actions: config.actions.map(action => TableAction[action]),
8390
}));
8491
return serializedPrivileges;
8592
},

packages/@aws-cdk/aws-redshift-alpha/test/database-query-provider/privileges.test.ts

+84-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import type * as AWSLambda from 'aws-lambda';
33

44
const username = 'username';
55
const tableName = 'tableName';
6-
const tablePrivileges = [{ tableName, actions: ['INSERT', 'SELECT'] }];
6+
const tableId = 'tableId';
7+
const actions = ['INSERT', 'SELECT'];
8+
const tablePrivileges = [{ tableId, tableName, actions }];
79
const clusterName = 'clusterName';
810
const adminUserArn = 'adminUserArn';
911
const databaseName = 'databaseName';
@@ -147,22 +149,99 @@ describe('update', () => {
147149
}));
148150
});
149151

150-
test('does not replace when privileges change', async () => {
152+
test('does not replace when table name is changed', async () => {
151153
const newTableName = 'newTableName';
152-
const newTablePrivileges = [{ tableName: newTableName, actions: ['DROP'] }];
154+
const newTablePrivileges = [{ tableId, tableName: newTableName, actions }];
153155
const newResourceProperties = {
154156
...resourceProperties,
155157
tablePrivileges: newTablePrivileges,
156158
};
157159

160+
// Checking if the table resource has not been recreated
158161
await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({
159162
PhysicalResourceId: physicalResourceId,
160163
});
164+
// Upon a table name change, Redshift maintains the same table priviliges as before.
165+
// The name of the table has changed, a new table has not been created.
166+
// Therefore 'REVOKE' statements should not be used.
167+
expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({
168+
Sql: `REVOKE INSERT, SELECT ON ${newTableName} FROM ${username}`,
169+
}));
170+
// Likewise, here the table name has changed, so the current priviliges will still be intact.
171+
expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({
172+
Sql: expect.stringMatching(new RegExp(`.+ ON ${tableName} TO ${username}`)),
173+
}));
174+
});
175+
176+
test('does not replace when table actions are changed', async () => {
177+
const newTablePrivileges = [{ tableId, tableName, actions: ['DROP'] }];
178+
const newResourceProperties = {
179+
...resourceProperties,
180+
tablePrivileges: newTablePrivileges,
181+
};
182+
183+
// Checking if the table resource has not been recreated
184+
await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({
185+
PhysicalResourceId: physicalResourceId,
186+
});
187+
// Old actions are REVOKED, as they are not included in the list
161188
expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({
162189
Sql: `REVOKE INSERT, SELECT ON ${tableName} FROM ${username}`,
163190
}));
191+
// New actions are GRANTED, as they are included in the list
164192
expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({
165-
Sql: `GRANT DROP ON ${newTableName} TO ${username}`,
193+
Sql: `GRANT DROP ON ${tableName} TO ${username}`,
166194
}));
167195
});
168-
});
196+
197+
test('does not replace when table id is changed', async () => {
198+
const newTableId = 'newTableId';
199+
const newTablePrivileges = [{ tableId: newTableId, tableName, actions }];
200+
const newResourceProperties = {
201+
...resourceProperties,
202+
tablePrivileges: newTablePrivileges,
203+
};
204+
205+
// Checking if the table resource has not been recreated, we are not changing on table id either.
206+
// Due to the construct only needing to be changed on a new user, not a new table
207+
await expect(managePrivileges(newResourceProperties, event)).resolves.toMatchObject({
208+
PhysicalResourceId: physicalResourceId,
209+
});
210+
// Upon removal of the old table, the priviliges will also be revoked automatically,
211+
// as the table no longer exists.
212+
// Calling REVOKE statments on a non-existing table will throw errors by Redshift
213+
expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({
214+
Sql: expect.stringMatching(new RegExp(`REVOKE .+ ON ${tableName} FROM ${username}`)),
215+
}));
216+
// Adds the permissions onto the newly created table
217+
expect(mockExecuteStatement).toHaveBeenCalledWith(expect.objectContaining({
218+
Sql: expect.stringMatching(new RegExp(`GRANT .+ ON ${tableName} TO ${username}`)),
219+
}));
220+
});
221+
222+
test('does not replace when table id is appended', async () => {
223+
const newTablePrivileges = [{ tableId: 'newTableId', tableName, actions }];
224+
const newResourceProperties = {
225+
...resourceProperties,
226+
tablePrivileges: newTablePrivileges,
227+
};
228+
229+
const newEvent = {
230+
...event,
231+
OldResourceProperties: {
232+
...event.OldResourceProperties,
233+
tablePrivileges: [{ tableName, actions }],
234+
},
235+
};
236+
237+
// Checking if the table resource has not been recreated
238+
await expect(managePrivileges(newResourceProperties, newEvent)).resolves.toMatchObject({
239+
PhysicalResourceId: physicalResourceId,
240+
});
241+
// Upon initial deployment from non table id usage to table id usage,
242+
// permissions would not need to be granted/revoked, as the table should already exist
243+
expect(mockExecuteStatement).not.toHaveBeenCalledWith(expect.objectContaining({
244+
Sql: expect.stringMatching(new RegExp(`.+ ON ${tableName} FROM ${username}`)),
245+
}));
246+
});
247+
});

0 commit comments

Comments
 (0)