# 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: ```php 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: ```php // 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: ```php 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). ```php 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: ) ``` **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. ```php $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: ```php $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.