diff --git a/server/server/audit/audit_logger.js b/server/server/audit/audit_logger.js new file mode 100644 index 00000000..7fc2b4d1 --- /dev/null +++ b/server/server/audit/audit_logger.js @@ -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 }; diff --git a/server/server/database/db_config.js b/server/server/database/db_config.js index 35084b61..694a58a5 100644 --- a/server/server/database/db_config.js +++ b/server/server/database/db_config.js @@ -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", diff --git a/server/server/database/table_sql/audit_log.sql b/server/server/database/table_sql/audit_log.sql new file mode 100644 index 00000000..5cf393d3 --- /dev/null +++ b/server/server/database/table_sql/audit_log.sql @@ -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) +); diff --git a/server/server/database/table_sql/create_all_tables.sql b/server/server/database/table_sql/create_all_tables.sql index 6aa28bbe..aeb9a297 100644 --- a/server/server/database/table_sql/create_all_tables.sql +++ b/server/server/database/table_sql/create_all_tables.sql @@ -9,4 +9,5 @@ .read table_sql/users.sql .read table_sql/sponsor_notes.sql .read table_sql/page_html.sql -.read table_sql/error_log.sql \ No newline at end of file +.read table_sql/error_log.sql +.read table_sql/audit_log.sql diff --git a/server/server/database/test_data/audit_log_dummy.sql b/server/server/database/test_data/audit_log_dummy.sql new file mode 100644 index 00000000..c6fb3a3c --- /dev/null +++ b/server/server/database/test_data/audit_log_dummy.sql @@ -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"]}' + ); diff --git a/server/server/routing/db_routes.js b/server/server/routing/db_routes.js index 1f0cdd86..5e617cd4 100644 --- a/server/server/routing/db_routes.js +++ b/server/server/routing/db_routes.js @@ -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; @@ -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) => { /** @@ -1730,6 +1801,7 @@ module.exports = (db) => { db.query(deleteCoachesSQL), ]) .then((values) => { + recordProjectEditAudit(req, body); return res.sendStatus(200); }) .catch((err) => { @@ -3404,6 +3476,7 @@ module.exports = (db) => { db.query(updateQuery, params) .then(() => { + recordActionEditAudit(req, body); return res.status(200).send(); }) .catch((err) => { @@ -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) => { diff --git a/server/server/util/format_utils.js b/server/server/util/format_utils.js new file mode 100644 index 00000000..3ac30064 --- /dev/null +++ b/server/server/util/format_utils.js @@ -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, +};