-
Notifications
You must be signed in to change notification settings - Fork 2
[POC] DB Changes Audit Trail #562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
79478fa
ba5006a
62c75d0
ca8fc8c
ef55dfb
3544884
c93c6b3
bed100a
835803a
af3ed6e
3d7428d
5ec713d
306c962
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import { | |
| TransactWriteCommand, | ||
| BatchWriteCommand | ||
| } from "@aws-sdk/lib-dynamodb"; | ||
| import { AUDIT_TABLE } from "../constants/tables.js"; | ||
|
|
||
| export default { | ||
| // DATABASE HELPER FUNCTIONS | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
@@ -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 } | ||
| })); | ||
|
|
@@ -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 = { | ||
|
|
@@ -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)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
@@ -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); | ||
| } | ||
| }, | ||
|
|
||
| }; | ||
| 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); | ||
| }; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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