Files
opencrm/backend/src/middleware/audit.ts
T
duffyduckandClaude Opus 5 41671cbb96 Dienstkonto-Anmeldungen als Routine statt CRITICAL (Pentest R182)
Das Gegenbuch meldet sich stuendlich an - protokolliert wurde das als
Authentication/CRITICAL. Damit stand das vorhersagbarste Ereignis im System auf
der hoechsten Stufe: Jeder legitime Lauf trainiert den Betreiber darauf,
CRITICAL wegzuklicken, und der erste echte Vorfall erbt diesen Reflex. Dieselbe
Alarm-Muedigkeits-Klasse wie R162, diesmal von uns selbst erzeugt.

Nicht unterdrueckt, sondern eingestuft: Neues Kennzeichen isServiceAccount am
Benutzer (Migration 20260822100000). Anmeldungen solcher Konten erscheinen
weiterhin im Log - man soll sehen, dass das Gegenbuch arbeitet - aber als
Routine (LOW) mit eigenem Label 'Dienstkonto ... angemeldet (planmaessig)'.
Alle anderen Anmeldungen bleiben CRITICAL.

Offen und dem Pentester so benannt: Der eigentliche Tripwire waere der
AUSBLEIBENDE Heartbeat (Dienstkonto meldet sich N Intervalle nicht mehr) sowie
Anmeldungen ausserhalb der Kadenz oder von fremder Quell-IP. Das braucht einen
Zeitgeber und ist noch nicht gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:18:54 +02:00

