Skip to main content

Audit Logging

Every significant action is logged with who, what, when, and why. Designed for SOC 2 compliance.

What’s Logged

CategoryActionsDetails Captured
FormsCreate, edit, publish, delete, duplicateSchema diff, field changes, version info
SubmissionsStatus changes, bulk operationsBefore/after status, affected IDs
SettingsSystem settings, branding, integrationsChanged fields, before/after values
User ManagementRole changes, invites, removalsTarget user, old/new role
Data AccessExports, PDF downloadsFormat, record count
AuthenticationLogin/logoutUser, IP (anonymized), user agent

Log Structure

interface AuditLog {
  id: string;
  action_type: string;    // "create", "update", "delete", "export", etc.
  entity_type: string;    // "form", "submission", "user", "settings"
  entity_id: string;
  user_id: string;
  user_email: string;
  user_role: string;
  workspace_id?: string;
  changes?: {
    [field: string]: { before: unknown; after: unknown };
  };
  metadata?: Record<string, unknown>;
  ip_address?: string;    // Anonymized (last octet zeroed)
  user_agent?: string;
  request_id?: string;    // For distributed tracing
  success: boolean;
  error_message?: string;
  created_at: string;
}

Using the Audit Functions

Basic Audit Log

import { auditLog } from "@/lib/audit";

await auditLog({
  actionType: "update",
  entityType: "form",
  entityId: formId,
  changes: {
    title: { before: "Old Title", after: "New Title" },
  },
  metadata: { formTitle: "New Title" },
});

With Request Context (IP/User Agent)

import { auditLog, getRequestContext } from "@/lib/audit";
import { headers } from "next/headers";

const requestContext = getRequestContext(await headers());

await auditLog({
  actionType: "export",
  entityType: "submission",
  entityId: "bulk:50",
  metadata: { format: "csv", count: 50 },
  requestContext,
});

Convenience Functions

import {
  auditFormCreate,
  auditFormPublish,
  auditSettingsChange,
  auditSubmissionStatusChange,
} from "@/lib/audit";

// Form operations
await auditFormCreate(formId, { title, createdBy });
await auditFormPublish(formId, { version: 1 });

// Settings changes
await auditSettingsChange("settings", "system", "system_settings", {
  before: oldSettings,
  after: newSettings,
});

// Submission status
await auditSubmissionStatusChange(
  submissionId,
  { oldStatus: "pending", newStatus: "approved" },
  { formId }
);

Querying Logs

Via API

// GET /api/audit?page=1&limit=50&actionType=update&entityType=form
const response = await fetch("/api/audit?" + params);
const { logs, total } = await response.json();

Direct SQL

-- Find all changes to a form
SELECT * FROM audit_logs
WHERE entity_type = 'form'
  AND entity_id = $1
ORDER BY created_at DESC;

-- Find all actions by a user
SELECT * FROM audit_logs
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 100;

-- Find all exports (for compliance review)
SELECT * FROM audit_logs
WHERE action_type = 'export'
ORDER BY created_at DESC;

Access Review Report

For SOC 2 access reviews, export a report of all users with system access:
# Download CSV
curl -H "Authorization: Bearer $TOKEN" \
  "https://your-domain.com/api/audit/access-report?format=csv" \
  -o access-review.csv

# Get JSON
curl -H "Authorization: Bearer $TOKEN" \
  "https://your-domain.com/api/audit/access-report"
The report includes:
  • All users with access
  • Their roles and permissions
  • Account status (Active/Pending)
  • Last activity timestamp

Retention Policy

Audit logs are retained for 7 years (2555 days) by default for SOC 2 compliance.

Configuration

The retention period is stored in system_settings.audit_retention_days.

Automated Cleanup

A daily cron job cleans up expired logs:
// /api/cron/audit-cleanup
// Called daily by Vercel cron

// Runs: SELECT * FROM cleanup_expired_audit_logs();
// Returns: { deleted_count, retention_days }
To set up the cron job, add to vercel.json:
{
  "crons": [
    {
      "path": "/api/cron/audit-cleanup",
      "schedule": "0 3 * * *"
    }
  ]
}

IP Anonymization

IP addresses are anonymized for privacy while preserving geographic analysis capability:
  • IPv4: Last octet zeroed (e.g., 192.168.1.100192.168.1.0)
  • IPv6: Last 80 bits zeroed
function anonymizeIP(ip: string): string {
  // IPv4: 192.168.1.100 → 192.168.1.0
  // IPv6: 2001:db8:85a3::8a2e:370:7334 → 2001:db8:85a3::
}

Immutability

Audit logs are append-only. The database enforces this via RLS:
-- Only service role can insert
CREATE POLICY "Service role can manage audit logs"
ON audit_logs
FOR ALL
USING (true)
WITH CHECK (true);
Application code never updates or deletes audit logs.

UI Dashboard

Access the audit log dashboard at Settings → Audit Logs:
  • View all actions with filtering by type, entity, date
  • Search by user email or entity ID
  • Export access review reports
  • View detailed change diffs

Database Schema

Full table reference

Security

Security best practices