@purposeinplay/payload-audit-log
v0.1.2
Published
Audit logging plugin for Payload CMS 3 — tracks create/update/delete across collections and globals, plus auth events.
Downloads
357
Readme
@purposeinplay/payload-audit-log
Audit logging plugin for Payload CMS 3 — tracks create/update/delete across collections and globals, plus auth events.
Table of contents
- Features
- Requirements
- Installation
- Quick start
- Migration requirement
- How it works
- Options
- Login tracking
- What gets logged
- Document titles
- Cascade guard
- Sensitive fields
- Access control
- Cleanup task
- Security notes
- Exports
- License
Features
- Track create, update, delete operations on collections and globals
- Log user login events with IP tracking (see Login tracking)
- Field-level change diffs with old/new values — relationship-aware, no-op updates are not logged
- Full document snapshots (previous and current versions), size-capped
- Sensitive field stripping — passwords, tokens, and sessions are never stored
- Cascade guard — suppress noise from relationship-triggered updates
- Retention cleanup as a registered Payload job task (you schedule it — see Cleanup task)
- Immutable logs — create/update/delete API access is always denied
- Admin-only read access by default, with customizable access control
- Non-fatal by design — audit-write failures are logged via
payload.loggerand never block the original operation - Zero environment variables — configured entirely through plugin options
Requirements
- Payload
^3.0.0(peer dependency) - Node.js
>= 20 - ESM only — the package ships
"type": "module"; no CommonJS build
This plugin is server-side only: it adds no admin UI components, so there is no payload generate:importmap step and no next/react peer dependency.
Installation
pnpm add @purposeinplay/payload-audit-log
# or
npm install @purposeinplay/payload-audit-log
# or
yarn add @purposeinplay/payload-audit-logQuick start
Add the plugin to your payload.config.ts:
import { buildConfig } from 'payload'
import { auditLogPlugin } from '@purposeinplay/payload-audit-log'
export default buildConfig({
// ...
plugins: [
auditLogPlugin({
// include your auth collection here if you want login events logged
collections: ['users', 'pages', 'media'],
globals: ['site-config', 'navigation'],
trackLogin: true,
retention: { days: 90 },
}),
],
})Only the slugs listed in collections / globals are tracked — omitting both means nothing is logged.
Migration requirement
This plugin injects an audit-logs collection into your Payload config. If your database adapter uses migrations (Postgres, SQLite), generate and run one after adding or updating the plugin:
npx payload migrate:create
npx payload migrateThe migrate:create command will prompt you about enum/table conflicts (create vs. rename). Choose create for all audit-log-related items unless you are intentionally renaming from an existing table.
How it works
The plugin injects an audit-logs collection and attaches Payload hooks (afterChange, afterDelete, afterLogin) to the collections and globals you specify. The plugin's hooks are prepended, so they run before any hooks you define yourself. When a tracked operation occurs, the hook:
- Identifies the user and extracts the client IP from
x-forwarded-for/x-real-ipheaders - Strips sensitive fields from document snapshots
- Computes a field-level diff (for updates) — updates with no meaningful change are not logged
- Creates an immutable audit log entry via
req.payload.create(same transaction as the original operation,overrideAccess: true)
Anonymous operations (no req.user) are skipped for collection and global hooks. Login events use the authenticated user returned by the hook itself. Globals only get an afterChange hook (action is always update).
If writing an audit entry fails, the error is logged via req.payload.logger.error and the original operation proceeds unaffected.
Options
All options are optional (AuditLogPluginOptions):
| Option | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Enable/disable the plugin entirely. When false, the plugin makes no changes other than recording its options under config.custom.auditLog |
| collections | string[] | [] | Collection slugs to track. Omitted = no collections tracked |
| globals | string[] | [] | Global slugs to track (via afterChange only) |
| collectionSlug | string | 'audit-logs' | Slug of the injected audit-logs collection |
| authCollectionSlug | string | 'users' | Auth collection slug — used for the user relationship field and login-hook attachment |
| trackLogin | boolean | true | Log user login events via afterLogin hook. Requires the auth collection to be listed in collections — see Login tracking |
| allowCascading | boolean | false | Log cascading writes to other collections/documents triggered within the same request (see Cascade guard) |
| retention | { days: number } \| false | { days: 90 } | Registers the audit-log-cleanup job task with this threshold. Set false to skip registering the task |
| excludeFields | string[] | [] | Additional field names to strip — merged with the built-in sensitive-field list, so they are removed from both change diffs and the previousVersion/currentVersion snapshots |
| jsonMaxBytes | number | 524288 (512 KB) | Max serialized size for previousVersion/currentVersion snapshots (see What gets logged) |
| titleFieldMap | Record<string, string> | { users: 'email', media: 'filename' } | Per-collection field to use as documentTitle; merged over the built-in map (see Document titles) |
| auditAutoTranslate | boolean | false | Log writes originating from the sibling ai-translate plugin (tagged source: 'ai-translate'). Off by default so machine-generated locale fan-out doesn't drown the log |
| access.read | Access | admin-only | Override read access for the audit-logs collection |
Login tracking
The afterLogin hook is only attached when the auth collection (authCollectionSlug, default 'users') is also listed in collections. With trackLogin: true but 'users' missing from collections, no login events are logged:
auditLogPlugin({
collections: ['users', 'pages'], // 'users' present → logins are logged
trackLogin: true,
})Login entries record action: 'login' with type: 'security', the user relationship, and the client IP — no snapshots or diffs.
What gets logged
Each audit log entry records (all fields read-only in the admin UI):
- authorEmail — email of the user who performed the action (falls back to user ID)
- action —
create,update,delete, orlogin - type —
info,audit,warning,error, orsecurity(writes useaudit, logins usesecurity) - collectionSlug / globalSlug — which collection or global was affected
- documentId / documentTitle — the affected document
- locale — locale context if applicable
- ipAddress — client IP address (see Security notes)
- source —
user,ai-translate, orsystem(defaultuser) - changes — diff of changed fields (
{ field: { old, new } });nullfor creates and deletes - previousVersion / currentVersion — full document snapshots (deletes store only
previousVersion) - user — relationship to the auth collection (
authCollectionSlug)
Snapshots larger than jsonMaxBytes are replaced with { __truncated: true, __originalSize, partial } (the first chunk of the serialized value); values that fail to serialize are stored as { __error: 'Failed to serialize value' }.
In the admin UI the collection appears under the System group, labeled Audit Log(s), with default columns authorEmail, action, collectionSlug, globalSlug, documentTitle, locale, createdAt.
Document titles
documentTitle is resolved per collection:
- The field from
titleFieldMapif set for that slug (built-ins:users→email,media→filename) - Otherwise the first non-empty of
title,name,slug,label,email,filename - Otherwise the document
idas a string
auditLogPlugin({
collections: ['products'],
titleFieldMap: { products: 'sku' },
})Cascade guard
By default (allowCascading: false), the first operation in a request that actually produces a log entry is marked as the audit source (per-request, via req.context) — no-op updates never claim the slot. Later operations in the same request are logged only if they target the same collection and document as that source; writes to a different collection or document (e.g. Payload's relationship cascades) are suppressed to reduce noise. Set allowCascading: true to log every change.
The guard applies uniformly to collection creates, updates, and deletes as well as global updates, and also handles ai-translate integration: writes flagged with req.context.skipAutoTranslate are always suppressed; writes flagged req.context.aiTranslateInternal (set by the ai-translate plugin) are suppressed unless auditAutoTranslate: true, in which case they are logged with source: 'ai-translate'.
Sensitive fields
The plugin recursively strips the following fields (including inside nested objects and arrays) from both diffs and snapshots:
hash, salt, password, token, secret, sessions, lockUntil, loginAttempts, resetPasswordToken, resetPasswordExpiration, lastLogin, lastLoginAt, emailVerified, emailVerificationToken, emailVerificationExpiration, forgotPasswordToken, forgotPasswordExpiration, totp, _verificationToken
Any fields in your excludeFields option are merged into this list (and therefore also stripped from snapshots, not just diffs).
System fields (id, createdAt, updatedAt, createdBy, updatedBy, _status) are excluded from diffs but retained in snapshots.
Access control
By default, only users with an admin role can read audit logs. Override this with the access.read option:
auditLogPlugin({
access: {
read: ({ req }) => req.user?.roles?.includes('auditor') ?? false,
},
})Create, update, and delete access is always denied — audit logs are immutable.
Cleanup task
When retention is configured (the default), the plugin registers a job task with slug audit-log-cleanup in config.jobs.tasks. The task deletes entries older than the configured number of days, processing them in batches of 500, and returns { deletedCount }.
The plugin does not schedule or run the task for you. Queue and run it yourself, e.g.:
// queue a run (e.g. from a cron endpoint or script)
await payload.jobs.queue({ task: 'audit-log-cleanup', input: {} })
await payload.jobs.run()or configure Payload's jobs autorun in your config:
jobs: {
autoRun: [{ cron: '0 3 * * *', queue: 'default' }],
}Set retention: false to skip registering the task entirely.
Security notes
- IP extraction — the client IP is taken from the last entry of
x-forwarded-for(the one appended by the proxy closest to your server; assumes a trusted reverse proxy), validated against IPv4/IPv6 formats to prevent log injection, withx-real-ipas fallback; otherwisenull. - Audit entries are written with
overrideAccess: trueon the same request/transaction as the original operation. - GraphQL is disabled on the audit-logs collection, and
lockDocumentsis off (audit writes never contend for document locks).
Exports
The package ships a single entry point:
import { auditLogPlugin } from '@purposeinplay/payload-audit-log'
import type {
AuditLogEntry,
AuditLogPluginOptions,
ChangeFormatterOptions,
} from '@purposeinplay/payload-audit-log'There is no ./client or other subpath — the plugin has no admin UI components and no client/server split. No environment variables are read.
License
Part of the purposeinplay/payload-plugins monorepo. Issues and contributions: GitHub issues.
