Files

25 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() Lives in Covers Customer-scope User-scope
ActivityLogDataProvider activity_log Modules\Core\Logging\Privacy activity_log (Spatie) for subject types Customer/Address/CartAddress/OrderAddress/Transaction Pseudonymized — properties redacted, who/what/when metadata kept Skipped — causer_id is an actor reference, not PII content; see below
CustomerDataProvider customer Modules\Core\Customer\Privacy lunar_customers, and separately the User's own name/email/OTP fields Erases the account's own fields only Erases that User's name/email/OTP fields only, and detaches them from every linked Customer
AddressDataProvider addresses Modules\Core\Customer\Privacy lunar_addresses Erased (deleted outright) Skipped — belongs to a Customer, not an individual
OrderDataProvider orders Modules\Core\Order\Privacy lunar_orders, lunar_order_addresses, and their meta (terms_accepted*, payment_method, box_now_locker) Pseudonymized, not erased — see below Skipped — belongs to a Customer, not an individual
CartDataProvider carts Modules\Core\Cart\Privacy lunar_cart_addresses, and lunar_carts.meta (recovery_consent*, payment_method, checkout_fingerprint) Erased Skipped — belongs to a Customer, not an individual
ReviewDataProvider reviews Modules\Core\Review\Privacy product_reviews Skipped — authored by an individual, not a business account Pseudonymized by matching reviewer_email; rating/title/body text kept
PaymentDataProvider payments Modules\Core\Payment\Privacy lunar_transactions (card_type/last_four), stripe_payment_intents Pseudonymized — card metadata cleared, correlation rows deleted, amounts/statuses kept Skipped — belongs to Customer-owned orders, not individual users
UserSessionDataProvider sessions Modules\Core\Auth\Privacy user_sessions (ip_address, user_agent) Skipped — belongs to an individual User, not a business account Erased (deleted outright)

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.

activity_log is redacted by subject, never by causer

Modules\Core\Logging\ActivityLogService (plus several Lunar models' own native use LogsActivity — Customer, CartAddress, OrderAddress, Transaction) durably retains a full snapshot of whatever it logged in properties, completely independent of the real row it describes — erasing/pseudonymizing a Customer/Address/Order/etc. elsewhere does nothing to this table on its own. ActivityLogDataProvider::eraseForCustomer() redacts properties on every row whose subject (not causer) resolves back to that customer, across all five PII-bearing subject types.

It deliberately never touches causer_id — the causer is "who performed this action," not PII content, and erasing it would defeat the audit trail's own purpose. eraseForUser() is therefore a no-op: a User appears in this table only as a causer, never as subject content, so there's nothing to redact from the User side alone.

Ordering dependency: ActivityLogDataProvider must run before AddressDataProvider in config('core.privacy.providers') — it resolves which activity_log rows are keyed by an Address id while those Address rows still exist; AddressDataProvider then hard-deletes them. Reversing the order would make matching those rows impossible once the addresses are gone.

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

  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; 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.