Skip to content
Draft
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
51 changes: 51 additions & 0 deletions server/server/audit/audit_logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const Logger = require("../logger");
const DBHandler = require("../database/db");
const DB_CONFIG = require("../database/db_config");
const {
getActor,
actorLabel,
summarizeChangedFields,
} = require("../util/format_utils");

const db = new DBHandler();

const ACTION_TYPES = {
CREATE: "CREATED",
UPDATE: "UPDATED",
DEACTIVATE: "DEACTIVATED",
REACTIVATE: "REACTIVATED",
};

async function record(
req,
{ actionType, entityType, entityId, message, details },
) {
const { system_id, mock_id } = getActor(req);

const insertQuery = `
INSERT INTO ${DB_CONFIG.tableNames.audit_log}
(system_id, mock_id, action_type, entity_type, entity_id, message, details_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;

const params = [
system_id,
mock_id,
actionType,
entityType,
entityId === undefined || entityId === null ? null : String(entityId),
message,
details === undefined ? null : JSON.stringify(details),
];

try {
await db.query(insertQuery, params);
} catch (err) {
Logger.error(
`Failed to write audit log entry for ${system_id || "unknown user"} ` +
`(${actionType} ${entityType}${entityId ? ":" + entityId : ""}): ${err.message}`,
);
}
}

module.exports = { record, actorLabel, summarizeChangedFields, ACTION_TYPES };
1 change: 1 addition & 0 deletions server/server/database/db_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module.exports = {
project_coaches: "project_coaches",
sponsors: "sponsors",
error_log: "error_log",
audit_log: "audit_log",
},
senior_project_proposal_keys: {
title: "Title",
Expand Down
12 changes: 12 additions & 0 deletions server/server/database/table_sql/audit_log.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE audit_log (
audit_log_id INTEGER PRIMARY KEY AUTOINCREMENT,
audit_datetime DATETIME DEFAULT CURRENT_TIMESTAMP,
system_id TEXT,
mock_id TEXT,
action_type TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
message TEXT NOT NULL,
details_json TEXT,
FOREIGN KEY (system_id) REFERENCES users(system_id)
);
3 changes: 2 additions & 1 deletion server/server/database/table_sql/create_all_tables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
.read table_sql/users.sql
.read table_sql/sponsor_notes.sql
.read table_sql/page_html.sql
Comment thread
yyi5708 marked this conversation as resolved.
.read table_sql/error_log.sql
.read table_sql/error_log.sql
.read table_sql/audit_log.sql
62 changes: 62 additions & 0 deletions server/server/database/test_data/audit_log_dummy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
INSERT INTO
audit_log (
audit_datetime,
system_id,
mock_id,
action_type,
entity_type,
entity_id,
message,
details_json
)
VALUES
(
DATETIME(DATE('now', '-6 days'), '09:15:00'),
'glados',
NULL,
'CREATED',
'action',
'45',
'Admin Account (glados) created action 45 (Team Name Submission)',
'{"semester":"2","action_title":"Team Name Submission","action_target":"team","start_date":"2026-01-20","due_date":"2026-02-01"}'
),
(
DATETIME(DATE('now', '-5 days'), '13:42:10'),
'glados',
NULL,
'UPDATED',
'action',
'45',
'Admin Account (glados) updated action 45 (Team Name Submission) — Due Date: "2026-02-01" → "2026-02-05"',
'{"due_date":["2026-02-01","2026-02-05"]}'
),
(
DATETIME(DATE('now', '-4 days'), '17:10:44'),
'glados',
NULL,
'DEACTIVATED',
'action',
'45',
'Admin Account (glados) deactivated action 45 (Team Name Submission)',
'{}'
),
(
DATETIME(DATE('now', '-3 days'), '08:05:22'),
'glados',
NULL,
'REACTIVATED',
'action',
'45',
'Admin Account (glados) reactivated action 45 (Team Name Submission)',
'{}'
),
(
DATETIME(DATE('now', '-2 days'), '11:30:00'),
'glados',
NULL,
'UPDATED',
'project',
'1_groweasy',
'Admin Account (glados) updated project 1_groweasy (GrowEasy) — Contact Email: "rachel.thompson@mail.com" → "rachel.thompson@mail.edu"',
'{"contact_email":["rachel.thompson@mail.com","rachel.thompson@mail.edu"]}'
);
83 changes: 83 additions & 0 deletions server/server/routing/db_routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const fileSizeParser = require("filesize-parser");
const he = require("he");
const { convert } = require("html-to-text");
const redeployDatabase = require("../../db_setup");
const AuditLog = require("../audit/audit_logger");

function humanFileSize(bytes, si = false, dp = 1) {
const thresh = si ? 1000 : 1024;
Expand Down Expand Up @@ -90,6 +91,76 @@ const ACTION_TARGETS = {
PEER_EVALUATION: "peer_evaluation",
};

function recordProjectEditAudit(req, body) {
let changedFields = {};
try {
changedFields = body.changed_fields ? JSON.parse(body.changed_fields) : {};
} catch (err) {
changedFields = {};
}

const changeSummary = AuditLog.summarizeChangedFields(changedFields);
const baseMessage = `${AuditLog.actorLabel(req)} updated project ${body.project_id} (${body.title})`;

return AuditLog.record(req, {
actionType: AuditLog.ACTION_TYPES.UPDATE,
entityType: "project",
entityId: body.project_id,
message: changeSummary ? `${baseMessage} — ${changeSummary}` : baseMessage,
details: changedFields,
});
}

function recordActionEditAudit(req, body) {
let changedFields = {};
try {
changedFields = body.changed_fields ? JSON.parse(body.changed_fields) : {};
} catch (err) {
changedFields = {};
}

let auditActionType = AuditLog.ACTION_TYPES.UPDATE;
let auditVerb = "updated";

if ("date_deleted" in changedFields) {
const [beforeRaw, afterRaw] = changedFields.date_deleted;
const isEmpty = (v) => v === "" || v === undefined || v === null;
const wasActive = isEmpty(beforeRaw);
const isActiveNow = isEmpty(afterRaw);

if (wasActive && !isActiveNow) {
auditActionType = AuditLog.ACTION_TYPES.DEACTIVATE;
auditVerb = "deactivated";
} else if (!wasActive && isActiveNow) {
auditActionType = AuditLog.ACTION_TYPES.REACTIVATE;
auditVerb = "reactivated";
}

delete changedFields.date_deleted;
}

const changeSummary = AuditLog.summarizeChangedFields(changedFields);
const baseMessage = `${AuditLog.actorLabel(req)} ${auditVerb} action ${body.action_id} (${body.action_title})`;

return AuditLog.record(req, {
actionType: auditActionType,
entityType: "action",
entityId: body.action_id,
message: changeSummary ? `${baseMessage} — ${changeSummary}` : baseMessage,
details: changedFields,
});
}

function recordActionCreateAudit(req, body, newActionId) {
return AuditLog.record(req, {
actionType: AuditLog.ACTION_TYPES.CREATE,
entityType: "action",
entityId: newActionId,
message: `${AuditLog.actorLabel(req)} created action ${newActionId} (${body.action_title})`,
details: body,
});
}

// Routes
module.exports = (db) => {
/**
Expand Down Expand Up @@ -1730,6 +1801,7 @@ module.exports = (db) => {
db.query(deleteCoachesSQL),
])
.then((values) => {
recordProjectEditAudit(req, body);
return res.sendStatus(200);
})
.catch((err) => {
Expand Down Expand Up @@ -3404,6 +3476,7 @@ module.exports = (db) => {

db.query(updateQuery, params)
.then(() => {
recordActionEditAudit(req, body);
return res.status(200).send();
})
.catch((err) => {
Expand Down Expand Up @@ -3966,6 +4039,16 @@ module.exports = (db) => {

db.query(updateQuery, params)
.then(() => {
return db.query(
`SELECT action_id FROM actions
WHERE semester = ? AND action_title = ? AND start_date = ? AND due_date = ?
ORDER BY action_id DESC LIMIT 1`,
[body.semester, body.action_title, body.start_date, body.due_date],
);
})
.then((rows) => {
const newActionId = rows && rows[0] ? rows[0].action_id : null;
recordActionCreateAudit(req, body, newActionId);
return res.status(200).send();
})
.catch((err) => {
Expand Down
41 changes: 41 additions & 0 deletions server/server/util/format_utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
function getActor(req) {
const system_id = req.user ? req.user.system_id : null;
const mock_id = req.user && req.user.mock ? req.user.mock.system_id : null;
return { system_id, mock_id };
}

function actorLabel(req) {
if (!req.user) return "Unknown user";
const name = [req.user.fname, req.user.lname].filter(Boolean).join(" ");
return name ? `${name} (${req.user.system_id})` : req.user.system_id;
}

function humanizeFieldName(field) {
return field.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

function displayValue(value) {
if (value === null || value === undefined || value === "") return "(empty)";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}

function summarizeChangedFields(changedFields, excludeFields = []) {
if (!changedFields || typeof changedFields !== "object") return "";

return Object.keys(changedFields)
.filter((field) => !excludeFields.includes(field))
.map((field) => {
const [before, after] = changedFields[field];
return `${humanizeFieldName(field)}: "${displayValue(before)}" → "${displayValue(after)}"`;
})
.join("; ");
}

module.exports = {
getActor,
actorLabel,
humanizeFieldName,
displayValue,
summarizeChangedFields,
};