Terug naar blog
securitycomplianceaudit-logginggdprnis2enterprise

Audit Logging en Compliance Trails voor je SaaS: de complete gids

Door SaaS Masters23 maart 20269 min leestijd

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:

  1. Geen UPDATE of DELETE op de audit_logs tabel (behalve archivering)
  2. Database-permissies: de applicatie-gebruiker heeft alleen INSERT rechten
  3. 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.