Audit table with better data

This commit is contained in:
2026-01-28 13:28:05 -06:00
parent 936471542a
commit 13cc4528ff
5 changed files with 300 additions and 89 deletions

View File

@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware';
import type { AuthenticatedRequest } from '../types';
import * as authService from '../services/auth.service';
import { LoginInput, RefreshInput } from '../validators/auth.validator';
import { createAuditLog, getIpAddress, getUserAgent } from '../services/audit.service';
@@ -106,7 +106,7 @@ export async function refresh(req: Request, res: Response): Promise<void> {
*/
export async function logout(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' });
@@ -121,12 +121,12 @@ export async function logout(req: AuthenticatedRequest, res: Response): Promise<
if (req.user) {
createAuditLog({
userId: req.user.id,
userId: req.user.userId,
userEmail: req.user.email,
userName: req.user.role || req.user.email,
userName: req.user.roleName || req.user.email,
action: 'LOGOUT',
tableName: 'users',
recordId: req.user.id,
recordId: req.user.userId,
description: `User logged out`,
ipAddress: getIpAddress(req),
userAgent: getUserAgent(req),
@@ -150,7 +150,7 @@ export async function logout(req: AuthenticatedRequest, res: Response): Promise<
*/
export async function getMe(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.id;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' });

View File

@@ -18,18 +18,141 @@ function methodToAction(method: string): AuditAction {
}
}
function extractTableName(path: string): string {
const segments = path.replace(/^\/api\//, '').split('/');
let tableName = segments[0] || 'unknown';
function extractSection(path: string): string {
const cleanPath = path.replace(/^\/api\//, '').replace(/^\//, '');
const segments = cleanPath.split('/').filter(s => s.length > 0);
const tableMapping: Record<string, string> = {
if (segments.length === 0) return 'general';
const sectionMapping: Record<string, string> = {
'auth': 'authentication',
'me': 'profile',
'users': 'user-management',
'roles': 'role-management',
'meters': 'meters',
'concentrators': 'concentrators',
'gateways': 'gateways',
'devices': 'devices',
'projects': 'projects',
'readings': 'readings',
'bulk-upload': 'bulk-operations',
'audit-logs': 'audit',
'webhooks': 'webhooks',
};
return sectionMapping[segments[0]] || segments[0];
}
function extractTableName(path: string): string {
const cleanPath = path.replace(/^\/api\//, '').replace(/^\//, '');
const segments = cleanPath.split('/').filter(s => s.length > 0);
if (segments.length === 0) {
return 'unknown';
}
const baseTableMapping: Record<string, string> = {
'auth': 'users',
'me': 'users',
'users': 'users',
'roles': 'roles',
'meters': 'meters',
'concentrators': 'concentrators',
'gateways': 'gateways',
'devices': 'devices',
'projects': 'projects',
'readings': 'readings',
'webhooks': 'webhooks',
'bulk-upload': 'bulk_operations',
'audit-logs': 'audit_logs',
};
return tableMapping[tableName] || tableName;
const operations = new Set([
'password',
'stats',
'health',
'template',
'summary',
'statistics',
'my-activity',
'record',
'refresh',
'logout',
'uplink',
'join',
'ack',
'dev-eui',
]);
const nestedResourceMapping: Record<string, string> = {
'meters/readings': 'readings',
'concentrators/meters': 'meters',
'gateways/devices': 'devices',
'projects/meters': 'meters',
'projects/concentrators': 'concentrators',
};
const firstSegment = segments[0];
const baseTable = baseTableMapping[firstSegment] || firstSegment;
if (segments.length === 1) {
return baseTable;
}
const secondSegment = segments[1];
const thirdSegment = segments[2];
if (segments.length >= 3 && isUUID(secondSegment)) {
const nestedKey = `${firstSegment}/${thirdSegment}`;
if (nestedResourceMapping[nestedKey]) {
return nestedResourceMapping[nestedKey];
}
if (operations.has(thirdSegment)) {
return baseTable;
}
if (baseTableMapping[thirdSegment] || isPluralResourceName(thirdSegment)) {
return baseTableMapping[thirdSegment] || thirdSegment;
}
return baseTable;
}
if (firstSegment === 'bulk-upload' && secondSegment) {
return baseTableMapping[secondSegment] || secondSegment;
}
if (firstSegment === 'audit-logs') {
if (secondSegment === 'record' && segments.length >= 4) {
return segments[3];
}
return 'audit_logs';
}
if (firstSegment === 'auth') {
return 'users';
}
if (firstSegment === 'devices' && secondSegment === 'dev-eui') {
return 'devices';
}
if (segments.length === 2 && isUUID(secondSegment)) {
return baseTable;
}
return baseTable;
}
function isUUID(str: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str) || /^\d+$/.test(str);
}
function isPluralResourceName(str: string): boolean {
return str.endsWith('s') || str.endsWith('ies') || str.includes('-');
}
function extractRecordId(req: Request): string | undefined {
@@ -54,74 +177,157 @@ function shouldExclude(path: string): boolean {
return EXCLUDED_PATHS.some(excluded => path.startsWith(excluded));
}
function generateDescription(
fullPath: string,
action: AuditAction,
tableName: string,
recordId?: string,
section?: string
): string {
const actionDescriptions: Record<AuditAction, string> = {
'CREATE': 'Created',
'UPDATE': 'Updated',
'DELETE': 'Deleted',
'READ': 'Viewed',
'LOGIN': 'Logged in',
'LOGOUT': 'Logged out',
'EXPORT': 'Exported',
'BULK_UPLOAD': 'Bulk uploaded',
'STATUS_CHANGE': 'Changed status of',
'PERMISSION_CHANGE': 'Changed permissions for',
};
const tableLabels: Record<string, string> = {
'users': 'user',
'roles': 'role',
'meters': 'meter',
'concentrators': 'concentrator',
'gateways': 'gateway',
'devices': 'device',
'projects': 'project',
'readings': 'reading',
'bulk_operations': 'bulk operation',
};
const actionLabel = actionDescriptions[action] || action;
const tableLabel = tableLabels[tableName] || tableName;
if (fullPath === '/api/me' || fullPath === '/me' || fullPath.endsWith('/auth/me')) {
return 'Viewed own profile';
}
if (fullPath.includes('/users') && action === 'READ' && !recordId) {
return 'Viewed users list';
}
if (fullPath.includes('/meters') && action === 'READ' && !recordId) {
return 'Viewed meters list';
}
if (fullPath.includes('/concentrators') && action === 'READ' && !recordId) {
return 'Viewed concentrators list';
}
if (fullPath.includes('/projects') && action === 'READ' && !recordId) {
return 'Viewed projects list';
}
if (fullPath.includes('/readings') && action === 'READ' && !recordId) {
return 'Viewed readings list';
}
if (recordId) {
if (tableName === 'unknown' && section) {
return `${actionLabel} record in ${section} (ID: ${recordId.substring(0, 8)}...)`;
}
return `${actionLabel} ${tableLabel} (ID: ${recordId.substring(0, 8)}...)`;
}
if (tableName === 'unknown' && section) {
return `${actionLabel} in ${section} section`;
}
return `${actionLabel} ${tableLabel}`;
}
export function auditMiddleware(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
console.log(`[AUDIT-MIDDLEWARE] Request received: ${req.method} ${req.path}`);
if (shouldExclude(req.path)) {
console.log(`[AUDIT-MIDDLEWARE] Path excluded: ${req.path}`);
next();
return;
}
if (!req.path.startsWith('/api')) {
console.log(`[AUDIT-MIDDLEWARE] Not an API path: ${req.path}`);
next();
return;
}
console.log(`[AUDIT-MIDDLEWARE] Setting up audit tracking for: ${req.method} ${req.path}`);
const originalSend = res.send;
const originalJson = res.json;
let responseBody: any;
let responseBody: unknown;
res.send = function (body: any): Response {
res.send = function (body: unknown): Response {
responseBody = body;
return originalSend.call(this, body);
};
res.json = function (body: any): Response {
res.json = function (body: unknown): Response {
responseBody = body;
return originalJson.call(this, body);
};
res.on('finish', async () => {
try {
console.log('🔍 [Audit] Response finished:', {
path: req.path,
method: req.method,
user: req.user ? req.user.email : 'NO USER',
statusCode: res.statusCode
});
if (!req.user) {
console.log('⚠️ [Audit] Skipping - no authenticated user');
if (!req.user || !req.user.userId || !req.user.email) {
return;
}
const action = methodToAction(req.method);
const tableName = extractTableName(req.path);
const fullPath = (req.originalUrl || req.url).split('?')[0];
const section = extractSection(fullPath);
const tableName = extractTableName(fullPath);
const recordId = extractRecordId(req);
const success = res.statusCode >= 200 && res.statusCode < 400;
let description = `${req.method} ${req.path}`;
let description = generateDescription(fullPath, action, tableName, recordId, section);
if (req.path.includes('/login')) {
description = 'User logged in';
} else if (req.path.includes('/logout')) {
if (fullPath.includes('/login')) {
description = 'User logged in successfully';
} else if (fullPath.includes('/logout')) {
description = 'User logged out';
} else if (req.path.includes('/password')) {
} else if (fullPath.includes('/password')) {
description = 'Password changed';
} else if (fullPath.includes('/bulk-upload')) {
description = `Bulk upload for ${tableName}`;
}
let newValues: unknown = undefined;
let oldValues: unknown = undefined;
if (['POST', 'PUT', 'PATCH'].includes(req.method) && req.body && Object.keys(req.body).length > 0) {
newValues = sanitizeData(req.body);
}
const bodyData = (responseBody as Record<string, unknown>)?.data;
if (req.method === 'GET' && recordId && bodyData && !Array.isArray(bodyData)) {
newValues = sanitizeData(bodyData);
}
if (req.method === 'DELETE' && bodyData) {
oldValues = sanitizeData(bodyData);
}
const userWithName = req.user as { name?: string; userName?: string; roleName: string; email: string };
const userName = userWithName.name || userWithName.userName || userWithName.roleName || userWithName.email;
const auditData = {
userId: req.user.userId,
userEmail: req.user.email,
userName: req.user.roleName || req.user.email,
userName,
action,
tableName,
recordId,
@@ -129,21 +335,13 @@ export function auditMiddleware(
ipAddress: getIpAddress(req),
userAgent: getUserAgent(req),
success,
newValues: ['POST', 'PUT', 'PATCH'].includes(req.method)
? sanitizeData(req.body)
: undefined,
errorMessage: !success && responseBody?.error
? responseBody.error
newValues: newValues as Record<string, unknown> | undefined,
oldValues: oldValues as Record<string, unknown> | undefined,
errorMessage: !success && typeof (responseBody as Record<string, unknown>)?.error === 'string'
? (responseBody as Record<string, unknown>).error as string
: undefined,
};
console.log('✅ [Audit] Creating audit log...', {
action,
tableName,
recordId,
userEmail: req.user.email
});
createAuditLog(auditData)
.then(logId => {
console.log('✅ [Audit] Log created successfully:', logId);
@@ -161,16 +359,25 @@ export function auditMiddleware(
next();
}
function sanitizeData(data: any): any {
if (!data || typeof data !== 'object') {
function sanitizeData(data: unknown): unknown {
if (!data) {
return data;
}
const sanitized = { ...data };
if (typeof data !== 'object') {
return data;
}
if (Array.isArray(data)) {
return data.map(item => sanitizeData(item));
}
const sanitized: Record<string, unknown> = {};
const sensitiveFields = [
'password',
'password_hash',
'passwordHash',
'currentPassword',
'newPassword',
'token',
@@ -179,13 +386,21 @@ function sanitizeData(data: any): any {
'secret',
'api_key',
'apiKey',
'credit_card',
'creditCard',
'ssn',
'cvv',
];
sensitiveFields.forEach(field => {
if (field in sanitized) {
sanitized[field] = '[REDACTED]';
for (const [key, value] of Object.entries(data)) {
if (sensitiveFields.includes(key)) {
sanitized[key] = '[REDACTED]';
} else if (value && typeof value === 'object') {
sanitized[key] = sanitizeData(value);
} else {
sanitized[key] = value;
}
});
}
return sanitized;
}

View File

@@ -1,16 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
/**
* Extended Request interface with authenticated user
*/
export interface AuthenticatedRequest extends Request {
user?: {
id: string;
email: string;
role: string;
};
}
import { AuthenticatedRequest } from '../types';
/**
* Middleware to authenticate JWT access tokens
@@ -47,9 +37,10 @@ export function authenticateToken(
}
req.user = {
id: decoded.id,
email: decoded.email,
role: decoded.role,
userId: (decoded as any).userId || (decoded as any).id,
email: (decoded as any).email,
roleId: (decoded as any).roleId || (decoded as any).role,
roleName: (decoded as any).roleName || (decoded as any).role,
};
next();
@@ -74,7 +65,7 @@ export function requireRole(...roles: string[]) {
return;
}
if (!roles.includes(req.user.role)) {
if (!roles.includes(req.user.roleId) && !roles.includes(req.user.roleName)) {
res.status(403).json({ error: 'Insufficient permissions' });
return;
}

View File

@@ -78,15 +78,15 @@ export async function login(
throw new Error('Invalid email or password');
}
// Generate tokens
const accessToken = generateAccessToken({
id: user.id,
userId: user.id,
email: user.email,
role: user.role_name,
roleId: user.id,
roleName: user.role_name,
});
const refreshToken = generateRefreshToken({
id: user.id,
userId: user.id,
});
// Hash and store refresh token
@@ -136,7 +136,8 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
// Hash token to check against database
const hashedToken = hashToken(refreshToken);
// Find token in database
const userId = (decoded as any).userId || (decoded as any).id;
const tokenResult = await query<{
id: string;
expires_at: Date;
@@ -144,7 +145,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
`SELECT id, expires_at FROM refresh_tokens
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
LIMIT 1`,
[hashedToken, decoded.id]
[hashedToken, userId]
);
const storedToken = tokenResult.rows[0];
@@ -175,7 +176,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true
LIMIT 1`,
[decoded.id]
[userId]
);
const user = userResult.rows[0];
@@ -184,11 +185,11 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
throw new Error('User not found');
}
// Generate new access token
const accessToken = generateAccessToken({
id: user.id,
userId: user.id,
email: user.email,
role: user.role_name,
roleId: user.id,
roleName: user.role_name,
});
return { accessToken };

View File

@@ -1,10 +1,14 @@
import jwt, { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
import jwt, { SignOptions, VerifyOptions } from 'jsonwebtoken';
import config from '../config';
import logger from './logger';
import type { JwtPayload } from '../types';
interface TokenPayload {
id: string;
userId?: string;
email?: string;
roleId?: string;
roleName?: string;
id?: string;
role?: string;
[key: string]: unknown;
}
@@ -58,7 +62,7 @@ export const generateRefreshToken = (payload: TokenPayload): string => {
* @param token - JWT access token to verify
* @returns Decoded payload if valid, null if invalid or expired
*/
export const verifyAccessToken = (token: string): JwtPayload | null => {
export const verifyAccessToken = (token: string): any => {
const options: VerifyOptions = {
algorithms: ['HS256'],
};
@@ -68,7 +72,7 @@ export const verifyAccessToken = (token: string): JwtPayload | null => {
token,
config.jwt.accessTokenSecret,
options
) as JwtPayload;
);
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
@@ -91,7 +95,7 @@ export const verifyAccessToken = (token: string): JwtPayload | null => {
* @param token - JWT refresh token to verify
* @returns Decoded payload if valid, null if invalid or expired
*/
export const verifyRefreshToken = (token: string): JwtPayload | null => {
export const verifyRefreshToken = (token: string): any => {
const options: VerifyOptions = {
algorithms: ['HS256'],
};
@@ -101,7 +105,7 @@ export const verifyRefreshToken = (token: string): JwtPayload | null => {
token,
config.jwt.refreshTokenSecret,
options
) as JwtPayload;
);
return decoded;
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
@@ -124,9 +128,9 @@ export const verifyRefreshToken = (token: string): JwtPayload | null => {
* @param token - JWT token to decode
* @returns Decoded payload or null
*/
export const decodeToken = (token: string): JwtPayload | null => {
export const decodeToken = (token: string): any => {
try {
const decoded = jwt.decode(token) as JwtPayload | null;
const decoded = jwt.decode(token);
return decoded;
} catch (error) {
logger.error('Error decoding token', {