audit logic

This commit is contained in:
2026-01-26 20:39:23 -06:00
parent 196f7a53b3
commit 6b9f6810ab
10 changed files with 5033 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
-- ============================================================================
-- Audit Logs Migration
-- Add audit logging table to track user actions and system changes
-- ============================================================================
-- ============================================================================
-- ENUM TYPE: audit_action
-- ============================================================================
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'READ',
'EXPORT',
'BULK_UPLOAD',
'STATUS_CHANGE',
'PERMISSION_CHANGE'
);
-- ============================================================================
-- TABLE: audit_logs
-- ============================================================================
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- User information
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255) NOT NULL,
user_name VARCHAR(255) NOT NULL,
-- Action details
action audit_action NOT NULL,
table_name VARCHAR(100) NOT NULL,
record_id UUID,
-- Change tracking
old_values JSONB,
new_values JSONB,
description TEXT,
-- Request metadata
ip_address INET,
user_agent TEXT,
-- Status
success BOOLEAN NOT NULL DEFAULT TRUE,
error_message TEXT,
-- Timestamp
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- INDEXES
-- ============================================================================
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_table_name ON audit_logs(table_name);
CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_user_id_created_at ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_audit_logs_table_name_record_id ON audit_logs(table_name, record_id);
-- Index for JSON queries on old_values and new_values
CREATE INDEX idx_audit_logs_old_values ON audit_logs USING GIN (old_values);
CREATE INDEX idx_audit_logs_new_values ON audit_logs USING GIN (new_values);
-- ============================================================================
-- COMMENTS
-- ============================================================================
COMMENT ON TABLE audit_logs IS 'System audit log tracking all user actions and data changes';
COMMENT ON COLUMN audit_logs.user_id IS 'Reference to user who performed the action (nullable if user deleted)';
COMMENT ON COLUMN audit_logs.user_email IS 'Email snapshot at time of action';
COMMENT ON COLUMN audit_logs.user_name IS 'Name snapshot at time of action';
COMMENT ON COLUMN audit_logs.action IS 'Type of action performed';
COMMENT ON COLUMN audit_logs.table_name IS 'Database table affected by the action';
COMMENT ON COLUMN audit_logs.record_id IS 'ID of the specific record affected';
COMMENT ON COLUMN audit_logs.old_values IS 'JSON snapshot of values before change';
COMMENT ON COLUMN audit_logs.new_values IS 'JSON snapshot of values after change';
COMMENT ON COLUMN audit_logs.description IS 'Human-readable description of the action';
COMMENT ON COLUMN audit_logs.ip_address IS 'IP address of the user';
COMMENT ON COLUMN audit_logs.user_agent IS 'Browser/client user agent string';
COMMENT ON COLUMN audit_logs.success IS 'Whether the action completed successfully';
COMMENT ON COLUMN audit_logs.error_message IS 'Error message if action failed';
-- ============================================================================
-- HELPER FUNCTION: Get current user info from request context
-- ============================================================================
CREATE OR REPLACE FUNCTION get_current_user_info()
RETURNS TABLE (
user_id UUID,
user_email VARCHAR(255),
user_name VARCHAR(255)
) AS $$
BEGIN
-- This will be called from application code with current_setting
RETURN QUERY
SELECT
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
NULLIF(current_setting('app.current_user_email', true), ''),
NULLIF(current_setting('app.current_user_name', true), '');
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- HELPER FUNCTION: Log audit entry
-- ============================================================================
CREATE OR REPLACE FUNCTION log_audit(
p_user_id UUID,
p_user_email VARCHAR(255),
p_user_name VARCHAR(255),
p_action audit_action,
p_table_name VARCHAR(100),
p_record_id UUID DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL,
p_description TEXT DEFAULT NULL,
p_ip_address INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL,
p_success BOOLEAN DEFAULT TRUE,
p_error_message TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
BEGIN
INSERT INTO audit_logs (
user_id,
user_email,
user_name,
action,
table_name,
record_id,
old_values,
new_values,
description,
ip_address,
user_agent,
success,
error_message
) VALUES (
p_user_id,
p_user_email,
p_user_name,
p_action,
p_table_name,
p_record_id,
p_old_values,
p_new_values,
p_description,
p_ip_address,
p_user_agent,
p_success,
p_error_message
) RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- VIEW: audit_logs_summary
-- ============================================================================
CREATE OR REPLACE VIEW audit_logs_summary AS
SELECT
al.id,
al.user_email,
al.user_name,
al.action,
al.table_name,
al.record_id,
al.description,
al.success,
al.created_at,
al.ip_address,
-- User reference (may be null if user deleted)
u.id AS current_user_id,
u.is_active AS user_is_active
FROM audit_logs al
LEFT JOIN users u ON al.user_id = u.id
ORDER BY al.created_at DESC;
COMMENT ON VIEW audit_logs_summary IS 'Audit logs with user status information';
-- ============================================================================
-- VIEW: audit_statistics
-- ============================================================================
CREATE OR REPLACE VIEW audit_statistics AS
SELECT
DATE(created_at) AS date,
action,
table_name,
COUNT(*) AS action_count,
COUNT(DISTINCT user_id) AS unique_users,
SUM(CASE WHEN success THEN 1 ELSE 0 END) AS successful_actions,
SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) AS failed_actions
FROM audit_logs
GROUP BY DATE(created_at), action, table_name
ORDER BY date DESC, action_count DESC;
COMMENT ON VIEW audit_statistics IS 'Daily statistics of audit log actions';
-- ============================================================================
-- END OF MIGRATION
-- ============================================================================

