Files
core/docs/privacy.md
T

18 KiB

Privacy / GDPR Data-Subject Requests

Modules\Core\Privacy implements the right of access (export) and right of erasure for customers, as an extensible contract rather than a fixed list of tables — any module (core, or a future ERP/banking/etc. module) can register its own data without core knowing it exists.


User-scope vs Customer-scope — two genuinely different operations

A Lunar Customer (business account: orders, addresses, buyer record) and a User (individual login identity) are linked many-to-many via the customer_user pivot (see docs/modules.md "Customer/User Pairing") — one User can belong to many Customer accounts, and one Customer account can have many linked Users. This is the real shape of B2B multi-seat access: a person can have login access to several separate business accounts, and a business account can have several employees each with their own login.

That means "delete my personal data" and "delete this business account" are not the same request, and conflating them is actively wrong:

  • Erasing a Customer must never touch any linked User's login or identity. Erasing "Acme Corp" must not deactivate or destroy access for the employees who work there — and must not touch any other Customer account, even one sharing some of the same Users.
  • Erasing a User must never touch any Customer account's own data. John asking to delete his account must clear his name/email/login wherever it appears — and correctly end his membership on every Customer he's linked to (detach the pivot) — but must not erase Acme Corp's orders or addresses, and must not affect any other employee still linked to Acme Corp.

Every part of this module is split along that line — a PersonalDataProvider, a PrivacyService method, a request record — is always explicitly for a Customer or for a User, never both at once, and never one with an implicit cascade into the other.


Why an extensible contract, not a hardcoded script

A GDPR erasure/export request has to touch every module that holds personal data, but core can't know in advance what future modules will exist or what data they'll hold — and different data needs fundamentally different handling (freely erasable PII vs. financial records that must be pseudonymized-not-deleted for legal retention vs. data that must be retained outright). There's deliberately no central taxonomy for this in the contract — each module owns its own retention judgment, since only the module that owns a table actually knows its legal requirements.

Modules\Core\Privacy\Contracts\PersonalDataProvider is the whole contract:

interface PersonalDataProvider
{
    public function name(): string;

    public function exportForUser(UserSubject $subject): ProviderExportResult;
    public function exportForCustomer(CustomerSubject $subject): ProviderExportResult;

    public function eraseForUser(UserSubject $subject): ProviderErasureResult;
    public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult;
}

Every provider implements all four methods. A provider with nothing relevant to one scope implements that method as a no-op — ErasureOutcome::Skipped with a reason for erase, an empty payload for export (e.g. AddressDataProvider::eraseForUser(), since addresses belong to a Customer, not an individual).

A module registers by adding its provider class to config('core.privacy.providers') — the same shape as Lunar's own config('lunar.search.indexers') model→indexer map:

// config/core.php
'privacy' => [
    'providers' => [
        \Modules\Core\Privacy\Providers\CustomerDataProvider::class,
        \Modules\Core\Privacy\Providers\AddressDataProvider::class,
        \Modules\Core\Privacy\Providers\OrderDataProvider::class,
        \Modules\Core\Privacy\Providers\CartDataProvider::class,
        \Modules\Core\Privacy\Providers\ReviewDataProvider::class,
        // A future module just adds its own provider here.
    ],
],

PrivacyManager resolves each class via the container and asserts every name() is unique — two providers registering the same name throws, so a naming collision fails loudly at resolution time rather than silently overwriting one provider's data in an export/report.


UserSubject and CustomerSubject — identifying "the person" vs "the account"

Two separate value objects, not one — each deliberately carries only what its own scope needs, so a provider can't accidentally reach across the boundary:

class CustomerSubject
{
    public readonly int $customerId;
    // No userIds, no email — Customer-scope has no business knowing about logins.
}

class UserSubject
{
    public readonly int $userId;
    public readonly ?string $email;
    // No customerId — one User can be linked to many Customers; a provider that
    // needs to know which ones looks that up itself (e.g. to detach the pivot),
    // rather than this value object assuming or privileging any single one.
}

CustomerSubject::forCustomer(Customer $customer) and UserSubject::forUser($user) build one from the record staff (or the person themselves) look up.


Providers shipped in core

Provider name() Covers Customer-scope User-scope
CustomerDataProvider customer lunar_customers, and separately the User's own name/email Erases the account's own fields only Erases that User's name/email only, and detaches them from every linked Customer
AddressDataProvider addresses lunar_addresses Erased (deleted outright) Skipped — belongs to a Customer, not an individual
OrderDataProvider orders lunar_orders, lunar_order_addresses Pseudonymized, not erased — see below Skipped — belongs to a Customer, not an individual
CartDataProvider carts lunar_cart_addresses Erased Skipped — belongs to a Customer, not an individual
ReviewDataProvider reviews product_reviews Skipped — authored by an individual, not a business account Pseudonymized by matching reviewer_email; rating/title/body text kept

