diff --git a/config/core.php b/config/core.php index 54eabde..77c55fa 100644 --- a/config/core.php +++ b/config/core.php @@ -35,11 +35,19 @@ return [ 'privacy' => [ 'providers' => [ + // ActivityLogDataProvider MUST run before AddressDataProvider — + // it resolves which activity_log rows belong to this customer + // (including ones keyed by an Address id) before + // AddressDataProvider hard-deletes those Address rows. See that + // provider's own class docblock. + \Modules\Core\Logging\Privacy\ActivityLogDataProvider::class, \Modules\Core\Customer\Privacy\CustomerDataProvider::class, \Modules\Core\Customer\Privacy\AddressDataProvider::class, \Modules\Core\Order\Privacy\OrderDataProvider::class, \Modules\Core\Cart\Privacy\CartDataProvider::class, \Modules\Core\Review\Privacy\ReviewDataProvider::class, + \Modules\Core\Payment\Privacy\PaymentDataProvider::class, + \Modules\Core\Auth\Privacy\UserSessionDataProvider::class, ], 'grace_period_days' => 30, diff --git a/docs/privacy.md b/docs/privacy.md index 2359c44..0c63afd 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -124,17 +124,40 @@ 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 | +| Provider | `name()` | Lives in | Covers | Customer-scope | User-scope | +|---|---|---|---|---|---| +| `ActivityLogDataProvider` | `activity_log` | `Modules\Core\Logging\Privacy` | `activity_log` (Spatie) for subject types `Customer`/`Address`/`CartAddress`/`OrderAddress`/`Transaction` | **Pseudonymized** — `properties` redacted, who/what/when metadata kept | Skipped — `causer_id` is an actor reference, not PII content; see below | +| `CustomerDataProvider` | `customer` | `Modules\Core\Customer\Privacy` | `lunar_customers`, and separately the `User`'s own name/email/OTP fields | Erases the account's own fields only | Erases that User's name/email/OTP fields only, and detaches them from every linked Customer | +| `AddressDataProvider` | `addresses` | `Modules\Core\Customer\Privacy` | `lunar_addresses` | Erased (deleted outright) | Skipped — belongs to a Customer, not an individual | +| `OrderDataProvider` | `orders` | `Modules\Core\Order\Privacy` | `lunar_orders`, `lunar_order_addresses`, and their `meta` (`terms_accepted*`, `payment_method`, `box_now_locker`) | **Pseudonymized, not erased** — see below | Skipped — belongs to a Customer, not an individual | +| `CartDataProvider` | `carts` | `Modules\Core\Cart\Privacy` | `lunar_cart_addresses`, and `lunar_carts.meta` (`recovery_consent*`, `payment_method`, `checkout_fingerprint`) | Erased | Skipped — belongs to a Customer, not an individual | +| `ReviewDataProvider` | `reviews` | `Modules\Core\Review\Privacy` | `product_reviews` | Skipped — authored by an individual, not a business account | Pseudonymized by matching `reviewer_email`; rating/title/body text kept | +| `PaymentDataProvider` | `payments` | `Modules\Core\Payment\Privacy` | `lunar_transactions` (`card_type`/`last_four`), `stripe_payment_intents` | **Pseudonymized** — card metadata cleared, correlation rows deleted, amounts/statuses kept | Skipped — belongs to Customer-owned orders, not individual users | +| `UserSessionDataProvider` | `sessions` | `Modules\Core\Auth\Privacy` | `user_sessions` (`ip_address`, `user_agent`) | Skipped — belongs to an individual User, not a business account | Erased (deleted outright) | `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. +### `activity_log` is redacted by subject, never by causer + +`Modules\Core\Logging\ActivityLogService` (plus several Lunar models' own native `use +LogsActivity` — `Customer`, `CartAddress`, `OrderAddress`, `Transaction`) durably retains a full +snapshot of whatever it logged in `properties`, completely independent of the real row it +describes — erasing/pseudonymizing a `Customer`/`Address`/`Order`/etc. elsewhere does nothing to +this table on its own. `ActivityLogDataProvider::eraseForCustomer()` redacts `properties` on +every row whose **subject** (not causer) resolves back to that customer, across all five +PII-bearing subject types. + +It deliberately never touches `causer_id` — the causer is "who performed this action," not PII +content, and erasing it would defeat the audit trail's own purpose. `eraseForUser()` is +therefore a no-op: a `User` appears in this table only as a causer, never as subject content, so +there's nothing to redact from the User side alone. + +**Ordering dependency**: `ActivityLogDataProvider` must run *before* `AddressDataProvider` in +`config('core.privacy.providers')` — it resolves which `activity_log` rows are keyed by an +`Address` id while those Address rows still exist; `AddressDataProvider` then hard-deletes them. +Reversing the order would make matching those rows impossible once the addresses are gone. + **`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 diff --git a/src/Auth/Privacy/UserSessionDataProvider.php b/src/Auth/Privacy/UserSessionDataProvider.php new file mode 100644 index 0000000..621b565 --- /dev/null +++ b/src/Auth/Privacy/UserSessionDataProvider.php @@ -0,0 +1,68 @@ +userId)->get(); + + return new ProviderExportResult('sessions', $sessions->map(fn (UserSession $session) => [ + 'id' => $session->id, + 'ip_address' => $session->ip_address, + 'user_agent' => $session->user_agent, + 'last_used_at' => $session->last_used_at?->toIso8601String(), + 'revoked_at' => $session->revoked_at?->toIso8601String(), + ])->all()); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult('sessions', ErasureOutcome::Skipped, 'Login sessions belong to individual Users, not Customer accounts.'); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + $deleted = UserSession::where('user_id', $subject->userId)->delete(); + + if ($deleted === 0) { + return new ProviderErasureResult('sessions', ErasureOutcome::Skipped, 'No login sessions for this user.'); + } + + return new ProviderErasureResult('sessions', ErasureOutcome::Erased); + } +} diff --git a/src/Cart/Privacy/CartDataProvider.php b/src/Cart/Privacy/CartDataProvider.php index 2ef7360..1d24630 100644 --- a/src/Cart/Privacy/CartDataProvider.php +++ b/src/Cart/Privacy/CartDataProvider.php @@ -19,9 +19,25 @@ use Modules\Core\Privacy\DTOs\UserSubject; * 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. + * + * Also covers Cart.meta's own PII-adjacent keys — Modules\Core\Checkout\Services\ + * CheckoutService::setRecoveryConsent()/selectPaymentMethod() write + * recovery_consent/recovery_consent_at/recovery_consent_policy_version and + * payment_method/checkout_fingerprint directly onto this same Cart row, which the + * address-only erase above never touched. Kept Customer-scope, consistent with + * how Cart itself is already classified — see docs/privacy.md for the + * User-vs-Customer discussion this raised. */ class CartDataProvider implements PersonalDataProvider { + private const META_KEYS = [ + 'recovery_consent', + 'recovery_consent_at', + 'recovery_consent_policy_version', + 'payment_method', + 'checkout_fingerprint', + ]; + public function name(): string { return 'carts'; @@ -29,18 +45,26 @@ class CartDataProvider implements PersonalDataProvider public function exportForCustomer(CustomerSubject $subject): ProviderExportResult { - $addresses = CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->get(); + $carts = Cart::where('customer_id', $subject->customerId)->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()); + $addresses = CartAddress::whereIn('cart_id', $carts->pluck('id'))->get(); + + return new ProviderExportResult('carts', [ + 'addresses' => $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(), + 'carts' => $carts->map(fn (Cart $cart) => [ + 'id' => $cart->id, + 'meta' => $this->metaOnly($cart), + ])->all(), + ]); } public function exportForUser(UserSubject $subject): ProviderExportResult @@ -50,7 +74,19 @@ class CartDataProvider implements PersonalDataProvider public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult { - CartAddress::whereIn('cart_id', Cart::where('customer_id', $subject->customerId)->pluck('id'))->delete(); + $carts = Cart::where('customer_id', $subject->customerId)->get(); + + CartAddress::whereIn('cart_id', $carts->pluck('id'))->delete(); + + foreach ($carts as $cart) { + $meta = (array) $cart->meta; + + foreach (self::META_KEYS as $key) { + unset($meta[$key]); + } + + $cart->update(['meta' => $meta]); + } return new ProviderErasureResult('carts', ErasureOutcome::Erased); } @@ -59,4 +95,14 @@ class CartDataProvider implements PersonalDataProvider { return new ProviderErasureResult('carts', ErasureOutcome::Skipped, 'Carts belong to Customer accounts, not individual users.'); } + + /** + * @return array + */ + private function metaOnly(Cart $cart): array + { + $meta = (array) $cart->meta; + + return array_intersect_key($meta, array_flip(self::META_KEYS)); + } } diff --git a/src/Customer/Privacy/CustomerDataProvider.php b/src/Customer/Privacy/CustomerDataProvider.php index e883e60..dbb96bb 100644 --- a/src/Customer/Privacy/CustomerDataProvider.php +++ b/src/Customer/Privacy/CustomerDataProvider.php @@ -108,6 +108,13 @@ class CustomerDataProvider implements PersonalDataProvider $user->update([ 'name' => null, 'email' => "erased-user-{$user->id}@example.invalid", + // A live OTP code left on an otherwise-erased row is a residual + // secret tied to an identity that no longer exists here — clear + // it alongside name/email rather than leaving it to expire on + // its own 10-minute window. + 'otp_code' => null, + 'otp_expires_at' => null, + 'otp_attempts' => 0, ]); return new ProviderErasureResult('customer', ErasureOutcome::Erased); diff --git a/src/Logging/Privacy/ActivityLogDataProvider.php b/src/Logging/Privacy/ActivityLogDataProvider.php new file mode 100644 index 0000000..2dbe81d --- /dev/null +++ b/src/Logging/Privacy/ActivityLogDataProvider.php @@ -0,0 +1,148 @@ +where(fn ($query) => $this->scopeToCustomer($query, $subject->customerId)) + ->get(); + + return new ProviderExportResult('activity_log', $activities->map(fn (Activity $activity) => [ + 'id' => $activity->id, + 'log_name' => $activity->log_name, + 'description' => $activity->description, + 'subject_type' => $activity->subject_type, + 'subject_id' => $activity->subject_id, + 'event' => $activity->event, + 'properties' => $activity->properties?->toArray(), + 'created_at' => $activity->created_at?->toIso8601String(), + ])->all()); + } + + public function exportForUser(UserSubject $subject): ProviderExportResult + { + return new ProviderExportResult('activity_log', []); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + $affected = Activity::query() + ->where(fn ($query) => $this->scopeToCustomer($query, $subject->customerId)) + ->get(); + + if ($affected->isEmpty()) { + return new ProviderErasureResult('activity_log', ErasureOutcome::Skipped, 'No activity log entries for this customer.'); + } + + foreach ($affected as $activity) { + $activity->update(['properties' => $this->redact($activity->properties?->toArray() ?? [])]); + } + + return new ProviderErasureResult( + 'activity_log', + ErasureOutcome::Pseudonymized, + 'PII-bearing properties redacted on matching audit log entries; who/what/when metadata (log_name, subject, event, timestamp, causer) retained for audit integrity.' + ); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult( + 'activity_log', + ErasureOutcome::Skipped, + 'A User only ever appears here as causer_id (who performed an action), not as the PII content of a log entry — redacting that would erode the audit trail\'s own record of who acted.' + ); + } + + private function scopeToCustomer($query, int $customerId): void + { + $customerMorph = (new Customer)->getMorphClass(); + $addressMorph = (new Address)->getMorphClass(); + $cartAddressMorph = (new CartAddress)->getMorphClass(); + $orderAddressMorph = (new OrderAddress)->getMorphClass(); + $transactionMorph = (new Transaction)->getMorphClass(); + + $addressIds = Address::where('customer_id', $customerId)->pluck('id'); + $cartIds = Cart::where('customer_id', $customerId)->pluck('id'); + $cartAddressIds = CartAddress::whereIn('cart_id', $cartIds)->pluck('id'); + $orderIds = Order::where('customer_id', $customerId)->pluck('id'); + $orderAddressIds = OrderAddress::whereIn('order_id', $orderIds)->pluck('id'); + $transactionIds = Transaction::whereIn('order_id', $orderIds)->pluck('id'); + + $query + ->where(fn ($q) => $q->where('subject_type', $customerMorph)->where('subject_id', $customerId)) + ->orWhere(fn ($q) => $q->where('subject_type', $addressMorph)->whereIn('subject_id', $addressIds)) + ->orWhere(fn ($q) => $q->where('subject_type', $cartAddressMorph)->whereIn('subject_id', $cartAddressIds)) + ->orWhere(fn ($q) => $q->where('subject_type', $orderAddressMorph)->whereIn('subject_id', $orderAddressIds)) + ->orWhere(fn ($q) => $q->where('subject_type', $transactionMorph)->whereIn('subject_id', $transactionIds)); + } + + /** + * @param array $properties + * @return array + */ + private function redact(array $properties): array + { + return array_map(function ($value) { + if (is_array($value)) { + return array_map(fn () => self::REDACTED, $value); + } + + return self::REDACTED; + }, $properties); + } +} diff --git a/src/Order/Privacy/OrderDataProvider.php b/src/Order/Privacy/OrderDataProvider.php index 99c3e74..745bd0b 100644 --- a/src/Order/Privacy/OrderDataProvider.php +++ b/src/Order/Privacy/OrderDataProvider.php @@ -20,9 +20,28 @@ use Modules\Core\Privacy\DTOs\UserSubject; * 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. + * + * Also covers PII-adjacent keys living in Order.meta and OrderAddress.meta — + * Modules\Core\Checkout\Services\CheckoutService::initiatePayment() writes + * terms_accepted/terms_accepted_at/terms_accepted_policy_version/payment_method + * onto Order.meta, and Modules\Core\Shipping\Carriers\BoxNow\ + * BoxNowFulfillmentService writes the shopper's chosen box_now_locker onto + * OrderAddress.meta — neither of which the free-text column erase above ever + * touched. Kept Customer-scope, consistent with Order/OrderAddress themselves. */ class OrderDataProvider implements PersonalDataProvider { + private const ORDER_META_KEYS = [ + 'terms_accepted', + 'terms_accepted_at', + 'terms_accepted_policy_version', + 'payment_method', + ]; + + private const ADDRESS_META_KEYS = [ + 'box_now_locker', + ]; + public function name(): string { return 'orders'; @@ -38,6 +57,7 @@ class OrderDataProvider implements PersonalDataProvider 'status' => $order->status, 'total' => $order->total?->decimal(), 'placed_at' => $order->placed_at?->toIso8601String(), + 'meta' => $this->onlyKeys((array) $order->meta, self::ORDER_META_KEYS), 'addresses' => $order->addresses->map(fn (OrderAddress $address) => [ 'type' => $address->type, 'first_name' => $address->first_name, @@ -47,6 +67,7 @@ class OrderDataProvider implements PersonalDataProvider 'postcode' => $address->postcode, 'contact_email' => $address->contact_email, 'contact_phone' => $address->contact_phone, + 'meta' => $this->onlyKeys((array) $address->meta, self::ADDRESS_META_KEYS), ])->all(), ])->all()); } @@ -58,35 +79,41 @@ class OrderDataProvider implements PersonalDataProvider public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult { - $orderIds = Order::where('customer_id', $subject->customerId)->pluck('id'); + $orders = Order::where('customer_id', $subject->customerId)->with('addresses')->get(); - if ($orderIds->isEmpty()) { + if ($orders->isEmpty()) { return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'No orders for this customer.'); } - Order::whereIn('id', $orderIds)->update([ - 'customer_reference' => null, - 'notes' => null, - ]); + foreach ($orders as $order) { + $order->update([ + 'customer_reference' => null, + 'notes' => null, + 'meta' => $this->withoutKeys((array) $order->meta, self::ORDER_META_KEYS), + ]); - 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, - ]); + foreach ($order->addresses as $address) { + $address->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, + 'meta' => $this->withoutKeys((array) $address->meta, self::ADDRESS_META_KEYS), + ]); + } + } 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.' + 'Order and address free-text fields and PII-bearing meta keys cleared; order records, totals, and line items retained for legal/tax record-keeping.' ); } @@ -94,4 +121,28 @@ class OrderDataProvider implements PersonalDataProvider { return new ProviderErasureResult('orders', ErasureOutcome::Skipped, 'Orders belong to Customer accounts, not individual users.'); } + + /** + * @param array $meta + * @param array $keys + * @return array + */ + private function onlyKeys(array $meta, array $keys): array + { + return array_intersect_key($meta, array_flip($keys)); + } + + /** + * @param array $meta + * @param array $keys + * @return array + */ + private function withoutKeys(array $meta, array $keys): array + { + foreach ($keys as $key) { + unset($meta[$key]); + } + + return $meta; + } } diff --git a/src/Payment/Privacy/PaymentDataProvider.php b/src/Payment/Privacy/PaymentDataProvider.php new file mode 100644 index 0000000..48595a9 --- /dev/null +++ b/src/Payment/Privacy/PaymentDataProvider.php @@ -0,0 +1,108 @@ +customerId)->pluck('id'); + + $transactions = Transaction::whereIn('order_id', $orderIds)->get(); + $intents = StripePaymentIntent::whereIn('order_id', $orderIds)->get(); + + return new ProviderExportResult('payments', [ + 'transactions' => $transactions->map(fn (Transaction $transaction) => [ + 'id' => $transaction->id, + 'order_id' => $transaction->order_id, + 'type' => $transaction->type, + 'status' => $transaction->status, + 'amount' => $transaction->amount, + 'card_type' => $transaction->card_type, + 'last_four' => $transaction->last_four, + 'reference' => $transaction->reference, + ])->all(), + 'stripe_payment_intents' => $intents->map(fn (StripePaymentIntent $intent) => [ + 'id' => $intent->id, + 'order_id' => $intent->order_id, + 'intent_id' => $intent->intent_id, + 'status' => $intent->status, + ])->all(), + ]); + } + + public function exportForUser(UserSubject $subject): ProviderExportResult + { + return new ProviderExportResult('payments', []); + } + + public function eraseForCustomer(CustomerSubject $subject): ProviderErasureResult + { + $orderIds = Order::where('customer_id', $subject->customerId)->pluck('id'); + + if ($orderIds->isEmpty()) { + return new ProviderErasureResult('payments', ErasureOutcome::Skipped, 'No orders, and therefore no payment records, for this customer.'); + } + + Transaction::whereIn('order_id', $orderIds)->update([ + 'card_type' => null, + 'last_four' => null, + ]); + + // stripe_payment_intents only ever existed to correlate a webhook + // callback back to a cart/order (see docs/payments.md "Async + // resolution") — that correlation has already served its purpose by + // the time an erasure request runs, so these rows are deleted + // outright rather than pseudonymized, unlike Transaction, which is + // the actual audit-trail record. + StripePaymentIntent::whereIn('order_id', $orderIds)->delete(); + + return new ProviderErasureResult( + 'payments', + ErasureOutcome::Pseudonymized, + 'Card brand/last-four cleared from transaction records; amounts, statuses, and references retained for legal/tax record-keeping. Stripe correlation rows (no longer needed post-settlement) deleted.' + ); + } + + public function eraseForUser(UserSubject $subject): ProviderErasureResult + { + return new ProviderErasureResult('payments', ErasureOutcome::Skipped, 'Payments belong to Customer-owned orders, not individual users.'); + } +} diff --git a/src/Privacy/DTOs/ExportReport.php b/src/Privacy/DTOs/ExportReport.php index 70f2271..55953ab 100644 --- a/src/Privacy/DTOs/ExportReport.php +++ b/src/Privacy/DTOs/ExportReport.php @@ -25,7 +25,13 @@ class ExportReport $data = []; foreach ($this->results as $result) { - $data[$result->provider] = $result->data; + // A provider that threw (ProviderExportResult::$error set — see + // Modules\Core\Privacy\Jobs\ExportDataSubjectJob::safeExport()) + // surfaces as an explicit error marker rather than an empty + // array indistinguishable from "genuinely nothing to export." + $data[$result->provider] = $result->error !== null + ? ['error' => $result->error] + : $result->data; } return $data; diff --git a/src/Privacy/DTOs/ProviderExportResult.php b/src/Privacy/DTOs/ProviderExportResult.php index 05c7ef2..8c23438 100644 --- a/src/Privacy/DTOs/ProviderExportResult.php +++ b/src/Privacy/DTOs/ProviderExportResult.php @@ -6,6 +6,12 @@ namespace Modules\Core\Privacy\DTOs; * 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. + * + * `error` is set only when the provider threw an exception instead of returning + * normally — see Modules\Core\Privacy\Jobs\ExportDataSubjectJob, which catches + * per-provider so one provider throwing doesn't discard every other provider's + * already-gathered data for the same request. `data` is empty whenever `error` is + * set, never a partial/best-effort payload. */ class ProviderExportResult { @@ -15,5 +21,6 @@ class ProviderExportResult public function __construct( public readonly string $provider, public readonly array $data, + public readonly ?string $error = null, ) {} } diff --git a/src/Privacy/Enums/ErasureOutcome.php b/src/Privacy/Enums/ErasureOutcome.php index 22dd0b9..79732be 100644 --- a/src/Privacy/Enums/ErasureOutcome.php +++ b/src/Privacy/Enums/ErasureOutcome.php @@ -3,9 +3,14 @@ namespace Modules\Core\Privacy\Enums; /** - * 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. + * What actually happened to a provider's data on an erasure request. Erased/ + * Pseudonymized/Retained/Skipped are never failures — Retained is a valid, often + * legally-required outcome (e.g. an Order kept intact for tax retention), distinct + * from a provider erroring out. Failed is the one genuine failure case: a provider + * threw an exception instead of returning normally — see Modules\Core\Privacy\ + * Services\PrivacyService::completeErasure(), which catches per-provider so one + * provider throwing doesn't discard every other provider's already-computed + * result for the same request. */ enum ErasureOutcome: string { @@ -13,4 +18,5 @@ enum ErasureOutcome: string case Pseudonymized = 'pseudonymized'; case Retained = 'retained'; case Skipped = 'skipped'; + case Failed = 'failed'; } diff --git a/src/Privacy/Filament/Resources/DataErasureRequestResource.php b/src/Privacy/Filament/Resources/DataErasureRequestResource.php index d339aec..1ff1f96 100644 --- a/src/Privacy/Filament/Resources/DataErasureRequestResource.php +++ b/src/Privacy/Filament/Resources/DataErasureRequestResource.php @@ -5,9 +5,11 @@ namespace Modules\Core\Privacy\Filament\Resources; use Filament\Schemas\Schema; use Filament\Actions\ViewAction; use Filament\Actions\Action; -use Filament\Infolists\Components\KeyValueEntry; +use Filament\Infolists\Components\RepeatableEntry; +use Filament\Infolists\Components\RepeatableEntry\TableColumn; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Components\Section; +use Modules\Core\Privacy\Enums\ErasureOutcome; use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ListDataErasureRequests; use Modules\Core\Privacy\Filament\Resources\DataErasureRequestResource\Pages\ViewDataErasureRequest; use Filament\Resources\Resource; @@ -112,12 +114,32 @@ class DataErasureRequestResource extends Resource ]), Section::make('Outcome') - ->description('Each provider\'s outcome once the erasure completed — see docs/privacy.md.') + ->description('What happened to each data category once the erasure ran. "Retained"/"Pseudonymized" usually means the data is kept in an anonymized form for legal or accounting reasons.') ->icon('heroicon-o-document-check') ->visible(fn (DataErasureRequest $record) => $record->report !== null) ->components([ - KeyValueEntry::make('report') - ->label(''), + RepeatableEntry::make('report') + ->hiddenLabel() + ->table([ + TableColumn::make('Data category'), + TableColumn::make('Outcome'), + TableColumn::make('Reason'), + ]) + ->components([ + TextEntry::make('provider'), + TextEntry::make('outcome') + ->badge() + ->formatStateUsing(fn (string $state) => ucfirst($state)) + ->color(fn (string $state) => match ($state) { + ErasureOutcome::Erased->value => 'success', + ErasureOutcome::Pseudonymized->value, ErasureOutcome::Retained->value => 'info', + ErasureOutcome::Skipped->value => 'gray', + ErasureOutcome::Failed->value => 'danger', + default => 'gray', + }), + TextEntry::make('reason') + ->placeholder('—'), + ]), ]), ]); } diff --git a/src/Privacy/Jobs/ExportDataSubjectJob.php b/src/Privacy/Jobs/ExportDataSubjectJob.php index d9bda4e..ee5ee94 100644 --- a/src/Privacy/Jobs/ExportDataSubjectJob.php +++ b/src/Privacy/Jobs/ExportDataSubjectJob.php @@ -9,9 +9,12 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; +use Modules\Core\Privacy\Contracts\PersonalDataProvider; use Modules\Core\Privacy\DTOs\CustomerSubject; use Modules\Core\Privacy\Events\PersonalDataGathered; use Modules\Core\Privacy\DTOs\ExportReport; +use Modules\Core\Privacy\DTOs\ProviderExportResult; use Modules\Core\Privacy\Enums\ExportRequestStatus; use Modules\Core\Privacy\Models\DataExportRequest; use Modules\Core\Privacy\Services\PrivacyManager; @@ -49,10 +52,16 @@ class ExportDataSubjectJob implements ShouldQueue { if ($this->request->isForCustomer()) { $subject = new CustomerSubject(customerId: $this->request->subject_id); - $results = array_map(fn ($provider) => $provider->exportForCustomer($subject), $manager->providers()); + $results = array_map( + fn (PersonalDataProvider $provider) => $this->safeExport($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()); + $results = array_map( + fn (PersonalDataProvider $provider) => $this->safeExport($provider, 'exportForUser', $subject), + $manager->providers() + ); } Event::dispatch(new PersonalDataGathered( @@ -65,4 +74,29 @@ class ExportDataSubjectJob implements ShouldQueue { $this->request->update(['status' => ExportRequestStatus::Failed]); } + + /** + * Catches per-provider so one provider throwing doesn't discard every + * other provider's already-gathered export data for this same request — + * without this, the whole array_map aborts, handle() never reaches + * Event::dispatch(), and failed() marks the ENTIRE request Failed even + * though most providers may have already gathered their data + * successfully. Logged via Log::error() so a thrown provider is still + * visible to staff, not just an empty/missing section in the export. + * + * @param 'exportForCustomer'|'exportForUser' $method + */ + private function safeExport(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderExportResult + { + try { + return $provider->{$method}($subject); + } catch (Throwable $e) { + Log::error("Privacy provider {$provider->name()}::{$method}() threw during export", [ + 'provider' => $provider->name(), + 'exception' => $e, + ]); + + return new ProviderExportResult($provider->name(), [], $e->getMessage()); + } + } } diff --git a/src/Privacy/Services/PrivacyService.php b/src/Privacy/Services/PrivacyService.php index 663871c..8f8e1d7 100644 --- a/src/Privacy/Services/PrivacyService.php +++ b/src/Privacy/Services/PrivacyService.php @@ -5,19 +5,23 @@ namespace Modules\Core\Privacy\Services; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; use Lunar\Base\LunarUser; use Lunar\Models\Customer; use Modules\Core\Auth\Models\Staff; +use Modules\Core\Privacy\Contracts\PersonalDataProvider; use Modules\Core\Privacy\DTOs\CustomerSubject; use Modules\Core\Privacy\DTOs\ErasureReport; use Modules\Core\Privacy\DTOs\ProviderErasureResult; use Modules\Core\Privacy\DTOs\UserSubject; +use Modules\Core\Privacy\Enums\ErasureOutcome; use Modules\Core\Privacy\Enums\ErasureRequestStatus; use Modules\Core\Privacy\Enums\ExportRequestStatus; use Modules\Core\Privacy\Events\UserErasureRequested; use Modules\Core\Privacy\Jobs\ExportDataSubjectJob; use Modules\Core\Privacy\Models\DataErasureRequest; use Modules\Core\Privacy\Models\DataExportRequest; +use Throwable; /** * Entry point for right-of-access and right-of-erasure requests, split into two @@ -244,15 +248,30 @@ class PrivacyService * 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. + * + * Each provider call is caught individually — a provider throwing (a bug, + * an unexpected DB state) converts to ErasureOutcome::Failed rather than + * aborting the whole array_map, so one broken provider never discards + * every OTHER provider's already-completed erasure for this same request. + * Without this, the $request->update() below would never run at all on a + * throw, silently leaving providers that already succeeded unrecorded and + * the request stuck Pending forever. Logged via Log::error() so a thrown + * provider is still visible to staff, not just swallowed into "Failed." */ 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()); + $results = array_map( + fn (PersonalDataProvider $provider) => $this->safeErase($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()); + $results = array_map( + fn (PersonalDataProvider $provider) => $this->safeErase($provider, 'eraseForUser', $subject), + $this->manager->providers() + ); } $report = new ErasureReport($subject, $results); @@ -282,4 +301,21 @@ class PrivacyService 'deactivated_at' => $deactivated ? now() : null, ]); } + + /** + * @param 'eraseForCustomer'|'eraseForUser' $method + */ + private function safeErase(PersonalDataProvider $provider, string $method, CustomerSubject|UserSubject $subject): ProviderErasureResult + { + try { + return $provider->{$method}($subject); + } catch (Throwable $e) { + Log::error("Privacy provider {$provider->name()}::{$method}() threw during erasure", [ + 'provider' => $provider->name(), + 'exception' => $e, + ]); + + return new ProviderErasureResult($provider->name(), ErasureOutcome::Failed, $e->getMessage()); + } + } }