feat(phase2): Implement Management Audit-Log (Task 10)

Audit-Logging System:
- Migration 007: management_audit_log table with indexes
- Tracks all management portal actions
- IP address, user-agent, request data logging
- Token masking (only first 8 chars stored)
- Success/failure tracking with error messages

ManagementAuditLogRepository:
- logAction() - Log management actions
- getRecentLogs() - Get last N logs
- getLogsByGroupId() - Get logs for specific group
- getFailedActionsByIP() - Security monitoring
- getStatistics() - Overview statistics
- cleanupOldLogs() - Maintenance (90 days retention)

Audit-Log Middleware:
- Adds res.auditLog() helper function
- Auto-captures IP, User-Agent
- Integrated into all management routes
- Non-blocking (errors don't fail main operation)

Admin API Endpoints:
- GET /api/admin/management-audit?limit=N
- GET /api/admin/management-audit/stats
- GET /api/admin/management-audit/group/:groupId

Tested:
 Migration executed successfully
 Audit logs written on token validation
 Admin API returns logs with stats
 Token masking working
 Statistics accurate
This commit is contained in:
Matthias Lotz 2025-11-11 21:12:07 +01:00
parent 0dce5fddac
commit 0f77db6f02
5 changed files with 360 additions and 1 deletions

View File

@ -0,0 +1,32 @@
-- Migration 007: Create management audit log table
-- Date: 2025-11-11
-- Description: Track all management portal actions for security and compliance
-- ============================================================================
-- Table: management_audit_log
-- Purpose: Audit trail for all user actions via management portal
-- ============================================================================
CREATE TABLE IF NOT EXISTS management_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT, -- Group ID (NULL if token validation failed)
management_token TEXT, -- Management token used (partially masked in queries)
action TEXT NOT NULL, -- Action type: 'validate_token', 'revoke_consent', 'update_metadata', 'add_image', 'delete_image', 'delete_group'
success BOOLEAN NOT NULL DEFAULT 1, -- Whether action succeeded
error_message TEXT, -- Error message if action failed
ip_address TEXT, -- Client IP address
user_agent TEXT, -- Client user agent
request_data TEXT, -- JSON of request data (sanitized)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- Foreign key (optional, NULL if group was deleted)
FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE SET NULL
);
-- ============================================================================
-- Indexes for query performance
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_audit_group_id ON management_audit_log(group_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON management_audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_success ON management_audit_log(success);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON management_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_ip ON management_audit_log(ip_address);

View File

@ -0,0 +1,47 @@
/**
* Audit-Log Middleware für Management Routes
* Loggt alle Aktionen im Management Portal für Security & Compliance
*/
const auditLogRepository = require('../repositories/ManagementAuditLogRepository');
/**
* Middleware zum Loggen von Management-Aktionen
* Fügt res.auditLog() Funktion hinzu
*/
const auditLogMiddleware = (req, res, next) => {
// Extrahiere Client-Informationen
const ipAddress = req.ip || req.connection.remoteAddress || 'unknown';
const userAgent = req.get('user-agent') || 'unknown';
const managementToken = req.params.token || null;
/**
* Log-Funktion für Controllers
* @param {string} action - Aktion (z.B. 'validate_token', 'revoke_consent')
* @param {boolean} success - Erfolg
* @param {string} groupId - Gruppen-ID (optional)
* @param {string} errorMessage - Fehlermeldung (optional)
* @param {Object} requestData - Request-Daten (optional)
*/
res.auditLog = async (action, success, groupId = null, errorMessage = null, requestData = null) => {
try {
await auditLogRepository.logAction({
groupId,
managementToken,
action,
success,
errorMessage,
ipAddress,
userAgent,
requestData
});
} catch (error) {
console.error('Failed to write audit log:', error);
// Audit-Log-Fehler sollen die Hauptoperation nicht blockieren
}
};
next();
};
module.exports = auditLogMiddleware;

View File

@ -0,0 +1,182 @@
/**
* ManagementAuditLogRepository
*
* Repository für Management Audit Logging
* Verwaltet management_audit_log Tabelle
*/
const dbManager = require('../database/DatabaseManager');
class ManagementAuditLogRepository {
/**
* Log eine Management-Aktion
* @param {Object} logData - Audit-Log-Daten
* @param {string} logData.groupId - Gruppen-ID (optional)
* @param {string} logData.managementToken - Management-Token (wird maskiert)
* @param {string} logData.action - Aktion (validate_token, revoke_consent, etc.)
* @param {boolean} logData.success - Erfolg
* @param {string} logData.errorMessage - Fehlermeldung (optional)
* @param {string} logData.ipAddress - IP-Adresse
* @param {string} logData.userAgent - User-Agent
* @param {Object} logData.requestData - Request-Daten (wird als JSON gespeichert)
* @returns {Promise<number>} ID des Log-Eintrags
*/
async logAction(logData) {
// Maskiere Token (zeige nur erste 8 Zeichen)
const maskedToken = logData.managementToken
? logData.managementToken.substring(0, 8) + '...'
: null;
// Sanitiere Request-Daten (entferne sensible Daten)
const sanitizedData = logData.requestData ? {
...logData.requestData,
managementToken: undefined // Token nie loggen
} : null;
const query = `
INSERT INTO management_audit_log
(group_id, management_token, action, success, error_message, ip_address, user_agent, request_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
const result = await dbManager.run(query, [
logData.groupId || null,
maskedToken,
logData.action,
logData.success ? 1 : 0,
logData.errorMessage || null,
logData.ipAddress || null,
logData.userAgent || null,
sanitizedData ? JSON.stringify(sanitizedData) : null
]);
return result.lastID;
}
/**
* Hole letzte N Audit-Einträge
* @param {number} limit - Anzahl der Einträge (default: 100)
* @returns {Promise<Array>} Array von Audit-Einträgen
*/
async getRecentLogs(limit = 100) {
const query = `
SELECT
id,
group_id,
management_token,
action,
success,
error_message,
ip_address,
user_agent,
request_data,
created_at
FROM management_audit_log
ORDER BY created_at DESC
LIMIT ?
`;
const logs = await dbManager.all(query, [limit]);
// Parse request_data JSON
return logs.map(log => ({
...log,
requestData: log.request_data ? JSON.parse(log.request_data) : null,
request_data: undefined
}));
}
/**
* Hole Audit-Logs für eine Gruppe
* @param {string} groupId - Gruppen-ID
* @returns {Promise<Array>} Array von Audit-Einträgen
*/
async getLogsByGroupId(groupId) {
const query = `
SELECT
id,
group_id,
management_token,
action,
success,
error_message,
ip_address,
user_agent,
request_data,
created_at
FROM management_audit_log
WHERE group_id = ?
ORDER BY created_at DESC
`;
const logs = await dbManager.all(query, [groupId]);
return logs.map(log => ({
...log,
requestData: log.request_data ? JSON.parse(log.request_data) : null,
request_data: undefined
}));
}
/**
* Hole fehlgeschlagene Aktionen nach IP
* @param {string} ipAddress - IP-Adresse
* @param {number} hours - Zeitraum in Stunden (default: 24)
* @returns {Promise<Array>} Array von fehlgeschlagenen Aktionen
*/
async getFailedActionsByIP(ipAddress, hours = 24) {
const query = `
SELECT
id,
group_id,
management_token,
action,
error_message,
created_at
FROM management_audit_log
WHERE ip_address = ?
AND success = 0
AND created_at >= datetime('now', '-${hours} hours')
ORDER BY created_at DESC
`;
return await dbManager.all(query, [ipAddress]);
}
/**
* Statistiken für Audit-Log
* @returns {Promise<Object>} Statistiken
*/
async getStatistics() {
const query = `
SELECT
COUNT(*) as totalActions,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successfulActions,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failedActions,
COUNT(DISTINCT group_id) as uniqueGroups,
COUNT(DISTINCT ip_address) as uniqueIPs,
MAX(created_at) as lastAction
FROM management_audit_log
`;
return await dbManager.get(query);
}
/**
* Lösche alte Audit-Logs (Cleanup)
* @param {number} days - Lösche Logs älter als X Tage (default: 90)
* @returns {Promise<number>} Anzahl gelöschter Einträge
*/
async cleanupOldLogs(days = 90) {
const query = `
DELETE FROM management_audit_log
WHERE created_at < datetime('now', '-${days} days')
`;
const result = await dbManager.run(query);
return result.changes;
}
}
module.exports = new ManagementAuditLogRepository();

View File

@ -1,7 +1,9 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const DeletionLogRepository = require('../repositories/DeletionLogRepository'); const DeletionLogRepository = require('../repositories/DeletionLogRepository');
const ManagementAuditLogRepository = require('../repositories/ManagementAuditLogRepository');
const GroupCleanupService = require('../services/GroupCleanupService'); const GroupCleanupService = require('../services/GroupCleanupService');
const { getStatistics: getRateLimiterStats } = require('../middlewares/rateLimiter');
// GroupCleanupService ist bereits eine Instanz, keine Klasse // GroupCleanupService ist bereits eine Instanz, keine Klasse
const cleanupService = GroupCleanupService; const cleanupService = GroupCleanupService;
@ -135,4 +137,91 @@ router.get('/cleanup/preview', async (req, res) => {
}); });
// Rate-Limiter Statistiken (für Monitoring)
router.get('/rate-limiter/stats', async (req, res) => {
try {
const stats = getRateLimiterStats();
res.json({
success: true,
...stats
});
} catch (error) {
console.error('[Admin API] Error fetching rate-limiter stats:', error);
res.status(500).json({
error: 'Internal server error',
message: error.message
});
}
});
// Management Audit-Log (letzte N Einträge)
router.get('/management-audit', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
if (limit < 1 || limit > 1000) {
return res.status(400).json({
error: 'Invalid limit',
message: 'Limit must be between 1 and 1000'
});
}
const logs = await ManagementAuditLogRepository.getRecentLogs(limit);
res.json({
success: true,
logs: logs,
total: logs.length,
limit: limit
});
} catch (error) {
console.error('[Admin API] Error fetching management audit log:', error);
res.status(500).json({
error: 'Internal server error',
message: error.message
});
}
});
// Management Audit-Log Statistiken
router.get('/management-audit/stats', async (req, res) => {
try {
const stats = await ManagementAuditLogRepository.getStatistics();
res.json({
success: true,
...stats
});
} catch (error) {
console.error('[Admin API] Error fetching audit log stats:', error);
res.status(500).json({
error: 'Internal server error',
message: error.message
});
}
});
// Management Audit-Log nach Group-ID
router.get('/management-audit/group/:groupId', async (req, res) => {
try {
const { groupId } = req.params;
const logs = await ManagementAuditLogRepository.getLogsByGroupId(groupId);
res.json({
success: true,
groupId: groupId,
logs: logs,
total: logs.length
});
} catch (error) {
console.error('[Admin API] Error fetching audit log for group:', error);
res.status(500).json({
error: 'Internal server error',
message: error.message
});
}
});
module.exports = router; module.exports = router;

View File

@ -4,9 +4,11 @@ const groupRepository = require('../repositories/GroupRepository');
const deletionLogRepository = require('../repositories/DeletionLogRepository'); const deletionLogRepository = require('../repositories/DeletionLogRepository');
const dbManager = require('../database/DatabaseManager'); const dbManager = require('../database/DatabaseManager');
const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter'); const { rateLimitMiddleware, recordFailedTokenValidation } = require('../middlewares/rateLimiter');
const auditLogMiddleware = require('../middlewares/auditLog');
// Apply rate limiting to all management routes // Apply middleware to all management routes
router.use(rateLimitMiddleware); router.use(rateLimitMiddleware);
router.use(auditLogMiddleware);
// Helper: Validate UUID v4 token format // Helper: Validate UUID v4 token format
const validateToken = (token) => { const validateToken = (token) => {
@ -40,12 +42,17 @@ router.get('/:token', async (req, res) => {
if (!groupData) { if (!groupData) {
recordFailedTokenValidation(req); // Track brute-force attempts recordFailedTokenValidation(req); // Track brute-force attempts
await res.auditLog('validate_token', false, null, 'Token not found or group deleted');
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
error: 'Management token not found or group has been deleted' error: 'Management token not found or group has been deleted'
}); });
} }
// Log successful token validation
await res.auditLog('validate_token', true, groupData.groupId);
// Return complete group data // Return complete group data
res.json({ res.json({
success: true, success: true,
@ -54,6 +61,8 @@ router.get('/:token', async (req, res) => {
} catch (error) { } catch (error) {
console.error('Error validating management token:', error); console.error('Error validating management token:', error);
await res.auditLog('validate_token', false, null, error.message);
res.status(500).json({ res.status(500).json({
success: false, success: false,
error: 'Failed to validate management token' error: 'Failed to validate management token'