23 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 provider implementation lives inside the module that owns the data it erases/exports, under
that module's own Privacy/ subdirectory (e.g. Modules\Core\Order\Privacy\OrderDataProvider,
Modules\Core\Customer\Privacy\CustomerDataProvider) — never inside Modules\Core\Privacy
itself, which only owns the shared contract (Contracts\PersonalDataProvider), the request
lifecycle (Services\PrivacyManager/PrivacyService), and the DTOs/enums every provider
returns. This mirrors how this codebase already handles other cross-cutting-but-domain-specific
code (e.g. a resource's own Filament/Extensions/ subdirectory) — and matters concretely if a
module is ever extracted into its own composer package (see docs/modules.md): the provider
that knows how to erase that module's data must travel with it, not get stranded in Privacy
depending on a package that no longer ships in this repo.
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\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\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\Services\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, queued — see below) 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, then reverts every Customer erasure request
it caused (see "The sole-owner cascade" below). Logging back in is the "I changed my mind"
action — no separate UI/flow needed for reactivation.
This listener is queued rather than synchronous, so login returns to the browser without waiting
on the bookkeeping. Nothing else in this codebase currently reads deactivated_at besides this
listener and PrivacyService itself — UserOtpService::validate() never gates the login on it —
so the brief window between the login response and the job actually running has no other consumer
to observe it as stale.
The sole-owner cascade — erasing the last User on a Customer also erases the Customer
If a User is erased and they were the only User linked to a given Customer, that Customer's data (orders, addresses, buyer record) becomes permanently unreachable through any login the moment the User's identity is gone — nobody could ever again log in to exercise a data-subject right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit there indefinitely with no legitimate purpose.
requestErasureForUser() and requestImmediateErasureForUser() both fire
Modules\Core\Privacy\Events\UserErasureRequested right after the request is created (and, for
the immediate path, before completeErasure() runs — see below).
Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener (queued, registered in
PrivacyServiceProvider) handles it: for every Customer the User is linked to, if that User is
currently the sole linked User (count is 1, and that one User is this one — not just count ===
1, to be explicit rather than relying on an assumption), it opens a second, independent
grace-period request via requestErasureForCustomer($customer, $user, causedByRequestId: ...).
Both requests then run through their own separate 30-day windows.
User erasure requested
│
▼
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
│
▼
for each linked Customer: sole owner?
│ yes
▼
requestErasureForCustomer(..., causedByRequestId: <user request id>)
Tracing the cascade — caused_by_request_id. A cascade-created Customer request's
caused_by_request_id points back at the User request that triggered it. This is what lets
CancelErasureOnLoginListener revert exactly the cascade a User's own cancellation should
undo (via DataErasureRequest::caused()) without ever touching an unrelated, independently
staff-requested Customer erasure the User happens to still be linked to.
Why this is queued, not synchronous. CascadeCustomerErasureListener runs as an independent,
separately-retryable job rather than inline inside requestErasureForUser() — a failure in the
cascade check never rolls back or blocks the User's own request, and there's no
DB::transaction() wrapping needed, since the two writes (the User's request, and any cascaded
Customer request) aren't required to be atomic with each other.
A known, accepted race on the immediate-erasure path only. Because the listener is queued,
Eloquent re-fetches its models fresh when the job actually runs (see
Illuminate\Queue\SerializesModels) — so $event->request->subject->customers reflects the
real state at execution time, not a stale snapshot from dispatch time. For
requestImmediateErasureForUser(), that job may run before or after completeErasure() detaches
the User's memberships in the same call. If the detach happens first, the User is simply no
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
everyday requestErasureForUser() grace-period path, where nothing detaches until its own later,
separate completeErasure() run — well after the cascade job has had time to fire.
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; Staff acting on their behalf; or, for a cascade-created Customer
request, the User whose erasure caused it — see "The sole-owner cascade") at request time.
DataErasureRequest.caused_by_request_id is set only on a cascade-created Customer request,
pointing back at the User request that triggered it; null on every normal, directly-requested
erasure — see DataErasureRequest::causedBy()/::caused().
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.