View File

@@ -0,0 +1,213 @@
/**
* Audit Controller
* Handles HTTP requests for audit log operations
*/
import { Response } from 'express';
import { AuthenticatedRequest } from '../types';
import * as auditService from '../services/audit.service';
/**
* GET /audit-logs
* Get audit logs with filters and pagination (admin only)
*/
export async function getAuditLogs(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const {
userId,
action,
tableName,
recordId,
startDate,
endDate,
success,
page = '1',
limit = '50',
} = req.query;
const filters: auditService.AuditLogFilters = {
userId: userId as string,
action: action as auditService.AuditAction,
tableName: tableName as string,
recordId: recordId as string,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
success: success === 'true' ? true : success === 'false' ? false : undefined,
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
};
const result = await auditService.getAuditLogs(filters);
const totalPages = Math.ceil(result.total / filters.limit!);
const hasNextPage = filters.page! < totalPages;
const hasPreviousPage = filters.page! > 1;
res.status(200).json({
success: true,
message: 'Audit logs retrieved successfully',
data: result.logs,
pagination: {
page: filters.page,
limit: filters.limit,
total: result.total,
totalPages,
hasNextPage,
hasPreviousPage,
},
});
} catch (error: any) {
console.error('Error fetching audit logs:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit logs',
});
}
}
/**
* GET /audit-logs/:id
* Get a single audit log by ID (admin only)
*/
export async function getAuditLogById(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const log = await auditService.getAuditLogById(id);
if (!log) {
res.status(404).json({
success: false,
error: 'Audit log not found',
});
return;
}
res.status(200).json({
success: true,
message: 'Audit log retrieved successfully',
data: log,
});
} catch (error: any) {
console.error('Error fetching audit log:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit log',
});
}
}
/**
* GET /audit-logs/record/:tableName/:recordId
* Get audit logs for a specific record (admin only)
*/
export async function getAuditLogsForRecord(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, recordId } = req.params;
const logs = await auditService.getAuditLogsForRecord(tableName, recordId);
res.status(200).json({
success: true,
message: 'Audit logs retrieved successfully',
data: logs,
});
} catch (error: any) {
console.error('Error fetching audit logs for record:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit logs for record',
});
}
}
/**
* GET /audit-logs/statistics
* Get audit statistics (admin only)
*/
export async function getAuditStatistics(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { days = '30' } = req.query;
const daysNum = parseInt(days as string, 10);
const stats = await auditService.getAuditStatistics(daysNum);
res.status(200).json({
success: true,
message: 'Audit statistics retrieved successfully',
data: stats,
});
} catch (error: any) {
console.error('Error fetching audit statistics:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch audit statistics',
});
}
}
/**
* GET /audit-logs/my-activity
* Get current user's own audit logs
*/
export async function getMyActivity(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const user = req.user;
if (!user) {
res.status(401).json({
success: false,
error: 'User not authenticated',
});
return;
}
const { page = '1', limit = '50' } = req.query;
const filters: auditService.AuditLogFilters = {
userId: user.userId,
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
};
const result = await auditService.getAuditLogs(filters);
const totalPages = Math.ceil(result.total / filters.limit!);
const hasNextPage = filters.page! < totalPages;
const hasPreviousPage = filters.page! > 1;
res.status(200).json({
success: true,
message: 'Your activity logs retrieved successfully',
data: result.logs,
pagination: {
page: filters.page,
limit: filters.limit,
total: result.total,
totalPages,
hasNextPage,
hasPreviousPage,
},
});
} catch (error: any) {
console.error('Error fetching user activity:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch activity logs',
});
}
}

