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
280 lines
12 KiB
PHP
280 lines
12 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
/**
|
|
* 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 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|(Authenticatable&LunarUser) $requestedBy,
|
|
?int $causedByRequestId = null,
|
|
): 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)),
|
|
'caused_by_request_id' => $causedByRequestId,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* 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().
|
|
*
|
|
* 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
|
|
{
|
|
$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);
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
]);
|
|
}
|
|
}
|