Feature: Creating Privacy Basics

This commit is contained in:
2026-08-24 21:06:11 +03:00
parent e31de1b4e3
commit 9f540cbaa4
38 changed files with 1969 additions and 3 deletions
+2 -1
View File
@@ -36,7 +36,8 @@
"Modules\\Core\\Providers\\AuthServiceProvider",
"Modules\\Core\\Providers\\CustomerServiceProvider",
"Modules\\Core\\Providers\\LocalizationServiceProvider",
"Modules\\Core\\Providers\\ReviewServiceProvider"
"Modules\\Core\\Providers\\ReviewServiceProvider",
"Modules\\Core\\Providers\\PrivacyServiceProvider"
]
}
},
+29
View File
@@ -16,4 +16,33 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
|--------------------------------------------------------------------------
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
| that should be consulted for right-of-access/right-of-erasure requests. A
| module never needs to be known to core in advance — it just adds its own
| provider class here, the same way config('lunar.search.indexers') maps a
| model to its indexer. See docs/privacy.md.
|
| 'grace_period_days' is how long an erasure request stays cancellable
| (account deactivated, not yet erased) before it's actually processed by
| the privacy:process-erasure-requests scheduled command.
|
*/
'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,
],
'grace_period_days' => 30,
],
];
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('deactivated_at')->nullable()->after('otp_expires_at');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('deactivated_at');
});
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_erasure_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — a request targets either a
// Lunar Customer (business account) or a User (individual), never
// both at once. See docs/privacy.md "User-scope vs Customer-scope".
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live-looked-up value — the subject's email may
// change or the record may be gone by the time this is read.
$table->string('email')->nullable();
// Who asked for this: the subject themselves (self-service deletion)
// or a staff member acting on their behalf. Plain nullable type+id
// columns rather than morphs() — only ever one of two concrete actor
// types, not an open-ended polymorphic set.
$table->string('requested_by_type');
$table->unsignedBigInteger('requested_by_id');
$table->string('status')->default('pending');
// now() + config('core.privacy.grace_period_days') at creation time —
// when privacy:process-erasure-requests will actually run this.
$table->timestamp('scheduled_for');
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('completed_at')->nullable();
// Every provider's outcome, written once the request completes —
// see Modules\Core\Privacy\ErasureReport. Null until then.
$table->json('report')->nullable();
$table->timestamps();
$table->index(['status', 'scheduled_for']);
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_erasure_requests');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('data_export_requests', function (Blueprint $table) {
$table->id();
// Polymorphic, not a fixed customer_id — see data_erasure_requests
// for the same shape and reasoning.
$table->string('subject_type');
$table->unsignedBigInteger('subject_id');
// Snapshot, not a live lookup — same reasoning as
// data_erasure_requests.email (see that migration).
$table->string('email')->nullable();
$table->string('status')->default('pending');
// Storage path of the assembled export .zip, set once the queued job
// finishes. Null while pending.
$table->string('file_path')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index('status');
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('data_export_requests');
}
};
+318
View File
@@ -0,0 +1,318 @@
# 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 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\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:
```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()` | 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).
```php
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.
```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, 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.
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Modules\Core\Auth\Events;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Dispatched by UserOtpService::validate() on every successful OTP login, not just
* a first-time one. Modules\Core\Privacy listens on this to auto-cancel a pending
* DataErasureRequest — logging back in during the grace period is the "I changed
* my mind" action (see Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener),
* which needs $user->customers to resolve any pending request. Typed as
* Authenticatable&LunarUser rather than plain Authenticatable (unlike the sibling
* UserCreated event) specifically because that listener depends on it — every real
* User in this codebase implements LunarUser (see docs/lunar.md "LunarUser trait"),
* and User is the only Authenticatable entity in this project (Customer is not —
* see docs/modules.md "Customer/User Pairing").
*/
class UserAuthenticated
{
public function __construct(
public readonly Authenticatable&LunarUser $user,
) {}
}
+4
View File
@@ -2,7 +2,9 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Auth\Mail\UserOtpMail;
class UserOtpService
@@ -43,6 +45,8 @@ class UserOtpService
$user->otp_expires_at = null;
$user->save();
Event::dispatch(new UserAuthenticated($user));
return $user;
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Jobs\EraseDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
/**
* Finds every erasure request whose grace period (config('core.privacy.
* grace_period_days')) has passed and dispatches one EraseDataSubjectJob per
* request — see docs/privacy.md. This command itself just finds due requests and
* dispatches; the actual erasure work happens in the queue, one job per request,
* so one failing request doesn't block the others. Meant to run daily via the
* scheduler; each consuming app wires that in its own Console\Kernel (or
* bootstrap/app.php schedule closure on Laravel 11+), the same way it owns any
* other scheduled task — this package doesn't register schedules itself.
*/
class ProcessErasureRequestsCommand extends Command
{
protected $signature = 'boboko:privacy:process-erasure-requests';
protected $description = 'Dispatch an erasure job for every pending data-erasure request whose grace period has passed';
public function handle(): void
{
$due = DataErasureRequest::where('status', ErasureRequestStatus::Pending)
->where('scheduled_for', '<=', now())
->get();
if ($due->isEmpty()) {
$this->info('No due erasure requests.');
return;
}
foreach ($due as $request) {
EraseDataSubjectJob::dispatch($request);
$scope = $request->isForCustomer() ? 'customer' : 'user';
$this->info("Dispatched erasure job for {$scope} #{$request->subject_id} (request #{$request->id})");
}
$this->info('Dispatched '.$due->count().' erasure job(s).');
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Export;
/**
* One column in a CsvWriter schema: a header label plus a closure that pulls this
* column's value out of one record. The closure doesn't care what shape a record
* is — an array, an Eloquent model, a DTO — so the same CsvWriter serves any
* domain (GDPR export, an admin catalog export, an accounting export) by simply
* being handed a different column schema and a different row source.
*/
final class CsvColumn
{
/**
* @param \Closure(mixed): (string|int|float|null) $value
*/
public function __construct(
public readonly string $header,
public readonly \Closure $value,
) {}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\Export;
/**
* A generic columns + rows -> CSV file writer. No knowledge of any domain (GDPR,
* catalog, accounting, ...) — a caller supplies the schema (CsvColumn[]) and the
* data source (any iterable of records), and this writes one CSV. Reusable for
* any future bulk-export need without modification.
*/
class CsvWriter
{
/**
* @param array<int, CsvColumn> $columns
* @param iterable<mixed> $rows
*/
public function write(array $columns, iterable $rows, string $path): void
{
$handle = fopen($path, 'w');
fputcsv($handle, array_map(fn (CsvColumn $column) => $column->header, $columns));
foreach ($rows as $row) {
fputcsv($handle, array_map(
fn (CsvColumn $column) => $this->stringify(($column->value)($row)),
$columns
));
}
fclose($handle);
}
private function stringify(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
}
@@ -0,0 +1,55 @@
<?php
namespace Modules\Core\Privacy\Contracts;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Implemented by any module that holds personal data and wants it included in
* right-of-access/right-of-erasure requests — core, or a future ERP/banking/etc.
* module. Core has no knowledge of what a provider actually stores or how; it only
* calls these four methods and collects the results (see PrivacyManager).
*
* Two independent scopes, not one — see docs/privacy.md "User-scope vs
* Customer-scope". A Customer (business account, per Lunar's model) can have many
* linked Users, and one User can be linked to many Customer accounts (B2B
* multi-seat access — see docs/modules.md "Customer/User Pairing"), so "erase this
* person's identity" and "erase this business account's data" are genuinely
* different operations with different blast radii:
* - *ForUser(): erase/export one individual — their login, name, email —
* wherever it appears, without touching any Customer account's own data
* (orders, addresses) or any other User linked to those accounts.
* - *ForCustomer(): erase/export one business account's own data, without
* touching any linked User's login or personal identity.
* A provider with nothing relevant to one scope implements that method as a
* no-op returning ErasureOutcome::Skipped (for erase) or an empty payload (for
* export) — see e.g. AddressDataProvider::eraseForUser().
*
* A provider owns its own retention judgment. There's no central taxonomy of "PII
* vs financial data" in this contract on purpose — only the module that owns a
* given table actually knows whether its data is freely erasable, must be
* pseudonymized (e.g. financial records under a legal retention requirement), or
* must be retained outright (e.g. fraud/security records). erase*() expresses
* that by returning a ProviderErasureResult with the outcome that actually
* happened.
*/
interface PersonalDataProvider
{
/**
* A short, stable, unique machine name for this provider (e.g. 'customer',
* 'orders', 'reviews') — used as the export payload's top-level key and in
* erasure reports. Must not collide with another registered provider's name.
*/
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;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Privacy;
use Lunar\Models\Customer;
/**
* Identifies "the business account" for a Customer-scoped data-subject request —
* erasing/exporting a Customer's own data (orders, addresses, the account record
* itself). Deliberately carries no userIds/email: Customer-scope must never touch
* any linked User's login or personal identity, only the account's own data — see
* docs/privacy.md "User-scope vs Customer-scope". A provider that needs to know
* which Users are linked (e.g. to export their names as account contacts, without
* erasing their logins) looks that up itself via the Customer model, rather than
* this value object handing it out — keeping "erase a Customer" structurally
* incapable of touching a User row is the whole point of the split.
*/
class CustomerSubject
{
public function __construct(
public readonly int $customerId,
) {}
public static function forCustomer(Customer $customer): self
{
return new self(customerId: $customer->id);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Core\Privacy;
/**
* What actually happened to a provider's data on an erasure request. None of these
* are failures — Retained is a valid, often legally-required outcome (e.g. an Order
* kept intact for tax retention), distinct from a provider erroring out.
*/
enum ErasureOutcome: string
{
case Erased = 'erased';
case Pseudonymized = 'pseudonymized';
case Retained = 'retained';
case Skipped = 'skipped';
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Core\Privacy;
/**
* Every registered provider's outcome, assembled into one right-of-erasure response
* — the audit trail proving what happened and, for anything not fully erased, why.
* $subject is whichever scope the request was for — see docs/privacy.md
* "User-scope vs Customer-scope".
*/
class ErasureReport
{
/**
* @param array<int, ProviderErasureResult> $results
*/
public function __construct(
public readonly UserSubject|CustomerSubject $subject,
public readonly array $results,
) {}
/**
* @return array<int, ProviderErasureResult>
*/
public function retained(): array
{
return array_values(array_filter(
$this->results,
fn (ProviderErasureResult $result) => $result->outcome === ErasureOutcome::Retained
));
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Core\Privacy;
enum ErasureRequestStatus: string
{
case Pending = 'pending';
case Cancelled = 'cancelled';
case Completed = 'completed';
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
* Fired once the export file exists and $request has been marked completed. Core
* has no opinion on how the customer should be told — a consuming app registers
* its own notification against this event via Modules\Core\Notification\
* NotificationRegistry, the same pattern as App\Notifications\
* QuestionnaireResultsSentNotification listening on App\Events\
* QuestionnaireResultsSent.
*/
class PersonalDataExportFileWritten
{
public function __construct(
public readonly DataExportRequest $request,
) {}
}
@@ -0,0 +1,22 @@
<?php
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
* Fired once ExportDataSubjectJob has gathered every registered provider's data —
* no file exists yet at this point. Modules\Core\Privacy\Listeners\
* WriteExportToCsvListener (registered in PrivacyServiceProvider) is what actually
* turns this into a file, kept as its own listener rather than inline in the job
* so the export *format* (CSV today) is swappable without touching how the data
* is gathered.
*/
class PersonalDataGathered
{
public function __construct(
public readonly DataExportRequest $request,
public readonly ExportReport $report,
) {}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Privacy;
/**
* Every registered provider's export, assembled into one right-of-access response.
* $subject is whichever scope the request was for — see docs/privacy.md
* "User-scope vs Customer-scope".
*/
class ExportReport
{
/**
* @param array<int, ProviderExportResult> $results
*/
public function __construct(
public readonly UserSubject|CustomerSubject $subject,
public readonly array $results,
) {}
/**
* @return array<string, array<string, mixed>> keyed by provider name
*/
public function toArray(): array
{
$data = [];
foreach ($this->results as $result) {
$data[$result->provider] = $result->data;
}
return $data;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Modules\Core\Privacy;
enum ExportRequestStatus: string
{
case Pending = 'pending';
case Completed = 'completed';
case Failed = 'failed';
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Modules\Core\Privacy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
/**
* Runs PrivacyService::completeErasure() for one due DataErasureRequest, dispatched
* per-request by ProcessErasureRequestsCommand rather than looping over
* completeErasure() calls inline in the command. 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.
*/
class EraseDataSubjectJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly DataErasureRequest $request,
) {}
public function handle(PrivacyService $privacyService): void
{
$privacyService->completeErasure($this->request);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Modules\Core\Privacy\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Event;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportReport;
use Modules\Core\Privacy\ExportRequestStatus;
use Modules\Core\Privacy\Models\DataExportRequest;
use Modules\Core\Privacy\PrivacyManager;
use Modules\Core\Privacy\UserSubject;
/**
* Gathers every registered PersonalDataProvider's export data for one request, all
* sequentially in this single job — deliberately 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 — not before.
*
* Calls each provider's *ForCustomer() or *ForUser() method depending on the
* request's polymorphic subject — see Modules\Core\Privacy\PrivacyService and
* docs/privacy.md "User-scope vs Customer-scope".
*
* Writing the gathered data to a file is intentionally NOT done here — see
* PersonalDataGathered and Modules\Core\Privacy\Listeners\WriteExportToCsvListener,
* which keeps the export *format* swappable without touching how data is gathered.
*/
class ExportDataSubjectJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly DataExportRequest $request,
) {}
public function handle(PrivacyManager $manager): void
{
if ($this->request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $this->request->subject_id);
$results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers());
} else {
$subject = new UserSubject(userId: $this->request->subject_id, email: $this->request->email);
$results = array_map(fn ($provider) => $provider->exportForUser($subject), $manager->providers());
}
Event::dispatch(new PersonalDataGathered(
$this->request,
new ExportReport($subject, $results)
));
}
public function failed(\Throwable $exception): void
{
$this->request->update(['status' => ExportRequestStatus::Failed]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Core\Privacy\Listeners;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\PrivacyService;
/**
* Logging back in during a pending erasure request's grace period IS the "I
* changed my mind" action (same pattern as Shopify's own account-deletion flow).
* Authentication itself is never blocked by deactivation — the OTP check in
* UserOtpService::validate() already passed by the time this fires — only what
* happens to the account afterward: any pending request is cancelled and the
* login block lifted (see PrivacyService::cancelErasure()).
*
* Only checks this User's own erasure request, not any Customer-scoped one — a
* Customer-scoped erasure never deactivates a User's login at all (see
* docs/privacy.md "User-scope vs Customer-scope"), so there is nothing for a
* login to reactivate on that side. Only a User-scoped request (keyed on this
* User's own id) can have deactivated this login in the first place.
*/
class CancelErasureOnLoginListener
{
public function __construct(private readonly PrivacyService $privacyService) {}
public function handle(UserAuthenticated $event): void
{
$request = DataErasureRequest::where('subject_type', $event->user->getMorphClass())
->where('subject_id', $event->user->id)
->where('status', ErasureRequestStatus::Pending)
->latest()
->first();
if ($request) {
$this->privacyService->cancelErasure($request);
}
}
}
@@ -0,0 +1,92 @@
<?php
namespace Modules\Core\Privacy\Listeners;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Modules\Core\Export\CsvColumn;
use Modules\Core\Export\CsvWriter;
use Modules\Core\Privacy\Events\PersonalDataExportFileWritten;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\ExportRequestStatus;
use ZipArchive;
/**
* Turns a PersonalDataGathered event's ExportReport into one CSV per
* provider, zipped together, using the generic Modules\Core\Export\CsvWriter — kept
* as its own listener (not inline in ExportDataSubjectJob) so the export *format*
* is swappable (e.g. an app could unregister this and register its own JSON-only
* listener) without touching how the data is gathered.
*
* Column schema: every provider's data is either a list of associative arrays
* (rows directly) or a single associative array (one row) — see the providers in
* Modules\Core\Privacy\Providers, all of which return exactly one of those two
* shapes. Any nested array value within a row (e.g. an order's `addresses`) is
* JSON-encoded into that one cell rather than exploded into further columns —
* CsvWriter's generic stringify() behavior, not special-cased here.
*/
class WriteExportToCsvListener
{
public function __construct(private readonly CsvWriter $writer) {}
public function handle(PersonalDataGathered $event): void
{
$disk = Storage::disk('local');
$exportDir = $disk->path('exports/privacy');
if (! is_dir($exportDir)) {
mkdir($exportDir, 0755, true);
}
$stamp = now()->format('Y_m_d_His');
$zipPath = "{$exportDir}/export_{$event->request->id}_{$stamp}.zip";
$zip = new ZipArchive;
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($event->report->results as $result) {
$csvPath = "{$exportDir}/{$result->provider}_{$stamp}.csv";
$this->writer->write($this->columnsFor($result->data), $this->rowsFor($result->data), $csvPath);
$zip->addFile($csvPath, "{$result->provider}.csv");
}
$zip->close();
foreach ($event->report->results as $result) {
@unlink("{$exportDir}/{$result->provider}_{$stamp}.csv");
}
$event->request->update([
'status' => ExportRequestStatus::Completed,
'file_path' => $zipPath,
'completed_at' => now(),
]);
Event::dispatch(new PersonalDataExportFileWritten($event->request));
}
/**
* @return array<int, mixed>
*/
private function rowsFor(array $data): array
{
// A list of records (addresses, orders, reviews) -> those are the rows.
// A single associative record (customer) -> one row.
return array_is_list($data) ? $data : [$data];
}
/**
* @return array<int, CsvColumn>
*/
private function columnsFor(array $data): array
{
$sample = array_is_list($data) ? ($data[0] ?? []) : $data;
return array_map(
fn (string $key) => new CsvColumn($key, fn (array $row) => $row[$key] ?? null),
array_keys($sample)
);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
/**
* A pending, cancelled, or completed right-of-erasure request — the grace-period
* record between "subject/staff asked for this" and "providers actually erased
* their data" (see Modules\Core\Privacy\PrivacyService, which creates/processes
* these).
*
* `subject` is polymorphic — either a Lunar Customer (business account) or a User
* (individual), never both. See docs/privacy.md "User-scope vs Customer-scope" for
* why these are two genuinely different operations with different blast radii,
* not one "erase this customer and cascade to their users" flow.
*
* `requestedBy` is separately polymorphic (the subject themselves, self-service,
* or Staff acting on their behalf), stored as plain type+id columns rather than
* morphs() since it's always exactly one of those two concrete actor types.
*/
class DataErasureRequest extends Model
{
protected $guarded = [];
protected $casts = [
'status' => ErasureRequestStatus::class,
'scheduled_for' => 'datetime',
'cancelled_at' => 'datetime',
'completed_at' => 'datetime',
'report' => 'array',
];
public function subject(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_id');
}
public function requestedBy(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'requested_by_type', 'requested_by_id');
}
public function isForCustomer(): bool
{
return $this->subject_type === (new Customer)->getMorphClass();
}
public function isPending(): bool
{
return $this->status === ErasureRequestStatus::Pending;
}
public function isDue(): bool
{
return $this->isPending() && $this->scheduled_for->isPast();
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ExportRequestStatus;
/**
* A right-of-access export request. Created synchronously (fast — one insert), then
* ExportDataSubjectJob (queued) does the actual work of gathering every registered
* provider's data and, via Modules\Core\Privacy\Listeners\WriteExportToCsvListener,
* writing it to a file. file_path is null until that completes.
*
* `subject` is polymorphic — either a Lunar Customer (business account) or a User
* (individual), never both. See docs/privacy.md "User-scope vs Customer-scope".
*/
class DataExportRequest extends Model
{
protected $guarded = [];
protected $casts = [
'status' => ExportRequestStatus::class,
'completed_at' => 'datetime',
];
public function subject(): MorphTo
{
return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_id');
}
public function isForCustomer(): bool
{
return $this->subject_type === (new Customer)->getMorphClass();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Privacy;
use Illuminate\Contracts\Container\Container;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
/**
* The registry every PersonalDataProvider is collected through. A module registers
* by adding its provider's class name to config('core.privacy.providers') — the
* same shape as Lunar's own config('lunar.search.indexers') model->indexer map, just
* a plain list since a provider isn't keyed to one model. Core never references a
* specific provider class; a future ERP/banking/etc. module just adds its own
* provider class to that config array and PrivacyService picks it up automatically.
*/
class PrivacyManager
{
public function __construct(private readonly Container $container) {}
/**
* @return array<int, PersonalDataProvider>
*/
public function providers(): array
{
$providers = array_map(
fn (string $class) => $this->container->make($class),
config('core.privacy.providers', [])
);
$this->assertUniqueNames($providers);
return $providers;
}
/**
* @param array<int, PersonalDataProvider> $providers
*/
private function assertUniqueNames(array $providers): void
{
$names = array_map(fn (PersonalDataProvider $provider) => $provider->name(), $providers);
$duplicates = array_diff_assoc($names, array_unique($names));
if ($duplicates !== []) {
throw new \LogicException(
'Duplicate Modules\Core\Privacy provider name(s): '.implode(', ', array_unique($duplicates))
.'. Each provider registered in config(\'core.privacy.providers\') must return a unique name().'
);
}
}
}
+240
View File
@@ -0,0 +1,240 @@
<?php
namespace Modules\Core\Privacy;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
/**
* Entry point for right-of-access and right-of-erasure requests, split into two
* independent scopes — see docs/privacy.md "User-scope vs Customer-scope":
*
* - *ForCustomer(): erases/exports one business account's own data (orders,
* addresses, the account record itself). Never touches any linked User's
* login or personal identity — a Customer erasure request must not deactivate
* or destroy access for anyone who works there.
* - *ForUser(): erases/exports one individual's own identity (login, name,
* email) wherever it appears, and detaches them from every Customer account
* they're linked to as part of erasure — without touching any Customer
* account's own data or any other User still linked to it.
*
* A Customer (business account) can have many linked Users, and one User can be
* linked to many Customer accounts (B2B multi-seat access — see docs/modules.md
* "Customer/User Pairing"), so these are genuinely different operations with
* different blast radii, not one flow with an optional cascade.
*
* Both directions are handled as requests, not immediate synchronous actions:
* requestExport*() queues the (potentially slow) work of gathering every
* provider's data and writing a file, rather than blocking whatever triggered it.
* requestErasure*() opens a cancellable grace-period request (deactivating the
* account for a User-scoped request only — see below) — the same shape as
* Shopify's own account-deletion flow: a window where the subject can change
* their mind before anything is actually erased.
*/
class PrivacyService
{
public function __construct(private readonly PrivacyManager $manager) {}
/**
* Creates a DataExportRequest (fast — one insert) and dispatches
* ExportDataSubjectJob to do the actual gathering/writing work. The job fires
* PersonalDataGathered once every provider's data is collected;
* Modules\Core\Privacy\Listeners\WriteExportToCsvListener turns that into a file
* and fires PersonalDataExportFileWritten — a consuming app registers its own
* notification against that event (see docs/privacy.md).
*/
public function requestExportForCustomer(Customer $customer): DataExportRequest
{
return $this->createExportRequest($customer->getMorphClass(), $customer->id, null);
}
public function requestExportForUser(Authenticatable&LunarUser $user): DataExportRequest
{
return $this->createExportRequest($user->getMorphClass(), $user->id, $user->email);
}
private function createExportRequest(string $subjectType, int $subjectId, ?string $email): DataExportRequest
{
$request = DataExportRequest::create([
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'email' => $email,
'status' => ExportRequestStatus::Pending,
]);
ExportDataSubjectJob::dispatch($request);
return $request;
}
/**
* Opens a grace-period erasure request for the Customer's own data. Deactivates
* NO User — erasing a business account must not destroy anyone's login access,
* even the account's own primary contact. Nothing is actually erased until
* privacy:process-erasure-requests picks this up once scheduled_for has
* passed, unless cancelErasure() is called first.
*
* $requestedBy is either the Customer themselves (self-service deletion) or a
* Staff member acting on their behalf.
*/
public function requestErasureForCustomer(Customer $customer, Customer|Staff $requestedBy): DataErasureRequest
{
return DataErasureRequest::create([
'subject_type' => $customer->getMorphClass(),
'subject_id' => $customer->id,
'email' => null,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
]);
}
/**
* Opens a grace-period erasure request for one individual and deactivates
* their login immediately (blocks it — see Modules\Core\Auth\Services\
* UserOtpService — without touching any Customer account's data). $requestedBy
* is either the User themselves (self-service deletion) or a Staff member
* acting on their behalf.
*/
public function requestErasureForUser(Authenticatable&LunarUser $user, (Authenticatable&LunarUser)|Staff $requestedBy): DataErasureRequest
{
$request = DataErasureRequest::create([
'subject_type' => $user->getMorphClass(),
'subject_id' => $user->id,
'email' => $user->email,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
]);
$this->setUserDeactivated($user->id, true);
return $request;
}
/**
* Erases a Customer's data right now, bypassing the grace period entirely.
* Staff-only by construction — $requestedBy is typed to Staff specifically,
* so a self-service/customer-facing code path cannot reach this method at
* all, only accidentally call it with the wrong actor type and get a
* compile-time error. This exists for a formal legal request or regulator
* inquiry that genuinely requires immediate action — not a convenience
* option for an impatient customer. The grace period is deliberately not
* skippable from any customer-facing flow; see docs/privacy.md.
*/
public function requestImmediateErasureForCustomer(Customer $customer, Staff $requestedBy): ErasureReport
{
$request = DataErasureRequest::create([
'subject_type' => $customer->getMorphClass(),
'subject_id' => $customer->id,
'email' => null,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now(),
]);
return $this->completeErasure($request);
}
/**
* Erases a User's data right now, bypassing the grace period entirely.
* Staff-only by construction — see requestImmediateErasureForCustomer().
*/
public function requestImmediateErasureForUser(Authenticatable&LunarUser $user, Staff $requestedBy): ErasureReport
{
$request = DataErasureRequest::create([
'subject_type' => $user->getMorphClass(),
'subject_id' => $user->id,
'email' => $user->email,
'requested_by_type' => $requestedBy->getMorphClass(),
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now(),
]);
$this->setUserDeactivated($user->id, true);
return $this->completeErasure($request);
}
/**
* Cancels a pending request. For a User-scoped request, reactivates the
* account (see requestErasureForUser()). A Customer-scoped request never
* deactivated anything, so there's nothing to reactivate for it. No-op
* (returns false) if the request isn't pending — e.g. already completed or
* cancelled.
*/
public function cancelErasure(DataErasureRequest $request): bool
{
if (! $request->isPending()) {
return false;
}
$request->update([
'status' => ErasureRequestStatus::Cancelled,
'cancelled_at' => now(),
]);
if (! $request->isForCustomer()) {
$this->setUserDeactivated($request->subject_id, false);
}
return true;
}
/**
* Actually erases the data for a due request: runs every registered
* provider's *ForCustomer() or *ForUser() method (whichever matches the
* request's subject), records the outcome on the request, and marks it
* completed. Called by privacy:process-erasure-requests — not meant to be
* called directly for a request that hasn't passed its grace period, since
* that defeats the point of the window; ProcessErasureRequestsCommand
* enforces isDue() before calling this.
*/
public function completeErasure(DataErasureRequest $request): ErasureReport
{
if ($request->isForCustomer()) {
$subject = new CustomerSubject(customerId: $request->subject_id);
$results = array_map(fn ($provider) => $provider->eraseForCustomer($subject), $this->manager->providers());
} else {
$subject = new UserSubject(userId: $request->subject_id, email: $request->email);
$results = array_map(fn ($provider) => $provider->eraseForUser($subject), $this->manager->providers());
}
$report = new ErasureReport($subject, $results);
$request->update([
'status' => ErasureRequestStatus::Completed,
'completed_at' => now(),
'report' => array_map(
fn (ProviderErasureResult $result) => [
'provider' => $result->provider,
'outcome' => $result->outcome->value,
'reason' => $result->reason,
],
$results
),
]);
return $report;
}
private function setUserDeactivated(int $userId, bool $deactivated): void
{
$model = config('auth.providers.users.model');
/** @var class-string<Model> $model */
$model::where('id', $userId)->update([
'deactivated_at' => $deactivated ? now() : null,
]);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace Modules\Core\Privacy;
/**
* One provider's outcome on an erasure request. `reason` is required whenever
* outcome isn't Erased, so a compliance report or admin UI can show *why* something
* wasn't deleted (e.g. "orders retained per tax law for 7 years from placement")
* without reading that module's source.
*/
class ProviderErasureResult
{
public function __construct(
public readonly string $provider,
public readonly ErasureOutcome $outcome,
public readonly ?string $reason = null,
) {}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Privacy;
/**
* One provider's contribution to a right-of-access export. `provider` is a short,
* stable machine name (e.g. 'customer', 'orders', 'reviews') used as the top-level
* key when PrivacyService assembles every provider's data into one export payload.
*/
class ProviderExportResult
{
/**
* @param array<string, mixed> $data
*/
public function __construct(
public readonly string $provider,
public readonly array $data,
) {}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Address;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* A customer's saved addresses (lunar_addresses) — belong to the Customer
* (business account) via customer_id, not to an individual User, so this is
* Customer-scope only. No legal retention requirement of their own (unlike
* OrderAddress, handled by OrderDataProvider), so they're freely deleted outright
* rather than pseudonymized in place.
*/
class AddressDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'addresses';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = Address::where('customer_id', $subject->customerId)->get();
return new ProviderExportResult('addresses', $addresses->map(fn (Address $address) => [
'id' => $address->id,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'company_name' => $address->company_name,
'line_one' => $address->line_one,
'line_two' => $address->line_two,
'line_three' => $address->line_three,
'city' => $address->city,
'state' => $address->state,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('addresses', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
Address::where('customer_id', $subject->customerId)->delete();
return new ProviderErasureResult('addresses', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('addresses', ErasureOutcome::Skipped, 'Addresses belong to Customer accounts, not individual users.');
}
}
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Cart;
use Lunar\Models\CartAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Carts and cart addresses (lunar_carts, lunar_cart_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. Unlike Order/OrderAddress, an abandoned cart has no
* legal retention requirement, so its addresses are freely deleted. The Cart row
* itself is left alone (any completed order it produced is handled separately by
* OrderDataProvider, which is what retention law actually cares about) — only its
* address PII is removed.
*/
class CartDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'carts';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$addresses = CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->get();
return new ProviderExportResult('carts', $addresses->map(fn (CartAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('carts', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->delete();
return new ProviderErasureResult('carts', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('carts', ErasureOutcome::Skipped, 'Carts belong to Customer accounts, not individual users.');
}
}
@@ -0,0 +1,115 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* The Customer record itself (lunar_customers) and, on the User side, the User's
* own name/email. This is the one provider that implements both scopes
* meaningfully, and they are deliberately kept from touching each other's data:
*
* - eraseForCustomer() clears the account's own fields (name, company, tax id)
* only — it never touches any linked User's login or identity, even though
* $customer->users exists. Erasing a business account must not destroy the
* login access of every person who works there.
* - eraseForUser() clears that one person's name/email only — it never touches
* the Customer record's own fields, and it also detaches the User from every
* Customer they're linked to (the customer_user pivot — see docs/modules.md
* "Customer/User Pairing"), since erasing a person's identity should end
* their membership everywhere, without erasing the business accounts
* themselves or any other User still linked to them.
*
* No legal retention requirement applies to this table on its own, so both
* directions are freely erased — Order/OrderAddress, which DO have a retention
* requirement, are handled separately by OrderDataProvider.
*/
class CustomerDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'customer';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$customer = Customer::find($subject->customerId);
return new ProviderExportResult('customer', $customer ? [
'id' => $customer->id,
'title' => $customer->title,
'first_name' => $customer->first_name,
'last_name' => $customer->last_name,
'company_name' => $customer->company_name,
'tax_identifier' => $customer->tax_identifier,
'meta' => $customer->meta,
'users' => $customer->users->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
])->all(),
] : []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
return new ProviderExportResult('customer', $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'customers' => $user->customers->map(fn (Customer $customer) => [
'id' => $customer->id,
'company_name' => $customer->company_name,
])->all(),
] : []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$customer = Customer::find($subject->customerId);
if (! $customer) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'Customer record not found.');
}
$customer->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => "Customer #{$customer->id}",
'company_name' => null,
'tax_identifier' => null,
'account_ref' => null,
'meta' => null,
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
$model = config('auth.providers.users.model');
$user = $model::find($subject->userId);
if (! $user) {
return new ProviderErasureResult('customer', ErasureOutcome::Skipped, 'User record not found.');
}
$user->customers()->detach();
$user->update([
'name' => null,
'email' => "erased-user-{$user->id}@example.invalid",
]);
return new ProviderErasureResult('customer', ErasureOutcome::Erased);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Lunar\Models\Order;
use Lunar\Models\OrderAddress;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
/**
* Orders and order addresses (lunar_orders, lunar_order_addresses) belong to the
* Customer (business account) via customer_id, not to an individual User, so this
* is Customer-scope only. They're also subject to legal retention (tax/accounting
* law generally requires invoices be kept for several years — GDPR Art. 17(3)(b)
* explicitly allows this to override an erasure request). eraseForCustomer()
* therefore pseudonymizes the PII-bearing free-text fields in place rather than
* deleting the order: totals, line items, tax data, and the order itself all
* remain intact and auditable.
*/
class OrderDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'orders';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
$orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get();
return new ProviderExportResult('orders', $orders->map(fn (Order $order) => [
'id' => $order->id,
'reference' => $order->reference,
'status' => $order->status,
'total' => $order->total?->decimal(),
'placed_at' => $order->placed_at?->toIso8601String(),
'addresses' => $order->addresses->map(fn (OrderAddress $address) => [
'type' => $address->type,
'first_name' => $address->first_name,
'last_name' => $address->last_name,
'line_one' => $address->line_one,
'city' => $address->city,
'postcode' => $address->postcode,
'contact_email' => $address->contact_email,
'contact_phone' => $address->contact_phone,
])->all(),
])->all());
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
return new ProviderExportResult('orders', []);
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
$orderIds = Order::where('customer_id', $subject->customerId)->pluck('id');
if ($orderIds->isEmpty()) {
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.');
}
Order::whereIn('id', $orderIds)->update([
'customer_reference' => null,
'notes' => null,
]);
OrderAddress::whereIn('order_id', $orderIds)->update([
'title' => null,
'first_name' => 'Erased',
'last_name' => 'Customer',
'company_name' => null,
'tax_identifier' => null,
'line_one' => null,
'line_two' => null,
'line_three' => null,
'delivery_instructions' => null,
'contact_email' => null,
'contact_phone' => null,
]);
return new ProviderErasureResult(
'orders',
ErasureOutcome::Pseudonymized,
'Order and address free-text fields cleared; order records, totals, and line items retained for legal/tax record-keeping.'
);
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.');
}
}
@@ -0,0 +1,99 @@
<?php
namespace Modules\Core\Privacy\Providers;
use Illuminate\Database\Eloquent\Builder;
use Modules\Core\Privacy\Contracts\PersonalDataProvider;
use Modules\Core\Privacy\CustomerSubject;
use Modules\Core\Privacy\ErasureOutcome;
use Modules\Core\Privacy\ProviderErasureResult;
use Modules\Core\Privacy\ProviderExportResult;
use Modules\Core\Privacy\UserSubject;
use Modules\Core\Review\Models\ProductReview;
/**
* ProductReview (product_reviews) has no FK to Customer/User at all — it's
* deliberately anonymous, just free-text reviewer_name/reviewer_email (see
* docs/product-listing.md "Reviews"). A review is authored by an individual, not a
* business account, so this is User-scope only — matched best-effort by email
* against UserSubject::$email.
*
* NEEDS REVIEW: moved from Customer-scope to User-scope during the User/Customer
* split (see docs/privacy.md "User-scope vs Customer-scope") on the reasoning that
* authorship is a personal attribute — but this hasn't been fully validated against
* how reviews are actually attributed in this codebase; revisit before relying on
* it for a real erasure/export request.
*
* Matching by email is itself a real, documented limitation regardless of scope: a
* review submitted under a different email than the one on file won't be found.
* There's no stronger signal available without changing ProductReview's schema.
*/
class ReviewDataProvider implements PersonalDataProvider
{
public function name(): string
{
return 'reviews';
}
public function exportForCustomer(CustomerSubject $subject): ProviderExportResult
{
return new ProviderExportResult('reviews', []);
}
public function exportForUser(UserSubject $subject): ProviderExportResult
{
if (! $subject->email) {
return new ProviderExportResult('reviews', []);
}
$reviews = $this->matchingReviews($subject->email)->get();
return new ProviderExportResult('reviews', $reviews->map(fn (ProductReview $review) => [
'id' => $review->id,
'product_id' => $review->product_id,
'title' => $review->title,
'body' => $review->body,
'rating' => $review->rating,
'reviewer_name' => $review->reviewer_name,
'reviewer_email' => $review->reviewer_email,
'reviewed_at' => $review->reviewed_at?->toIso8601String(),
])->all());
}
public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult
{
return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'Reviews are authored by individuals, not Customer accounts.');
}
public function eraseForUser(UserSubject $subject): ProviderErasureResult
{
if (! $subject->email) {
return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'No email on this subject to match reviews by.');
}
$matched = $this->matchingReviews($subject->email)->count();
if ($matched === 0) {
return new ProviderErasureResult('reviews', ErasureOutcome::Skipped, 'No reviews matched this email.');
}
// The review content itself (rating/title/body) is kept — it's the
// reviewer's own product feedback, not identity data on its own — only
// the identifying fields are cleared.
$this->matchingReviews($subject->email)->update([
'reviewer_name' => 'Anonymous',
'reviewer_email' => null,
]);
return new ProviderErasureResult(
'reviews',
ErasureOutcome::Pseudonymized,
'Reviewer name/email cleared on reviews matched by email; rating/title/body text retained.'
);
}
private function matchingReviews(string $email): Builder
{
return ProductReview::where('reviewer_email', $email);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Modules\Core\Privacy;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Base\LunarUser;
/**
* Identifies "the person" for a User-scoped data-subject request — erasing/
* exporting one individual's own identity (login, name, email) wherever it
* appears, regardless of how many Customer (business) accounts they're linked to.
* Deliberately carries no customerId: a provider that needs to know which
* Customer accounts this User is linked to (e.g. to detach them, or to find data
* keyed by a shared email) looks that up itself, rather than this value object
* assuming one fixed Customer — the whole point is that one User can belong to
* many Customer accounts (B2B multi-seat access) and erasing the User must not
* assume or privilege any single one of them.
*/
class UserSubject
{
public function __construct(
public readonly int $userId,
public readonly ?string $email = null,
) {}
public static function forUser(Authenticatable&LunarUser $user): self
{
return new self(userId: $user->id, email: $user->email);
}
}
+3 -2
View File
@@ -10,6 +10,7 @@ use Modules\Core\Command\ExportCommand;
use Modules\Core\Command\ImportCommand;
use Modules\Core\Command\InstallLunarCommand;
use Modules\Core\Command\MigrateImportCommand;
use Modules\Core\Command\ProcessErasureRequestsCommand;
class CoreServiceProvider extends ServiceProvider
{
@@ -35,10 +36,10 @@ class CoreServiceProvider extends ServiceProvider
], 'core-assets');
if ($this->app->runningInConsole()) {
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class]);
$this->commands([AnonymizeCommand::class, ExportCommand::class, ExportCleanupCommand::class, ImportCommand::class, MigrateImportCommand::class, ProcessErasureRequestsCommand::class]);
//Overriding lunar:install
$this->app->booted(fn () => $this->commands([InstallLunarCommand::class]));
$this->app->booted(fn() => $this->commands([InstallLunarCommand::class]));
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\Events\PersonalDataGathered;
use Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener;
use Modules\Core\Privacy\Listeners\WriteExportToCsvListener;
class PrivacyServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(UserAuthenticated::class, CancelErasureOnLoginListener::class);
Event::listen(PersonalDataGathered::class, WriteExportToCsvListener::class);
}
}