Files
core/src/Privacy/PrivacyService.php
T
arvanitakis af380a7fa0 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
2026-08-24 21:41:23 +03:00

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,
]);
}
}