@payload-pln/anonymizer-masking
v1.0.2
Published
A blank template to get started with Payload 3.0
Readme
Payload Plugin Template
A template repo to create a Payload CMS plugin.
Payload is built with a robust infrastructure intended to support Plugins with ease. This provides a simple, modular, and reusable way for developers to extend the core capabilities of Payload.
To build your own Payload plugin, all you need is:
- An understanding of the basic Payload concepts
- And some JavaScript/Typescript experience
Background
Here is a short recap on how to integrate plugins with Payload, to learn more visit the plugin overview page.
How to install a plugin
To install any plugin, simply add it to your payload.config() in the Plugin array.
import myPlugin from 'my-plugin'
export const config = buildConfig({
plugins: [
// You can pass options to the plugin
myPlugin({
enabled: true,
}),
],
})Initialization
The initialization process goes in the following order:
- Incoming config is validated
- Plugins execute
- Default options are integrated
- Sanitization cleans and validates data
- Final config gets initialized
Building the Plugin
When you build a plugin, you are purely building a feature for your project and then abstracting it outside of the project.
Template Files
In the Payload plugin template, you will see a common file structure that is used across all plugins:
- root folder
- /src folder
- /dev folder
Root
In the root folder, you will see various files that relate to the configuration of the plugin. We set up our environment in a similar manner in Payload core and across other projects, so hopefully these will look familiar:
- README.md* - This contains instructions on how to use the template. When you are ready, update this to contain instructions on how to use your Plugin.
- package.json* - Contains necessary scripts and dependencies. Overwrite the metadata in this file to describe your Plugin.
- .eslint.config.js - Eslint configuration for reporting on problematic patterns.
- .gitignore - List specific untracked files to omit from Git.
- .prettierrc.json - Configuration for Prettier code formatting.
- tsconfig.json - Configures the compiler options for TypeScript
- .swcrc - Configuration for SWC, a fast compiler that transpiles and bundles TypeScript.
- vitest.config.js - Config file for Vitest, defining how tests are run and how modules are resolved
IMPORTANT*: You will need to modify these files.
Dev
In the dev folder, you’ll find a basic payload project, created with npx create-payload-app and the blank template.
IMPORTANT: Make a copy of the .env.example file and rename it to .env. Update the DATABASE_URL to match the database you are using and your plugin name. Update PAYLOAD_SECRET to a unique string.
You will not be able to run pnpm/yarn dev until you have created this .env file.
myPlugin has already been added to the payload.config() file in this project.
plugins: [
myPlugin({
collections: {
posts: true,
},
}),
]Later when you rename the plugin or add additional options, make sure to update it here.
Data Anonymization Workflow
This plugin registers anonymization-requests and anonymized-identities.
An authenticated user can create a pending request for a user, but only a
user whose roles field contains admin can read or update requests. When an
admin changes a request from pending to approved, the plugin creates one
anonymous ID and processes every configured collection related to that user.
Configure the relation field and exact fields to replace for every collection:
anonymizerMasking({
collections: {
users: {
userField: 'id',
fields: {
name: ({ anonymousId }) => `Anonymous User ${anonymousId.slice(0, 8)}`,
email: ({ anonymousId }) => `anon-${anonymousId}@anonymized.local`,
phone: null,
},
},
transactions: {
userField: 'user',
fields: {
privateNote: null,
// amount and paymentMethod are intentionally not listed and stay unchanged
},
},
},
})The target collections must expose the configured relation and fields. Values
can be literals or functions receiving the generated anonymousId. The
identity record stores the user ID, affected collections, documents, and
masked field names.
How the masking job runs
Approving a request queues an anonymizeDataTask job. The job row is committed
immediately (outside the approval transaction) so it is visible to any runner.
- Development (
NODE_ENV=development) — the approval hook defers apayload.jobs.runByID(...)call by ~1.5 seconds (long enough for the outer approval transaction to commit), so masking starts almost immediately after an admin clicks Approve. If that run fails, the job is retried automatically. - Production — queued jobs are drained either by your own scheduler (e.g. a
system cron hitting
/api/payload-jobs/run, orpayload run-schedules), by the admin Jobs panel, or by the plugin'sautoRuncron (* * * * *by default, see thejobsoption).
A safety check in the task refuses to mask when the request is no longer
approved/processing (e.g. the approval transaction was rolled back).
Anonymized? checkbox & read protection
Every collection listed under collections is automatically augmented with an
Anonymized? checkbox (isAnonymized, default false). Only admins can
toggle it, and the anonymization job sets it to true on every document it
masks.
The plugin also adds a read access guard to those collections: documents where
isAnonymized is true are hidden from all reads (admin panel, REST,
GraphQL, and the Local API unless overrideAccess: true is passed) — list
queries, counts, and findByID. Documents where the flag is false or unset
(undefined/null) remain readable.
// A document that has been anonymized can no longer be read:
const user = await payload.findByID({
collection: 'users',
id: userId,
overrideAccess: false, // enforce access control
})
// throws NotFound for anonymized documentsInternal plugin operations (e.g. the anonymizeDataTask) use
overrideAccess: true internally so anonymization keeps working end-to-end.
Metadata storage is disabled by default. When metadata is omitted or
enabled: false, no snapshot is stored.
To store plaintext snapshots (no encryption):
anonymizerMasking({
metadata: {
enabled: true,
},
collections: {
// masking policies
},
})To encrypt snapshots with AES-256-GCM, provide an encryptionKey (32-byte
base64 or 64-character hex):
anonymizerMasking({
metadata: {
enabled: true,
encryptionKey: process.env.ANONYMIZER_METADATA_KEY!,
},
collections: {
// masking policies
},
})The pre-mask snapshot is stored in the hidden anonymization-metadata
collection. It is encrypted only if encryptionKey is provided. The collection
is not available in the admin panel, denies reads and updates, allows one record
per identity, and does not allow deletion. When encryption is enabled, keep both
the environment key and database key fragment protected; losing either means the
stored snapshots cannot be decrypted.
Every run also creates an anonymization-logs record. It records the run
status, anonymous ID, start and completion timestamps, collection and document
counts, per-collection results, and the error message when a run fails.
Masking operations use the Payload request transaction: a processing error
rolls back the masking and approval, while the failure log is persisted
separately for diagnosis. The request remains pending so an administrator can
correct the configuration or data and retry.
On successful completion, anonymized-identities.metadata stores the complete
pre-mask JSON snapshot for every configured collection and document. The
metadata and internal fields are hidden in the admin UI and have field-level
read access disabled; internal server code can access them only with explicit
overrideAccess: true. startedAt, completedAt, and maskedAt are Payload
date fields, so they retain the full timestamp, while the admin picker displays
both date and time.
You may wish to add collections or expand the test project depending on the purpose of your plugin. Just make sure to keep this dev environment as simplified as possible - users should be able to install your plugin without additional configuration required.
When you’re ready to start development, initiate the project with pnpm/npm/yarn dev and pull up http://localhost:3000 in your browser.
Src
Now that we have our environment setup and we have a dev project ready to - it’s time to build the plugin!
index.ts
The essence of a Payload plugin is simply to extend the payload config - and that is exactly what we are doing in this file.
export const myPlugin =
(pluginOptions: MyPluginConfig) =>
(config: Config): Config => {
// do cool stuff with the config here
return config
}First, we receive the existing payload config along with any plugin options.
From here, you can extend the config as you wish.
Finally, you return the config and that is it!
Spread Syntax
Spread syntax (or the spread operator) is a feature in JavaScript that uses the dot notation (...) to spread elements from arrays, strings, or objects into various contexts.
We are going to use spread syntax to allow us to add data to existing arrays without losing the existing data. It is crucial to spread the existing data correctly – else this can cause adverse behavior and conflicts with Payload config and other plugins.
Let’s say you want to build a plugin that adds a new collection:
config.collections = [
...(config.collections || []),
// Add additional collections here
]First we spread the config.collections to ensure that we don’t lose the existing collections, then you can add any additional collections just as you would in a regular payload config.
This same logic is applied to other properties like admin, hooks, globals:
config.globals = [
...(config.globals || []),
// Add additional globals here
]
config.hooks = {
...(incomingConfig.hooks || {}),
// Add additional hooks here
}Some properties will be slightly different to extend, for instance the onInit property:
import { onInitExtension } from './onInitExtension' // example file
config.onInit = async (payload) => {
if (incomingConfig.onInit) await incomingConfig.onInit(payload)
// Add additional onInit code by defining an onInitExtension function
onInitExtension(pluginOptions, payload)
}If you wish to add to the onInit, you must include the async/await. We don’t use spread syntax in this case, instead you must await the existing onInit before running additional functionality.
Types.ts
If your plugin has options, you should define and provide types for these options.
export type MyPluginConfig = {
collections: Record<string, {
fields: Record<string, string | number | boolean | null | ((context: { anonymousId: string }) => unknown)>
}>
/**
* Disable the plugin
*/
disabled?: boolean
}If possible, include JSDoc comments to describe the options and their types. This allows a developer to see details about the options in their editor.
Testing
Having a test suite for your plugin is essential to ensure quality and stability. Vitest is a fast, modern testing framework that works seamlessly with Vite and supports TypeScript out of the box.
Vitest organizes tests into test suites and cases, similar to other testing frameworks. We recommend creating individual tests based on the expected behavior of your plugin from start to finish.
Writing tests with Vitest is very straightforward, and you can learn more about how it works in the Vitest documentation.
For this template, we stubbed out int.spec.ts in the dev folder where you can write your tests.
describe('Plugin tests', () => {
// Create tests to ensure expected behavior from the plugin
it('some condition that must be met', () => {
// Write your test logic here
expect(...)
})
})Best practices
With this tutorial and the plugin template, you should have everything you need to start building your own plugin. In addition to the setup, here are other best practices aim we follow:
- Providing an enable / disable option: For a better user experience, provide a way to disable the plugin without uninstalling it. This is especially important if your plugin adds additional webpack aliases, this will allow you to still let the webpack run to prevent errors.
- Include tests in your GitHub CI workflow: If you’ve configured tests for your package, integrate them into your workflow to run the tests each time you commit to the plugin repository. Learn more about how to configure tests into your GitHub CI workflow.
- Publish your finished plugin to NPM: The best way to share and allow others to use your plugin once it is complete is to publish an NPM package. This process is straightforward and well documented, find out more creating and publishing a NPM package here..
- Add payload-plugin topic tag: Apply the tag **payload-plugin **to your GitHub repository. This will boost the visibility of your plugin and ensure it gets listed with existing payload plugins.
- Use Semantic Versioning (SemVar) - With the SemVar system you release version numbers that reflect the nature of changes (major, minor, patch). Ensure all major versions reference their Payload compatibility.
Questions
Please contact Payload with any questions about using this plugin template.
