Skip to content
1 change: 1 addition & 0 deletions constants/tables.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ export const QRS_TABLE = "biztechQRs";
export const TEAMS_TABLE = "biztechTeams";
export const QR_SCANS_RECORD = "biztechQRScans";
export const PROFILES_TABLE = "biztechProfiles";
export const AUDIT_TABLE = "biztechAudit";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

j make sure to set up these tables on cloud before we merge

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sgsg, I might have to modify the sort key though
timestamp#email#record_id, since if we batch delete with the same timestamp we have a non-unique PKs now so they might get overwritten


export const IMMUTABLE_USER_PROPS = ["admin"]; // make sure you check all calls to /user's patch in the frontend if you add to this list
150 changes: 145 additions & 5 deletions lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TransactWriteCommand,
BatchWriteCommand
} from "@aws-sdk/lib-dynamodb";
import { AUDIT_TABLE } from "../constants/tables.js";

export default {
// DATABASE HELPER FUNCTIONS
Expand Down Expand Up @@ -78,7 +79,7 @@ export default {

// DATABASE INTERACTIONS

create: async function (item, table) {
create: async function (item, table, email = null) {
try {
const params = {
Item: item,
Expand All @@ -88,6 +89,10 @@ export default {

const command = new PutCommand(params);
const res = await docClient.send(command);

if (email) {
await this.logChange(table, item.id, email, "CREATE");
}
return res;
} catch (err) {
const errorResponse = this.dynamoErrorResponse(err);
Expand Down Expand Up @@ -172,7 +177,7 @@ export default {
return docClient.send(command);
},

batchDelete: async function (items, tableName) {
batchDelete: async function (items, tableName, email = null) {
const deleteRequests = items.map((key) => ({
DeleteRequest: { Key: key }
}));
Expand All @@ -184,7 +189,14 @@ export default {
};

const command = new BatchWriteCommand(batchRequestParams);
return docClient.send(command);

const res = await docClient.send(command); // this should succeed first

if (email) {
await this.logChangeBatch(tableName, items, email, "DELETE");
}

return res;
},

deleteOne: async function (id, table, extraKeys = {
Expand Down Expand Up @@ -241,17 +253,80 @@ export default {
}
},

updateDBCustom: async function (params) {
updateDBCustom: async function (params, before = null, after = null, email = null) {
try {
const command = new UpdateCommand(params);
const res = await docClient.send(command);

if (email) {
await this.logChange(params.TableName, params.Key.id, email, "UPDATE", this.calculateDelta(before, after));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems fairly computationally expensive (or at least will take up more compute in a serverless function call. I'll comments below on certain things.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is indeed expensive, and will be 2 DB reads instead of 1, which is also my rationale of making it optional (if you purposely want to log the change, you must include authorizer email in the handler).

}
return res;
} catch (err) {
const errorResponse = this.dynamoErrorResponse(err);
throw errorResponse;
}
},

normalize: function (obj) { // normalize the objects, make sure keys are in same order
return Object.keys(obj).sort().reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {});
},

deepEqual: function (a, b) {
if (a === b) return true;

if (typeof a !== typeof b) return false;

// for arrays (like registration questions) make sure order doesn't matter
// can also refer to https://stackoverflow.com/questions/47666515/comparing-arrays-in-javascript-where-order-doesnt-matter
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;

// 1. Keys have to be in the same order
// 2. Sort based on stringified value to ensure consistency

const sortedA = [...a].map(this.normalize).sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));
const sortedB = [...b].map(this.normalize).sort((x, y) => JSON.stringify(x).localeCompare(JSON.stringify(y)));

return sortedA.every((val, i) => this.deepEqual(val, sortedB[i]));
}

if (a && b && typeof a === "object") {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every(k => this.deepEqual(a[k], b[k]));
}

return false;
},

calculateDelta: function (before, after) {
const changes = {};
const allKeys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);

for (const key of allKeys) {
if (key === "updatedAt") continue;

// in before but not in after (no change)
if (!after.hasOwnProperty(key)) continue;

const beforeVal = before[key];
const afterVal = after[key];

if (!this.deepEqual(beforeVal, afterVal)) {
changes[key] = {
before: before[key] || "", // not present before
after: after[key]
};
}
}
return changes;
},

put: async function (obj, table, createNew) {
let conditionExpression = "attribute_exists(id)";
if (createNew) {
Expand Down Expand Up @@ -372,5 +447,70 @@ export default {
const errorResponse = this.dynamoErrorResponse(err);
throw errorResponse;
}
}
},

logChange: async function (tableName, recordId, email, changeType, delta = null) {
try {
const timestamp = new Date().toISOString();

let item = {
table_name: tableName,
["timestamp#email#record_id"]: `${timestamp}#${email}#${recordId}`,
record_id: recordId,
email,
change_type: changeType, // CREATE | UPDATE | DELETE
timestamp
};

if (delta) {
item = {
...item,
delta
};
}

const params = {
TableName: AUDIT_TABLE + (process.env.ENVIRONMENT || ""),
Item: item
};

const command = new PutCommand(params);
await docClient.send(command);
} catch (err) {
throw this.dynamoErrorResponse(err);
}
},

// only supports CREATE and DELETE
logChangeBatch: async function (tableName, records, email, changeType) {
try {
const baseTimestamp = Date.now();
const table = AUDIT_TABLE + (process.env.ENVIRONMENT || "");

const putRequests = records.map(record => ({
PutRequest: {
Item: {
table_name: tableName,
["timestamp#email#record_id"]: `${baseTimestamp}#${email}#${record.id}`,
record_id: record.id,
email,
change_type: changeType,
timestamp: new Date(baseTimestamp).toISOString()
},
},
}));

const params = {
RequestItems: {
[table]: putRequests,
},
};

const command = new BatchWriteCommand(params);
return await docClient.send(command);
} catch (err) {
throw this.dynamoErrorResponse(err);
}
},

};
4 changes: 1 addition & 3 deletions lib/docClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ const destinationAWSConfig = {
}
};

const client = process.env.NODE_ENV === "development"
? new DynamoDBClient(destinationAWSConfig)
: new DynamoDBClient();
const client = new DynamoDBClient(destinationAWSConfig);

const docClient = DynamoDBDocumentClient.from(client);

Expand Down
12 changes: 12 additions & 0 deletions services/audit/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import db from "../../lib/db.js";
import { AUDIT_TABLE } from "../../constants/tables.js";
import helpers from "../../lib/handlerHelpers.js";

export const getAuditLogs = async () => {
const result = await db.scan(AUDIT_TABLE, {}, null);
const sortedLogs = result.sort((a, b) =>
new Date(b.timestamp) - new Date(a.timestamp)
);

return helpers.createResponse(200, sortedLogs);
};
Loading
Loading