@purposeinplay/payload-audit-log
v0.1.3
Published
Audit logging plugin for Payload CMS 3 — tracks create/update/delete across collections and globals, plus auth events.
Downloads
241
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
- Scheduling requires a consumer migration
- How it works
- Options
- Login tracking
- What gets logged
- Document titles
- System writes
- 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
- Optional document snapshots (previous and current versions), size-capped — off by default
- Sensitive field stripping — passwords, tokens, and sessions are never stored
- Cascade guard — suppress noise from relationship-triggered updates
- Retention cleanup as a scheduled Payload job task — daily at 04:00 on the
defaultqueue (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.
Scheduling requires a consumer migration
This version schedules its cleanup task, and that turns on Payload's job scheduling for your whole config. Adopting it without the matching database migration stalls every job on the default queue.
Payload flips jobs.scheduling = true during config sanitization as soon as any task declares a schedule, and injects the payload-jobs-stats global. From then on handleSchedules reads that global on every autoRun tick, before jobs.run. If the payload_jobs_stats table does not exist, that read throws, jobs.run is never reached, and the consumer's entire default queue stops draining — every minute, with nothing else in the config having changed.
What you must do:
Ship the plugin bump and the migration in the same release. After upgrading, run
npx payload migrate:createandnpx payload migrate. The migration must createpayload_jobs_stats(wild consumers additionally need thepayload_jobs.metacolumn).Keep the schedule on a queue you actually drain.
handleSchedulesskips every queue the running autoRun config does not drain unless it is invoked withallQueues, so the default schedule targetsdefault. If you pointretention.scheduleat a dedicated queue, make sure something drains that queue.A dedicated worker must pass
--handle-schedules. Payload's bin only callshandleScheduleswhen the flag is present:payload jobs:run --handle-schedules # or, to only queue scheduled jobs: payload jobs:handle-schedulesIn-process
jobs.autoRunhandles schedules on its own; a separate worker process does not, unless you pass the flag.
At boot (onInit) the plugin runs three checks and logs what it finds: it reads the payload-jobs-stats global and logs an error naming this migration when that fails; it selects the payload-jobs meta field, which enabling scheduling also adds (jobs.stats), so a missing payload_jobs.meta column is reported the same way; and it warns when nothing in jobs.autoRun drains the queue the cleanup is scheduled onto — that case is a warning because a dedicated --handle-schedules worker is invisible from inside the process. It never throws and never creates tables, so a missing migration is loud but not fatal to boot. retention: false registers neither the task nor the schedule and leaves jobs.scheduling untouched; retention: { days, schedule: [] } registers the task unscheduled, for consumers that want to queue it themselves without turning scheduling on — that case logs a one-line warning at init, since nothing will delete old rows.
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)
Operations without req.user are attributed to a system actor rather than skipped (see System writes). 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; schedule?: { cron: string; queue: string }[] } \| false | { days: 90, schedule: [{ cron: '0 4 * * *', queue: 'default' }] } | Registers and schedules the audit-log-cleanup job task. Read Scheduling requires a consumer migration before adopting. Set false to register neither |
| storeSnapshots | boolean \| { collections: string[] } | false | Store the full previousVersion/currentVersion snapshots on creates and updates. Off by default — they are ~2/3 of a row's bytes and changes is always kept. { collections: [...] } matches a collection slug or a global slug. Deletes always snapshot |
| 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 the changes diff and for the 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) |
| auditSystemWrites | boolean | true | Audit writes performed without an authenticated request — job runs, scripts, ai-translate bulk translation. Attributed to system with a null user (see System writes). false restores the pre-0.1.3 behaviour of skipping them |
| 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, only when
storeSnapshotsis enabled — except deletes, which always storepreviousVersionregardless of the option: a delete has no diff to fall back on, and it is one row per deletion rather than the per-update bulk that made snapshots expensive. There is no opt-out, so a bulk delete of 500 documents writes 500 audit rows each carrying a snapshot (richText summarized, sensitive fields stripped, capped atjsonMaxBytes) - user — relationship to the auth collection (
authCollectionSlug)
richText fields
A Lexical diff would otherwise embed the full old and new node trees — 422 KB of raw JSON for one field on a real document. Instead, richText values are compared on the raw trees (so nothing is missed) but recorded as a compact summary on each side:
{ "body": {
"old": { "__richText": true, "text": "Welcome to…", "truncated": false, "bytes": 118442 },
"new": { "__richText": true, "text": "Welcome back to…", "truncated": false, "bytes": 118501 }
} }text is the extracted plain text, capped at 2000 characters per side (truncated says whether it was cut, including when the walk hit the cap and skipped later nodes); bytes is the UTF-8 size of the tree it replaced, so the reduction is visible in the row itself. A hash was rejected as the representation: it answers "did this change", not "what did it say", which is the question an audit row exists to answer. The same replacement is applied to richText nested inside blocks and arrays, and to stored previousVersion/currentVersion snapshots — otherwise the trees the diff no longer carries would come straight back in the snapshot.
Size caps
Both changes and the 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' }. Sizes are UTF-8 bytes — what the column actually costs — and partial is cut on a code-point boundary, so an emoji straddling the cut never becomes a lone surrogate that Postgres jsonb would reject.
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' },
})System writes
Not every write carries an authenticated request. Anything driven by Payload's persisted jobs — ai-translate bulk translation and its retries above all — runs on a req with no user, and those writes used to be dropped from the audit log entirely: the machine writes that most need a record left none.
They are now recorded with:
- authorEmail —
system, orsystem (triggered by <email>)when the originating context carried the email of the human who started the run. The plugin reads that from theaiTranslateTriggeredByEmailrequest-context key, which@purposeinplay/payload-ai-translatesets on its locale writes; the key is matched by name only, so there is no dependency on that package, and any other producer can set it (a plain email string). - source —
system, orai-translatewhen the write also carries theaiTranslateInternalflag. - user —
null. The relationship is only set for a real authenticated user.
Everything else behaves as it does for a user edit: the cascade guard, auditAutoTranslate gating, diffs, snapshots and size caps all apply unchanged. Set auditSystemWrites: false to go back to skipping these writes.
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 and schedules it — daily at 04:00 on the default queue. The task deletes entries older than the configured number of days and retries 3 times so a transient failure does not skip the day's pass.
Each invocation is bounded: it deletes in batches of 500 and stops after 50 batches, returning { deletedCount, hasMore }. hasMore: true means the cap was reached and the next scheduled run continues — relevant on the first run after adopting scheduling, which meets the whole accumulated backlog.
Drain rate: 50 × 500 = 25,000 rows per pass, so 25,000 rows per day on the default daily schedule. For scale, the backlog measured on a wild production dump (2026-09-02) was 2,204 rows past the 90-day threshold — one pass, about 11 seconds. A backlog bigger than the cap takes ceil(rows / 25000) days to clear: 100,000 rows is 4 days, 1,000,000 is 40. If you shorten retention.days on a large table, expect the catch-up to span days, or queue the task by hand a few times to get through it faster. Rows are located with pagination: false, depth: 0, select: { id: true } (no per-batch COUNT(*), ids only) and deleted one by one outside the job's request, so a caller-supplied transaction can never grow to cover the entire pass.
Override the schedule per consumer:
auditLogPlugin({
retention: {
days: 90,
schedule: [{ cron: '0 4 * * *', queue: 'default' }],
},
})Something has to drain that queue — in-process autoRun:
jobs: {
autoRun: [{ cron: '* * * * *', queue: 'default' }],
}or a dedicated worker, which must pass --handle-schedules (Payload's bin only calls handleSchedules with that flag):
payload jobs:run --handle-schedulesBefore adopting, read Scheduling requires a consumer migration — the payload_jobs_stats table is mandatory.
You can still queue a run by hand:
await payload.jobs.queue({ task: 'audit-log-cleanup', input: {} })
await payload.jobs.run()Set retention: false to register neither the task nor the schedule.
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,
AuditLogScheduleConfig,
ChangeFormatterOptions,
StoreSnapshotsOption,
} 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.