View File

@@ -0,0 +1,69 @@
/**
* Audit Routes
* Defines API endpoints for audit log operations
*/
import { Router } from 'express';
import * as auditController from '../controllers/audit.controller';
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
const router = Router();
/**
* GET /audit-logs
* Get all audit logs with filters (admin only)
* Query params: userId, action, tableName, recordId, startDate, endDate, success, page, limit
*/
router.get(
'/',
authenticateToken,
requireRole('ADMIN'),
auditController.getAuditLogs
);
/**
* GET /audit-logs/my-activity
* Get current user's own audit logs
* Query params: page, limit
*/
router.get(
'/my-activity',
authenticateToken,
auditController.getMyActivity
);
/**
* GET /audit-logs/statistics
* Get audit statistics (admin only)
* Query params: days
*/
router.get(
'/statistics',
authenticateToken,
requireRole('ADMIN'),
auditController.getAuditStatistics
);
/**
* GET /audit-logs/:id
* Get a single audit log by ID (admin only)
*/
router.get(
'/:id',
authenticateToken,
requireRole('ADMIN'),
auditController.getAuditLogById
);
/**
* GET /audit-logs/record/:tableName/:recordId
* Get audit logs for a specific record (admin only)
*/
router.get(
'/record/:tableName/:recordId',
authenticateToken,
requireRole('ADMIN'),
auditController.getAuditLogsForRecord
);
export default router;

View File

@@ -12,6 +12,7 @@ import roleRoutes from './role.routes';
import ttsRoutes from './tts.routes';
import readingRoutes from './reading.routes';
import bulkUploadRoutes from './bulk-upload.routes';
import auditRoutes from './audit.routes';
// Create main router
const router = Router();
@@ -130,4 +131,14 @@ router.use('/readings', readingRoutes);
*/
router.use('/bulk-upload', bulkUploadRoutes);
/**
* Audit routes:
* - GET /audit-logs - List all audit logs (admin only)
* - GET /audit-logs/my-activity - Get current user's activity
* - GET /audit-logs/statistics - Get audit statistics (admin only)
* - GET /audit-logs/:id - Get audit log by ID (admin only)
* - GET /audit-logs/record/:tableName/:recordId - Get logs for specific record (admin only)
*/
router.use('/audit-logs', auditRoutes);
export default router;

View File

