mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 23:14:12 +00:00
feat: implement monitoring & alerting system
- Add AuditLogs collection for tracking critical system actions - User changes (create, update, delete) - Tenant changes with sensitive data masking - Login events tracking - Add Alert Service with multi-channel support - Email, Slack, Discord, Console channels - Configurable alert levels (info, warning, error, critical) - Environment-based configuration - Add Email failure alerting - Automatic alerts on repeated failed emails - Per-tenant failure counting with hourly reset - Add Email-Logs API endpoints - GET /api/email-logs/export (CSV/JSON export) - GET /api/email-logs/stats (statistics with filters) - Add audit hooks for Users and Tenants collections - Update TODO.md with completed monitoring tasks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
966af755b4
commit
6bbbea52fc
17 changed files with 16605 additions and 15 deletions
|
|
@ -81,10 +81,10 @@
|
||||||
|
|
||||||
### Hohe Priorität
|
### Hohe Priorität
|
||||||
|
|
||||||
- [ ] **[!] Tenant-Domains konfigurieren**
|
- [x] **Tenant-Domains konfigurieren** (Erledigt: 07.12.2025)
|
||||||
- Domains in Tenants Collection eintragen
|
- [x] Domains in Tenants Collection eingetragen
|
||||||
- DNS-Einträge prüfen
|
- [x] DNS-Einträge konfiguriert
|
||||||
- Caddy-Konfiguration für alle Domains
|
- [x] ~~Caddy-Konfiguration~~ (nicht benötigt im Tech-Stack)
|
||||||
|
|
||||||
- [x] **E-Mail-System** (Erledigt: 06.12.2025)
|
- [x] **E-Mail-System** (Erledigt: 06.12.2025)
|
||||||
- [x] Multi-Tenant Email Adapter für Payload CMS
|
- [x] Multi-Tenant Email Adapter für Payload CMS
|
||||||
|
|
@ -185,14 +185,128 @@
|
||||||
- [ ] Disaster Recovery Plan
|
- [ ] Disaster Recovery Plan
|
||||||
- [ ] Backup-Rotation (30 Tage Retention)
|
- [ ] Backup-Rotation (30 Tage Retention)
|
||||||
|
|
||||||
- [ ] **Monitoring & Logging**
|
- [ ] **Monitoring & Logging** (→ siehe Phase 4: Produktionsreife)
|
||||||
- Sentry Error Tracking
|
- Sentry Error Tracking
|
||||||
- Prometheus Metrics
|
- Prometheus Metrics
|
||||||
- Grafana Dashboard
|
- Grafana Dashboard
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4: Tenant-spezifische Features
|
## Phase 4: Produktionsreife (Audit-basiert)
|
||||||
|
|
||||||
|
> Basierend auf Audit-Analyse vom 07.12.2025
|
||||||
|
|
||||||
|
### [!] Hohe Priorität - Stabilität & Sicherheit
|
||||||
|
|
||||||
|
#### Monitoring & Alerting
|
||||||
|
- [x] **AuditLogs Collection** (Erledigt: 07.12.2025)
|
||||||
|
- [x] Collection erstellen für: Tenant-Änderungen, Admin-Login, kritische Aktionen
|
||||||
|
- [x] Automatisches Logging via Collection Hooks (Users, Tenants)
|
||||||
|
- [x] Sensitive Data Masking (Passwörter, Secrets)
|
||||||
|
- [ ] Retention Policy (90 Tage) - Cron-Job TODO
|
||||||
|
- [x] **Email-Fehler Alerting** (Erledigt: 07.12.2025)
|
||||||
|
- [x] Hook bei wiederholten `failed`-Status in EmailLogs
|
||||||
|
- [x] Multi-Channel Alert Service (E-Mail, Slack, Discord, Console)
|
||||||
|
- [x] Konfigurierbare Alert-Level (info, warning, error, critical)
|
||||||
|
- [ ] Dashboard-Widget für Email-Status im Admin
|
||||||
|
- [x] **Email-Logs Admin-Verbesserungen** (Erledigt: 07.12.2025)
|
||||||
|
- [x] Filter nach Status (pending/sent/failed) im Admin
|
||||||
|
- [x] Export-Endpoint für Email-Logs (CSV/JSON) - `/api/email-logs/export`
|
||||||
|
- [x] Statistik-Endpoint (letzte 24h/7d/30d) - `/api/email-logs/stats`
|
||||||
|
|
||||||
|
#### Backup & Recovery
|
||||||
|
- [ ] **Automatisierte Datenbank-Backups**
|
||||||
|
- [ ] Cron-Job für tägliche pg_dump
|
||||||
|
- [ ] Offsite-Storage (S3/MinIO)
|
||||||
|
- [ ] Backup-Rotation (30 Tage Retention)
|
||||||
|
- [ ] Dokumentierter Restore-Prozess
|
||||||
|
- [ ] **Media-Backup**
|
||||||
|
- [ ] S3/MinIO Integration für Media-Uploads
|
||||||
|
- [ ] Versionierung aktivieren
|
||||||
|
- [ ] Sync-Script für Offsite-Backup
|
||||||
|
|
||||||
|
#### Security Hardening
|
||||||
|
- [ ] **API-Schutz erweitern**
|
||||||
|
- [ ] Globales Rate-Limiting für alle öffentlichen Endpoints
|
||||||
|
- [ ] IP-Allowlist Option für `/api/send-email`
|
||||||
|
- [ ] CSRF-Schutz für Browser-basierte API-Calls
|
||||||
|
- [ ] **Sensitive Data Masking**
|
||||||
|
- [ ] Email-Error-Logs maskieren (keine Secrets in Admin UI)
|
||||||
|
- [ ] SMTP-Passwörter in Logs redacten
|
||||||
|
- [ ] **Secrets Scanning**
|
||||||
|
- [ ] Pre-commit Hook für Secret-Detection
|
||||||
|
- [ ] GitHub Secret Scanning aktivieren
|
||||||
|
|
||||||
|
### Mittlere Priorität - Performance & Skalierung
|
||||||
|
|
||||||
|
#### Search Performance
|
||||||
|
- [ ] **Full-Text-Search aktivieren**
|
||||||
|
- [ ] `USE_FTS=true` in Production setzen
|
||||||
|
- [ ] PostgreSQL `to_tsvector`-Indices erstellen
|
||||||
|
- [ ] Performance-Test mit Produktionsdaten
|
||||||
|
- [ ] **Redis-Migration für Caches**
|
||||||
|
- [ ] Search-Cache von In-Memory auf Redis migrieren
|
||||||
|
- [ ] Rate-Limit-Maps auf Redis migrieren
|
||||||
|
- [ ] Suggestions-Cache auf Redis
|
||||||
|
|
||||||
|
#### Background Jobs
|
||||||
|
- [ ] **Queue-System implementieren**
|
||||||
|
- [ ] BullMQ oder Agenda.js evaluieren
|
||||||
|
- [ ] E-Mail-Versand über Queue (non-blocking)
|
||||||
|
- [ ] PDF-Generierung über Queue
|
||||||
|
- [ ] Job-Dashboard im Admin
|
||||||
|
|
||||||
|
#### Database Optimization
|
||||||
|
- [ ] **Index-Audit**
|
||||||
|
- [ ] Composite-Indices für lokalisierte Felder (slug + locale)
|
||||||
|
- [ ] Query-Performance-Analyse
|
||||||
|
- [ ] EXPLAIN ANALYZE für häufige Queries
|
||||||
|
- [ ] **Connection Pooling**
|
||||||
|
- [ ] PgBouncer evaluieren für Multi-Instanz-Betrieb
|
||||||
|
|
||||||
|
#### Build & Infrastructure
|
||||||
|
- [ ] **Memory-Problem lösen**
|
||||||
|
- [ ] Swap auf Server aktivieren (2-4GB)
|
||||||
|
- [ ] Alternativ: Build auf separatem Runner
|
||||||
|
- [ ] **PM2 Cluster Mode**
|
||||||
|
- [ ] Multi-Instanz Konfiguration testen
|
||||||
|
- [ ] Shared State via Redis sicherstellen
|
||||||
|
|
||||||
|
### Niedrige Priorität - Developer Experience & UX
|
||||||
|
|
||||||
|
#### Testing & CI/CD
|
||||||
|
- [ ] **Test-Suite reparieren**
|
||||||
|
- [ ] Test-DB mit Migrationen aufsetzen
|
||||||
|
- [ ] Skipped Tests aktivieren (email-logs, i18n)
|
||||||
|
- [ ] Coverage-Report generieren
|
||||||
|
- [ ] **CI/CD Pipeline**
|
||||||
|
- [ ] GitHub Actions Workflow erstellen
|
||||||
|
- [ ] Automatisches Lint/Test/Build
|
||||||
|
- [ ] Secrets-Scanning in Pipeline
|
||||||
|
- [ ] Staging-Deployment
|
||||||
|
|
||||||
|
#### Admin UX
|
||||||
|
- [ ] **Tenant-Wechsel UI**
|
||||||
|
- [ ] Dropdown in Admin-Leiste für schnellen Tenant-Wechsel
|
||||||
|
- [ ] Tenant-Kontext in Breadcrumbs anzeigen
|
||||||
|
- [ ] **Email-Konfiguration UX**
|
||||||
|
- [ ] Formularvalidierung für SMTP-Settings
|
||||||
|
- [ ] Tooltips für SPF/DKIM-Hinweise
|
||||||
|
- [ ] "Test-Email senden" Button
|
||||||
|
- [ ] **Tenant Self-Service**
|
||||||
|
- [ ] API für Tenant-Admins zum Testen der SMTP-Settings
|
||||||
|
- [ ] Email-Logs Einsicht für eigenen Tenant
|
||||||
|
- [ ] Eigene Statistiken Dashboard
|
||||||
|
|
||||||
|
#### Data Retention
|
||||||
|
- [ ] **Automatische Datenbereinigung**
|
||||||
|
- [ ] Cron-Job für Email-Log Cleanup (älter als X Tage)
|
||||||
|
- [ ] Consent-Logs Archivierung
|
||||||
|
- [ ] Media-Orphan-Cleanup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Tenant-spezifische Features
|
||||||
|
|
||||||
### porwoll.de
|
### porwoll.de
|
||||||
- [ ] Immobilien-Collection (falls benötigt)
|
- [ ] Immobilien-Collection (falls benötigt)
|
||||||
|
|
@ -281,13 +395,15 @@
|
||||||
|
|
||||||
3. **PM2 Cluster Mode:** Aktuell 1 Instanz. Für Skalierung `instances: "max"` setzen.
|
3. **PM2 Cluster Mode:** Aktuell 1 Instanz. Für Skalierung `instances: "max"` setzen.
|
||||||
|
|
||||||
### Nächste Schritte
|
### Nächste Schritte (Priorisiert)
|
||||||
|
|
||||||
1. Tenant-Domains in DB eintragen
|
1. **[KRITISCH]** AuditLogs Collection implementieren
|
||||||
2. E-Mail-Adapter konfigurieren
|
2. **[KRITISCH]** Automatisierte Backups einrichten
|
||||||
3. Frontend-Entwicklung starten
|
3. **[HOCH]** Full-Text-Search aktivieren (USE_FTS=true)
|
||||||
4. Erste Inhalte einpflegen (DE + EN)
|
4. **[HOCH]** Rate-Limits auf Redis migrieren
|
||||||
5. Admin-User für Tenants erstellen
|
5. **[MITTEL]** CI/CD Pipeline mit GitHub Actions
|
||||||
|
6. **[MITTEL]** Frontend-Entwicklung starten
|
||||||
|
7. **[NIEDRIG]** Admin UX Verbesserungen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -299,4 +415,4 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Letzte Aktualisierung: 07.12.2025 (E-Mail-System, Portfolio-Collections, Redis Caching)*
|
*Letzte Aktualisierung: 07.12.2025 (Monitoring & Alerting implementiert)*
|
||||||
|
|
|
||||||
209
src/app/(payload)/api/email-logs/export/route.ts
Normal file
209
src/app/(payload)/api/email-logs/export/route.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
/**
|
||||||
|
* Email-Logs Export Endpoint
|
||||||
|
*
|
||||||
|
* GET /api/email-logs/export
|
||||||
|
*
|
||||||
|
* Exportiert Email-Logs als CSV oder JSON.
|
||||||
|
* Nur für Super Admins und Tenant-Admins (für ihre eigenen Tenants).
|
||||||
|
*
|
||||||
|
* Query-Parameter:
|
||||||
|
* - format: 'csv' | 'json' (default: 'json')
|
||||||
|
* - tenantId: number (optional, für Filterung)
|
||||||
|
* - status: 'pending' | 'sent' | 'failed' (optional)
|
||||||
|
* - from: ISO date string (optional)
|
||||||
|
* - to: ISO date string (optional)
|
||||||
|
* - limit: number (default: 1000, max: 10000)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
interface UserWithTenants {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
isSuperAdmin?: boolean
|
||||||
|
tenants?: Array<{ tenant: { id: number } | number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailLogEntry {
|
||||||
|
id: number
|
||||||
|
to: string
|
||||||
|
from: string
|
||||||
|
subject: string
|
||||||
|
status: string
|
||||||
|
source: string
|
||||||
|
messageId?: string
|
||||||
|
error?: string
|
||||||
|
createdAt: string
|
||||||
|
tenant?: { id: number; name?: string } | number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserTenantIds(user: UserWithTenants): number[] {
|
||||||
|
return (user.tenants || []).map((t) => (typeof t.tenant === 'object' ? t.tenant.id : t.tenant))
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(field: string | undefined | null): string {
|
||||||
|
if (field === undefined || field === null) return ''
|
||||||
|
const str = String(field)
|
||||||
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCSV(logs: EmailLogEntry[]): string {
|
||||||
|
const headers = [
|
||||||
|
'ID',
|
||||||
|
'Datum',
|
||||||
|
'Empfänger',
|
||||||
|
'Absender',
|
||||||
|
'Betreff',
|
||||||
|
'Status',
|
||||||
|
'Quelle',
|
||||||
|
'Message-ID',
|
||||||
|
'Fehler',
|
||||||
|
'Tenant-ID',
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = logs.map((log) => {
|
||||||
|
const tenantId = typeof log.tenant === 'object' ? log.tenant?.id : log.tenant
|
||||||
|
return [
|
||||||
|
log.id,
|
||||||
|
log.createdAt,
|
||||||
|
log.to,
|
||||||
|
log.from,
|
||||||
|
log.subject,
|
||||||
|
log.status,
|
||||||
|
log.source,
|
||||||
|
log.messageId || '',
|
||||||
|
log.error || '',
|
||||||
|
tenantId || '',
|
||||||
|
]
|
||||||
|
.map((field) => escapeCsvField(String(field)))
|
||||||
|
.join(',')
|
||||||
|
})
|
||||||
|
|
||||||
|
return [headers.join(','), ...rows].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
// Auth prüfen
|
||||||
|
const { user } = await payload.auth({ headers: req.headers })
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Nicht authentifiziert' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedUser = user as unknown as UserWithTenants
|
||||||
|
|
||||||
|
// Query-Parameter parsen
|
||||||
|
const searchParams = req.nextUrl.searchParams
|
||||||
|
const format = searchParams.get('format') || 'json'
|
||||||
|
const tenantIdParam = searchParams.get('tenantId')
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
const fromDate = searchParams.get('from')
|
||||||
|
const toDate = searchParams.get('to')
|
||||||
|
const limitParam = searchParams.get('limit')
|
||||||
|
|
||||||
|
// Limit validieren
|
||||||
|
let limit = parseInt(limitParam || '1000', 10)
|
||||||
|
if (isNaN(limit) || limit < 1) limit = 1000
|
||||||
|
if (limit > 10000) limit = 10000
|
||||||
|
|
||||||
|
// Tenant-Zugriff prüfen
|
||||||
|
const userTenantIds = getUserTenantIds(typedUser)
|
||||||
|
let tenantFilter: number[] | undefined
|
||||||
|
|
||||||
|
if (typedUser.isSuperAdmin) {
|
||||||
|
// Super Admin kann alle oder gefiltert abrufen
|
||||||
|
if (tenantIdParam) {
|
||||||
|
tenantFilter = [parseInt(tenantIdParam, 10)]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normale User können nur ihre Tenants sehen
|
||||||
|
if (tenantIdParam) {
|
||||||
|
const requestedTenant = parseInt(tenantIdParam, 10)
|
||||||
|
if (!userTenantIds.includes(requestedTenant)) {
|
||||||
|
return NextResponse.json({ error: 'Kein Zugriff auf diesen Tenant' }, { status: 403 })
|
||||||
|
}
|
||||||
|
tenantFilter = [requestedTenant]
|
||||||
|
} else {
|
||||||
|
tenantFilter = userTenantIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query erstellen
|
||||||
|
const where: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if (tenantFilter) {
|
||||||
|
where.tenant = { in: tenantFilter }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && ['pending', 'sent', 'failed'].includes(status)) {
|
||||||
|
where.status = { equals: status }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
...(where.createdAt as object),
|
||||||
|
greater_than_equal: new Date(fromDate).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate) {
|
||||||
|
where.createdAt = {
|
||||||
|
...(where.createdAt as object),
|
||||||
|
less_than_equal: new Date(toDate).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs abrufen - Type assertion für where da email-logs noch nicht in payload-types
|
||||||
|
const result = await (payload.find as Function)({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where,
|
||||||
|
limit,
|
||||||
|
sort: '-createdAt',
|
||||||
|
depth: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const logs = result.docs as EmailLogEntry[]
|
||||||
|
|
||||||
|
// Format-spezifische Response
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csv = convertToCSV(logs)
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="email-logs-${new Date().toISOString().split('T')[0]}.csv"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON Response
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
total: result.totalDocs,
|
||||||
|
exported: logs.length,
|
||||||
|
logs: logs.map((log) => ({
|
||||||
|
id: log.id,
|
||||||
|
to: log.to,
|
||||||
|
from: log.from,
|
||||||
|
subject: log.subject,
|
||||||
|
status: log.status,
|
||||||
|
source: log.source,
|
||||||
|
messageId: log.messageId,
|
||||||
|
error: log.error,
|
||||||
|
createdAt: log.createdAt,
|
||||||
|
tenantId: typeof log.tenant === 'object' ? log.tenant?.id : log.tenant,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmailLogs:Export] Error:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/app/(payload)/api/email-logs/stats/route.ts
Normal file
175
src/app/(payload)/api/email-logs/stats/route.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* Email-Logs Statistics Endpoint
|
||||||
|
*
|
||||||
|
* GET /api/email-logs/stats
|
||||||
|
*
|
||||||
|
* Liefert Statistiken über Email-Logs.
|
||||||
|
* Nur für Super Admins und Tenant-Admins (für ihre eigenen Tenants).
|
||||||
|
*
|
||||||
|
* Query-Parameter:
|
||||||
|
* - tenantId: number (optional)
|
||||||
|
* - period: '24h' | '7d' | '30d' (default: '7d')
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
interface UserWithTenants {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
isSuperAdmin?: boolean
|
||||||
|
tenants?: Array<{ tenant: { id: number } | number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserTenantIds(user: UserWithTenants): number[] {
|
||||||
|
return (user.tenants || []).map((t) => (typeof t.tenant === 'object' ? t.tenant.id : t.tenant))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriodDate(period: string): Date {
|
||||||
|
const now = new Date()
|
||||||
|
switch (period) {
|
||||||
|
case '24h':
|
||||||
|
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
case '30d':
|
||||||
|
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
case '7d':
|
||||||
|
default:
|
||||||
|
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
|
||||||
|
// Auth prüfen
|
||||||
|
const { user } = await payload.auth({ headers: req.headers })
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Nicht authentifiziert' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedUser = user as unknown as UserWithTenants
|
||||||
|
|
||||||
|
// Query-Parameter parsen
|
||||||
|
const searchParams = req.nextUrl.searchParams
|
||||||
|
const tenantIdParam = searchParams.get('tenantId')
|
||||||
|
const period = searchParams.get('period') || '7d'
|
||||||
|
|
||||||
|
// Tenant-Zugriff prüfen
|
||||||
|
const userTenantIds = getUserTenantIds(typedUser)
|
||||||
|
let tenantFilter: number[] | undefined
|
||||||
|
|
||||||
|
if (typedUser.isSuperAdmin) {
|
||||||
|
if (tenantIdParam) {
|
||||||
|
tenantFilter = [parseInt(tenantIdParam, 10)]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (tenantIdParam) {
|
||||||
|
const requestedTenant = parseInt(tenantIdParam, 10)
|
||||||
|
if (!userTenantIds.includes(requestedTenant)) {
|
||||||
|
return NextResponse.json({ error: 'Kein Zugriff auf diesen Tenant' }, { status: 403 })
|
||||||
|
}
|
||||||
|
tenantFilter = [requestedTenant]
|
||||||
|
} else {
|
||||||
|
tenantFilter = userTenantIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodDate = getPeriodDate(period)
|
||||||
|
|
||||||
|
// Basis-Where für alle Queries
|
||||||
|
const baseWhere: Record<string, unknown> = {
|
||||||
|
createdAt: { greater_than_equal: periodDate.toISOString() },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenantFilter) {
|
||||||
|
baseWhere.tenant = { in: tenantFilter }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistiken parallel abrufen - Type assertions für email-logs Collection
|
||||||
|
const countFn = payload.count as Function
|
||||||
|
const findFn = payload.find as Function
|
||||||
|
|
||||||
|
const [totalResult, sentResult, failedResult, pendingResult, recentFailed] = await Promise.all([
|
||||||
|
// Gesamt
|
||||||
|
countFn({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where: baseWhere,
|
||||||
|
}),
|
||||||
|
// Gesendet
|
||||||
|
countFn({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where: { ...baseWhere, status: { equals: 'sent' } },
|
||||||
|
}),
|
||||||
|
// Fehlgeschlagen
|
||||||
|
countFn({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where: { ...baseWhere, status: { equals: 'failed' } },
|
||||||
|
}),
|
||||||
|
// Ausstehend
|
||||||
|
countFn({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where: { ...baseWhere, status: { equals: 'pending' } },
|
||||||
|
}),
|
||||||
|
// Letzte 5 fehlgeschlagene (für Quick-View)
|
||||||
|
findFn({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where: { ...baseWhere, status: { equals: 'failed' } },
|
||||||
|
limit: 5,
|
||||||
|
sort: '-createdAt',
|
||||||
|
depth: 1,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Erfolgsrate berechnen
|
||||||
|
const total = totalResult.totalDocs
|
||||||
|
const sent = sentResult.totalDocs
|
||||||
|
const failed = failedResult.totalDocs
|
||||||
|
const pending = pendingResult.totalDocs
|
||||||
|
const successRate = total > 0 ? Math.round((sent / total) * 100 * 10) / 10 : 0
|
||||||
|
|
||||||
|
// Statistiken nach Quelle
|
||||||
|
const sourceStats: Record<string, number> = {}
|
||||||
|
const sources = ['manual', 'form', 'system', 'newsletter']
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
sources.map(async (source) => {
|
||||||
|
const result = await countFn({
|
||||||
|
collection: 'email-logs',
|
||||||
|
where: { ...baseWhere, source: { equals: source } },
|
||||||
|
})
|
||||||
|
sourceStats[source] = result.totalDocs
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
period,
|
||||||
|
periodStart: periodDate.toISOString(),
|
||||||
|
stats: {
|
||||||
|
total,
|
||||||
|
sent,
|
||||||
|
failed,
|
||||||
|
pending,
|
||||||
|
successRate,
|
||||||
|
},
|
||||||
|
bySource: sourceStats,
|
||||||
|
recentFailures: recentFailed.docs.map((doc: Record<string, unknown>) => ({
|
||||||
|
id: doc.id,
|
||||||
|
to: doc.to,
|
||||||
|
subject: doc.subject,
|
||||||
|
error: doc.error,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
tenantId:
|
||||||
|
typeof doc.tenant === 'object' && doc.tenant
|
||||||
|
? (doc.tenant as { id?: number }).id
|
||||||
|
: doc.tenant,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmailLogs:Stats] Error:', error)
|
||||||
|
return NextResponse.json({ error: 'Interner Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/collections/AuditLogs.ts
Normal file
181
src/collections/AuditLogs.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuditLogs Collection
|
||||||
|
*
|
||||||
|
* Protokolliert wichtige System-Aktionen für Compliance und Debugging:
|
||||||
|
* - Admin-Logins
|
||||||
|
* - Tenant-Änderungen
|
||||||
|
* - User-Änderungen (Rollen, Berechtigungen)
|
||||||
|
* - Kritische Konfigurationsänderungen
|
||||||
|
* - Fehlgeschlagene Authentifizierungsversuche
|
||||||
|
*/
|
||||||
|
export const AuditLogs: CollectionConfig = {
|
||||||
|
slug: 'audit-logs',
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'action',
|
||||||
|
group: 'System',
|
||||||
|
description: 'Protokoll wichtiger System-Aktionen',
|
||||||
|
defaultColumns: ['action', 'entityType', 'user', 'severity', 'createdAt'],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
// Nur Super Admins können Audit-Logs lesen
|
||||||
|
read: ({ req }) => {
|
||||||
|
return Boolean((req.user as { isSuperAdmin?: boolean })?.isSuperAdmin)
|
||||||
|
},
|
||||||
|
// Niemand kann manuell Logs erstellen/bearbeiten
|
||||||
|
create: () => false,
|
||||||
|
update: () => false,
|
||||||
|
delete: () => false,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Login erfolgreich', value: 'login_success' },
|
||||||
|
{ label: 'Login fehlgeschlagen', value: 'login_failed' },
|
||||||
|
{ label: 'Logout', value: 'logout' },
|
||||||
|
{ label: 'Passwort geändert', value: 'password_changed' },
|
||||||
|
{ label: 'Passwort zurückgesetzt', value: 'password_reset' },
|
||||||
|
{ label: 'Dokument erstellt', value: 'create' },
|
||||||
|
{ label: 'Dokument aktualisiert', value: 'update' },
|
||||||
|
{ label: 'Dokument gelöscht', value: 'delete' },
|
||||||
|
{ label: 'Konfiguration geändert', value: 'config_changed' },
|
||||||
|
{ label: 'E-Mail fehlgeschlagen', value: 'email_failed' },
|
||||||
|
{ label: 'Zugriff verweigert', value: 'access_denied' },
|
||||||
|
{ label: 'API Rate-Limit erreicht', value: 'rate_limit' },
|
||||||
|
],
|
||||||
|
label: 'Aktion',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'severity',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'info',
|
||||||
|
options: [
|
||||||
|
{ label: 'Info', value: 'info' },
|
||||||
|
{ label: 'Warnung', value: 'warning' },
|
||||||
|
{ label: 'Fehler', value: 'error' },
|
||||||
|
{ label: 'Kritisch', value: 'critical' },
|
||||||
|
],
|
||||||
|
label: 'Schweregrad',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'entityType',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Benutzer', value: 'users' },
|
||||||
|
{ label: 'Tenant', value: 'tenants' },
|
||||||
|
{ label: 'Seite', value: 'pages' },
|
||||||
|
{ label: 'Beitrag', value: 'posts' },
|
||||||
|
{ label: 'Medien', value: 'media' },
|
||||||
|
{ label: 'Formular', value: 'forms' },
|
||||||
|
{ label: 'E-Mail', value: 'email' },
|
||||||
|
{ label: 'Global', value: 'global' },
|
||||||
|
{ label: 'System', value: 'system' },
|
||||||
|
],
|
||||||
|
label: 'Entitätstyp',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'entityId',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Entitäts-ID',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'ID des betroffenen Dokuments',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'users',
|
||||||
|
label: 'Benutzer',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Benutzer, der die Aktion ausgeführt hat',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userEmail',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Benutzer-E-Mail',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'E-Mail zum Zeitpunkt der Aktion (für gelöschte User)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tenant',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'tenants',
|
||||||
|
label: 'Tenant',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Betroffener Tenant (falls zutreffend)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ipAddress',
|
||||||
|
type: 'text',
|
||||||
|
label: 'IP-Adresse',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userAgent',
|
||||||
|
type: 'text',
|
||||||
|
label: 'User-Agent',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'textarea',
|
||||||
|
label: 'Beschreibung',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Detaillierte Beschreibung der Aktion',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'previousValue',
|
||||||
|
type: 'json',
|
||||||
|
label: 'Vorheriger Wert',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Zustand vor der Änderung',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'newValue',
|
||||||
|
type: 'json',
|
||||||
|
label: 'Neuer Wert',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Zustand nach der Änderung',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metadata',
|
||||||
|
type: 'json',
|
||||||
|
label: 'Zusätzliche Daten',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Weitere Kontextinformationen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
|
import { emailFailureAlertHook } from '../hooks/emailFailureAlertHook'
|
||||||
|
|
||||||
export const EmailLogs: CollectionConfig = {
|
export const EmailLogs: CollectionConfig = {
|
||||||
slug: 'email-logs',
|
slug: 'email-logs',
|
||||||
|
|
@ -8,6 +9,9 @@ export const EmailLogs: CollectionConfig = {
|
||||||
description: 'Protokoll aller gesendeten E-Mails',
|
description: 'Protokoll aller gesendeten E-Mails',
|
||||||
defaultColumns: ['to', 'subject', 'status', 'tenant', 'createdAt'],
|
defaultColumns: ['to', 'subject', 'status', 'tenant', 'createdAt'],
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [emailFailureAlertHook],
|
||||||
|
},
|
||||||
access: {
|
access: {
|
||||||
// Nur Admins können Logs lesen
|
// Nur Admins können Logs lesen
|
||||||
read: ({ req }) => {
|
read: ({ req }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache'
|
import { invalidateEmailCacheHook } from '../hooks/invalidateEmailCache'
|
||||||
|
import { auditTenantAfterChange, auditTenantAfterDelete } from '../hooks/auditTenantChanges'
|
||||||
|
|
||||||
export const Tenants: CollectionConfig = {
|
export const Tenants: CollectionConfig = {
|
||||||
slug: 'tenants',
|
slug: 'tenants',
|
||||||
|
|
@ -7,7 +8,8 @@ export const Tenants: CollectionConfig = {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
afterChange: [invalidateEmailCacheHook],
|
afterChange: [invalidateEmailCacheHook, auditTenantAfterChange],
|
||||||
|
afterDelete: [auditTenantAfterDelete],
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
|
import { auditUserAfterChange, auditUserAfterDelete } from '../hooks/auditUserChanges'
|
||||||
|
|
||||||
export const Users: CollectionConfig = {
|
export const Users: CollectionConfig = {
|
||||||
slug: 'users',
|
slug: 'users',
|
||||||
|
|
@ -6,6 +7,10 @@ export const Users: CollectionConfig = {
|
||||||
useAsTitle: 'email',
|
useAsTitle: 'email',
|
||||||
},
|
},
|
||||||
auth: true,
|
auth: true,
|
||||||
|
hooks: {
|
||||||
|
afterChange: [auditUserAfterChange],
|
||||||
|
afterDelete: [auditUserAfterDelete],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'isSuperAdmin',
|
name: 'isSuperAdmin',
|
||||||
|
|
|
||||||
71
src/hooks/auditTenantChanges.ts
Normal file
71
src/hooks/auditTenantChanges.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Audit Hook für Tenant-Änderungen
|
||||||
|
*
|
||||||
|
* Loggt alle Änderungen an Tenant-Dokumenten für Compliance und Debugging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||||
|
import { logTenantChange } from '../lib/audit/audit-service'
|
||||||
|
|
||||||
|
interface TenantUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
isSuperAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Loggt Tenant-Erstellung und -Aktualisierung
|
||||||
|
*/
|
||||||
|
export const auditTenantAfterChange: CollectionAfterChangeHook = async ({
|
||||||
|
doc,
|
||||||
|
previousDoc,
|
||||||
|
operation,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
const user = req.user as TenantUser | undefined
|
||||||
|
|
||||||
|
if (!user) return doc
|
||||||
|
|
||||||
|
// Sensitive Felder aus dem Log entfernen
|
||||||
|
const sanitizeDoc = (document: Record<string, unknown> | undefined) => {
|
||||||
|
if (!document) return undefined
|
||||||
|
const sanitized = { ...document }
|
||||||
|
// SMTP-Passwort entfernen
|
||||||
|
if (sanitized.email && typeof sanitized.email === 'object') {
|
||||||
|
const emailConfig = { ...(sanitized.email as Record<string, unknown>) }
|
||||||
|
if (emailConfig.smtp && typeof emailConfig.smtp === 'object') {
|
||||||
|
const smtp = { ...(emailConfig.smtp as Record<string, unknown>) }
|
||||||
|
delete smtp.pass
|
||||||
|
emailConfig.smtp = smtp
|
||||||
|
}
|
||||||
|
sanitized.email = emailConfig
|
||||||
|
}
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
await logTenantChange(
|
||||||
|
req.payload,
|
||||||
|
doc.id,
|
||||||
|
operation,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
sanitizeDoc(previousDoc),
|
||||||
|
sanitizeDoc(doc),
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Loggt Tenant-Löschung
|
||||||
|
*/
|
||||||
|
export const auditTenantAfterDelete: CollectionAfterDeleteHook = async ({ doc, req }) => {
|
||||||
|
const user = req.user as TenantUser | undefined
|
||||||
|
|
||||||
|
if (!user) return doc
|
||||||
|
|
||||||
|
await logTenantChange(req.payload, doc.id, 'delete', user.id, user.email, doc, undefined, req)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
86
src/hooks/auditUserChanges.ts
Normal file
86
src/hooks/auditUserChanges.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Audit Hook für User-Änderungen
|
||||||
|
*
|
||||||
|
* Loggt alle Änderungen an User-Dokumenten für Compliance und Debugging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||||
|
import { logUserChange } from '../lib/audit/audit-service'
|
||||||
|
|
||||||
|
interface AuditUser {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
isSuperAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Loggt User-Erstellung und -Aktualisierung
|
||||||
|
*/
|
||||||
|
export const auditUserAfterChange: CollectionAfterChangeHook = async ({
|
||||||
|
doc,
|
||||||
|
previousDoc,
|
||||||
|
operation,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
const performingUser = req.user as AuditUser | undefined
|
||||||
|
|
||||||
|
// Nur loggen wenn ein User die Änderung durchführt (nicht bei System-Operationen)
|
||||||
|
if (!performingUser) return doc
|
||||||
|
|
||||||
|
// Sensitive Felder aus dem Log entfernen
|
||||||
|
const sanitizeDoc = (document: Record<string, unknown> | undefined) => {
|
||||||
|
if (!document) return undefined
|
||||||
|
const sanitized = { ...document }
|
||||||
|
// Passwort-Hash entfernen
|
||||||
|
delete sanitized.hash
|
||||||
|
delete sanitized.salt
|
||||||
|
delete sanitized.password
|
||||||
|
delete sanitized.resetPasswordToken
|
||||||
|
delete sanitized.resetPasswordExpiration
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
await logUserChange(
|
||||||
|
req.payload,
|
||||||
|
doc.id,
|
||||||
|
operation,
|
||||||
|
performingUser.id,
|
||||||
|
performingUser.email,
|
||||||
|
{
|
||||||
|
previousValue: sanitizeDoc(previousDoc),
|
||||||
|
newValue: sanitizeDoc(doc),
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Loggt User-Löschung
|
||||||
|
*/
|
||||||
|
export const auditUserAfterDelete: CollectionAfterDeleteHook = async ({ doc, req }) => {
|
||||||
|
const performingUser = req.user as AuditUser | undefined
|
||||||
|
|
||||||
|
if (!performingUser) return doc
|
||||||
|
|
||||||
|
// Sensitive Felder entfernen
|
||||||
|
const sanitizedDoc = { ...doc }
|
||||||
|
delete sanitizedDoc.hash
|
||||||
|
delete sanitizedDoc.salt
|
||||||
|
delete sanitizedDoc.password
|
||||||
|
|
||||||
|
await logUserChange(
|
||||||
|
req.payload,
|
||||||
|
doc.id,
|
||||||
|
'delete',
|
||||||
|
performingUser.id,
|
||||||
|
performingUser.email,
|
||||||
|
{
|
||||||
|
previousValue: sanitizedDoc,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
125
src/hooks/emailFailureAlertHook.ts
Normal file
125
src/hooks/emailFailureAlertHook.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* Email Failure Alert Hook
|
||||||
|
*
|
||||||
|
* Überwacht EmailLogs auf wiederholte Fehler und löst Alerts aus.
|
||||||
|
* Integriert mit dem Alert-Service für Multi-Channel-Benachrichtigungen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CollectionAfterChangeHook } from 'payload'
|
||||||
|
import { alertEmailFailed } from '../lib/alerting/alert-service'
|
||||||
|
import { logEmailFailed } from '../lib/audit/audit-service'
|
||||||
|
|
||||||
|
// In-Memory Counter für failed E-Mails pro Tenant (innerhalb von 1 Stunde)
|
||||||
|
const failedEmailCounter: Map<number, { count: number; lastReset: number }> = new Map()
|
||||||
|
const RESET_INTERVAL = 60 * 60 * 1000 // 1 Stunde
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Anzahl der fehlgeschlagenen E-Mails für einen Tenant zurück
|
||||||
|
*/
|
||||||
|
function getFailedCount(tenantId: number): number {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = failedEmailCounter.get(tenantId)
|
||||||
|
|
||||||
|
if (!entry || now - entry.lastReset > RESET_INTERVAL) {
|
||||||
|
failedEmailCounter.set(tenantId, { count: 0, lastReset: now })
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inkrementiert den Zähler für fehlgeschlagene E-Mails
|
||||||
|
*/
|
||||||
|
function incrementFailedCount(tenantId: number): number {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = failedEmailCounter.get(tenantId)
|
||||||
|
|
||||||
|
if (!entry || now - entry.lastReset > RESET_INTERVAL) {
|
||||||
|
failedEmailCounter.set(tenantId, { count: 1, lastReset: now })
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return entry.count
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailLogDoc {
|
||||||
|
id: number
|
||||||
|
tenant: { id: number; name?: string } | number
|
||||||
|
to: string
|
||||||
|
subject: string
|
||||||
|
status: 'pending' | 'sent' | 'failed'
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: Reagiert auf neue EmailLog-Einträge mit Status "failed"
|
||||||
|
*/
|
||||||
|
export const emailFailureAlertHook: CollectionAfterChangeHook = async ({
|
||||||
|
doc,
|
||||||
|
previousDoc,
|
||||||
|
operation,
|
||||||
|
req,
|
||||||
|
}) => {
|
||||||
|
const emailDoc = doc as EmailLogDoc
|
||||||
|
|
||||||
|
// Nur bei Status-Änderung zu "failed" oder neuen failed-Einträgen
|
||||||
|
const isNewFailed = operation === 'create' && emailDoc.status === 'failed'
|
||||||
|
const isChangedToFailed =
|
||||||
|
operation === 'update' && emailDoc.status === 'failed' && previousDoc?.status !== 'failed'
|
||||||
|
|
||||||
|
if (!isNewFailed && !isChangedToFailed) {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
const { payload } = req
|
||||||
|
|
||||||
|
// Tenant-ID und Name ermitteln
|
||||||
|
const tenantId = typeof emailDoc.tenant === 'object' ? emailDoc.tenant.id : emailDoc.tenant
|
||||||
|
|
||||||
|
let tenantName = 'Unbekannt'
|
||||||
|
if (typeof emailDoc.tenant === 'object' && emailDoc.tenant.name) {
|
||||||
|
tenantName = emailDoc.tenant.name
|
||||||
|
} else {
|
||||||
|
// Tenant-Name nachladen
|
||||||
|
try {
|
||||||
|
const tenant = await payload.findByID({
|
||||||
|
collection: 'tenants',
|
||||||
|
id: tenantId,
|
||||||
|
})
|
||||||
|
tenantName = tenant?.name || `Tenant ${tenantId}`
|
||||||
|
} catch {
|
||||||
|
tenantName = `Tenant ${tenantId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fehler-Zähler erhöhen
|
||||||
|
const failedCount = incrementFailedCount(tenantId)
|
||||||
|
|
||||||
|
// Audit-Log erstellen
|
||||||
|
await logEmailFailed(
|
||||||
|
payload,
|
||||||
|
tenantId,
|
||||||
|
emailDoc.to,
|
||||||
|
emailDoc.subject,
|
||||||
|
emailDoc.error || 'Unbekannter Fehler',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert senden (bei jedem Fehler, aber mit unterschiedlichem Level basierend auf failedCount)
|
||||||
|
await alertEmailFailed(
|
||||||
|
payload,
|
||||||
|
tenantId,
|
||||||
|
tenantName,
|
||||||
|
emailDoc.to,
|
||||||
|
emailDoc.subject,
|
||||||
|
emailDoc.error || 'Unbekannter Fehler',
|
||||||
|
failedCount,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[EmailFailureAlert] Tenant ${tenantId} (${tenantName}): ${failedCount} fehlgeschlagene E-Mails in der letzten Stunde`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doc
|
||||||
|
}
|
||||||
344
src/lib/alerting/alert-service.ts
Normal file
344
src/lib/alerting/alert-service.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
/**
|
||||||
|
* Alert Service
|
||||||
|
*
|
||||||
|
* Zentraler Service für System-Benachrichtigungen.
|
||||||
|
* Unterstützt verschiedene Kanäle: E-Mail, Slack, Discord, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Payload } from 'payload'
|
||||||
|
|
||||||
|
export type AlertChannel = 'email' | 'slack' | 'discord' | 'console'
|
||||||
|
export type AlertLevel = 'info' | 'warning' | 'error' | 'critical'
|
||||||
|
|
||||||
|
export interface AlertConfig {
|
||||||
|
channels: AlertChannel[]
|
||||||
|
recipients?: string[] // E-Mail-Adressen
|
||||||
|
slackWebhook?: string
|
||||||
|
discordWebhook?: string
|
||||||
|
minLevel: AlertLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertInput {
|
||||||
|
level: AlertLevel
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
tenantId?: number
|
||||||
|
tenantName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert-Konfiguration aus Environment
|
||||||
|
const alertConfig: AlertConfig = {
|
||||||
|
channels: (process.env.ALERT_CHANNELS?.split(',') as AlertChannel[]) || ['console'],
|
||||||
|
recipients: process.env.ALERT_EMAIL_RECIPIENTS?.split(','),
|
||||||
|
slackWebhook: process.env.ALERT_SLACK_WEBHOOK,
|
||||||
|
discordWebhook: process.env.ALERT_DISCORD_WEBHOOK,
|
||||||
|
minLevel: (process.env.ALERT_MIN_LEVEL as AlertLevel) || 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level-Hierarchie für Filterung
|
||||||
|
const levelPriority: Record<AlertLevel, number> = {
|
||||||
|
info: 0,
|
||||||
|
warning: 1,
|
||||||
|
error: 2,
|
||||||
|
critical: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob ein Alert basierend auf dem Level gesendet werden soll
|
||||||
|
*/
|
||||||
|
function shouldSendAlert(level: AlertLevel): boolean {
|
||||||
|
return levelPriority[level] >= levelPriority[alertConfig.minLevel]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert die Alert-Nachricht für verschiedene Kanäle
|
||||||
|
*/
|
||||||
|
function formatAlertMessage(alert: AlertInput, format: 'text' | 'html' | 'markdown'): string {
|
||||||
|
const timestamp = new Date().toLocaleString('de-DE')
|
||||||
|
const levelEmoji: Record<AlertLevel, string> = {
|
||||||
|
info: 'ℹ️',
|
||||||
|
warning: '⚠️',
|
||||||
|
error: '❌',
|
||||||
|
critical: '🚨',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'html') {
|
||||||
|
return `
|
||||||
|
<div style="font-family: Arial, sans-serif; padding: 20px; border-left: 4px solid ${
|
||||||
|
alert.level === 'critical'
|
||||||
|
? '#dc2626'
|
||||||
|
: alert.level === 'error'
|
||||||
|
? '#f97316'
|
||||||
|
: alert.level === 'warning'
|
||||||
|
? '#eab308'
|
||||||
|
: '#3b82f6'
|
||||||
|
};">
|
||||||
|
<h2 style="margin: 0 0 10px;">${levelEmoji[alert.level]} ${alert.title}</h2>
|
||||||
|
<p style="color: #666; margin: 0 0 15px;">${timestamp}</p>
|
||||||
|
${alert.tenantName ? `<p><strong>Tenant:</strong> ${alert.tenantName}</p>` : ''}
|
||||||
|
<p>${alert.message}</p>
|
||||||
|
${
|
||||||
|
alert.details
|
||||||
|
? `
|
||||||
|
<details>
|
||||||
|
<summary>Details</summary>
|
||||||
|
<pre style="background: #f5f5f5; padding: 10px; overflow: auto;">${JSON.stringify(alert.details, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'markdown') {
|
||||||
|
let md = `${levelEmoji[alert.level]} **${alert.title}**\n\n`
|
||||||
|
md += `*${timestamp}*\n\n`
|
||||||
|
if (alert.tenantName) md += `**Tenant:** ${alert.tenantName}\n\n`
|
||||||
|
md += `${alert.message}\n`
|
||||||
|
if (alert.details) {
|
||||||
|
md += `\n\`\`\`json\n${JSON.stringify(alert.details, null, 2)}\n\`\`\``
|
||||||
|
}
|
||||||
|
return md
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text
|
||||||
|
let text = `${levelEmoji[alert.level]} ${alert.title}\n`
|
||||||
|
text += `${timestamp}\n\n`
|
||||||
|
if (alert.tenantName) text += `Tenant: ${alert.tenantName}\n`
|
||||||
|
text += `${alert.message}\n`
|
||||||
|
if (alert.details) {
|
||||||
|
text += `\nDetails: ${JSON.stringify(alert.details)}`
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Alert via E-Mail
|
||||||
|
*/
|
||||||
|
async function sendEmailAlert(payload: Payload, alert: AlertInput): Promise<void> {
|
||||||
|
if (!alertConfig.recipients?.length) {
|
||||||
|
console.warn('[AlertService] No email recipients configured')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelLabels: Record<AlertLevel, string> = {
|
||||||
|
info: 'Info',
|
||||||
|
warning: 'Warnung',
|
||||||
|
error: 'Fehler',
|
||||||
|
critical: 'KRITISCH',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await payload.sendEmail({
|
||||||
|
to: alertConfig.recipients.join(','),
|
||||||
|
subject: `[${levelLabels[alert.level]}] ${alert.title}`,
|
||||||
|
html: formatAlertMessage(alert, 'html'),
|
||||||
|
text: formatAlertMessage(alert, 'text'),
|
||||||
|
})
|
||||||
|
console.log(`[AlertService] Email alert sent to ${alertConfig.recipients.join(', ')}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AlertService] Failed to send email alert:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Alert via Slack Webhook
|
||||||
|
*/
|
||||||
|
async function sendSlackAlert(alert: AlertInput): Promise<void> {
|
||||||
|
if (!alertConfig.slackWebhook) {
|
||||||
|
console.warn('[AlertService] No Slack webhook configured')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors: Record<AlertLevel, string> = {
|
||||||
|
info: '#3b82f6',
|
||||||
|
warning: '#eab308',
|
||||||
|
error: '#f97316',
|
||||||
|
critical: '#dc2626',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(alertConfig.slackWebhook, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
color: colors[alert.level],
|
||||||
|
title: alert.title,
|
||||||
|
text: alert.message,
|
||||||
|
fields: [
|
||||||
|
...(alert.tenantName
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: 'Tenant',
|
||||||
|
value: alert.tenantName,
|
||||||
|
short: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
title: 'Level',
|
||||||
|
value: alert.level.toUpperCase(),
|
||||||
|
short: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
footer: 'Payload CMS Alert',
|
||||||
|
ts: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Slack responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
console.log('[AlertService] Slack alert sent')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AlertService] Failed to send Slack alert:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Alert via Discord Webhook
|
||||||
|
*/
|
||||||
|
async function sendDiscordAlert(alert: AlertInput): Promise<void> {
|
||||||
|
if (!alertConfig.discordWebhook) {
|
||||||
|
console.warn('[AlertService] No Discord webhook configured')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors: Record<AlertLevel, number> = {
|
||||||
|
info: 0x3b82f6,
|
||||||
|
warning: 0xeab308,
|
||||||
|
error: 0xf97316,
|
||||||
|
critical: 0xdc2626,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(alertConfig.discordWebhook, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: alert.title,
|
||||||
|
description: alert.message,
|
||||||
|
color: colors[alert.level],
|
||||||
|
fields: [
|
||||||
|
...(alert.tenantName
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Tenant',
|
||||||
|
value: alert.tenantName,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
name: 'Level',
|
||||||
|
value: alert.level.toUpperCase(),
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
text: 'Payload CMS Alert',
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Discord responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
console.log('[AlertService] Discord alert sent')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AlertService] Failed to send Discord alert:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Alert an alle konfigurierten Kanäle
|
||||||
|
*/
|
||||||
|
export async function sendAlert(payload: Payload, alert: AlertInput): Promise<void> {
|
||||||
|
if (!shouldSendAlert(alert.level)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (const channel of alertConfig.channels) {
|
||||||
|
switch (channel) {
|
||||||
|
case 'email':
|
||||||
|
promises.push(sendEmailAlert(payload, alert))
|
||||||
|
break
|
||||||
|
case 'slack':
|
||||||
|
promises.push(sendSlackAlert(alert))
|
||||||
|
break
|
||||||
|
case 'discord':
|
||||||
|
promises.push(sendDiscordAlert(alert))
|
||||||
|
break
|
||||||
|
case 'console':
|
||||||
|
console.log(`[Alert:${alert.level.toUpperCase()}]`, formatAlertMessage(alert, 'text'))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email-Fehler Alert
|
||||||
|
*/
|
||||||
|
export async function alertEmailFailed(
|
||||||
|
payload: Payload,
|
||||||
|
tenantId: number,
|
||||||
|
tenantName: string,
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
error: string,
|
||||||
|
failedCount: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await sendAlert(payload, {
|
||||||
|
level: failedCount >= 5 ? 'critical' : 'error',
|
||||||
|
title: `E-Mail-Versand fehlgeschlagen${failedCount >= 5 ? ' (wiederholt)' : ''}`,
|
||||||
|
message: `E-Mail an "${to}" mit Betreff "${subject}" konnte nicht gesendet werden.`,
|
||||||
|
tenantId,
|
||||||
|
tenantName,
|
||||||
|
details: {
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
error: error.substring(0, 200), // Gekürzt um Secrets zu vermeiden
|
||||||
|
failedCount,
|
||||||
|
recommendation:
|
||||||
|
failedCount >= 5
|
||||||
|
? 'SMTP-Konfiguration prüfen! Mehrere Fehler in Folge.'
|
||||||
|
: 'Einzelner Fehler. Weiter beobachten.',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate-Limit Alert
|
||||||
|
*/
|
||||||
|
export async function alertRateLimitReached(
|
||||||
|
payload: Payload,
|
||||||
|
endpoint: string,
|
||||||
|
userId?: number,
|
||||||
|
userEmail?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await sendAlert(payload, {
|
||||||
|
level: 'warning',
|
||||||
|
title: 'Rate-Limit erreicht',
|
||||||
|
message: `Rate-Limit für Endpoint "${endpoint}" wurde erreicht.`,
|
||||||
|
details: {
|
||||||
|
endpoint,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
336
src/lib/audit/audit-service.ts
Normal file
336
src/lib/audit/audit-service.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
/**
|
||||||
|
* Audit Service
|
||||||
|
*
|
||||||
|
* Zentraler Service für das Logging von Audit-Events.
|
||||||
|
* Verwendet von Hooks und anderen System-Komponenten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Payload, PayloadRequest } from 'payload'
|
||||||
|
|
||||||
|
export type AuditAction =
|
||||||
|
| 'login_success'
|
||||||
|
| 'login_failed'
|
||||||
|
| 'logout'
|
||||||
|
| 'password_changed'
|
||||||
|
| 'password_reset'
|
||||||
|
| 'create'
|
||||||
|
| 'update'
|
||||||
|
| 'delete'
|
||||||
|
| 'config_changed'
|
||||||
|
| 'email_failed'
|
||||||
|
| 'access_denied'
|
||||||
|
| 'rate_limit'
|
||||||
|
|
||||||
|
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical'
|
||||||
|
|
||||||
|
export type AuditEntityType =
|
||||||
|
| 'users'
|
||||||
|
| 'tenants'
|
||||||
|
| 'pages'
|
||||||
|
| 'posts'
|
||||||
|
| 'media'
|
||||||
|
| 'forms'
|
||||||
|
| 'email'
|
||||||
|
| 'global'
|
||||||
|
| 'system'
|
||||||
|
|
||||||
|
export interface AuditLogInput {
|
||||||
|
action: AuditAction
|
||||||
|
severity?: AuditSeverity
|
||||||
|
entityType?: AuditEntityType
|
||||||
|
entityId?: string | number
|
||||||
|
userId?: number
|
||||||
|
userEmail?: string
|
||||||
|
tenantId?: number
|
||||||
|
ipAddress?: string
|
||||||
|
userAgent?: string
|
||||||
|
description?: string
|
||||||
|
previousValue?: Record<string, unknown>
|
||||||
|
newValue?: Record<string, unknown>
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert Client-Informationen aus dem Request
|
||||||
|
*/
|
||||||
|
function getClientInfo(req?: PayloadRequest): { ipAddress?: string; userAgent?: string } {
|
||||||
|
if (!req) return {}
|
||||||
|
|
||||||
|
// IP-Adresse aus verschiedenen Headern extrahieren
|
||||||
|
const forwarded = req.headers?.get?.('x-forwarded-for')
|
||||||
|
const realIp = req.headers?.get?.('x-real-ip')
|
||||||
|
const ipAddress =
|
||||||
|
(typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined) ||
|
||||||
|
(typeof realIp === 'string' ? realIp : undefined) ||
|
||||||
|
'unknown'
|
||||||
|
|
||||||
|
const userAgent = req.headers?.get?.('user-agent') || undefined
|
||||||
|
|
||||||
|
return { ipAddress, userAgent }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt den Schweregrad basierend auf der Aktion
|
||||||
|
*/
|
||||||
|
function getDefaultSeverity(action: AuditAction): AuditSeverity {
|
||||||
|
switch (action) {
|
||||||
|
case 'login_failed':
|
||||||
|
case 'access_denied':
|
||||||
|
case 'rate_limit':
|
||||||
|
return 'warning'
|
||||||
|
case 'email_failed':
|
||||||
|
return 'error'
|
||||||
|
case 'delete':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Audit-Log-Eintrag
|
||||||
|
*/
|
||||||
|
export async function createAuditLog(
|
||||||
|
payload: Payload,
|
||||||
|
input: AuditLogInput,
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const clientInfo = getClientInfo(req)
|
||||||
|
|
||||||
|
// Type assertion notwendig bis payload-types.ts regeneriert wird
|
||||||
|
await (payload.create as Function)({
|
||||||
|
collection: 'audit-logs',
|
||||||
|
data: {
|
||||||
|
action: input.action,
|
||||||
|
severity: input.severity || getDefaultSeverity(input.action),
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId?.toString(),
|
||||||
|
user: input.userId,
|
||||||
|
userEmail: input.userEmail,
|
||||||
|
tenant: input.tenantId,
|
||||||
|
ipAddress: input.ipAddress || clientInfo.ipAddress,
|
||||||
|
userAgent: input.userAgent || clientInfo.userAgent,
|
||||||
|
description: input.description,
|
||||||
|
previousValue: input.previousValue,
|
||||||
|
newValue: input.newValue,
|
||||||
|
metadata: input.metadata,
|
||||||
|
},
|
||||||
|
// Bypass Access Control für System-Logging
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// Fehler beim Audit-Logging sollten die Hauptoperation nicht blockieren
|
||||||
|
console.error('[AuditService] Error creating audit log:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt einen erfolgreichen Login
|
||||||
|
*/
|
||||||
|
export async function logLoginSuccess(
|
||||||
|
payload: Payload,
|
||||||
|
userId: number,
|
||||||
|
userEmail: string,
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await createAuditLog(
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
action: 'login_success',
|
||||||
|
entityType: 'users',
|
||||||
|
entityId: userId,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
description: `Benutzer ${userEmail} hat sich erfolgreich angemeldet`,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt einen fehlgeschlagenen Login
|
||||||
|
*/
|
||||||
|
export async function logLoginFailed(
|
||||||
|
payload: Payload,
|
||||||
|
email: string,
|
||||||
|
reason: string,
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await createAuditLog(
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
action: 'login_failed',
|
||||||
|
severity: 'warning',
|
||||||
|
entityType: 'users',
|
||||||
|
userEmail: email,
|
||||||
|
description: `Fehlgeschlagener Login-Versuch für ${email}: ${reason}`,
|
||||||
|
metadata: { reason },
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt eine Tenant-Änderung
|
||||||
|
*/
|
||||||
|
export async function logTenantChange(
|
||||||
|
payload: Payload,
|
||||||
|
tenantId: number,
|
||||||
|
action: 'create' | 'update' | 'delete',
|
||||||
|
userId: number,
|
||||||
|
userEmail: string,
|
||||||
|
previousValue?: Record<string, unknown>,
|
||||||
|
newValue?: Record<string, unknown>,
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const actionLabels = {
|
||||||
|
create: 'erstellt',
|
||||||
|
update: 'aktualisiert',
|
||||||
|
delete: 'gelöscht',
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog(
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
severity: action === 'delete' ? 'warning' : 'info',
|
||||||
|
entityType: 'tenants',
|
||||||
|
entityId: tenantId,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
tenantId,
|
||||||
|
description: `Tenant ${tenantId} wurde ${actionLabels[action]} von ${userEmail}`,
|
||||||
|
previousValue,
|
||||||
|
newValue,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt eine User-Änderung
|
||||||
|
*/
|
||||||
|
export async function logUserChange(
|
||||||
|
payload: Payload,
|
||||||
|
targetUserId: number,
|
||||||
|
action: 'create' | 'update' | 'delete',
|
||||||
|
performedByUserId: number,
|
||||||
|
performedByEmail: string,
|
||||||
|
changes?: { previousValue?: Record<string, unknown>; newValue?: Record<string, unknown> },
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const actionLabels = {
|
||||||
|
create: 'erstellt',
|
||||||
|
update: 'aktualisiert',
|
||||||
|
delete: 'gelöscht',
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog(
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
severity: action === 'delete' ? 'warning' : 'info',
|
||||||
|
entityType: 'users',
|
||||||
|
entityId: targetUserId,
|
||||||
|
userId: performedByUserId,
|
||||||
|
userEmail: performedByEmail,
|
||||||
|
description: `Benutzer ${targetUserId} wurde ${actionLabels[action]} von ${performedByEmail}`,
|
||||||
|
previousValue: changes?.previousValue,
|
||||||
|
newValue: changes?.newValue,
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt einen E-Mail-Fehler
|
||||||
|
*/
|
||||||
|
export async function logEmailFailed(
|
||||||
|
payload: Payload,
|
||||||
|
tenantId: number,
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
error: string,
|
||||||
|
userId?: number,
|
||||||
|
userEmail?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await createAuditLog(payload, {
|
||||||
|
action: 'email_failed',
|
||||||
|
severity: 'error',
|
||||||
|
entityType: 'email',
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
description: `E-Mail an ${to} fehlgeschlagen: ${subject}`,
|
||||||
|
metadata: {
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
// Fehler maskieren um Secrets zu schützen
|
||||||
|
error: maskSensitiveData(error),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt einen Zugriffsverweigerung
|
||||||
|
*/
|
||||||
|
export async function logAccessDenied(
|
||||||
|
payload: Payload,
|
||||||
|
resource: string,
|
||||||
|
userId?: number,
|
||||||
|
userEmail?: string,
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await createAuditLog(
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
action: 'access_denied',
|
||||||
|
severity: 'warning',
|
||||||
|
entityType: 'system',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
description: `Zugriff auf ${resource} verweigert`,
|
||||||
|
metadata: { resource },
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loggt ein Rate-Limit-Ereignis
|
||||||
|
*/
|
||||||
|
export async function logRateLimit(
|
||||||
|
payload: Payload,
|
||||||
|
endpoint: string,
|
||||||
|
userId?: number,
|
||||||
|
userEmail?: string,
|
||||||
|
req?: PayloadRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await createAuditLog(
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
action: 'rate_limit',
|
||||||
|
severity: 'warning',
|
||||||
|
entityType: 'system',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
description: `Rate-Limit erreicht für ${endpoint}`,
|
||||||
|
metadata: { endpoint },
|
||||||
|
},
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maskiert sensible Daten in Fehlermeldungen
|
||||||
|
*/
|
||||||
|
function maskSensitiveData(text: string): string {
|
||||||
|
// Maskiere Passwörter, Tokens, etc.
|
||||||
|
return text
|
||||||
|
.replace(/password['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'password: [REDACTED]')
|
||||||
|
.replace(/pass['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'pass: [REDACTED]')
|
||||||
|
.replace(/secret['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'secret: [REDACTED]')
|
||||||
|
.replace(/token['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'token: [REDACTED]')
|
||||||
|
.replace(/auth['":\s]*['"]?[^'"\s,}]+['"]?/gi, 'auth: [REDACTED]')
|
||||||
|
}
|
||||||
14766
src/migrations/20251207_205727_audit_logs_collection.json
Normal file
14766
src/migrations/20251207_205727_audit_logs_collection.json
Normal file
File diff suppressed because it is too large
Load diff
49
src/migrations/20251207_205727_audit_logs_collection.ts
Normal file
49
src/migrations/20251207_205727_audit_logs_collection.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_audit_logs_action" AS ENUM('login_success', 'login_failed', 'logout', 'password_changed', 'password_reset', 'create', 'update', 'delete', 'config_changed', 'email_failed', 'access_denied', 'rate_limit');
|
||||||
|
CREATE TYPE "public"."enum_audit_logs_severity" AS ENUM('info', 'warning', 'error', 'critical');
|
||||||
|
CREATE TYPE "public"."enum_audit_logs_entity_type" AS ENUM('users', 'tenants', 'pages', 'posts', 'media', 'forms', 'email', 'global', 'system');
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"action" "enum_audit_logs_action" NOT NULL,
|
||||||
|
"severity" "enum_audit_logs_severity" DEFAULT 'info' NOT NULL,
|
||||||
|
"entity_type" "enum_audit_logs_entity_type",
|
||||||
|
"entity_id" varchar,
|
||||||
|
"user_id" integer,
|
||||||
|
"user_email" varchar,
|
||||||
|
"tenant_id" integer,
|
||||||
|
"ip_address" varchar,
|
||||||
|
"user_agent" varchar,
|
||||||
|
"description" varchar,
|
||||||
|
"previous_value" jsonb,
|
||||||
|
"new_value" jsonb,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "audit_logs_id" integer;
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
CREATE INDEX "audit_logs_user_idx" ON "audit_logs" USING btree ("user_id");
|
||||||
|
CREATE INDEX "audit_logs_tenant_idx" ON "audit_logs" USING btree ("tenant_id");
|
||||||
|
CREATE INDEX "audit_logs_updated_at_idx" ON "audit_logs" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_audit_logs_fk" FOREIGN KEY ("audit_logs_id") REFERENCES "public"."audit_logs"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_audit_logs_id_idx" ON "payload_locked_documents_rels" USING btree ("audit_logs_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "audit_logs" DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP TABLE "audit_logs" CASCADE;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_audit_logs_fk";
|
||||||
|
|
||||||
|
DROP INDEX "payload_locked_documents_rels_audit_logs_id_idx";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "audit_logs_id";
|
||||||
|
DROP TYPE "public"."enum_audit_logs_action";
|
||||||
|
DROP TYPE "public"."enum_audit_logs_severity";
|
||||||
|
DROP TYPE "public"."enum_audit_logs_entity_type";`)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import * as migration_20251202_081830_add_is_super_admin_to_users from './202512
|
||||||
import * as migration_20251206_071552_portfolio_collections from './20251206_071552_portfolio_collections';
|
import * as migration_20251206_071552_portfolio_collections from './20251206_071552_portfolio_collections';
|
||||||
import * as migration_20251206_134750_tenant_email_config from './20251206_134750_tenant_email_config';
|
import * as migration_20251206_134750_tenant_email_config from './20251206_134750_tenant_email_config';
|
||||||
import * as migration_20251206_141403_email_logs_collection from './20251206_141403_email_logs_collection';
|
import * as migration_20251206_141403_email_logs_collection from './20251206_141403_email_logs_collection';
|
||||||
|
import * as migration_20251207_205727_audit_logs_collection from './20251207_205727_audit_logs_collection';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -28,6 +29,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20251206_141403_email_logs_collection.up,
|
up: migration_20251206_141403_email_logs_collection.up,
|
||||||
down: migration_20251206_141403_email_logs_collection.down,
|
down: migration_20251206_141403_email_logs_collection.down,
|
||||||
name: '20251206_141403_email_logs_collection'
|
name: '20251206_141403_email_logs_collection',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20251207_205727_audit_logs_collection.up,
|
||||||
|
down: migration_20251207_205727_audit_logs_collection.down,
|
||||||
|
name: '20251207_205727_audit_logs_collection'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export interface Config {
|
||||||
'consent-logs': ConsentLog;
|
'consent-logs': ConsentLog;
|
||||||
'privacy-policy-settings': PrivacyPolicySetting;
|
'privacy-policy-settings': PrivacyPolicySetting;
|
||||||
'email-logs': EmailLog;
|
'email-logs': EmailLog;
|
||||||
|
'audit-logs': AuditLog;
|
||||||
forms: Form;
|
forms: Form;
|
||||||
'form-submissions': FormSubmission;
|
'form-submissions': FormSubmission;
|
||||||
redirects: Redirect;
|
redirects: Redirect;
|
||||||
|
|
@ -109,6 +110,7 @@ export interface Config {
|
||||||
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
||||||
'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>;
|
'privacy-policy-settings': PrivacyPolicySettingsSelect<false> | PrivacyPolicySettingsSelect<true>;
|
||||||
'email-logs': EmailLogsSelect<false> | EmailLogsSelect<true>;
|
'email-logs': EmailLogsSelect<false> | EmailLogsSelect<true>;
|
||||||
|
'audit-logs': AuditLogsSelect<false> | AuditLogsSelect<true>;
|
||||||
forms: FormsSelect<false> | FormsSelect<true>;
|
forms: FormsSelect<false> | FormsSelect<true>;
|
||||||
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
'form-submissions': FormSubmissionsSelect<false> | FormSubmissionsSelect<true>;
|
||||||
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
|
||||||
|
|
@ -1225,6 +1227,90 @@ export interface EmailLog {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Protokoll wichtiger System-Aktionen
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "audit-logs".
|
||||||
|
*/
|
||||||
|
export interface AuditLog {
|
||||||
|
id: number;
|
||||||
|
action:
|
||||||
|
| 'login_success'
|
||||||
|
| 'login_failed'
|
||||||
|
| 'logout'
|
||||||
|
| 'password_changed'
|
||||||
|
| 'password_reset'
|
||||||
|
| 'create'
|
||||||
|
| 'update'
|
||||||
|
| 'delete'
|
||||||
|
| 'config_changed'
|
||||||
|
| 'email_failed'
|
||||||
|
| 'access_denied'
|
||||||
|
| 'rate_limit';
|
||||||
|
severity: 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
entityType?: ('users' | 'tenants' | 'pages' | 'posts' | 'media' | 'forms' | 'email' | 'global' | 'system') | null;
|
||||||
|
/**
|
||||||
|
* ID des betroffenen Dokuments
|
||||||
|
*/
|
||||||
|
entityId?: string | null;
|
||||||
|
/**
|
||||||
|
* Benutzer, der die Aktion ausgeführt hat
|
||||||
|
*/
|
||||||
|
user?: (number | null) | User;
|
||||||
|
/**
|
||||||
|
* E-Mail zum Zeitpunkt der Aktion (für gelöschte User)
|
||||||
|
*/
|
||||||
|
userEmail?: string | null;
|
||||||
|
/**
|
||||||
|
* Betroffener Tenant (falls zutreffend)
|
||||||
|
*/
|
||||||
|
tenant?: (number | null) | Tenant;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string | null;
|
||||||
|
/**
|
||||||
|
* Detaillierte Beschreibung der Aktion
|
||||||
|
*/
|
||||||
|
description?: string | null;
|
||||||
|
/**
|
||||||
|
* Zustand vor der Änderung
|
||||||
|
*/
|
||||||
|
previousValue?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* Zustand nach der Änderung
|
||||||
|
*/
|
||||||
|
newValue?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
/**
|
||||||
|
* Weitere Kontextinformationen
|
||||||
|
*/
|
||||||
|
metadata?:
|
||||||
|
| {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
| unknown[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "forms".
|
* via the `definition` "forms".
|
||||||
|
|
@ -1509,6 +1595,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'email-logs';
|
relationTo: 'email-logs';
|
||||||
value: number | EmailLog;
|
value: number | EmailLog;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'audit-logs';
|
||||||
|
value: number | AuditLog;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'forms';
|
relationTo: 'forms';
|
||||||
value: number | Form;
|
value: number | Form;
|
||||||
|
|
@ -2321,6 +2411,27 @@ export interface EmailLogsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "audit-logs_select".
|
||||||
|
*/
|
||||||
|
export interface AuditLogsSelect<T extends boolean = true> {
|
||||||
|
action?: T;
|
||||||
|
severity?: T;
|
||||||
|
entityType?: T;
|
||||||
|
entityId?: T;
|
||||||
|
user?: T;
|
||||||
|
userEmail?: T;
|
||||||
|
tenant?: T;
|
||||||
|
ipAddress?: T;
|
||||||
|
userAgent?: T;
|
||||||
|
description?: T;
|
||||||
|
previousValue?: T;
|
||||||
|
newValue?: T;
|
||||||
|
metadata?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "forms_select".
|
* via the `definition` "forms_select".
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ import { multiTenantEmailAdapter } from './lib/email/payload-email-adapter'
|
||||||
// Email Logs
|
// Email Logs
|
||||||
import { EmailLogs } from './collections/EmailLogs'
|
import { EmailLogs } from './collections/EmailLogs'
|
||||||
|
|
||||||
|
// Audit Logs
|
||||||
|
import { AuditLogs } from './collections/AuditLogs'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
||||||
|
|
@ -118,6 +121,7 @@ export default buildConfig({
|
||||||
PrivacyPolicySettings,
|
PrivacyPolicySettings,
|
||||||
// System
|
// System
|
||||||
EmailLogs,
|
EmailLogs,
|
||||||
|
AuditLogs,
|
||||||
],
|
],
|
||||||
globals: [SiteSettings, Navigation, SEOSettings],
|
globals: [SiteSettings, Navigation, SEOSettings],
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue