import type { AuthSession } from './auth'; import { getAuthSession } from './auth'; import { getNocoDbConfiguration } from './nocodb'; export interface AuditLogEntry { id?: number; timestamp: string; user_id: string; user_email: string; user_groups: string[]; action: string; resource_type: string; resource_id?: string; changes?: any; metadata: { ip_address?: string; user_agent?: string; session_id?: string; request_url?: string; request_method?: string; response_status?: number; request_duration?: number; }; status: 'success' | 'failure'; error_message?: string; } export interface AuditLogFilters { startDate?: string; endDate?: string; userId?: string; userEmail?: string; action?: string; resourceType?: string; status?: 'success' | 'failure'; limit?: number; offset?: number; } // NocoDB table ID for audit logs (will be updated when table is created) const AUDIT_LOGS_TABLE_ID = "audit_logs"; // Placeholder - update with actual table ID /** * Log an audit event */ export const logAuditEvent = async ( event: any, action: string, resourceType: string, details: { resourceId?: string; changes?: any; status?: 'success' | 'failure'; errorMessage?: string; duration?: number; } = {} ): Promise => { try { // Get user info from the event let userInfo = { user_id: 'anonymous', user_email: 'anonymous', user_groups: [] as string[] }; try { const authSession = await getAuthSession(event); if (authSession.authenticated && authSession.user) { userInfo = { user_id: authSession.user.id, user_email: authSession.user.email, user_groups: authSession.groups || [] }; } } catch (authError) { // Continue with anonymous user if auth fails console.warn('[AUDIT] Could not get user info:', authError); } // Extract request metadata const headers = event.node.req.headers; const metadata = { ip_address: getClientIP(event), user_agent: headers['user-agent'] || 'unknown', session_id: headers['cookie']?.split('nuxt-oidc-auth=')[1]?.split(';')[0] || 'none', request_url: event.node.req.url || 'unknown', request_method: event.node.req.method || 'unknown', response_status: details.status === 'failure' ? 500 : 200, request_duration: details.duration || 0 }; // Create audit log entry const auditEntry: Omit = { timestamp: new Date().toISOString(), user_id: userInfo.user_id, user_email: userInfo.user_email, user_groups: userInfo.user_groups, action, resource_type: resourceType, resource_id: details.resourceId, changes: details.changes, metadata, status: details.status || 'success', error_message: details.errorMessage }; // Store in NocoDB await storeAuditLog(auditEntry); console.log('[AUDIT] Logged event:', { action, resourceType, resourceId: details.resourceId, user: userInfo.user_email, status: details.status || 'success' }); } catch (error) { console.error('[AUDIT] Failed to log audit event:', error); // Don't throw - audit logging should not break the main request } }; /** * Store audit log in NocoDB */ const storeAuditLog = async (entry: Omit): Promise => { const config = getNocoDbConfiguration(); // Prepare data for NocoDB const data = { timestamp: entry.timestamp, user_id: entry.user_id, user_email: entry.user_email, user_groups: JSON.stringify(entry.user_groups), action: entry.action, resource_type: entry.resource_type, resource_id: entry.resource_id || null, changes: entry.changes ? JSON.stringify(entry.changes) : null, metadata: JSON.stringify(entry.metadata), status: entry.status, error_message: entry.error_message || null }; try { await $fetch(`${config.url}/api/v2/tables/${AUDIT_LOGS_TABLE_ID}/records`, { method: 'POST', headers: { 'xc-token': config.token, 'Content-Type': 'application/json' }, body: data }); } catch (error) { console.error('[AUDIT] Failed to store audit log in NocoDB:', error); throw error; } }; /** * Get audit logs with filtering */ export const getAuditLogs = async (filters: AuditLogFilters = {}): Promise<{ list: AuditLogEntry[]; totalCount: number; }> => { const config = getNocoDbConfiguration(); try { const params: any = { limit: filters.limit || 100, offset: filters.offset || 0, sort: '-timestamp' // Newest first }; // Build where clause const whereConditions: string[] = []; if (filters.startDate) { whereConditions.push(`(timestamp,gte,${filters.startDate})`); } if (filters.endDate) { whereConditions.push(`(timestamp,lte,${filters.endDate})`); } if (filters.userId) { whereConditions.push(`(user_id,eq,${filters.userId})`); } if (filters.userEmail) { whereConditions.push(`(user_email,like,%${filters.userEmail}%)`); } if (filters.action) { whereConditions.push(`(action,eq,${filters.action})`); } if (filters.resourceType) { whereConditions.push(`(resource_type,eq,${filters.resourceType})`); } if (filters.status) { whereConditions.push(`(status,eq,${filters.status})`); } if (whereConditions.length > 0) { params.where = whereConditions.join('~and'); } const response = await $fetch(`${config.url}/api/v2/tables/${AUDIT_LOGS_TABLE_ID}/records`, { headers: { 'xc-token': config.token }, params }) as any; // Parse JSON fields const parsedList = (response.list || []).map((item: any) => ({ ...item, user_groups: JSON.parse(item.user_groups || '[]'), changes: item.changes ? JSON.parse(item.changes) : null, metadata: JSON.parse(item.metadata || '{}') })); return { list: parsedList, totalCount: response.PageInfo?.totalRows || parsedList.length }; } catch (error) { console.error('[AUDIT] Failed to get audit logs:', error); return { list: [], totalCount: 0 }; } }; /** * Get audit statistics */ export const getAuditStats = async (days: number = 30): Promise<{ totalEvents: number; successEvents: number; failureEvents: number; topActions: Array<{ action: string; count: number }>; topUsers: Array<{ user_email: string; count: number }>; dailyActivity: Array<{ date: string; count: number }>; }> => { const endDate = new Date().toISOString(); const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); try { const logs = await getAuditLogs({ startDate, endDate, limit: 10000 // Get a large sample for stats }); const totalEvents = logs.totalCount; const successEvents = logs.list.filter(log => log.status === 'success').length; const failureEvents = logs.list.filter(log => log.status === 'failure').length; // Calculate top actions const actionCounts = logs.list.reduce((acc, log) => { acc[log.action] = (acc[log.action] || 0) + 1; return acc; }, {} as Record); const topActions = Object.entries(actionCounts) .sort(([,a], [,b]) => b - a) .slice(0, 10) .map(([action, count]) => ({ action, count })); // Calculate top users const userCounts = logs.list.reduce((acc, log) => { if (log.user_email !== 'anonymous') { acc[log.user_email] = (acc[log.user_email] || 0) + 1; } return acc; }, {} as Record); const topUsers = Object.entries(userCounts) .sort(([,a], [,b]) => b - a) .slice(0, 10) .map(([user_email, count]) => ({ user_email, count })); // Calculate daily activity const dailyCounts = logs.list.reduce((acc, log) => { const date = log.timestamp.split('T')[0]; acc[date] = (acc[date] || 0) + 1; return acc; }, {} as Record); const dailyActivity = Object.entries(dailyCounts) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, count]) => ({ date, count })); return { totalEvents, successEvents, failureEvents, topActions, topUsers, dailyActivity }; } catch (error) { console.error('[AUDIT] Failed to get audit stats:', error); return { totalEvents: 0, successEvents: 0, failureEvents: 0, topActions: [], topUsers: [], dailyActivity: [] }; } }; /** * Get client IP address from request */ const getClientIP = (event: any): string => { const headers = event.node.req.headers; // Check various headers for the real IP const forwarded = headers['x-forwarded-for']; const realIp = headers['x-real-ip']; const clientIp = headers['x-client-ip']; if (forwarded) { // x-forwarded-for can be a comma-separated list return forwarded.toString().split(',')[0].trim(); } if (realIp) { return realIp.toString(); } if (clientIp) { return clientIp.toString(); } // Fallback to socket remote address return event.node.req.socket?.remoteAddress || 'unknown'; }; /** * Convenience functions for common audit events */ export const auditInterestCreated = (event: any, interestId: string, interestData: any) => { return logAuditEvent(event, 'CREATE_INTEREST', 'interest', { resourceId: interestId, changes: { created: interestData }, status: 'success' }); }; export const auditInterestUpdated = (event: any, interestId: string, changes: any) => { return logAuditEvent(event, 'UPDATE_INTEREST', 'interest', { resourceId: interestId, changes, status: 'success' }); }; export const auditInterestDeleted = (event: any, interestId: string) => { return logAuditEvent(event, 'DELETE_INTEREST', 'interest', { resourceId: interestId, status: 'success' }); }; export const auditInterestMerged = (event: any, masterInterestId: string, mergedInterestIds: string[], mergeDetails: any) => { return logAuditEvent(event, 'MERGE_INTERESTS', 'interest', { resourceId: masterInterestId, changes: { master_interest_id: masterInterestId, merged_interest_ids: mergedInterestIds, merge_details: mergeDetails }, status: 'success' }); }; export const auditLogin = (event: any, userId: string, userEmail: string) => { return logAuditEvent(event, 'USER_LOGIN', 'authentication', { resourceId: userId, changes: { user_email: userEmail }, status: 'success' }); }; export const auditLogout = (event: any, userId: string, userEmail: string) => { return logAuditEvent(event, 'USER_LOGOUT', 'authentication', { resourceId: userId, changes: { user_email: userEmail }, status: 'success' }); }; export const auditAccessDenied = (event: any, resource: string, requiredRoles: string[]) => { return logAuditEvent(event, 'ACCESS_DENIED', 'authorization', { resourceId: resource, changes: { required_roles: requiredRoles }, status: 'failure', errorMessage: 'Insufficient permissions' }); };