@@ -0,0 +1,298 @@
/**
* Audit Service
* Handles logging of user actions and system changes
*/
import { query } from '../config/database';
import type { Request } from 'express';
export type AuditAction =
| 'CREATE'
| 'UPDATE'
| 'DELETE'
| 'LOGIN'
| 'LOGOUT'
| 'READ'
| 'EXPORT'
| 'BULK_UPLOAD'
| 'STATUS_CHANGE'
| 'PERMISSION_CHANGE';
export interface AuditLogData {
userId: string;
userEmail: string;
userName: string;
action: AuditAction;
tableName: string;
recordId?: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
description?: string;
ipAddress?: string;
userAgent?: string;
success?: boolean;
errorMessage?: string;
}
export interface AuditLog {
id: string;
user_id: string | null;
user_email: string;
user_name: string;
action: AuditAction;
table_name: string;
record_id: string | null;
old_values: Record<string, any> | null;
new_values: Record<string, any> | null;
description: string | null;
ip_address: string | null;
user_agent: string | null;
success: boolean;
error_message: string | null;
created_at: Date;
}
export interface AuditLogFilters {
userId?: string;
action?: AuditAction;
tableName?: string;
recordId?: string;
startDate?: Date;
endDate?: Date;
success?: boolean;
page?: number;
limit?: number;
}
/**
* Create an audit log entry
*/
export async function createAuditLog(data: AuditLogData): Promise<string> {
const sql = `
SELECT log_audit(
$1::UUID,
$2,
$3,
$4::audit_action,
$5,
$6::UUID,
$7::JSONB,
$8::JSONB,
$9,
$10::INET,
$11,
$12,
$13
) as log_id
`;
const result = await query(sql, [
data.userId,
data.userEmail,
data.userName,
data.action,
data.tableName,
data.recordId || null,
data.oldValues ? JSON.stringify(data.oldValues) : null,
data.newValues ? JSON.stringify(data.newValues) : null,
data.description || null,
data.ipAddress || null,
data.userAgent || null,
data.success !== undefined ? data.success : true,
data.errorMessage || null,
]);
return result.rows[0].log_id;
}
/**
* Get audit logs with filters and pagination
*/
export async function getAuditLogs(
filters: AuditLogFilters = {}
): Promise<{ logs: AuditLog[]; total: number }> {
const {
userId,
action,
tableName,
recordId,
startDate,
endDate,
success,
page = 1,
limit = 50,
} = filters;
const offset = (page - 1) * limit;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (userId) {
conditions.push(`user_id = $${paramIndex++}`);
params.push(userId);
}
if (action) {
conditions.push(`action = $${paramIndex++}`);
params.push(action);
}
if (tableName) {
conditions.push(`table_name = $${paramIndex++}`);
params.push(tableName);
}
if (recordId) {
conditions.push(`record_id = $${paramIndex++}`);
params.push(recordId);
}
if (startDate) {
conditions.push(`created_at >= $${paramIndex++}`);
params.push(startDate);
}
if (endDate) {
conditions.push(`created_at <= $${paramIndex++}`);
params.push(endDate);
}
if (success !== undefined) {
conditions.push(`success = $${paramIndex++}`);
params.push(success);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countSql = `
SELECT COUNT(*) as total
FROM audit_logs
${whereClause}
`;
const countResult = await query(countSql, params);
const total = parseInt(countResult.rows[0].total, 10);
// Get paginated results
const dataSql = `
SELECT *
FROM audit_logs
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`;
const dataResult = await query(dataSql, [...params, limit, offset]);
return {
logs: dataResult.rows as AuditLog[],
total,
};
}
/**
* Get audit log by ID
*/
export async function getAuditLogById(id: string): Promise<AuditLog | null> {
const sql = `
SELECT *
FROM audit_logs
WHERE id = $1
`;
const result = await query(sql, [id]);
return (result.rows[0] as AuditLog) || null;
}
/**
* Get audit logs for a specific record
*/
export async function getAuditLogsForRecord(
tableName: string,
recordId: string
): Promise<AuditLog[]> {
const sql = `
SELECT *
FROM audit_logs
WHERE table_name = $1 AND record_id = $2
ORDER BY created_at DESC
`;
const result = await query(sql, [tableName, recordId]);
return result.rows as AuditLog[];
}
/**
* Get audit statistics
*/
export async function getAuditStatistics(days: number = 30): Promise<any[]> {
const sql = `
SELECT *
FROM audit_statistics
WHERE date >= CURRENT_DATE - INTERVAL '${days} days'
ORDER BY date DESC, action_count DESC
`;
const result = await query(sql);
return result.rows;
}
/**
* Helper: Extract IP address from request
*/
export function getIpAddress(req: Request): string | undefined {
return (
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
(req.headers['x-real-ip'] as string) ||
req.socket.remoteAddress ||
undefined
);
}
/**
* Helper: Extract user agent from request
*/
export function getUserAgent(req: Request): string | undefined {
return req.headers['user-agent'];
}
/**
* Helper: Create audit log from authenticated request
*/
export async function logAction(
req: any, // AuthenticatedRequest
action: AuditAction,
tableName: string,
options: {
recordId?: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
description?: string;
success?: boolean;
errorMessage?: string;
} = {}
): Promise<string> {
const user = req.user;
if (!user) {
throw new Error('User not found in request for audit log');
}
return createAuditLog({
userId: user.userId,
userEmail: user.email,
userName: user.roleName || user.email,
action,
tableName,
recordId: options.recordId,
oldValues: options.oldValues,
newValues: options.newValues,
description: options.description,
ipAddress: getIpAddress(req),
userAgent: getUserAgent(req),
success: options.success,
errorMessage: options.errorMessage,
});
}