diff --git a/api/migrations/20260212165353-audit-log.js b/api/migrations/20260212165353-audit-log.js new file mode 100644 index 0000000..951ad5a --- /dev/null +++ b/api/migrations/20260212165353-audit-log.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20260212165353-audit-log-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20260212165353-audit-log-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/api/migrations/sqls/20260212165353-audit-log-down.sql b/api/migrations/sqls/20260212165353-audit-log-down.sql new file mode 100644 index 0000000..44f074e --- /dev/null +++ b/api/migrations/sqls/20260212165353-audit-log-down.sql @@ -0,0 +1 @@ +/* Replace with your SQL commands */ \ No newline at end of file diff --git a/api/migrations/sqls/20260212165353-audit-log-up.sql b/api/migrations/sqls/20260212165353-audit-log-up.sql new file mode 100644 index 0000000..d375024 --- /dev/null +++ b/api/migrations/sqls/20260212165353-audit-log-up.sql @@ -0,0 +1,17 @@ +CREATE TABLE audit_log ( + id INT PRIMARY KEY AUTO_INCREMENT, + -- "area.action" (e.g., 'calendarEvent.create', 'member.update_rank') + action_type VARCHAR(100) NOT NULL, + -- The JSON blob containing detailed information + payload JSON DEFAULT NULL, + -- Identifying the actor + created_by INT, + -- The ID of the resource being acted upon + target_id INT DEFAULT NULL, + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_created_by FOREIGN KEY (created_by) REFERENCES members(id) ON DELETE + SET NULL, + INDEX idx_action (action_type), + INDEX idx_target (target_id) +); \ No newline at end of file diff --git a/api/src/services/logging/auditLog.ts b/api/src/services/logging/auditLog.ts new file mode 100644 index 0000000..f6e5adc --- /dev/null +++ b/api/src/services/logging/auditLog.ts @@ -0,0 +1,50 @@ +import pool from "../../db"; +import { logger } from "./logger"; + +export type AuditArea = 'member' | 'calendar' | 'unit' | 'auth' | 'admin' | 'application'; + +export interface AuditContext { + actorId: number; // The person doing the action (created_by) + targetId?: number; // The ID of the thing being changed (target_id) +} + +class AuditLogger { + async record( + area: AuditArea, + action: string, + context: AuditContext, + data: Record = {} // Already optional with default {} + ) { + const actionType = `${area}.${action}`; + + try { + await pool.query( + `INSERT INTO audit_log (action_type, payload, target_id, created_by) + VALUES (?, ?, ?, ?)`, // Fixed: removed extra comma/placeholder + [ + actionType, + JSON.stringify(data), + context.targetId || null, + context.actorId, + ] + ); + } catch (err) { + logger.error('audit', `AUDIT_FAILURE: Failed to log ${actionType}`, { error: err }); + } + } + + // Making data optional using '?' and default parameter + member(action: 'update_rank' | 'status_change' | 'create', context: AuditContext, data: any = {}) { + return this.record('member', action, context, data); + } + + calendar(action: 'event_signup' | 'event_create' | 'attendance', context: AuditContext, data: any = {}) { + return this.record('calendar', action, context, data); + } + + application(action: 'created' | 'approved' | 'denied' | 'restarted', context: AuditContext, data: any = {}) { + return this.record('application', action, context, data); + } +} + +export const audit = new AuditLogger(); \ No newline at end of file diff --git a/api/src/services/logging/logger.ts b/api/src/services/logging/logger.ts index cc4c39e..73adba2 100644 --- a/api/src/services/logging/logger.ts +++ b/api/src/services/logging/logger.ts @@ -1,6 +1,6 @@ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogDepth = 'normal' | 'verbose' | 'profiling'; -export type LogType = 'http' | 'app' | 'auth' | 'profiling'; +export type LogType = 'http' | 'app' | 'auth' | 'profiling' | 'audit'; export interface LogHeader { timestamp: string;