535 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Response, NextFunction } from 'express';
import { AuditAction } from '@prisma/client';
import { AuthRequest } from '../types/index.js';
import { createAuditLog } from '../services/audit.service.js';
import { getAuditContext, AuditContext } from './auditContext.js';
// Resource-Typ-Mapping basierend auf Route-Patterns
const RESOURCE_MAPPING: Record<string, { type: string; extractId?: (req: AuthRequest) => string | undefined }> = {
'/api/customers': { type: 'Customer', extractId: (req) => req.params.id || req.params.customerId },
'/api/customers/*/bank-cards': { type: 'BankCard', extractId: (req) => req.params.bankCardId },
'/api/customers/*/documents': { type: 'IdentityDocument', extractId: (req) => req.params.documentId },
'/api/customers/*/addresses': { type: 'Address', extractId: (req) => req.params.addressId },
'/api/customers/*/meters': { type: 'Meter', extractId: (req) => req.params.meterId },
'/api/customers/*/consents': { type: 'CustomerConsent', extractId: (req) => req.params.type },
'/api/contracts': { type: 'Contract', extractId: (req) => req.params.id },
'/api/contracts/*/history': { type: 'ContractHistoryEntry', extractId: (req) => req.params.entryId },
'/api/contracts/*/tasks': { type: 'ContractTask', extractId: (req) => req.params.taskId },
'/api/users': { type: 'User', extractId: (req) => req.params.id },
'/api/providers': { type: 'Provider', extractId: (req) => req.params.id },
'/api/tariffs': { type: 'Tariff', extractId: (req) => req.params.id },
'/api/platforms': { type: 'SalesPlatform', extractId: (req) => req.params.id },
'/api/contract-categories': { type: 'ContractCategory', extractId: (req) => req.params.id },
'/api/cancellation-periods': { type: 'CancellationPeriod', extractId: (req) => req.params.id },
'/api/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id },
'/api/meters': { type: 'Meter', extractId: (req) => req.params.id || req.params.meterId },
'/api/upload': { type: 'Upload' },
'/api/email-logs': { type: 'EmailLog' },
'/api/auth': { type: 'Authentication' },
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
'/api/gdpr': { type: 'GDPR' },
};
// Routen die nicht geloggt werden sollen
const EXCLUDED_ROUTES = [
'/api/health',
'/api/uploads',
];
/**
* Bestimmt die Aktion basierend auf HTTP-Methode und Erfolg
*/
function determineAction(method: string, path: string, success: boolean): AuditAction {
// Spezielle Auth-Aktionen
if (path.includes('/auth/login')) {
return success ? 'LOGIN' : 'LOGIN_FAILED';
}
if (path.includes('/auth/logout')) {
return 'LOGOUT';
}
// Stiller Token-Refresh (Cookie) kein interaktiver Login, eigene Action.
// Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides
// ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH)
// und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01).
if (path.includes('/auth/refresh')) {
return 'TOKEN_REFRESH';
}
// Standard CRUD-Aktionen
switch (method.toUpperCase()) {
case 'GET':
return 'READ';
case 'POST':
return 'CREATE';
case 'PUT':
case 'PATCH':
return 'UPDATE';
case 'DELETE':
return 'DELETE';
default:
return 'READ';
}
}
/**
* Findet den passenden Resource-Typ für einen Pfad
*/
function findResourceMapping(path: string): { type: string; extractId?: (req: AuthRequest) => string | undefined } | null {
// Exakte Matches zuerst prüfen
for (const [pattern, mapping] of Object.entries(RESOURCE_MAPPING)) {
// Konvertiere Pattern zu Regex
const regexPattern = pattern
.replace(/\*/g, '[^/]+')
.replace(/\//g, '\\/');
const regex = new RegExp(`^${regexPattern}(?:/|$)`);
if (regex.test(path)) {
return mapping;
}
}
return null;
}
/**
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
*/
function extractDataSubjectId(req: AuthRequest, fullPath: string): number | undefined {
// Aus Route-Parameter
const customerId = req.params.customerId || req.params.id;
if (customerId && fullPath.includes('/customers')) {
return parseInt(customerId);
}
// Aus Request-Body (bei Create)
if (req.body?.customerId) {
return parseInt(req.body.customerId);
}
// Bei Kundenportal-Zugriff
if (req.user?.customerId) {
return req.user.customerId;
}
return undefined;
}
/**
* Extrahiert die IP-Adresse des Clients
*/
function getClientIp(req: AuthRequest): string {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
}
return req.socket.remoteAddress || 'unknown';
}
// Menschenlesbare Bezeichnungen für Resource-Typen
const RESOURCE_TYPE_LABELS: Record<string, string> = {
Customer: 'Kunde',
Contract: 'Vertrag',
BankCard: 'Bankverbindung',
IdentityDocument: 'Ausweis',
Address: 'Adresse',
Meter: 'Zähler',
MeterReading: 'Zählerstand',
User: 'Benutzer',
Provider: 'Anbieter',
Tariff: 'Tarif',
SalesPlatform: 'Vertriebsplattform',
ContractCategory: 'Vertragskategorie',
CancellationPeriod: 'Kündigungsfrist',
ContractDuration: 'Vertragslaufzeit',
EmailProviderConfig: 'E-Mail-Provider',
AppSetting: 'Einstellung',
CustomerConsent: 'Einwilligung',
ContractTask: 'Aufgabe',
ContractHistoryEntry: 'Vertragshistorie',
GDPR: 'Datenschutz',
Authentication: 'Anmeldung',
AuditLog: 'Audit-Protokoll',
StressfreiEmail: 'Stressfrei-E-Mail',
CachedEmail: 'E-Mail',
};
const ACTION_LABELS: Record<string, string> = {
CREATE: 'erstellt',
READ: 'aufgerufen',
UPDATE: 'aktualisiert',
DELETE: 'gelöscht',
EXPORT: 'exportiert',
ANONYMIZE: 'anonymisiert',
LOGIN: 'angemeldet',
LOGOUT: 'abgemeldet',
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
TOKEN_REFRESH: 'Sitzung verlängert',
};
/**
* Ist das die planmaessige Anmeldung eines Dienstkontos? (Pentest R182)
*
* Vorhersagbare Ereignisse sind miserable Alarme und exzellente Grundlinien.
* Das Gegenbuch meldet sich stuendlich an als CRITICAL gefuehrt trainiert
* genau diese Routine den Betreiber darauf, die hoechste Stufe wegzuklicken,
* und der erste ECHTE Vorfall erbt den Reflex. Der Eintrag bleibt (man soll
* sehen, dass das Gegenbuch arbeitet), aber als Routine.
*/
function istDienstkontoAnmeldung(responseBody: unknown, success: boolean): boolean {
if (!success || !responseBody || typeof responseBody !== 'object') return false;
const data = (responseBody as { data?: { user?: { isServiceAccount?: boolean } } }).data;
return data?.user?.isServiceAccount === true;
}
/**
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
*
* "Kein Cookie vorhanden" ist KEIN Ablehnungsfall: Der Frontend-Interceptor
* ruft /refresh beim App-Start auch dann, wenn nie ein Token gesetzt war
* (normaler Erstbesuch). Das als HIGH/"abgelehnt" zu fuehren erzeugt genau das
* Rauschen, das mit der Entrauschung beseitigt werden sollte.
*/
function refreshOutcome(responseBody: unknown, success: boolean): 'ok' | 'no-token' | 'rejected' {
if (success) return 'ok';
const err = responseBody && typeof responseBody === 'object' && 'error' in responseBody
? String((responseBody as { error?: unknown }).error ?? '')
: '';
return /kein refresh-token/i.test(err) ? 'no-token' : 'rejected';
}
/**
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
*/
function generateHumanLabel(
action: AuditAction,
resourceType: string,
req: AuthRequest,
responseBody: unknown,
fullPath: string
): string {
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
const actionName = ACTION_LABELS[action] || action;
// Identifikator aus Response oder Request extrahieren
let identifier = '';
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
const data = (responseBody as { data: Record<string, unknown> }).data;
if (data) {
identifier =
(data.contractNumber as string) ||
(data.customerNumber as string) ||
(data.meterNumber as string) ||
(data.name as string) ||
(data.email as string) ||
(data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : '') ||
'';
}
}
// Spezial-Labels für bestimmte Endpunkte
// (fullPath statt req.path siehe Hinweis in auditMiddleware, Pentest R165)
const path = fullPath;
// Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
const email = req.body?.email || '';
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
return `Dienstkonto ${email} angemeldet (planmäßig)`;
}
return action === 'LOGIN'
? `Benutzer ${email} hat sich angemeldet`
: `Anmeldung fehlgeschlagen für ${email}`;
}
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
if (path.includes('/auth/refresh')) {
const success = !(responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false);
switch (refreshOutcome(responseBody, success)) {
case 'ok': return 'Sitzung verlängert (Token erneuert)';
case 'no-token': return 'Token-Refresh ohne vorliegenden Token (kein Cookie)';
default: return 'Token-Refresh abgelehnt (ungültig/abgelaufen)';
}
}
// Kunden-Operationen
if (resourceType === 'Customer') {
if (action === 'CREATE') return `Kunde ${identifier} angelegt`;
if (action === 'UPDATE') return `Kundendaten ${identifier} aktualisiert`;
if (action === 'DELETE') return `Kunde ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Kundendaten ${identifier} aufgerufen`;
if (action === 'READ') return 'Kundenliste aufgerufen';
}
// Verträge
if (resourceType === 'Contract') {
if (path.includes('/cockpit')) return 'Vertrags-Cockpit aufgerufen';
if (path.includes('/follow-up')) return `Folgevertrag für ${identifier} erstellt`;
if (path.includes('/snooze')) return `Vertrag ${identifier} zurückgestellt`;
if (path.includes('/password')) return `Passwort für Vertrag ${identifier} abgerufen`;
if (path.includes('/sip-credentials')) return 'SIP-Zugangsdaten abgerufen';
if (path.includes('/internet-credentials')) return `Internet-Zugangsdaten für Vertrag ${identifier} abgerufen`;
if (path.includes('/successor-meter')) return `Folgezähler zu Vertrag ${identifier} hinzugefügt`;
if (action === 'CREATE') return `Vertrag ${identifier} angelegt`;
if (action === 'UPDATE') return `Vertrag ${identifier} aktualisiert`;
if (action === 'DELETE') return `Vertrag ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Vertrag ${identifier} aufgerufen`;
if (action === 'READ') return 'Vertragsliste aufgerufen';
}
// Bankverbindungen
if (resourceType === 'BankCard') {
if (action === 'CREATE') return `Bankverbindung hinzugefügt`;
if (action === 'UPDATE') return `Bankverbindung aktualisiert`;
if (action === 'DELETE') return `Bankverbindung gelöscht`;
}
// Ausweise
if (resourceType === 'IdentityDocument') {
if (action === 'CREATE') return `Ausweis ${identifier} hinzugefügt`;
if (action === 'UPDATE') return `Ausweis ${identifier} aktualisiert`;
if (action === 'DELETE') return `Ausweis gelöscht`;
}
// Adressen
if (resourceType === 'Address') {
if (action === 'CREATE') return `Adresse hinzugefügt`;
if (action === 'UPDATE') return `Adresse aktualisiert`;
if (action === 'DELETE') return `Adresse gelöscht`;
}
// Zähler
if (resourceType === 'Meter') {
if (action === 'CREATE') return `Zähler ${identifier} angelegt`;
if (action === 'UPDATE') return `Zähler ${identifier} aktualisiert`;
if (action === 'DELETE') return `Zähler gelöscht`;
}
// Einwilligungen
if (resourceType === 'CustomerConsent') {
const consentType = req.params.consentType || '';
const consentLabels: Record<string, string> = {
DATA_PROCESSING: 'Datenverarbeitung',
MARKETING_EMAIL: 'E-Mail-Marketing',
MARKETING_PHONE: 'Telefonmarketing',
DATA_SHARING_PARTNER: 'Datenweitergabe',
};
const consentName = consentLabels[consentType] || consentType;
if (action === 'UPDATE') {
const status = req.body?.status;
return status === 'GRANTED'
? `Einwilligung "${consentName}" erteilt`
: `Einwilligung "${consentName}" widerrufen`;
}
if (action === 'READ') return 'Einwilligungen abgerufen';
}
// Benutzer
if (resourceType === 'User') {
if (action === 'CREATE') return `Benutzer ${identifier} angelegt`;
if (action === 'UPDATE') return `Benutzer ${identifier} aktualisiert`;
if (action === 'DELETE') return `Benutzer ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Benutzerdaten ${identifier} aufgerufen`;
if (action === 'READ') return 'Benutzerliste aufgerufen';
}
// Aufgaben
if (resourceType === 'ContractTask') {
if (path.includes('/complete')) return `Aufgabe als erledigt markiert`;
if (action === 'CREATE') return `Aufgabe erstellt`;
if (action === 'UPDATE') return `Aufgabe aktualisiert`;
if (action === 'DELETE') return `Aufgabe gelöscht`;
}
// E-Mail-Provider
if (resourceType === 'EmailProviderConfig') {
if (path.includes('/test-connection')) return `E-Mail-Provider Verbindungstest`;
if (path.includes('/provision')) return `E-Mail-Adresse provisioniert`;
if (action === 'CREATE') return `E-Mail-Provider ${identifier} angelegt`;
if (action === 'UPDATE') return `E-Mail-Provider ${identifier} aktualisiert`;
if (action === 'DELETE') return `E-Mail-Provider ${identifier} gelöscht`;
}
// GDPR
if (resourceType === 'GDPR') {
if (path.includes('/dashboard')) return 'DSGVO-Dashboard aufgerufen';
if (path.includes('/export')) return 'Kundendaten exportiert (DSGVO Art. 15)';
if (path.includes('/privacy-policy')) {
return action === 'UPDATE' ? 'Datenschutzerklärung aktualisiert' : 'Datenschutzerklärung aufgerufen';
}
if (path.includes('/authorization-template')) {
return action === 'UPDATE' ? 'Vollmacht-Vorlage aktualisiert' : 'Vollmacht-Vorlage aufgerufen';
}
if (path.includes('/send-consent-link')) return 'Datenschutz-Link versendet';
if (path.includes('/authorizations') && path.includes('/send')) return 'Vollmacht-Anfrage versendet';
if (path.includes('/authorizations') && path.includes('/grant')) return 'Vollmacht erteilt';
if (path.includes('/authorizations') && path.includes('/withdraw')) return 'Vollmacht widerrufen';
if (path.includes('/authorizations') && path.includes('/upload')) return 'Vollmacht-PDF hochgeladen';
if (path.includes('/authorizations') && path.includes('/document') && action === 'DELETE') return 'Vollmacht-PDF gelöscht';
if (path.includes('/my-privacy')) return 'Eigene Datenschutzseite aufgerufen';
if (path.includes('/my-consent-status')) return 'Eigener Einwilligungsstatus geprüft';
if (path.includes('/my-authorizations')) return 'Eigene Vollmachten aufgerufen';
if (path.includes('/deletions')) {
if (action === 'CREATE') return 'Löschanfrage erstellt';
if (path.includes('/process')) return 'Löschanfrage bearbeitet';
return 'Löschanfragen aufgerufen';
}
if (path.includes('/consent-status')) return 'Einwilligungsstatus geprüft';
if (path.includes('/consents/overview')) return 'Einwilligungsübersicht aufgerufen';
}
// Einstellungen
if (resourceType === 'AppSetting') {
if (action === 'UPDATE') return `Einstellung "${req.params.key || ''}" geändert`;
if (action === 'READ') return 'Einstellungen aufgerufen';
}
// Zähler-Readings
if (path.includes('/readings')) {
if (path.includes('/report')) return 'Zählerstand vom Kunden gemeldet';
if (path.includes('/transfer')) return 'Zählerstand als übertragen markiert';
if (action === 'CREATE') return 'Zählerstand erfasst';
if (action === 'UPDATE') return 'Zählerstand aktualisiert';
if (action === 'DELETE') return 'Zählerstand gelöscht';
}
// Upload-Operationen
if (path.includes('/upload') || path.includes('/privacy-policy')) {
if (path.includes('/privacy-policy') && action === 'DELETE') return 'Datenschutzerklärung-PDF gelöscht';
if (path.includes('/privacy-policy')) return 'Datenschutzerklärung-PDF hochgeladen';
}
// Standard-Fallback
if (identifier) {
return `${typeName} ${identifier} ${actionName}`;
}
return `${typeName} ${actionName}`;
}
/**
* Audit Middleware - loggt alle API-Aufrufe asynchron
*/
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const startTime = Date.now();
// WICHTIG (Pentest R165): `req.path` ist im res.on('finish')-Handler NICHT mehr der
// volle Pfad. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url`
// und stellt ihn nur beim `next()`-Durchlauf wieder her ein Handler, der die
// Response terminiert (res.json()), ruft nie `next()`, also bleibt `req.path`
// router-relativ (`/refresh` statt `/api/auth/refresh`). Deshalb den vollen Pfad
// EINMAL hier synchron festhalten und downstream ausschliesslich diesen nutzen
// sonst matchen alle Pfad-Checks (/auth/login, /auth/refresh, …) ins Leere.
const fullPath = req.originalUrl?.split('?')[0] || req.path;
// Ausgeschlossene Routen überspringen
if (EXCLUDED_ROUTES.some((route) => fullPath.startsWith(route))) {
next();
return;
}
// Resource-Mapping finden
const mapping = findResourceMapping(fullPath);
if (!mapping) {
// Unbekannte Route - trotzdem loggen mit generischem Typ
next();
return;
}
// Original res.json überschreiben um Response zu capturen
const originalJson = res.json.bind(res);
let responseBody: unknown = null;
let responseSuccess = true;
res.json = function (body: unknown) {
responseBody = body;
if (typeof body === 'object' && body !== null && 'success' in body) {
responseSuccess = (body as { success: boolean }).success;
}
return originalJson(body);
};
// Response-Ende abfangen für Logging
// Audit-Kontext hier erfassen (bevor AsyncLocalStorage den Kontext verliert)
let capturedAuditContext: ReturnType<typeof getAuditContext> | undefined;
const origEnd = res.end;
(res as any).end = function(chunk?: any, encoding?: any, cb?: any) {
// Kontext VOR dem Ende erfassen
capturedAuditContext = getAuditContext();
return origEnd.call(this, chunk, encoding, cb);
};
res.on('finish', () => {
// Async Logging - blockiert nicht die Response
setImmediate(async () => {
try {
const durationMs = Date.now() - startTime;
const action = determineAction(req.method, fullPath, responseSuccess);
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
if (action === 'READ') return;
// Routen die bereits gezielt via logChange() geloggt werden → nicht doppelt loggen
const manuallyLoggedPaths = [
'/api/customers',
'/api/contracts',
'/api/meters',
'/api/gdpr',
'/api/upload',
];
// Login/Logout/Refresh immer loggen
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED' && action !== 'TOKEN_REFRESH') {
if (manuallyLoggedPaths.some(p => fullPath.startsWith(p))) return;
}
const resourceId = mapping.extractId?.(req);
const dataSubjectId = extractDataSubjectId(req, fullPath);
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
const auditContext = capturedAuditContext;
// Menschenlesbares Label generieren
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody, fullPath);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'anonymous',
userRole: req.user?.isCustomerPortal ? 'Kundenportal' : (req.user as any)?.roleName || 'Mitarbeiter',
customerId: req.user?.customerId,
isCustomerPortal: req.user?.isCustomerPortal,
action,
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
// Abgelehnter Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in der
// Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
// "Kein Cookie vorhanden" ist dagegen der normale Erstbesuch → LOW (R166-03).
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
sensitivity: action === 'TOKEN_REFRESH'
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
: action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
? 'LOW'
: undefined,
resourceType: mapping.type,
resourceId,
resourceLabel,
endpoint: fullPath,
httpMethod: req.method,
ipAddress: getClientIp(req),
userAgent: req.headers['user-agent'],
changesBefore: auditContext?.before,
changesAfter: auditContext?.after,
dataSubjectId,
success: responseSuccess,
errorMessage: !responseSuccess && responseBody && typeof responseBody === 'object' && 'error' in responseBody
? (responseBody as { error: string }).error
: undefined,
durationMs,
});
} catch (error) {
// Audit-Logging darf niemals die Anwendung beeinträchtigen
console.error('[AuditMiddleware] Fehler beim Logging:', error);
}
});
});
next();
}