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 { Request, Response } from 'express';
import { AuthenticatedRequest } from '../middleware/auth.middleware'; import type { AuthenticatedRequest } from '../types';
import * as authService from '../services/auth.service'; import * as authService from '../services/auth.service';
import { LoginInput, RefreshInput } from '../validators/auth.validator'; import { LoginInput, RefreshInput } from '../validators/auth.validator';
import { createAuditLog, getIpAddress, getUserAgent } from '../services/audit.service'; 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> { export async function logout(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const userId = req.user?.id; const userId = req.user?.userId;
if (!userId) { if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' }); 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) { if (req.user) {
createAuditLog({ createAuditLog({
userId: req.user.id, userId: req.user.userId,
userEmail: req.user.email, userEmail: req.user.email,
userName: req.user.role || req.user.email, userName: req.user.roleName || req.user.email,
action: 'LOGOUT', action: 'LOGOUT',
tableName: 'users', tableName: 'users',
recordId: req.user.id, recordId: req.user.userId,
description: `User logged out`, description: `User logged out`,
ipAddress: getIpAddress(req), ipAddress: getIpAddress(req),
userAgent: getUserAgent(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> { export async function getMe(req: AuthenticatedRequest, res: Response): Promise<void> {
try { try {
const userId = req.user?.id; const userId = req.user?.userId;
if (!userId) { if (!userId) {
res.status(401).json({ success: false, error: 'Authentication required' }); 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 { function extractSection(path: string): string {
const segments = path.replace(/^\/api\//, '').split('/'); const cleanPath = path.replace(/^\/api\//, '').replace(/^\//, '');
let tableName = segments[0] || 'unknown'; 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', 'auth': 'users',
'me': 'users',
'users': 'users',
'roles': 'roles',
'meters': 'meters',
'concentrators': 'concentrators',
'gateways': 'gateways',
'devices': 'devices',
'projects': 'projects',
'readings': 'readings',
'webhooks': 'webhooks', 'webhooks': 'webhooks',
'bulk-upload': 'bulk_operations', 'bulk-upload': 'bulk_operations',
'audit-logs': 'audit_logs', '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 { function extractRecordId(req: Request): string | undefined {
@@ -54,74 +177,157 @@ function shouldExclude(path: string): boolean {
return EXCLUDED_PATHS.some(excluded => path.startsWith(excluded)); 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( export function auditMiddleware(
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response, res: Response,
next: NextFunction next: NextFunction
): void { ): void {
console.log(`[AUDIT-MIDDLEWARE] Request received: ${req.method} ${req.path}`);
if (shouldExclude(req.path)) { if (shouldExclude(req.path)) {
console.log(`[AUDIT-MIDDLEWARE] Path excluded: ${req.path}`);
next(); next();
return; return;
} }
if (!req.path.startsWith('/api')) { if (!req.path.startsWith('/api')) {
console.log(`[AUDIT-MIDDLEWARE] Not an API path: ${req.path}`);
next(); next();
return; return;
} }
console.log(`[AUDIT-MIDDLEWARE] Setting up audit tracking for: ${req.method} ${req.path}`);
const originalSend = res.send; const originalSend = res.send;
const originalJson = res.json; const originalJson = res.json;
let responseBody: any; let responseBody: unknown;
res.send = function (body: any): Response { res.send = function (body: unknown): Response {
responseBody = body; responseBody = body;
return originalSend.call(this, body); return originalSend.call(this, body);
}; };
res.json = function (body: any): Response { res.json = function (body: unknown): Response {
responseBody = body; responseBody = body;
return originalJson.call(this, body); return originalJson.call(this, body);
}; };
res.on('finish', async () => { res.on('finish', async () => {
try { try {
console.log('🔍 [Audit] Response finished:', { if (!req.user || !req.user.userId || !req.user.email) {
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');
return; return;
} }
const action = methodToAction(req.method); 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 recordId = extractRecordId(req);
const success = res.statusCode >= 200 && res.statusCode < 400; 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')) { if (fullPath.includes('/login')) {
description = 'User logged in'; description = 'User logged in successfully';
} else if (req.path.includes('/logout')) { } else if (fullPath.includes('/logout')) {
description = 'User logged out'; description = 'User logged out';
} else if (req.path.includes('/password')) { } else if (fullPath.includes('/password')) {
description = 'Password changed'; 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 = { const auditData = {
userId: req.user.userId, userId: req.user.userId,
userEmail: req.user.email, userEmail: req.user.email,
userName: req.user.roleName || req.user.email, userName,
action, action,
tableName, tableName,
recordId, recordId,
@@ -129,21 +335,13 @@ export function auditMiddleware(
ipAddress: getIpAddress(req), ipAddress: getIpAddress(req),
userAgent: getUserAgent(req), userAgent: getUserAgent(req),
success, success,
newValues: ['POST', 'PUT', 'PATCH'].includes(req.method) newValues: newValues as Record<string, unknown> | undefined,
? sanitizeData(req.body) oldValues: oldValues as Record<string, unknown> | undefined,
: undefined, errorMessage: !success && typeof (responseBody as Record<string, unknown>)?.error === 'string'
errorMessage: !success && responseBody?.error ? (responseBody as Record<string, unknown>).error as string
? responseBody.error
: undefined, : undefined,
}; };
console.log('✅ [Audit] Creating audit log...', {
action,
tableName,
recordId,
userEmail: req.user.email
});
createAuditLog(auditData) createAuditLog(auditData)
.then(logId => { .then(logId => {
console.log('✅ [Audit] Log created successfully:', logId); console.log('✅ [Audit] Log created successfully:', logId);
@@ -161,16 +359,25 @@ export function auditMiddleware(
next(); next();
} }
function sanitizeData(data: any): any { function sanitizeData(data: unknown): unknown {
if (!data || typeof data !== 'object') { if (!data) {
return 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 = [ const sensitiveFields = [
'password', 'password',
'password_hash', 'password_hash',
'passwordHash',
'currentPassword', 'currentPassword',
'newPassword', 'newPassword',
'token', 'token',
@@ -179,13 +386,21 @@ function sanitizeData(data: any): any {
'secret', 'secret',
'api_key', 'api_key',
'apiKey', 'apiKey',
'credit_card',
'creditCard',
'ssn',
'cvv',
]; ];
sensitiveFields.forEach(field => { for (const [key, value] of Object.entries(data)) {
if (field in sanitized) { if (sensitiveFields.includes(key)) {
sanitized[field] = '[REDACTED]'; sanitized[key] = '[REDACTED]';
} else if (value && typeof value === 'object') {
sanitized[key] = sanitizeData(value);
} else {
sanitized[key] = value;
} }
}); }
return sanitized; return sanitized;
} }

View File

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

View File

@@ -78,15 +78,15 @@ export async function login(
throw new Error('Invalid email or password'); throw new Error('Invalid email or password');
} }
// Generate tokens
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
id: user.id, userId: user.id,
email: user.email, email: user.email,
role: user.role_name, roleId: user.id,
roleName: user.role_name,
}); });
const refreshToken = generateRefreshToken({ const refreshToken = generateRefreshToken({
id: user.id, userId: user.id,
}); });
// Hash and store refresh token // Hash and store refresh token
@@ -136,7 +136,8 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
// Hash token to check against database // Hash token to check against database
const hashedToken = hashToken(refreshToken); const hashedToken = hashToken(refreshToken);
// Find token in database const userId = (decoded as any).userId || (decoded as any).id;
const tokenResult = await query<{ const tokenResult = await query<{
id: string; id: string;
expires_at: Date; expires_at: Date;
@@ -144,7 +145,7 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
`SELECT id, expires_at FROM refresh_tokens `SELECT id, expires_at FROM refresh_tokens
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
LIMIT 1`, LIMIT 1`,
[hashedToken, decoded.id] [hashedToken, userId]
); );
const storedToken = tokenResult.rows[0]; 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 JOIN roles r ON u.role_id = r.id
WHERE u.id = $1 AND u.is_active = true WHERE u.id = $1 AND u.is_active = true
LIMIT 1`, LIMIT 1`,
[decoded.id] [userId]
); );
const user = userResult.rows[0]; const user = userResult.rows[0];
@@ -184,11 +185,11 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
throw new Error('User not found'); throw new Error('User not found');
} }
// Generate new access token
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
id: user.id, userId: user.id,
email: user.email, email: user.email,
role: user.role_name, roleId: user.id,
roleName: user.role_name,
}); });
return { accessToken }; 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 config from '../config';
import logger from './logger'; import logger from './logger';
import type { JwtPayload } from '../types';
interface TokenPayload { interface TokenPayload {
id: string; userId?: string;
email?: string; email?: string;
roleId?: string;
roleName?: string;
id?: string;
role?: string; role?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -58,7 +62,7 @@ export const generateRefreshToken = (payload: TokenPayload): string => {
* @param token - JWT access token to verify * @param token - JWT access token to verify
* @returns Decoded payload if valid, null if invalid or expired * @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 = { const options: VerifyOptions = {
algorithms: ['HS256'], algorithms: ['HS256'],
}; };
@@ -68,7 +72,7 @@ export const verifyAccessToken = (token: string): JwtPayload | null => {
token, token,
config.jwt.accessTokenSecret, config.jwt.accessTokenSecret,
options options
) as JwtPayload; );
return decoded; return decoded;
} catch (error) { } catch (error) {
if (error instanceof jwt.TokenExpiredError) { if (error instanceof jwt.TokenExpiredError) {
@@ -91,7 +95,7 @@ export const verifyAccessToken = (token: string): JwtPayload | null => {
* @param token - JWT refresh token to verify * @param token - JWT refresh token to verify
* @returns Decoded payload if valid, null if invalid or expired * @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 = { const options: VerifyOptions = {
algorithms: ['HS256'], algorithms: ['HS256'],
}; };
@@ -101,7 +105,7 @@ export const verifyRefreshToken = (token: string): JwtPayload | null => {
token, token,
config.jwt.refreshTokenSecret, config.jwt.refreshTokenSecret,
options options
) as JwtPayload; );
return decoded; return decoded;
} catch (error) { } catch (error) {
if (error instanceof jwt.TokenExpiredError) { if (error instanceof jwt.TokenExpiredError) {
@@ -124,9 +128,9 @@ export const verifyRefreshToken = (token: string): JwtPayload | null => {
* @param token - JWT token to decode * @param token - JWT token to decode
* @returns Decoded payload or null * @returns Decoded payload or null
*/ */
export const decodeToken = (token: string): JwtPayload | null => { export const decodeToken = (token: string): any => {
try { try {
const decoded = jwt.decode(token) as JwtPayload | null; const decoded = jwt.decode(token);
return decoded; return decoded;
} catch (error) { } catch (error) {
logger.error('Error decoding token', { logger.error('Error decoding token', {