CustomerDataProvider is the one provider that implements both scopes meaningfully, and keeps them from touching each other — see the class docblock for the full reasoning.

ReviewDataProvider needs review. It moved from Customer-scope to User-scope on the reasoning that authorship is a personal attribute, not a business-account attribute — but this hasn't been fully validated against how reviews are actually attributed in this codebase. The class carries a NEEDS REVIEW note; revisit before relying on it for a real request.

Orders are pseudonymized, not deleted

GDPR Art. 17(3)(b) explicitly allows retaining data an erasure request would otherwise cover, when a legal obligation requires it — tax/accounting law generally requires invoices be kept for several years. OrderDataProvider::eraseForCustomer() clears the free-text PII fields on Order/ OrderAddress (customer_reference, notes, name/address/contact fields) but leaves the order row, totals, line items, and tax data fully intact. Its ProviderErasureResult reports ErasureOutcome::Pseudonymized, not Erased — a compliance report or admin UI can see exactly why an order wasn't deleted without reading OrderDataProvider's source.

Reviews are matched by email — a real, documented limitation

ProductReview has no FK to Customer/User at all (see docs/product-listing.md "Reviews") — it's deliberately anonymous, just free-text reviewer_name/reviewer_email. ReviewDataProvider matches by reviewer_email against UserSubject::$email; a review submitted under a different email than the one on file simply won't be found. There's no stronger signal available without changing ProductReview's schema.

Staff/employee data is out of scope

Staff (admin/panel employees) is never a UserSubject/CustomerSubject at all — this feature is scoped to customer-initiated and staff-initiated-on-a-customer's-behalf requests. An employee's own data (a different HR/access-management concern) isn't reachable through this flow.


Erasure isn't immediate — a cancellable grace period

PrivacyService has parallel methods for each scope: requestErasureForCustomer() / requestErasureForUser(). Neither erases anything immediately. Each opens a DataErasureRequest (pending, scheduled_for = now + config('core.privacy.grace_period_days'), default 30). This mirrors Shopify's own account-deletion flow: a window where the subject can change their mind before anything is actually erased.

Only the User-scoped request deactivates a login. requestErasureForCustomer() deactivates no one — a business-account erasure must never block anyone's access. requestErasureForUser() deactivates that one User's login (blocks it — see Modules\Core\Auth\Services\UserOtpService — nothing else changes).

use Modules\Core\Privacy\PrivacyService;

$service = app(PrivacyService::class);

// Customer-scoped: either the Customer itself (self-service) or a Staff member.
$request = $service->requestErasureForCustomer($customer, $requestedBy);

// User-scoped: either the User itself (self-service) or a Staff member.
$request = $service->requestErasureForUser($user, $requestedBy);

// Cancel before scheduled_for — for a User-scoped request, reactivates the
// account. A Customer-scoped request never deactivated anything, so there's
// nothing to reactivate for it.
$service->cancelErasure($request);

Logging back in during the grace period cancels the request automatically

Authentication is never blocked by deactivation — UserOtpService::validate() still requires the correct OTP code. Once validated, it dispatches Modules\Core\Auth\Events\UserAuthenticated; Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener (registered in PrivacyServiceProvider) looks for a pending request keyed on that User's own id — never a Customer-scoped one, since Customer-scope never deactivates a login in the first place — and calls cancelErasure() on it. Logging back in is the "I changed my mind" action — no separate UI/flow needed for reactivation.

Processing due requests — one job per request

php artisan boboko:privacy:process-erasure-requests finds every pending request whose scheduled_for has passed and dispatches one Modules\Core\Privacy\Jobs\EraseDataSubjectJob per request — it does not run completeErasure() inline itself. Each job independently calls PrivacyService::completeErasure(), which checks the request's polymorphic subject and calls either every registered provider's eraseForCustomer() or eraseForUser(), writing the full per-provider outcome onto the request's report column and marking it completed. One job per request means one request's failure (a provider throwing, a DB error) doesn't block or crash processing of the others, and Laravel's normal per-job retry/failure handling applies to each request independently. This package doesn't register a schedule itself; each consuming app wires the command into its own scheduler (daily is reasonable), the same way it owns any other scheduled task.

Immediate erasure — staff-only, not self-service

requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport and requestImmediateErasureForUser($user, Staff $requestedBy): ErasureReport bypass the grace period entirely and erase right away. Both are Staff-only by type, not just by convention — their signatures take Staff $requestedBy specifically (not the union type the grace-period methods accept), so a self-service/customer-facing code path can't reach either one even by accident; calling with a Customer/User actor is a compile-time type error, not a runtime check to remember.

