Lock for Craft CMS

Extending

Adding a source

The one extension point that matters. A disclosure is only as complete as the list of places it looked, and every site has one more.

use justinholtweb\lock\collectors\BaseCollector;
use justinholtweb\lock\models\Bundle;
use justinholtweb\lock\models\DataRecord;
use justinholtweb\lock\models\ErasureTarget;
use justinholtweb\lock\models\Subject;

class LoyaltyCollector extends BaseCollector
{
    public static function handle(): string
    {
        return 'loyalty';
    }

    public function label(): string
    {
        return 'Loyalty scheme';
    }

    public function description(): string
    {
        return 'Points balances and the transactions behind them.';
    }

    /** Not searched, rather than searched and empty — the disclosure says which. */
    public function isAvailable(): bool
    {
        return Craft::$app->getDb()->tableExists('{{%loyalty_members}}');
    }

    public function unavailableReason(): ?string
    {
        return 'The loyalty scheme is not installed.';
    }

    /** May throw. The base class turns that into a recorded problem on the bundle. */
    protected function find(Subject $subject, Bundle $bundle): array
    {
        $rows = (new Query())
            ->from(['{{%loyalty_members}}'])
            ->where(['email' => $subject->normalisedEmail()])
            ->all();

        return array_map(function(array $row) {
            $record = $this->record("member:{$row['id']}", "Loyalty membership {$row['card']}", [
                'Card number' => $row['card'],
                'Points' => $row['points'],
                'Joined' => $row['dateCreated'],
            ]);

            $record->categories = [DataRecord::CATEGORY_ACCOUNT, DataRecord::CATEGORY_BEHAVIOUR];
            $record->basis = 'Running the scheme the person signed up to.';

            // Say what cannot be done, and why. It reaches both the operator and the subject.
            $record->erasable = false;
            $record->retainReason = 'Points are a liability in the accounts until they expire.';

            return $record;
        }, $rows);
    }

    public function apply(ErasureTarget $target, Subject $subject): void
    {
        Craft::$app->getDb()->createCommand()
            ->update('{{%loyalty_members}}', ['email' => $subject->pseudonym() . '@anonymised.invalid'], [
                'id' => (int)$this->keyId($target->key),
            ])
            ->execute();
    }
}

Register it:

use justinholtweb\lock\events\RegisterCollectorsEvent;
use justinholtweb\lock\services\Collectors;

Event::on(Collectors::class, Collectors::EVENT_REGISTER_COLLECTORS, function(RegisterCollectorsEvent $event) {
    $event->collectors[] = LoyaltyCollector::class;
});

That is the whole contract. The record's erasable, anonymisable and retainReason decide what a plan does with it, so a collector never has to think about modes — and a record it marks as retained is skipped whatever was asked for.

Adding retention

Two more methods, and the source joins the retention screen:

public function scopes(): array
{
    $scope = new RetentionScope();
    $scope->key = 'loyalty:closed';        // must start with this collector's handle
    $scope->source = self::handle();
    $scope->label = 'Closed memberships';
    $scope->measuredFrom = 'the day it was closed';
    $scope->erasable = true;
    $scope->suggestedMonths = 24;

    return [$scope];
}

public function stale(RetentionScope $scope, DateTime $cutoff, int $limit): array
{
    // Same DataRecord shape as find(). It goes through the same planner and the same executor,
    // so a nightly sweep gets every guarantee a hand-run erasure gets.
}

Give each record its address in data['Email'] where there is one. That is what subjectFor() reads to pseudonymise each row as its own person, which is what stops a sweep collapsing thousands of people into one linkable pseudonym.

Stopping an erasure

use justinholtweb\lock\events\ErasureEvent;
use justinholtweb\lock\services\Erasure;

Event::on(Erasure::class, Erasure::EVENT_BEFORE_ERASE, function(ErasureEvent $event) {
    if ($event->plan->subject->userId !== null && mySiteSaysNo($event->plan->subject)) {
        $event->isValid = false;
    }
});

The hook for when a site's rules about who may be erased are more complicated than a legal hold can express. EVENT_AFTER_ERASE carries the outcome.

Reacting to requests

Requests::EVENT_BEFORE_SAVE_REQUEST and EVENT_AFTER_SAVE_REQUEST both carry the request and an isNew flag. The before event is cancelable.

Writing to the ledger

Anything your own code does to somebody's data belongs in the same ledger:

Plugin::getInstance()->activity->log(
    ActivityRecord::CATEGORY_ERASURE,
    'loyalty.closed',
    'Membership closed at the member’s request.',
    $subject,
);

It never throws. A ledger write that can break the operation it is recording is a ledger that gets removed from the hot path six months later.

Pass the person as $subject and keep their address out of the summary. The ledger stores a keyed hash of the subject, never the address, because it is append-only — an address written into it could never be erased. As a backstop, log() replaces the subject's own address with [address] if it turns up in the summary or the data, but it cannot know about anybody else's.

Matching an address in text

If your collector searches free text, search with LIKE to narrow it down and then decide with justinholtweb\lock\helpers\Address:

Address::contains($text, $email);                  // exact, case-insensitive, raw or JSON-escaped
Address::replace($text, $email, $replacement);     // rewrites only what contains() would find

%lex@corp.co% matches alex@corp.com. A disclosure built on that hands one person's data to another, and an anonymisation built on it rewrites the middle of a stranger's address.