Feature: Handling Cases for User to Customer Relationships

This commit handles a case where customer data are "dead-data" menaing there is no way of erasure for them, which makes the app non-compliant
This commit is contained in:
2026-08-24 21:41:23 +03:00
parent 9f540cbaa4
commit af380a7fa0
8 changed files with 256 additions and 15 deletions
@@ -25,6 +25,15 @@ return new class extends Migration
$table->string('requested_by_type');
$table->unsignedBigInteger('requested_by_id');
$table->string('status')->default('pending');
// Set only on a Customer-scoped request that was auto-created because
// erasing a User left them as the sole remaining user on that Customer
// (see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener).
// Null for every normal, directly-requested erasure. Lets login-
// reactivation find and revert exactly the Customer request THIS
// User's cancellation caused, without touching an unrelated,
// independently-requested Customer erasure the User happens to be
// linked to.
$table->foreignId('caused_by_request_id')->nullable()->constrained('data_erasure_requests')->nullOnDelete();
// now() + config('core.privacy.grace_period_days') at creation time —
// when privacy:process-erasure-requests will actually run this.
$table->timestamp('scheduled_for');
+70 -5
View File
@@ -190,10 +190,71 @@ $service->cancelErasure($request);
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.
`PrivacyServiceProvider`, **queued** — see below) looks for a pending request keyed on *that
User's own id* — never a Customer-scoped one, since Customer-scope never deactivates a login in
the first place — and calls `cancelErasure()` on it, then reverts every Customer erasure request
it caused (see "The sole-owner cascade" below). Logging back in **is** the "I changed my mind"
action — no separate UI/flow needed for reactivation.
This listener is queued rather than synchronous, so login returns to the browser without waiting
on the bookkeeping. Nothing else in this codebase currently reads `deactivated_at` besides this
listener and `PrivacyService` itself — `UserOtpService::validate()` never gates the login on it —
so the brief window between the login response and the job actually running has no other consumer
to observe it as stale.
### The sole-owner cascade — erasing the last User on a Customer also erases the Customer
If a User is erased and they were the **only** User linked to a given Customer, that Customer's
data (orders, addresses, buyer record) becomes permanently unreachable through any login the
moment the User's identity is gone — nobody could ever again log in to exercise a data-subject
right over it. GDPR's data minimization principle (Art. 5(1)(c)) means it shouldn't just sit
there indefinitely with no legitimate purpose.
`requestErasureForUser()` and `requestImmediateErasureForUser()` both fire
`Modules\Core\Privacy\Events\UserErasureRequested` right after the request is created (and, for
the immediate path, before `completeErasure()` runs — see below).
`Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener` (**queued**, registered in
`PrivacyServiceProvider`) handles it: for every Customer the User is linked to, if that User is
currently the *sole* linked User (count is 1, and that one User is this one — not just count ===
1, to be explicit rather than relying on an assumption), it opens a second, independent
grace-period request via `requestErasureForCustomer($customer, $user, causedByRequestId: ...)`.
Both requests then run through their own separate 30-day windows.
```
User erasure requested
│
▼
UserErasureRequested event ──▶ CascadeCustomerErasureListener (queued)
│
▼
for each linked Customer: sole owner?
│ yes
▼
requestErasureForCustomer(..., causedByRequestId: <user request id>)
```
**Tracing the cascade — `caused_by_request_id`.** A cascade-created Customer request's
`caused_by_request_id` points back at the User request that triggered it. This is what lets
`CancelErasureOnLoginListener` revert *exactly* the cascade a User's own cancellation should
undo (via `DataErasureRequest::caused()`) without ever touching an unrelated, independently
staff-requested Customer erasure the User happens to still be linked to.
**Why this is queued, not synchronous.** `CascadeCustomerErasureListener` runs as an independent,
separately-retryable job rather than inline inside `requestErasureForUser()` — a failure in the
cascade check never rolls back or blocks the User's own request, and there's no
`DB::transaction()` wrapping needed, since the two writes (the User's request, and any cascaded
Customer request) aren't required to be atomic with each other.
**A known, accepted race on the immediate-erasure path only.** Because the listener is queued,
Eloquent re-fetches its models fresh when the job actually runs (see
`Illuminate\Queue\SerializesModels`) — so `$event->request->subject->customers` reflects the
*real* state at execution time, not a stale snapshot from dispatch time. For
`requestImmediateErasureForUser()`, that job may run before or after `completeErasure()` detaches
the User's memberships in the same call. If the detach happens first, the User is simply no
longer linked to anything by the time the cascade job runs, and nothing cascades — an accepted
race for that rare, staff-only path (see "Immediate erasure" below), not a concern for the
everyday `requestErasureForUser()` grace-period path, where nothing detaches until its own later,
separate `completeErasure()` run — well after the cascade job has had time to fire.
### Processing due requests — one job per request
@@ -305,7 +366,11 @@ whole point is for these tables to remain readable after the record they're abou
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.
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.
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Privacy\Events;
use Modules\Core\Privacy\Models\DataErasureRequest;
/**
* Fired by PrivacyService::requestErasureForUser() right after the grace-period
* request is created (not at completeErasure() time — see
* Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener, which needs to
* act while the User is still linked to their Customers, before any detach has
* happened).
*/
class UserErasureRequested
{
public function __construct(
public readonly DataErasureRequest $request,
) {}
}
@@ -2,6 +2,7 @@
namespace Modules\Core\Privacy\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Core\Auth\Events\UserAuthenticated;
use Modules\Core\Privacy\ErasureRequestStatus;
use Modules\Core\Privacy\Models\DataErasureRequest;
@@ -15,13 +16,27 @@ use Modules\Core\Privacy\PrivacyService;
* 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
* Only checks this User's own erasure request, not any Customer-scoped one
* directly — 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.
*
* If cancelling that request undoes it, this also reverts every Customer
* erasure request it caused (via Modules\Core\Privacy\Listeners\
* CascadeCustomerErasureListener — see DataErasureRequest::caused()). Those are
* traced by caused_by_request_id specifically so only the cascade THIS User's
* own request triggered is reverted, never an unrelated, independently-requested
* Customer erasure the User happens to be linked to.
*
* Queued (ShouldQueue) — login should return to the browser quickly, without
* waiting on this bookkeeping. Nothing else in this codebase currently reads
* deactivated_at except this listener and PrivacyService itself (grep before
* assuming otherwise, if that ever changes) — UserOtpService::validate() never
* gates the login on it — so a brief window between the login response and this
* job actually running has no other consumer to observe it as stale.
*/
class CancelErasureOnLoginListener
class CancelErasureOnLoginListener implements ShouldQueue
{
public function __construct(private readonly PrivacyService $privacyService) {}
@@ -33,8 +48,14 @@ class CancelErasureOnLoginListener
->latest()
->first();
if ($request) {
if (! $request) {
return;
}
$this->privacyService->cancelErasure($request);
foreach ($request->caused as $causedRequest) {
$this->privacyService->cancelErasure($causedRequest);
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Core\Privacy\Listeners;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\PrivacyService;
/**
* When a User's erasure leaves a Customer account with no remaining User at all,
* that Customer's PII (name, addresses, order history) becomes permanently
* unreachable through any login — GDPR data minimization (Art. 5(1)(c)) means it
* shouldn't just sit there. This listener checks every Customer the User is
* linked to: if this User is currently the SOLE user on that Customer (count ===
* 1 and that one user is this user — not just count === 1, in case of a
* stale/unexpected read), it also opens a grace-period Customer erasure request
* for that Customer, tagged via caused_by_request_id so
* CancelErasureOnLoginListener can revert exactly this cascade — and only this
* cascade — if the User logs back in and changes their mind.
*
* Queued (ShouldQueue), not synchronous — this runs as an independent,
* separately-retryable unit of work rather than inline inside
* PrivacyService::requestErasureForUser(), so a failure here never rolls back or
* blocks the User's own request. Because Eloquent models on a queued event are
* re-fetched fresh when the job actually runs (not a stale snapshot from dispatch
* time — see Illuminate\Queue\SerializesModels), $event->request->subject and its
* ->customers reflect the real, current state at execution time. That matters
* specifically for the immediate-erasure path (requestImmediateErasureForUser()):
* this job may run before or after completeErasure() detaches the User's
* memberships — if the detach happens first, ->customers is simply empty by the
* time this runs and nothing cascades, which is an accepted, understood race for
* that rare staff-triggered path (see docs/privacy.md). The everyday grace-period
* path (requestErasureForUser()) has no such race, since nothing detaches the
* User's memberships until its own later, separate completeErasure() run.
*
* Both requests then run through their own independent grace periods.
*/
class CascadeCustomerErasureListener implements ShouldQueue
{
public function __construct(private readonly PrivacyService $privacyService) {}
public function handle(UserErasureRequested $event): void
{
$user = $event->request->subject;
if (! $user) {
return;
}
foreach ($user->customers as $customer) {
if ($this->isSoleUser($customer, $user)) {
$this->privacyService->requestErasureForCustomer($customer, $user, causedByRequestId: $event->request->id);
}
}
}
private function isSoleUser(Customer $customer, Authenticatable&LunarUser $user): bool
{
return $customer->users->count() === 1 && $customer->users->first()->id === $user->id;
}
}
+21
View File
@@ -3,6 +3,8 @@
namespace Modules\Core\Privacy\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Lunar\Models\Customer;
use Modules\Core\Privacy\ErasureRequestStatus;
@@ -44,6 +46,25 @@ class DataErasureRequest extends Model
return $this->morphTo(__FUNCTION__, 'requested_by_type', 'requested_by_id');
}
/**
* The User erasure request that caused this one to be auto-created, if any —
* see Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener.
*/
public function causedBy(): BelongsTo
{
return $this->belongsTo(self::class, 'caused_by_request_id');
}
/**
* Every Customer erasure request THIS request caused (see causedBy()) —
* used by CancelErasureOnLoginListener to revert exactly the cascade this
* User's own cancellation should undo.
*/
public function caused(): HasMany
{
return $this->hasMany(self::class, 'caused_by_request_id');
}
public function isForCustomer(): bool
{
return $this->subject_type === (new Customer)->getMorphClass();
+43 -4
View File
@@ -4,9 +4,11 @@ namespace Modules\Core\Privacy;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Lunar\Base\LunarUser;
use Lunar\Models\Customer;
use Modules\Core\Auth\Models\Staff;
use Modules\Core\Privacy\Events\UserErasureRequested;
use Modules\Core\Privacy\Jobs\ExportDataSubjectJob;
use Modules\Core\Privacy\Models\DataErasureRequest;
use Modules\Core\Privacy\Models\DataExportRequest;
@@ -80,11 +82,21 @@ class PrivacyService
* 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.
* $requestedBy is the Customer themselves (self-service deletion), a Staff
* member acting on their behalf, or a User — the User case is for
* Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener, where erasing
* a User leaves a Customer with no remaining user: the User is a real,
* meaningful "who caused this," even though they didn't directly request the
* Customer's own erasure. $causedByRequestId links a cascade-created request
* back to the User erasure request that triggered it, so
* CancelErasureOnLoginListener can revert exactly that cascade on login,
* without touching an unrelated, independently-requested Customer erasure.
*/
public function requestErasureForCustomer(Customer $customer, Customer|Staff $requestedBy): DataErasureRequest
{
public function requestErasureForCustomer(
Customer $customer,
Customer|Staff|(Authenticatable&LunarUser) $requestedBy,
?int $causedByRequestId = null,
): DataErasureRequest {
return DataErasureRequest::create([
'subject_type' => $customer->getMorphClass(),
'subject_id' => $customer->id,
@@ -93,6 +105,7 @@ class PrivacyService
'requested_by_id' => $requestedBy->getKey(),
'status' => ErasureRequestStatus::Pending,
'scheduled_for' => now()->addDays(config('core.privacy.grace_period_days', 30)),
'caused_by_request_id' => $causedByRequestId,
]);
}
@@ -117,6 +130,13 @@ class PrivacyService
$this->setUserDeactivated($user->id, true);
// CascadeCustomerErasureListener implements ShouldQueue, so this just
// enqueues a job rather than running inline — no transaction wrapping
// needed here, since the cascade check happens as an independent,
// separately-retryable unit of work after this request is already
// committed, not as part of this same call.
Event::dispatch(new UserErasureRequested($request));
return $request;
}
@@ -148,6 +168,15 @@ class PrivacyService
/**
* Erases a User's data right now, bypassing the grace period entirely.
* Staff-only by construction — see requestImmediateErasureForCustomer().
*
* Still fires UserErasureRequested — and deliberately BEFORE completeErasure()
* runs, not after — so Modules\Core\Privacy\Listeners\
* CascadeCustomerErasureListener sees the User still linked to their Customers
* (completeErasure() -> CustomerDataProvider::eraseForUser() is what detaches
* the pivot). The User's own erasure is immediate, but any Customer left
* orphaned by it still gets a normal grace-period erasure request, not an
* immediate one — an orphaned Customer isn't itself the subject of the
* original urgent request.
*/
public function requestImmediateErasureForUser(Authenticatable&LunarUser $user, Staff $requestedBy): ErasureReport
{
@@ -163,6 +192,16 @@ class PrivacyService
$this->setUserDeactivated($user->id, true);
// Queued (see requestErasureForUser()) — the cascade job may run before
// or after completeErasure() below detaches the pivot. Either is fine:
// CascadeCustomerErasureListener re-reads $user->customers fresh when it
// runs, so it only cascades if this User is still linked at that point.
// If completeErasure() detaches first, the queued job simply finds no
// Customers left to check and no-ops — never a wrong cascade, at worst a
// missed one on a race that immediate (staff-triggered, rare) erasure
// doesn't need to guard against as tightly as the grace-period path.
Event::dispatch(new UserErasureRequested($request));
return $this->completeErasure($request);
}
+3
View File
@@ -6,7 +6,9 @@ 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\Events\UserErasureRequested;
use Modules\Core\Privacy\Listeners\CancelErasureOnLoginListener;
use Modules\Core\Privacy\Listeners\CascadeCustomerErasureListener;
use Modules\Core\Privacy\Listeners\WriteExportToCsvListener;
class PrivacyServiceProvider extends ServiceProvider
@@ -15,5 +17,6 @@ class PrivacyServiceProvider extends ServiceProvider
{
Event::listen(UserAuthenticated::class, CancelErasureOnLoginListener::class);
Event::listen(PersonalDataGathered::class, WriteExportToCsvListener::class);
Event::listen(UserErasureRequested::class, CascadeCustomerErasureListener::class);
}
}