Lock for Craft CMS

Configuration

Everything is in Lock → Settings, across three screens, and everything goes to project config. A config/lock.php overrides any of it in the usual Craft way:

<?php

return [
    'organisationName' => 'Acme Ltd',
    'contactEmail' => '$PRIVACY_EMAIL',
    'responseDays' => 30,
    'defaultMode' => 'anonymise',
];

Who answers

SettingDefault
organisationName(empty)the controller's name, on correspondence and the register
contactName(empty)the person who answers
contactEmail(empty)falls back to the system email address; takes an env var
policyUrl(empty)linked from subject emails
staffRecipients(empty)falls back to contactEmail

Deadlines

SettingDefault
responseDays30Article 12(3) says "one month"; 30 days is the safe reading
extensionDays60available once, and only if the subject is told why
reminderDays[7, 2]days remaining at which staff are nudged
clockStartsOnReceipttrueoff means the clock starts at confirmation — permitted, and a clock a subject can leave stopped

Intake

SettingDefault
intakeEnabledtrue
intakeTypesall sixwhich request types the public form may submit
requireVerificationtrueleave this on — see below
verificationTtl48hours a confirmation link lives
intakeRateLimit5per hour, per address and per IP; 0 disables
intakeGlobalLimit200per hour across the whole site; 0 disables. config/lock.php only
trustSignedInSubjectstrueskips confirmation for somebody asking about their own signed-in address

Turning requireVerification off makes the intake form a way to delete somebody else's account by typing their address into it. There is a legitimate case — an intranet where every visitor is authenticated — and there is no other one.

The per-address limit counts the cheap spellings of one mailbox as one: case, +tags, and for Gmail the dots and googlemail.com. The site-wide limit is the backstop against a script spread over many IPs, each naming a different address — every accepted submission sends an email. Every limited submission gets exactly the answer a genuine one does. The status lookup and the consent form are limited too, at four times intakeRateLimit.

Behind a proxy or CDN

The per-IP limits, and the IP recorded in the ledger and on consent evidence, come from Craft's getUserIP(). Behind a load balancer, Cloudflare or any reverse proxy, that is the proxy's address unless Craft has been told which proxies to trust — and then every visitor shares one rate limit and one IP. Configure trustedHosts (and, if your proxy uses something other than X-Forwarded-For, ipHeaders) on the request component in config/app.web.php:

return [
    'components' => [
        'request' => [
            'trustedHosts' => ['10.0.0.0/8', '173.245.48.0/20' /* … your proxy ranges */],
        ],
    ],
];

Do not trust X-Forwarded-For from everybody. An unvalidated forwarded header is a value the client chose, which makes the per-IP limit a limit the attacker sets.

Where to look

SettingDefault
disabledCollectors[]handles to skip; anything not listed is searched
scanLogstrue
customTables[]see below

customTables is the one that makes a disclosure complete rather than merely thorough. Every real site has a table somebody wrote:

'customTables' => [
    [
        'table' => 'oldsite_newsletter',
        'label' => 'Newsletter list',
        'emailColumn' => 'email_address',
        'userColumn' => '',
        'dateColumn' => 'subscribed_at',
        'mode' => 'erase',        // erase | anonymise | read
    ],
],

Names are validated as plain identifiers when saved and again when used. One validation on the way in is a policy; validating again at the point the string reaches a query is what makes it true. A table with a dateColumn and a mode other than read also becomes a retention scope.

Craft's own tables (users, elements, elements_sites, sessions, Craft 4's content, and everything else in Craft's table registry) and Lock's own lock_* tables are refused, at save time and again at query time. Each is already searched properly by a dedicated source, and a rule pointed at one could delete accounts outside the element layer or edit the append-only ledger.

Anonymise and erase

SettingDefault
defaultModeanonymise
anonymousNameAnonymised
anonymousDomainanonymised.invalidRFC 2606 reserves .invalid so it can never be delivered to
suppressAfterErasuretruekeeps a keyed hash so the address cannot be re-imported
holdBlocksRetentiontruean open request or a hold stops automated purging
backupBeforeErasuretrueskipped with a warning where the host forbids backups

Disclosures

SettingDefault
dossierTtl14days a built archive stays downloadable
protectDossiertrueAES-256, with a password shown once and never stored

A built dossier is the most concentrated personal data on the site — one file with everything about one person in it. lock/requests/tidy deletes the expired ones; put it on cron. Craft's own garbage collection does the same, so a site without cron is covered too. Archives are named by a prefix of the subject's keyed hash, never by their address, and the CSV inside defuses any value a spreadsheet would run as a formula.

Consent

SettingDefault
consentPurposesfourkey, label, description, basis — renaming a key orphans its history
consentExpiryMonths24after which a record is flagged stale; 0 never expires
adoptTosstruereads Toss's consent and acceptance rows into disclosures

Retention

SettingDefault
retentionRules[]see Retention
scheduleEnabledfalse
scheduleTriggercronor web, which fires from control-panel traffic
scheduleFrequencydaily
scheduleTime03:0024-hour, site time zone
retentionDryRunFirsttruea new rule's first run reports instead of deleting; the second applies it

Notifications and logging

SettingDefault
notifySubjecttrue
notifyStafftruenew requests, deadline reminders (Pro) and retention reports
logLevelinfowrites storage/logs/lock.log, kept 90 days
activityRetentionDays00 keeps the ledger forever

Lock keeps its own log. A tool whose job is deleting personal data needs a trail that survives the database it was deleting from; the ledger in the database is the convenient copy, not the authoritative one.

activityRetentionDays defaults to keeping everything. The ledger is the evidence that the plugin did what it says, and evidence that expires before the limitation period is not evidence.