From af380a7fa0815f73bb9b901b99864188240d1157 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 24 Aug 2026 21:41:23 +0300 Subject: [PATCH] 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 --- ...002_create_data_erasure_requests_table.php | 9 +++ docs/privacy.md | 75 +++++++++++++++++-- src/Privacy/Events/UserErasureRequested.php | 19 +++++ .../CancelErasureOnLoginListener.php | 33 ++++++-- .../CascadeCustomerErasureListener.php | 64 ++++++++++++++++ src/Privacy/Models/DataErasureRequest.php | 21 ++++++ src/Privacy/PrivacyService.php | 47 +++++++++++- src/Providers/PrivacyServiceProvider.php | 3 + 8 files changed, 256 insertions(+), 15 deletions(-) create mode 100644 src/Privacy/Events/UserErasureRequested.php create mode 100644 src/Privacy/Listeners/CascadeCustomerErasureListener.php diff --git a/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php b/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php index 361fa19..22a2490 100644 --- a/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php +++ b/database/migrations/2026_08_24_000002_create_data_erasure_requests_table.php @@ -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'); diff --git a/docs/privacy.md b/docs/privacy.md index e0b9fe8..1cc4dba 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -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: ) +``` + +**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. diff --git a/src/Privacy/Events/UserErasureRequested.php b/src/Privacy/Events/UserErasureRequested.php new file mode 100644 index 0000000..933eeec --- /dev/null +++ b/src/Privacy/Events/UserErasureRequested.php @@ -0,0 +1,19 @@ +latest() ->first(); - if ($request) { - $this->privacyService->cancelErasure($request); + if (! $request) { + return; + } + + $this->privacyService->cancelErasure($request); + + foreach ($request->caused as $causedRequest) { + $this->privacyService->cancelErasure($causedRequest); } } } diff --git a/src/Privacy/Listeners/CascadeCustomerErasureListener.php b/src/Privacy/Listeners/CascadeCustomerErasureListener.php new file mode 100644 index 0000000..9e43b63 --- /dev/null +++ b/src/Privacy/Listeners/CascadeCustomerErasureListener.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/src/Privacy/Models/DataErasureRequest.php b/src/Privacy/Models/DataErasureRequest.php index 7a7a5a0..27e6b27 100644 --- a/src/Privacy/Models/DataErasureRequest.php +++ b/src/Privacy/Models/DataErasureRequest.php @@ -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(); diff --git a/src/Privacy/PrivacyService.php b/src/Privacy/PrivacyService.php index b7f2ddb..830600f 100644 --- a/src/Privacy/PrivacyService.php +++ b/src/Privacy/PrivacyService.php @@ -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); } diff --git a/src/Providers/PrivacyServiceProvider.php b/src/Providers/PrivacyServiceProvider.php index 146ec51..b503d38 100644 --- a/src/Providers/PrivacyServiceProvider.php +++ b/src/Providers/PrivacyServiceProvider.php @@ -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); } }