Elke serieuze SaaS heeft het nodig, maar weinig founders denken er vroeg genoeg over na: audit logging. Wie heeft wat gedaan, wanneer, en wat was de vorige waarde? Zodra je te maken krijgt met enterprise-klanten, AVG-verzoeken of security-incidenten, is een goed audit trail niet "nice to have" — het is essentieel.
In dit artikel bouwen we stap voor stap een audit logging systeem dat schaalbaar, doorzoekbaar en compliance-ready is.
Waarom audit logging onmisbaar is
Audit logs beantwoorden de vraag: "Wat is er gebeurd?" Ze zijn cruciaal voor:
- Compliance: AVG (GDPR) vereist dat je kunt aantonen wie persoonsgegevens heeft ingezien of gewijzigd. NIS2 stelt eisen aan incident-traceerbaarheid.
- Security: Bij een breach wil je exact weten welke data is benaderd en door wie.
- Klantvragen: "Wie heeft mijn account-instellingen gewijzigd?" — zonder audit log kun je alleen maar raden.
- Debugging: Soms is een audit log de enige manier om te begrijpen hoe data in een bepaalde staat is beland.
- Enterprise sales: Grote klanten vragen standaard om audit logging in hun security questionnaires.
Wat moet je loggen?
Niet alles hoeft in je audit log. Focus op state-veranderende acties en gevoelige leesbewerkingen:
Altijd loggen
- Gebruiker aanmaken, wijzigen, verwijderen
- Rollen en permissies wijzigen
- Inlogpogingen (geslaagd én mislukt)
- Data-export en bulk-operaties
- Facturatie- en abonnementswijzigingen
- API-key aanmaken en intrekken
Optioneel loggen
- Leesbewerkingen op gevoelige data (PII, financieel)
- Configuratiewijzigingen
- Zoekacties op persoonsgegevens
Niet loggen
- Elke pageview of API-call (dat is analytics, geen audit)
- Healthchecks en interne systeem-calls
Het datamodel
Een goed audit log event bevat genoeg context om op zichzelf te staan — je moet het kunnen begrijpen zonder de rest van je database erbij te pakken.
interface AuditEvent {
id: string;
timestamp: Date;
// Wie
actorId: string;
actorType: 'user' | 'admin' | 'system' | 'api_key';
actorEmail?: string;
// Wat
action: string; // bijv. 'user.role.updated'
resourceType: string; // bijv. 'user'
resourceId: string;
// Context
tenantId: string;
ipAddress?: string;
userAgent?: string;
requestId?: string;
// Details
changes?: {
field: string;
oldValue: any;
newValue: any;
}[];
metadata?: Record<string, any>;
}
Naamconventies voor acties
Gebruik een consistente resource.subresource.actie notatie:
user.created
user.updated
user.deleted
user.role.updated
team.member.added
team.member.removed
invoice.created
invoice.paid
settings.billing.updated
api_key.created
api_key.revoked
auth.login.success
auth.login.failed
auth.mfa.enabled
Implementatie in de praktijk
Stap 1: Database-tabel
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
actor_id TEXT NOT NULL,
actor_type TEXT NOT NULL,
actor_email TEXT,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
ip_address INET,
user_agent TEXT,
request_id TEXT,
changes JSONB,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes voor snelle lookups
CREATE INDEX idx_audit_tenant_timestamp
ON audit_logs (tenant_id, timestamp DESC);
CREATE INDEX idx_audit_actor
ON audit_logs (actor_id, timestamp DESC);
CREATE INDEX idx_audit_resource
ON audit_logs (resource_type, resource_id, timestamp DESC);
CREATE INDEX idx_audit_action
ON audit_logs (action, timestamp DESC);
Stap 2: Audit service
// lib/audit.ts
import { db } from './db';
interface LogAuditParams {
actorId: string;
actorType: 'user' | 'admin' | 'system' | 'api_key';
actorEmail?: string;
action: string;
resourceType: string;
resourceId: string;
tenantId: string;
ipAddress?: string;
userAgent?: string;
requestId?: string;
changes?: { field: string; oldValue: any; newValue: any }[];
metadata?: Record<string, any>;
}
export async function logAudit(params: LogAuditParams): Promise<void> {
// Audit logging mag nooit je hoofdflow blokkeren
try {
await db.auditLog.create({
data: {
actorId: params.actorId,
actorType: params.actorType,
actorEmail: params.actorEmail,
action: params.action,
resourceType: params.resourceType,
resourceId: params.resourceId,
tenantId: params.tenantId,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
requestId: params.requestId,
changes: params.changes ?? undefined,
metadata: params.metadata ?? undefined,
},
});
} catch (error) {
// Log naar error tracking, maar laat de hoofdflow doorgaan
console.error('Audit logging failed:', error);
// Optioneel: stuur naar een fallback (bijv. een queue)
}
}
Stap 3: Middleware voor automatisch context
// middleware/audit-context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
interface AuditContext {
actorId: string;
actorType: string;
actorEmail?: string;
tenantId: string;
ipAddress?: string;
userAgent?: string;
requestId: string;
}
export const auditStorage = new AsyncLocalStorage<AuditContext>();
export function auditMiddleware(req, res, next) {
const context: AuditContext = {
actorId: req.user?.id ?? 'anonymous',
actorType: req.apiKey ? 'api_key' : 'user',
actorEmail: req.user?.email,
tenantId: req.tenant?.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
};
auditStorage.run(context, () => next());
}
Nu kun je overal in je code de context ophalen:
import { auditStorage } from '../middleware/audit-context';
import { logAudit } from '../lib/audit';
export async function updateUserRole(userId: string, newRole: string) {
const ctx = auditStorage.getStore();
const user = await db.user.findUnique({ where: { id: userId } });
const oldRole = user.role;
await db.user.update({
where: { id: userId },
data: { role: newRole },
});
await logAudit({
...ctx,
action: 'user.role.updated',
resourceType: 'user',
resourceId: userId,
changes: [{ field: 'role', oldValue: oldRole, newValue: newRole }],
});
}
Changes automatisch detecteren
Handmatig old/new values bijhouden is foutgevoelig. Automatiseer het:
function detectChanges(
oldObj: Record<string, any>,
newObj: Record<string, any>,
fields: string[]
): { field: string; oldValue: any; newValue: any }[] {
const changes = [];
for (const field of fields) {
const oldVal = oldObj[field];
const newVal = newObj[field];
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changes.push({
field,
oldValue: oldVal,
newValue: newVal,
});
}
}
return changes;
}
// Gebruik:
const oldUser = await db.user.findUnique({ where: { id } });
const updatedUser = await db.user.update({ where: { id }, data: updates });
const changes = detectChanges(oldUser, updatedUser, [
'name', 'email', 'role', 'status'
]);
if (changes.length > 0) {
await logAudit({
...ctx,
action: 'user.updated',
resourceType: 'user',
resourceId: id,
changes,
});
}
Gevoelige data in audit logs
Let op: audit logs bevatten zelf ook data die je moet beschermen. Enkele richtlijnen:
Wat je NIET in je audit log stopt
- Wachtwoorden (ook niet gehashed)
- Volledige creditcardnummers
- BSN/SSN-nummers
- API-keys (log alleen de laatste 4 karakters)
Masking toepassen
function maskSensitive(value: string, type: 'email' | 'key' | 'card'): string {
switch (type) {
case 'email':
const [user, domain] = value.split('@');
return user[0] + '***@' + domain;
case 'key':
return '***' + value.slice(-4);
case 'card':
return '****-****-****-' + value.slice(-4);
default:
return '***';
}
}
Audit logs doorzoekbaar maken
Een audit log dat je niet kunt doorzoeken is nutteloos. Bouw een API voor je admin-panel:
// api/admin/audit-logs.ts
export async function getAuditLogs(params: {
tenantId: string;
actorId?: string;
resourceType?: string;
resourceId?: string;
action?: string;
startDate?: Date;
endDate?: Date;
page?: number;
limit?: number;
}) {
const where: any = { tenantId: params.tenantId };
if (params.actorId) where.actorId = params.actorId;
if (params.resourceType) where.resourceType = params.resourceType;
if (params.resourceId) where.resourceId = params.resourceId;
if (params.action) where.action = { startsWith: params.action };
if (params.startDate || params.endDate) {
where.timestamp = {};
if (params.startDate) where.timestamp.gte = params.startDate;
if (params.endDate) where.timestamp.lte = params.endDate;
}
const limit = params.limit ?? 50;
const offset = ((params.page ?? 1) - 1) * limit;
const [logs, total] = await Promise.all([
db.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
take: limit,
skip: offset,
}),
db.auditLog.count({ where }),
]);
return { logs, total, page: params.page ?? 1, totalPages: Math.ceil(total / limit) };
}
Retentie en schaling
Audit logs groeien snel. Plan vooruit:
Partitioneren op tijd
-- PostgreSQL partitioning per maand
CREATE TABLE audit_logs (
id UUID NOT NULL DEFAULT gen_random_uuid(),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- ... andere kolommen
) PARTITION BY RANGE (timestamp);
CREATE TABLE audit_logs_2026_03 PARTITION OF audit_logs
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE audit_logs_2026_04 PARTITION OF audit_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
Retentiebeleid
- Hot storage (database): laatste 90 dagen — snel doorzoekbaar
- Warm storage (S3/object storage): 90 dagen tot 2 jaar — JSON exports, nog doorzoekbaar
- Cold storage (archief): 2-7 jaar — gecomprimeerd, voor compliance
// Maandelijkse archivering cron job
async function archiveOldAuditLogs() {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
// Export naar S3
const oldLogs = await db.auditLog.findMany({
where: { timestamp: { lt: cutoffDate } },
});
await uploadToS3(
\`audit-archive/\${cutoffDate.toISOString().slice(0, 7)}.jsonl\`,
oldLogs.map(l => JSON.stringify(l)).join('\n')
);
// Verwijder uit database
await db.auditLog.deleteMany({
where: { timestamp: { lt: cutoffDate } },
});
}
Audit logs als feature voor je klanten
Slimme SaaS-bedrijven maken audit logs ook zichtbaar voor hun klanten. Dit is een premium feature die enterprise-klanten verwachten:
- Activity feed: laat teamleden zien wie wat heeft gedaan
- Security log: login-historie, MFA-wijzigingen, API-key gebruik
- Compliance export: CSV/JSON export voor auditors
- Webhooks: stuur audit events naar de SIEM van je klant
Dit kan letterlijk het verschil maken bij een enterprise deal. Klanten betalen hier extra voor.
Immutability: audit logs zijn heilig
Een audit log dat je kunt wijzigen is waardeloos. Zorg voor onveranderlijkheid:
- Geen UPDATE of DELETE op de audit_logs tabel (behalve archivering)
- Database-permissies: de applicatie-gebruiker heeft alleen INSERT rechten
- Optioneel: gebruik append-only storage of blockchain-achtige hashing
-- Aparte database-gebruiker voor audit writing
CREATE ROLE audit_writer;
GRANT INSERT ON audit_logs TO audit_writer;
-- Geen UPDATE, DELETE, of TRUNCATE
Checklist voor productie
Voordat je audit logging live zet:
- Alle state-veranderende acties worden gelogd
- Login/logout en mislukte pogingen worden gelogd
- Gevoelige data wordt gemaskeerd
- Audit logs zijn immutable (geen update/delete)
- Retentiebeleid is gedefinieerd en geautomatiseerd
- Admin-panel heeft een audit log viewer
- Indexes zijn aanwezig voor veelvoorkomende queries
- Audit logging faalt graceful (blokkeert geen hoofdflow)
- Partitionering of archivering is ingericht voor groei
- Export-functionaliteit voor compliance-audits
Conclusie
Audit logging is een van die dingen die je liever te vroeg dan te laat implementeert. Begin simpel — een tabel, een `logAudit()` functie, en consistente naamconventies — en breid uit naarmate je groeit.
De investering betaalt zich terug bij je eerste security-incident, je eerste enterprise-klant, of je eerste AVG-verzoek. En met de NIS2-richtlijn die steeds meer bedrijven raakt, is het niet de vraag of je audit logging nodig hebt, maar wanneer.
Begin vandaag. Je toekomstige zelf zal je dankbaar zijn.