This exists for a formal legal request or regulator inquiry that genuinely requires immediate action, not as a convenience for an impatient customer. GDPR Art. 17 requires erasure "without undue delay," but doesn't set a maximum number of days for a grace period, and a short, disclosed, cancellable hold before executing a self-service request is a widely-used, generally accepted pattern (the same one Shopify and most major platforms use) — it is not offered as a same-click alternative on the self-service deletion flow, since doing so would mostly defeat the grace period's purpose (protecting an impulsive requester from themselves). If a subject explicitly insists on immediate deletion, that's a staff/support decision to make on the record via one of these methods, not a checkbox exposed to every customer.

$report = $service->requestImmediateErasureForCustomer($customer, $staffMember);
$report = $service->requestImmediateErasureForUser($user, $staffMember);
// Both run synchronously — no queueing, no grace period. $report is the same
// ErasureReport completeErasure() would produce.

Export — queued, not synchronous

Export gathers real data across every registered provider — potentially slow, and there's no reason to block whatever request triggered it (a customer clicking "export my data," an API call). requestExportForCustomer()/requestExportForUser() are fast synchronous calls that only create a DataExportRequest row and dispatch the actual work:

$request = $service->requestExportForCustomer($customer);
$request = $service->requestExportForUser($user);
// $request->status is 'pending'; nothing has been gathered yet.

The event chain

  1. ExportDataSubjectJob (queued) checks the request's polymorphic subject and calls every registered provider's exportForCustomer() or exportForUser() — all sequentially, in this one job, not fanned out into one job per provider. Per-subject export work is small (a handful of indexed queries per provider), so there's no real parallelism win, and one job means "finished" is just "handle() returned," with no Bus::batch()/completion-counting needed. If a future provider ever does something genuinely slow (an external API call, a generated PDF), that's the point to reconsider a per-provider batch — not before.
  2. Once every provider's data is gathered, the job fires PersonalDataGathered (carries the request and the assembled ExportReport) — no file exists yet.
  3. Modules\Core\Privacy\Listeners\WriteExportToCsvListener (registered in PrivacyServiceProvider) handles that event: turns each provider's data into its own CSV (via the generic Modules\Core\Export\CsvWriter — see below), zips them together, writes the zip to storage/app/exports/privacy/, and updates the request (status: completed, file_path). This is its own listener — not inline in the job — so the export format is swappable (an app could unregister this and register a JSON-only listener instead) without touching how data is gathered.
  4. Once the file exists, that listener fires PersonalDataExportFileWritten.
  5. Core has no opinion on how the subject is told. A consuming app registers its own notification against PersonalDataExportFileWritten via Modules\Core\Notification\NotificationRegistry — the same pattern as App\Notifications\QuestionnaireResultsSentNotification listening on App\Events\QuestionnaireResultsSent (see boboko-test for a working example). Core deliberately does not send an email itself.

CSV shape

Every provider's data is either a list of associative arrays (addresses, orders, reviews — each item becomes a row) or a single associative array (customer — becomes one row). Any nested array value within a row (e.g. an order's addresses sub-array) is JSON-encoded into that one cell rather than exploded into further columns — a generic, provider-agnostic rule in WriteExportToCsvListener, not something each provider has to think about.

Modules\Core\Export\CsvWriter — a generic, reusable piece

CsvWriter::write(array $columns, iterable $rows, string $path) has no knowledge of GDPR, customers, or Lunar at all — a caller supplies a schema (CsvColumn[], each just a header plus a closure that pulls that column's value out of one record) and any iterable data source. It's used here by WriteExportToCsvListener, but is equally usable for an unrelated future need — an admin bulk catalog export, an accounting handoff — by supplying a different schema and row source; nothing about it is GDPR-specific.


Audit trail

DataErasureRequest (data_erasure_requests) and DataExportRequest (data_export_requests) are the audit records for erasure and export respectively. Both have a polymorphic subject (subject_type/subject_id, pointing at either a Lunar Customer or a User — never both) — subject_type/subject_id/email are stored as a snapshot, not looked up live, since the whole point is for these tables to remain readable after the record they're about has been erased. DataErasureRequest::isForCustomer() tells you which scope a given request is.

DataErasureRequest.requested_by_type/requested_by_id capture who asked for it (the subject themselves, self-service, or Staff acting on their behalf) at request time. DataErasureRequest.report holds the full per-provider outcome once completeErasure() runs; DataExportRequest.file_path points at the generated zip once WriteExportToCsvListener finishes.

Not yet built: a standalone "leave/remove from a Customer account" action — unlinking a User from a Customer without any erasure involved (e.g. a teammate leaving a project, or an account admin removing someone) — is a related but separate, smaller feature, deliberately out of scope for this module so far. It shares the same pivot-detach primitive CustomerDataProvider:: eraseForUser() already uses as part of a full erasure, but as a standalone action it doesn't exist yet.