diff --git a/client/src/components/management/AuditLogs.jsx b/client/src/components/management/AuditLogs.jsx new file mode 100644 index 0000000..e65fb05 --- /dev/null +++ b/client/src/components/management/AuditLogs.jsx @@ -0,0 +1,519 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from '../../utils/tempTranslations'; + +const AuditLogs = () => { + const { t } = useTranslation(); + const [auditLogs, setAuditLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filters, setFilters] = useState({ + page: 1, + limit: 50, + level: '', + action: '', + tenantId: '', + userId: '', + startDate: '', + endDate: '', + search: '' + }); + const [pagination, setPagination] = useState({}); + const [availableActions, setAvailableActions] = useState([]); + const [summary, setSummary] = useState({}); + + useEffect(() => { + fetchAuditLogs(); + fetchAvailableActions(); + fetchSummary(); + }, [filters]); + + const fetchAuditLogs = async () => { + try { + setLoading(true); + const queryParams = new URLSearchParams(); + + Object.keys(filters).forEach(key => { + if (filters[key]) { + queryParams.append(key, filters[key]); + } + }); + + const token = localStorage.getItem('managementToken'); + const response = await fetch(`/api/management/audit-logs?${queryParams}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to fetch audit logs'); + } + + const data = await response.json(); + setAuditLogs(data.data.auditLogs); + setPagination(data.data.pagination); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const fetchAvailableActions = async () => { + try { + const token = localStorage.getItem('managementToken'); + const response = await fetch('/api/management/audit-logs/actions', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + setAvailableActions(data.data); + } + } catch (err) { + console.error('Failed to fetch available actions:', err); + } + }; + + const fetchSummary = async () => { + try { + const token = localStorage.getItem('managementToken'); + const response = await fetch('/api/management/audit-logs/summary', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + setSummary(data.data); + } + } catch (err) { + console.error('Failed to fetch summary:', err); + } + }; + + const handleFilterChange = (key, value) => { + setFilters(prev => ({ + ...prev, + [key]: value, + page: 1 // Reset to first page when filtering + })); + }; + + const handlePageChange = (newPage) => { + setFilters(prev => ({ + ...prev, + page: newPage + })); + }; + + const formatTimestamp = (timestamp) => { + return new Date(timestamp).toLocaleString(); + }; + + const getLevelBadgeClass = (level) => { + switch (level) { + case 'INFO': + return 'bg-blue-100 text-blue-800'; + case 'WARNING': + return 'bg-yellow-100 text-yellow-800'; + case 'ERROR': + return 'bg-red-100 text-red-800'; + case 'CRITICAL': + return 'bg-red-200 text-red-900 font-bold'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const getSuccessIndicator = (success) => { + if (success === true) { + return ; + } else if (success === false) { + return ; + } + return -; + }; + + const clearFilters = () => { + setFilters({ + page: 1, + limit: 50, + level: '', + action: '', + tenantId: '', + userId: '', + startDate: '', + endDate: '', + search: '' + }); + }; + + return ( +
+ {/* Header */} +
+

+ {t('management.auditLogs') || 'Security Audit Logs'} +

+ +
+ + {/* Summary Statistics */} + {summary.summary && ( +
+
+
+
+
+
+
+ {t('management.totalLogs') || 'Total Logs'} +
+
+ {summary.summary.totalLogs} +
+
+
+
+
+
+ +
+
+
+
+
+
+ {t('management.successfulActions') || 'Successful'} +
+
+ {summary.summary.successfulActions} +
+
+
+
+
+
+ +
+
+
+
+
+
+ {t('management.failedActions') || 'Failed'} +
+
+ {summary.summary.failedActions} +
+
+
+
+
+
+ +
+
+
+
+
+
+ {t('management.warnings') || 'Warnings'} +
+
+ {summary.summary.warningActions} +
+
+
+
+
+
+ +
+
+
+
+
+
+ {t('management.critical') || 'Critical'} +
+
+ {summary.summary.criticalActions} +
+
+
+
+
+
+
+ )} + + {/* Filters */} +
+

+ {t('common.filters') || 'Filters'} +

+ +
+ {/* Search */} +
+ + handleFilterChange('search', e.target.value)} + placeholder={t('management.searchPlaceholder') || 'Search logs...'} + className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+ + {/* Level */} +
+ + +
+ + {/* Action */} +
+ + +
+ + {/* Date Range */} +
+ +
+ handleFilterChange('startDate', e.target.value)} + className="flex-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" + /> + handleFilterChange('endDate', e.target.value)} + className="flex-1 border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+ +
+ + + {pagination.totalCount} {t('management.totalEntries') || 'total entries'} + +
+
+ + {/* Audit Logs Table */} +
+ {loading ? ( +
+
+ {t('common.loading') || 'Loading...'} +
+ ) : error ? ( +
+ {t('common.error') || 'Error'}: {error} +
+ ) : auditLogs.length === 0 ? ( +
+ {t('management.noAuditLogs') || 'No audit logs found'} +
+ ) : ( +
+ + + + + + + + + + + + + + + {auditLogs.map((log) => ( + + + + + + + + + + + ))} + +
+ {t('management.timestamp') || 'Timestamp'} + + {t('management.level') || 'Level'} + + {t('management.action') || 'Action'} + + {t('management.user') || 'User'} + + {t('management.tenant') || 'Tenant'} + + {t('management.message') || 'Message'} + + {t('management.success') || 'Success'} + + {t('management.ipAddress') || 'IP Address'} +
+ {formatTimestamp(log.timestamp)} + + + {log.level} + + + {log.action} + + {log.username || '-'} + + {log.tenant_slug || '-'} + + {log.message} + + {getSuccessIndicator(log.success)} + + {log.ip_address || '-'} +
+
+ )} +
+ + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+
+ + +
+
+
+

+ {t('common.showing') || 'Showing'}{' '} + {((pagination.currentPage - 1) * pagination.limit) + 1} + {' '}{t('common.to') || 'to'}{' '} + + {Math.min(pagination.currentPage * pagination.limit, pagination.totalCount)} + + {' '}{t('common.of') || 'of'}{' '} + {pagination.totalCount} + {' '}{t('common.results') || 'results'} +

+
+
+ +
+
+
+ )} +
+ ); +}; + +export default AuditLogs; \ No newline at end of file diff --git a/client/src/utils/tempTranslations.js b/client/src/utils/tempTranslations.js index e667556..0f109be 100644 --- a/client/src/utils/tempTranslations.js +++ b/client/src/utils/tempTranslations.js @@ -448,6 +448,38 @@ const translations = { type: 'Type', name: 'Name', description: 'Description', + filters: 'Filters', + clearFilters: 'Clear Filters', + refresh: 'Refresh', + search: 'Search', + loading: 'Loading...', + error: 'Error', + showing: 'Showing', + to: 'to', + results: 'results' + }, + management: { + auditLogs: 'Security Audit Logs', + totalLogs: 'Total Logs', + successfulActions: 'Successful Actions', + failedActions: 'Failed Actions', + warnings: 'Warnings', + critical: 'Critical Events', + level: 'Level', + action: 'Action', + user: 'User', + tenant: 'Tenant', + message: 'Message', + success: 'Success', + ipAddress: 'IP Address', + timestamp: 'Timestamp', + dateRange: 'Date Range', + searchPlaceholder: 'Search logs, users, or tenants...', + noAuditLogs: 'No audit logs found', + totalEntries: 'total entries', + logoManagement: 'Logo Management Events', + securityEvents: 'Security Events', + auditTrail: 'Audit Trail', actions: 'Actions' } }, @@ -867,6 +899,69 @@ const translations = { delete: 'Ta bort', edit: 'Redigera', view: 'Visa', + add: 'Lägg till', + update: 'Uppdatera', + create: 'Skapa', + remove: 'Ta bort', + close: 'Stäng', + open: 'Öppna', + back: 'Tillbaka', + continue: 'Fortsätt', + submit: 'Skicka', + confirm: 'Bekräfta', + yes: 'Ja', + no: 'Nej', + ok: 'OK', + apply: 'Tillämpa', + reset: 'Återställ', + clear: 'Rensa', + all: 'Alla', + none: 'Ingen', + selected: 'Vald', + total: 'Totalt', + page: 'Sida', + of: 'av', + previous: 'Föregående', + next: 'Nästa', + first: 'Första', + last: 'Sista', + date: 'Datum', + time: 'Tid', + status: 'Status', + type: 'Typ', + name: 'Namn', + description: 'Beskrivning', + filters: 'Filter', + clearFilters: 'Rensa filter', + refresh: 'Uppdatera', + search: 'Sök', + showing: 'Visar', + to: 'till', + results: 'resultat' + }, + management: { + auditLogs: 'Säkerhetsgranskningsloggar', + totalLogs: 'Totala loggar', + successfulActions: 'Lyckade åtgärder', + failedActions: 'Misslyckade åtgärder', + warnings: 'Varningar', + critical: 'Kritiska händelser', + level: 'Nivå', + action: 'Åtgärd', + user: 'Användare', + tenant: 'Klient', + message: 'Meddelande', + success: 'Framgång', + ipAddress: 'IP-adress', + timestamp: 'Tidsstämpel', + dateRange: 'Datumintervall', + searchPlaceholder: 'Sök loggar, användare eller klienter...', + noAuditLogs: 'Inga granskningsloggar hittades', + totalEntries: 'totala poster', + logoManagement: 'Logotyphanteringshändelser', + securityEvents: 'Säkerhetshändelser', + auditTrail: 'Granskningsspår', + view: 'Visa', close: 'Stäng', refresh: 'Uppdatera', search: 'Sök', diff --git a/server/middleware/logger.js b/server/middleware/logger.js index 359137a..3249f4b 100644 --- a/server/middleware/logger.js +++ b/server/middleware/logger.js @@ -9,6 +9,14 @@ class SecurityLogger { // Ensure log directory exists this.ensureLogDirectory(); + + // Initialize models reference (will be set when needed) + this.models = null; + } + + // Set models reference for database logging + setModels(models) { + this.models = models; } ensureLogDirectory() { @@ -23,7 +31,7 @@ class SecurityLogger { } } - logSecurityEvent(level, message, metadata = {}) { + async logSecurityEvent(level, message, metadata = {}) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, @@ -44,6 +52,49 @@ class SecurityLogger { console.error('Failed to write to security log file:', error.message); } } + + // Store in database if models are available + if (this.models && this.models.AuditLog) { + try { + await this.models.AuditLog.create({ + timestamp: new Date(), + level: level.toUpperCase(), + action: metadata.action || 'unknown', + message, + user_id: metadata.userId || null, + username: metadata.username || null, + tenant_id: metadata.tenantId || null, + tenant_slug: metadata.tenantSlug || null, + ip_address: metadata.ip || null, + user_agent: metadata.userAgent || null, + path: metadata.path || null, + metadata: metadata, + success: this.determineSuccess(level, metadata) + }); + } catch (error) { + console.error('Failed to store audit log in database:', error.message); + } + } + } + + determineSuccess(level, metadata) { + // Determine if the action was successful based on level and metadata + if (metadata.hasOwnProperty('success')) { + return metadata.success; + } + + // Assume success for info level, failure for error/critical + switch (level.toUpperCase()) { + case 'INFO': + return true; + case 'WARNING': + return null; // Neutral + case 'ERROR': + case 'CRITICAL': + return false; + default: + return null; + } } logIPRestriction(ip, tenant, userAgent, denied = true) { diff --git a/server/migrations/20250920-add-audit-logs.js b/server/migrations/20250920-add-audit-logs.js new file mode 100644 index 0000000..e07d460 --- /dev/null +++ b/server/migrations/20250920-add-audit-logs.js @@ -0,0 +1,103 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('audit_logs', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + timestamp: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW + }, + level: { + type: Sequelize.ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL'), + allowNull: false + }, + action: { + type: Sequelize.STRING(100), + allowNull: false, + comment: 'The action performed (e.g., logo_upload, logo_removal)' + }, + message: { + type: Sequelize.TEXT, + allowNull: false, + comment: 'Human-readable description of the event' + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: true, + comment: 'ID of the user who performed the action', + references: { + model: 'users', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, + username: { + type: Sequelize.STRING(255), + allowNull: true, + comment: 'Username of the user who performed the action' + }, + tenant_id: { + type: Sequelize.INTEGER, + allowNull: true, + comment: 'ID of the tenant affected by the action', + references: { + model: 'tenants', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, + tenant_slug: { + type: Sequelize.STRING(255), + allowNull: true, + comment: 'Slug of the tenant affected by the action' + }, + ip_address: { + type: Sequelize.STRING(45), + allowNull: true, + comment: 'IP address of the user (supports IPv6)' + }, + user_agent: { + type: Sequelize.TEXT, + allowNull: true, + comment: 'User agent string from the request' + }, + path: { + type: Sequelize.STRING(500), + allowNull: true, + comment: 'Request path that triggered the action' + }, + metadata: { + type: Sequelize.JSON, + allowNull: true, + comment: 'Additional metadata about the event' + }, + success: { + type: Sequelize.BOOLEAN, + allowNull: true, + comment: 'Whether the action was successful' + } + }); + + // Add indexes for performance + await queryInterface.addIndex('audit_logs', ['timestamp']); + await queryInterface.addIndex('audit_logs', ['action']); + await queryInterface.addIndex('audit_logs', ['user_id']); + await queryInterface.addIndex('audit_logs', ['tenant_id']); + await queryInterface.addIndex('audit_logs', ['level']); + await queryInterface.addIndex('audit_logs', ['timestamp', 'action']); + await queryInterface.addIndex('audit_logs', ['tenant_id', 'timestamp']); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('audit_logs'); + } +}; \ No newline at end of file diff --git a/server/models/AuditLog.js b/server/models/AuditLog.js new file mode 100644 index 0000000..8c8477b --- /dev/null +++ b/server/models/AuditLog.js @@ -0,0 +1,118 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const AuditLog = sequelize.define('AuditLog', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + timestamp: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + level: { + type: DataTypes.ENUM('INFO', 'WARNING', 'ERROR', 'CRITICAL'), + allowNull: false + }, + action: { + type: DataTypes.STRING(100), + allowNull: false, + comment: 'The action performed (e.g., logo_upload, logo_removal)' + }, + message: { + type: DataTypes.TEXT, + allowNull: false, + comment: 'Human-readable description of the event' + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'ID of the user who performed the action' + }, + username: { + type: DataTypes.STRING(255), + allowNull: true, + comment: 'Username of the user who performed the action' + }, + tenant_id: { + type: DataTypes.INTEGER, + allowNull: true, + comment: 'ID of the tenant affected by the action' + }, + tenant_slug: { + type: DataTypes.STRING(255), + allowNull: true, + comment: 'Slug of the tenant affected by the action' + }, + ip_address: { + type: DataTypes.STRING(45), + allowNull: true, + comment: 'IP address of the user (supports IPv6)' + }, + user_agent: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'User agent string from the request' + }, + path: { + type: DataTypes.STRING(500), + allowNull: true, + comment: 'Request path that triggered the action' + }, + metadata: { + type: DataTypes.JSON, + allowNull: true, + comment: 'Additional metadata about the event' + }, + success: { + type: DataTypes.BOOLEAN, + allowNull: true, + comment: 'Whether the action was successful' + } + }, { + tableName: 'audit_logs', + timestamps: false, // We use our own timestamp field + indexes: [ + { + fields: ['timestamp'] + }, + { + fields: ['action'] + }, + { + fields: ['user_id'] + }, + { + fields: ['tenant_id'] + }, + { + fields: ['level'] + }, + { + fields: ['timestamp', 'action'] + }, + { + fields: ['tenant_id', 'timestamp'] + } + ] + }); + + // Define associations + AuditLog.associate = function(models) { + // Association with User + AuditLog.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'user' + }); + + // Association with Tenant + AuditLog.belongsTo(models.Tenant, { + foreignKey: 'tenant_id', + as: 'tenant' + }); + }; + + return AuditLog; +}; \ No newline at end of file diff --git a/server/routes/management.js b/server/routes/management.js index f4f30a6..ced7ce1 100644 --- a/server/routes/management.js +++ b/server/routes/management.js @@ -1304,4 +1304,261 @@ router.post('/tenants/:tenantId/deactivate', async (req, res) => { } }); +/** + * GET /management/audit-logs + * Retrieve security audit logs with filtering and pagination + */ +router.get('/audit-logs', requireManagementAuth, async (req, res) => { + try { + const { + page = 1, + limit = 50, + level, + action, + tenantId, + userId, + startDate, + endDate, + search + } = req.query; + + // Build where clause for filtering + const where = {}; + + if (level) { + where.level = level.toUpperCase(); + } + + if (action) { + where.action = { [Op.like]: `%${action}%` }; + } + + if (tenantId) { + where.tenant_id = tenantId; + } + + if (userId) { + where.user_id = userId; + } + + if (startDate || endDate) { + where.timestamp = {}; + if (startDate) { + where.timestamp[Op.gte] = new Date(startDate); + } + if (endDate) { + where.timestamp[Op.lte] = new Date(endDate); + } + } + + if (search) { + where[Op.or] = [ + { message: { [Op.like]: `%${search}%` } }, + { username: { [Op.like]: `%${search}%` } }, + { tenant_slug: { [Op.like]: `%${search}%` } } + ]; + } + + // Calculate offset for pagination + const offset = (parseInt(page) - 1) * parseInt(limit); + + // Get audit logs with associated data + const { AuditLog } = require('../models'); + const { count, rows: auditLogs } = await AuditLog.findAndCountAll({ + where, + include: [ + { + model: User, + as: 'user', + attributes: ['id', 'username', 'email'], + required: false + }, + { + model: Tenant, + as: 'tenant', + attributes: ['id', 'name', 'slug'], + required: false + } + ], + order: [['timestamp', 'DESC']], + limit: parseInt(limit), + offset: offset + }); + + // Calculate pagination info + const totalPages = Math.ceil(count / parseInt(limit)); + const hasNextPage = parseInt(page) < totalPages; + const hasPrevPage = parseInt(page) > 1; + + // Log the management access + console.log(`[MANAGEMENT AUDIT] ${new Date().toISOString()} - Admin ${req.managementUser.username} accessed audit logs`); + + res.json({ + success: true, + data: { + auditLogs, + pagination: { + currentPage: parseInt(page), + totalPages, + totalCount: count, + limit: parseInt(limit), + hasNextPage, + hasPrevPage + }, + filters: { + level, + action, + tenantId, + userId, + startDate, + endDate, + search + } + } + }); + + } catch (error) { + console.error('Management: Error retrieving audit logs:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve audit logs' + }); + } +}); + +/** + * GET /management/audit-logs/actions + * Get list of available audit log actions for filtering + */ +router.get('/audit-logs/actions', requireManagementAuth, async (req, res) => { + try { + const { AuditLog } = require('../models'); + const actions = await AuditLog.findAll({ + attributes: [[AuditLog.sequelize.fn('DISTINCT', AuditLog.sequelize.col('action')), 'action']], + where: { + action: { [Op.ne]: null } + }, + raw: true + }); + + res.json({ + success: true, + data: actions.map(item => item.action).filter(Boolean).sort() + }); + + } catch (error) { + console.error('Management: Error retrieving audit log actions:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve audit log actions' + }); + } +}); + +/** + * GET /management/audit-logs/summary + * Get audit log summary statistics + */ +router.get('/audit-logs/summary', requireManagementAuth, async (req, res) => { + try { + const { timeframe = '24h' } = req.query; + + // Calculate time range + const now = new Date(); + let startTime; + + switch (timeframe) { + case '1h': + startTime = new Date(now.getTime() - 60 * 60 * 1000); + break; + case '24h': + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); + break; + case '7d': + startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case '30d': + startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + default: + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); + } + + const { AuditLog } = require('../models'); + + // Get summary statistics + const [totalLogs, successfulActions, failedActions, warningActions, criticalActions] = await Promise.all([ + AuditLog.count({ + where: { timestamp: { [Op.gte]: startTime } } + }), + AuditLog.count({ + where: { + timestamp: { [Op.gte]: startTime }, + success: true + } + }), + AuditLog.count({ + where: { + timestamp: { [Op.gte]: startTime }, + success: false + } + }), + AuditLog.count({ + where: { + timestamp: { [Op.gte]: startTime }, + level: 'WARNING' + } + }), + AuditLog.count({ + where: { + timestamp: { [Op.gte]: startTime }, + level: 'CRITICAL' + } + }) + ]); + + // Get top actions + const topActions = await AuditLog.findAll({ + attributes: [ + 'action', + [AuditLog.sequelize.fn('COUNT', AuditLog.sequelize.col('action')), 'count'] + ], + where: { + timestamp: { [Op.gte]: startTime }, + action: { [Op.ne]: null } + }, + group: ['action'], + order: [[AuditLog.sequelize.literal('count'), 'DESC']], + limit: 10, + raw: true + }); + + res.json({ + success: true, + data: { + timeframe, + period: { + start: startTime.toISOString(), + end: now.toISOString() + }, + summary: { + totalLogs, + successfulActions, + failedActions, + warningActions, + criticalActions + }, + topActions + } + }); + + } catch (error) { + console.error('Management: Error retrieving audit log summary:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve audit log summary' + }); + } +}); + module.exports = router; diff --git a/server/routes/tenant.js b/server/routes/tenant.js index 1b0c7ee..96d1f55 100644 --- a/server/routes/tenant.js +++ b/server/routes/tenant.js @@ -17,6 +17,10 @@ const { securityLogger } = require('../middleware/logger'); // Initialize multi-tenant auth const multiAuth = new MultiTenantAuth(); +// Initialize SecurityLogger with models +const models = require('../models'); +securityLogger.setModels(models); + // Configure multer for logo uploads const storage = multer.diskStorage({ destination: function (req, file, cb) {