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
ExportDataSubjectJob(queued) checks the request's polymorphicsubjectand calls every registered provider'sexportForCustomer()orexportForUser()— 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 noBus::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.- Once every provider's data is gathered, the job fires
PersonalDataGathered(carries the request and the assembledExportReport) — no file exists yet. Modules\Core\Privacy\Listeners\WriteExportToCsvListener(registered inPrivacyServiceProvider) handles that event: turns each provider's data into its own CSV (via the genericModules\Core\Export\CsvWriter— see below), zips them together, writes the zip tostorage/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.- Once the file exists, that listener fires
PersonalDataExportFileWritten. - Core has no opinion on how the subject is told. A consuming app registers its own notification
against
PersonalDataExportFileWrittenviaModules\Core\Notification\NotificationRegistry— the same pattern asApp\Notifications\QuestionnaireResultsSentNotificationlistening onApp\Events\QuestionnaireResultsSent(seeboboko-testfor 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.